diff --git a/docs/design-system.md b/docs/design-system.md
index dbacd6f3..eb1fce6d 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -54,6 +54,18 @@ scale adjustments can ship from a single source of truth.
- Keep the legacy search strings (like `text-[11px]`) in regression checklists—repositories should no longer include those
selectors after migrating a template or controller.
+### Tailwind arbitrary values lint
+
+Run `npm run lint:tailwind-brackets` after touching markup or prose that embeds HTML. The lint walks `.html`, `.njk`, and `.md`
+sources and fails when Tailwind's bracket syntax introduces hard-coded measurements (`text-[11px]`, `max-w-[18rem]`, etc.).
+
+- Prefer the semantic utilities shipped alongside the tokens (`max-w-popover-safe`, `max-h-modal-shell`, `text-3xs`).
+- When you truly need an arbitrary value, drive it with a design token by referencing the CSS custom property (`w-[var(--blog-layout-fluid-width)]`, `min-h-[var(--form-textarea-min-height)]`).
+- Update the allowlist sparingly—every entry should correspond to a documented token so future adjustments cascade automatically.
+
+The lint shares the same underlying script as `npm run lint:tokens`, so both commands stay in lockstep with the design-system
+guardrails.
+
## Theming
### `[data-theme]` contract
@@ -385,7 +397,7 @@ Compose `.popover` with a zap-specific panel variant when you need to surface th
0 ? content[index - 1] : "";
if (previousChar === "[") {
@@ -128,37 +187,152 @@ function collectViolations(filePath, content) {
return violations;
}
-async function main() {
- const files = await collectTargetFiles();
+function isAllowedBracketUtility(bracketValue) {
+ const inner = bracketValue.slice(1, -1).trim().toLowerCase();
+ if (!inner) {
+ return false;
+ }
+
+ if (inner.includes("var(--")) {
+ return true;
+ }
+
+ return false;
+}
+
+function extractUtilityFromClass(attributeValue, bracketIndex) {
+ let start = bracketIndex;
+ while (start > 0 && !/\s/.test(attributeValue[start - 1])) {
+ start -= 1;
+ }
+
+ let end = bracketIndex;
+ while (end < attributeValue.length && !/\s/.test(attributeValue[end])) {
+ end += 1;
+ }
+
+ return {
+ utility: attributeValue.slice(start, end),
+ startIndex: start,
+ };
+}
+
+function collectBracketViolations(filePath, content) {
const violations = [];
+ const lineBreaks = buildLineBreaks(content);
+ let classMatch;
+ CLASS_ATTRIBUTE_PATTERN.lastIndex = 0;
- for (const filePath of files) {
- const rel = toPosix(path.relative(repoRoot, filePath));
- if (ALLOWED_FILES.has(rel)) {
+ while ((classMatch = CLASS_ATTRIBUTE_PATTERN.exec(content))) {
+ const attributeValue = classMatch[2];
+ if (!attributeValue) {
continue;
}
- let content;
- try {
- content = await readFile(filePath, "utf8");
- } catch (error) {
- continue;
- }
- const fileViolations = collectViolations(filePath, content);
- violations.push(...fileViolations);
- }
- if (violations.length > 0) {
- console.error("Design token lint failed: raw measurements detected outside tokens.\n");
- for (const violation of violations) {
- console.error(
- `${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
+ BRACKET_UTILITY_PATTERN.lastIndex = 0;
+ let bracketMatch;
+ while ((bracketMatch = BRACKET_UTILITY_PATTERN.exec(attributeValue))) {
+ const bracketValue = bracketMatch[0];
+ if (isAllowedBracketUtility(bracketValue)) {
+ continue;
+ }
+
+ const { utility, startIndex } = extractUtilityFromClass(
+ attributeValue,
+ bracketMatch.index,
);
+
+ const attributeOffset = classMatch[0].indexOf(attributeValue);
+ const absoluteIndex =
+ classMatch.index + attributeOffset + startIndex;
+
+ violations.push({
+ file: toPosix(path.relative(repoRoot, filePath)),
+ line: getLineNumber(lineBreaks, absoluteIndex),
+ value: utility || bracketValue,
+ snippet: getLineSnippet(content, absoluteIndex),
+ });
}
- console.error("\nMove these measurements into css/tokens.css or use the metrics helper.");
- process.exit(1);
}
- process.exit(0);
+ return violations;
+}
+
+async function main() {
+ const mode = parseCheckMode(process.argv.slice(2));
+ const shouldCheckTokens = mode === "all" || mode === "tokens";
+ const shouldCheckBrackets = mode === "all" || mode === "brackets";
+
+ let exitCode = 0;
+
+ if (shouldCheckTokens) {
+ const files = await collectScriptFiles();
+ const violations = [];
+
+ for (const filePath of files) {
+ const rel = toPosix(path.relative(repoRoot, filePath));
+ if (ALLOWED_FILES.has(rel)) {
+ continue;
+ }
+ let content;
+ try {
+ content = await readFile(filePath, "utf8");
+ } catch (error) {
+ continue;
+ }
+ const fileViolations = collectViolations(filePath, content);
+ violations.push(...fileViolations);
+ }
+
+ if (violations.length > 0) {
+ console.error(
+ "Design token lint failed: raw measurements detected outside tokens.\n",
+ );
+ for (const violation of violations) {
+ console.error(
+ `${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
+ );
+ }
+ console.error(
+ "\nMove these measurements into css/tokens.css or use the metrics helper.",
+ );
+ exitCode = 1;
+ }
+ }
+
+ if (shouldCheckBrackets) {
+ const markupFiles = await collectMarkupFiles();
+ const bracketViolations = [];
+
+ for (const filePath of markupFiles) {
+ let content;
+ try {
+ content = await readFile(filePath, "utf8");
+ } catch (error) {
+ continue;
+ }
+
+ const fileViolations = collectBracketViolations(filePath, content);
+ bracketViolations.push(...fileViolations);
+ }
+
+ if (bracketViolations.length > 0) {
+ console.error(
+ "Tailwind bracket lint failed: arbitrary values must map to tokens.\n",
+ );
+ for (const violation of bracketViolations) {
+ console.error(
+ `${violation.file}:${violation.line} → ${violation.value}\n ${violation.snippet}`,
+ );
+ }
+ console.error(
+ "\nReplace these classes with tokenized utilities or sanctioned CSS variables.",
+ );
+ exitCode = 1;
+ }
+ }
+
+ process.exit(exitCode);
}
main().catch((error) => {