Design system tokens (#67)
ci / web (push) Successful in 58s
ci / rust (push) Failing after 1m3s
e2e / e2e (push) Successful in 1m33s

This commit was merged in pull request #67.
This commit is contained in:
2026-08-22 21:26:10 +01:00
parent de2a13060c
commit c7b57b13e6
6 changed files with 246 additions and 15 deletions
+2 -1
View File
@@ -9,7 +9,8 @@
"build": "tsc -b --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"lint": "biome ci ."
"lint": "biome ci .",
"check-tokens": "node scripts/check-tokens.mjs"
},
"devDependencies": {
"@biomejs/biome": "^2.5.8",
+128
View File
@@ -0,0 +1,128 @@
#!/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`);
+39 -12
View File
@@ -40,6 +40,24 @@
/* the one interactive hue */
--accent: oklch(0.8 0.12 220);
--accent-bright: oklch(0.88 0.12 220);
/* dead lamp and pressed-control lowlight */
--lamp-off: oklch(0.3 0.012 250);
--lowlight: oklch(0 0 0 / 30%);
/* release verdicts (§9.3) — rejected is quiet slate, never fault red */
--verdict-eligible: oklch(0.8 0.17 150);
--verdict-waived: oklch(0.82 0.14 80);
--verdict-rejected: oklch(0.68 0.02 250);
/* library status (§4.2) — informational, never a severity ramp:
activity in one violet family, satisfaction in neutrals */
--status-airing: oklch(0.78 0.14 305);
--status-incomplete: oklch(0.72 0.09 305);
--status-waiting: oklch(0.64 0.045 305);
--status-complete: oklch(0.85 0.01 250);
--status-ended: oklch(0.62 0.015 250);
/* type */
--font-label: system-ui, "Segoe UI", sans-serif;
@@ -47,6 +65,15 @@
ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--font-display: "Rajdhani", "Avenir Next Condensed", "Arial Narrow", sans-serif;
/* type scale */
--text-2xs: 0.6875rem;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 1rem;
--text-md: 1.0625rem;
--text-lg: 1.3125rem;
--text-xl: 1.625rem;
/* rhythm */
--space-1: 0.25rem;
--space-2: 0.5rem;
@@ -77,13 +104,13 @@ body {
background: var(--ground);
color: var(--ink);
font-family: var(--font-label);
font-size: 1rem;
font-size: var(--text-base);
line-height: 1.5;
}
.readout {
font-family: var(--font-readout);
font-size: 0.8125rem;
font-size: var(--text-sm);
}
.dim {
@@ -107,7 +134,7 @@ body {
border-bottom: 0;
border-top: 1px solid var(--line);
justify-content: space-between;
font-size: 0.75rem;
font-size: var(--text-xs);
}
.rail-id {
@@ -119,7 +146,7 @@ body {
.wordmark {
margin: 0;
font-family: var(--font-display);
font-size: 1.625rem;
font-size: var(--text-xl);
font-weight: 700;
letter-spacing: 0.1em;
line-height: 1;
@@ -193,7 +220,7 @@ body {
/* dead air: dark, no glow, no motion */
.lamp[data-state="off"] {
--lamp: oklch(0.3 0.012 250);
--lamp: var(--lamp-off);
box-shadow: none;
}
@@ -318,7 +345,7 @@ body {
.module-name {
margin: 0;
font-family: var(--font-display);
font-size: 1.0625rem;
font-size: var(--text-md);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
@@ -326,7 +353,7 @@ body {
}
.module-master .module-name {
font-size: 1.3125rem;
font-size: var(--text-lg);
}
.module-status {
@@ -349,7 +376,7 @@ body {
.module-role {
margin: var(--space-2) 0 0;
font-size: 0.6875rem;
font-size: var(--text-2xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
@@ -358,7 +385,7 @@ body {
.module-detail {
margin: var(--space-1) 0 0;
font-size: 0.75rem;
font-size: var(--text-xs);
color: var(--ink-muted);
min-height: 1.2em;
}
@@ -406,7 +433,7 @@ body {
min-height: 2.75rem;
padding: 0 var(--space-4);
font-family: var(--font-readout);
font-size: 0.8125rem;
font-size: var(--text-sm);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
@@ -422,11 +449,11 @@ body {
.control:hover {
border-color: var(--accent);
color: oklch(from var(--accent) calc(l + 0.08) c h);
color: var(--accent-bright);
}
.control:active {
box-shadow: inset 0 1px oklch(0 0 0 / 30%);
box-shadow: inset 0 1px var(--lowlight);
}
.control:disabled {