#!/usr/bin/env node // Design-token gate (issue #45): components consume tokens from :root in // web/src/style.css, never literal colour or spacing values. // // Allowed colour literals: inside a `:root` block (token definitions) and // inside `@font-face`. Relative colour derived from a token — // `oklch(from var(--x) …)` — counts as consuming the token and is allowed // anywhere. Spacing properties (margin/padding/gap/inset) may only use // var(), 0, auto and percentages; font-size only a --text-* token. import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import process from "node:process"; const webRoot = new URL("..", import.meta.url).pathname; const SKIP_DIRS = new Set(["node_modules", "dist", "api"]); function walk(dir, out = []) { for (const name of readdirSync(dir)) { const path = join(dir, name); if (statSync(path).isDirectory()) { if (!SKIP_DIRS.has(name)) { walk(path, out); } } else if (/\.(css|ts|html)$/.test(name)) { out.push(path); } } return out; } const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:oklch|oklab|lab|lch|rgba?|hsla?|hwb|color)\(/; const RELATIVE_FROM_TOKEN = /\b(?:oklch|oklab|lab|lch|rgb|hsl|hwb|color)\(\s*from\s+var\(--/g; const SPACING_PROP = /^(?:margin|padding|gap|row-gap|column-gap|inset)(?:-[a-z-]+)?$/; const SPACING_VALUE = /^(?:var\(--[\w-]+\)|0|auto|-?[\d.]+%)$/; const FONT_SIZE_VALUE = /^var\(--text-[\w-]+\)$/; const errors = []; function checkDeclaration(declaration, rel, line) { // Relative-colour-from-token is token consumption, not a literal. const neutral = declaration.replace(RELATIVE_FROM_TOKEN, "from-token("); if (COLOR_LITERAL.test(neutral)) { errors.push(`${rel}:${line}: literal colour — use a token from :root`); } const colon = declaration.indexOf(":"); if (colon === -1) { return; } const property = declaration.slice(0, colon).trim(); const value = declaration.slice(colon + 1).trim(); if (property === "font-size") { if (!FONT_SIZE_VALUE.test(value)) { errors.push(`${rel}:${line}: literal font-size "${value}" — use a --text-* token`); } return; } if (!SPACING_PROP.test(property)) { return; } const bad = value.split(/\s+/).filter((part) => part && !SPACING_VALUE.test(part)); if (bad.length > 0) { errors.push(`${rel}:${line}: literal spacing "${bad.join(" ")}" — use a --space-* token`); } } function checkCss(source, rel) { const stripped = source.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " ")); const blockStack = []; let buffer = ""; let bufferLine = 1; let line = 1; for (const char of stripped) { if (char === "\n") { line += 1; } if (char === "{") { blockStack.push(buffer.trim() || "(anonymous)"); buffer = ""; bufferLine = line; } else if (char === ";" || char === "}") { const declaration = buffer.trim(); const allowed = blockStack.some((sel) => /(^|,)\s*:root\b|@font-face/.test(sel)); if (declaration && !allowed) { checkDeclaration(declaration, rel, bufferLine); } if (char === "}") { blockStack.pop(); } buffer = ""; bufferLine = line; } else { if (buffer.trim() === "") { bufferLine = line; } buffer += char; } } } function checkPlain(source, rel) { source.split("\n").forEach((rawLine, index) => { const neutral = rawLine.replace(RELATIVE_FROM_TOKEN, "from-token("); if (COLOR_LITERAL.test(neutral)) { errors.push(`${rel}:${index + 1}: literal colour outside the token block`); } }); } const files = [join(webRoot, "index.html"), ...walk(join(webRoot, "src"))]; for (const file of files) { const rel = relative(webRoot, file); const source = readFileSync(file, "utf8"); if (file.endsWith(".css")) { checkCss(source, rel); } else { checkPlain(source, rel); } } if (errors.length > 0) { console.error("check-tokens: components must consume design tokens:\n"); for (const error of errors) { console.error(` ${error}`); } process.exit(1); } console.log(`check-tokens: ${files.length} files clean`);