44px controls, a separate 24px chip height, one row height, a gutter at every width, min-width breakpoints, and the shared control and row vocabulary in ui.ts. check-tokens now also rejects var() names that nothing defines. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
161 lines
5.3 KiB
JavaScript
161 lines
5.3 KiB
JavaScript
#!/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.
|
|
//
|
|
// Every `var(--x)` must name a custom property something defines — a token in
|
|
// `:root`, a component-local one in the CSS, or one the TS sets with
|
|
// `setProperty` — so a mistyped or deleted token fails here rather than
|
|
// silently falling back to the browser default.
|
|
//
|
|
// 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) {
|
|
// Comments are prose, not styling — `checkCss` already ignores them, and
|
|
// without the same treatment here an issue reference like `#140` reads as a
|
|
// three-digit hex colour and fails the gate.
|
|
const stripped = source
|
|
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, " "))
|
|
.replace(/\/\/.*$/gm, "");
|
|
stripped.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"))];
|
|
const sources = files.map((file) => ({
|
|
rel: relative(webRoot, file),
|
|
source: readFileSync(file, "utf8"),
|
|
}));
|
|
for (const { rel, source } of sources) {
|
|
if (rel.endsWith(".css")) {
|
|
checkCss(source, rel);
|
|
} else {
|
|
checkPlain(source, rel);
|
|
}
|
|
}
|
|
|
|
const defined = new Set();
|
|
for (const { source } of sources) {
|
|
for (const match of source.matchAll(/(--[\w-]+)\s*:/g)) {
|
|
defined.add(match[1]);
|
|
}
|
|
for (const match of source.matchAll(/setProperty\(\s*["'](--[\w-]+)["']/g)) {
|
|
defined.add(match[1]);
|
|
}
|
|
}
|
|
for (const { rel, source } of sources) {
|
|
source.split("\n").forEach((line, index) => {
|
|
for (const match of line.matchAll(/var\(\s*(--[\w-]+)/g)) {
|
|
if (!defined.has(match[1])) {
|
|
errors.push(`${rel}:${index + 1}: unknown token ${match[1]} — nothing defines it`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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`);
|