diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3da4d44..65396b2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -115,3 +115,7 @@ jobs: - name: typecheck if: steps.probe.outputs.present == 'true' run: pnpm -C web exec tsc -b --noEmit + + - name: design tokens + if: steps.probe.outputs.present == 'true' + run: pnpm -C web run check-tokens diff --git a/.impeccable/design.json b/.impeccable/design.json index 9b7812e..4e738ce 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -42,6 +42,30 @@ "displayName": "Phosphor Cyan", "canonical": "oklch(0.8 0.12 220)", "tonalRamp": ["oklch(0.5 0.1 220)", "oklch(0.65 0.11 220)", "oklch(0.8 0.12 220)", "oklch(0.9 0.08 220)"] + }, + "verdict-eligible": { + "role": "secondary", + "displayName": "Eligible Green", + "canonical": "oklch(0.8 0.17 150)", + "tonalRamp": ["oklch(0.45 0.1 150)", "oklch(0.62 0.14 150)", "oklch(0.8 0.17 150)", "oklch(0.88 0.12 150)"] + }, + "verdict-waived": { + "role": "tertiary", + "displayName": "Waiver Amber", + "canonical": "oklch(0.82 0.14 80)", + "tonalRamp": ["oklch(0.5 0.1 80)", "oklch(0.66 0.13 80)", "oklch(0.82 0.14 80)", "oklch(0.9 0.1 80)"] + }, + "verdict-rejected": { + "role": "neutral", + "displayName": "Rejected Slate", + "canonical": "oklch(0.68 0.02 250)", + "tonalRamp": ["oklch(0.42 0.02 250)", "oklch(0.55 0.02 250)", "oklch(0.68 0.02 250)", "oklch(0.8 0.015 250)"] + }, + "status-channel": { + "role": "tertiary", + "displayName": "Channel Violet", + "canonical": "oklch(0.78 0.14 305)", + "tonalRamp": ["oklch(0.64 0.045 305)", "oklch(0.72 0.09 305)", "oklch(0.78 0.14 305)", "oklch(0.86 0.09 305)"] } }, "typographyMeta": { @@ -56,6 +80,10 @@ "readout": { "displayName": "Readout (mono)", "purpose": "ui-monospace stack — every value the operator reads: status words, details, version, rtt. Mono is for measurement, never costume." + }, + "scale": { + "displayName": "Type scale", + "purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips), --text-sm 0.8125rem (readouts, controls), --text-base 1rem (body), --text-md 1.0625rem (module names), --text-lg 1.3125rem (master module), --text-xl 1.625rem (wordmark). Components never carry a literal font-size." } }, "shadows": [ @@ -104,7 +132,31 @@ "refersTo": "signal-ok", "description": "Status lamp. Hue by state (ok/degraded/unconfigured/unreachable), glow is its own light. data-state=\"off\" is dead air: dark, no glow, no motion. Always paired with a status word — remove color and the state must survive.", "html": "", - "css": ".lamp { --lamp: var(--signal-idle); width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--lamp); box-shadow: 0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%); } .lamp[data-state=\"ok\"] { --lamp: var(--signal-ok); } .lamp[data-state=\"off\"] { --lamp: oklch(0.3 0.012 250); box-shadow: none; }" + "css": ".lamp { --lamp: var(--signal-idle); width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--lamp); box-shadow: 0 0 6px var(--lamp), 0 0 14px oklch(from var(--lamp) l c h / 35%); } .lamp[data-state=\"ok\"] { --lamp: var(--signal-ok); } .lamp[data-state=\"off\"] { --lamp: var(--lamp-off); box-shadow: none; }" + }, + { + "name": "Chip", + "kind": "custom", + "refersTo": "ground", + "description": "The §9.3 column unit: a small machined panel holding one mono value (score, resolution, source, HDR, audio, size, seeders). Fixed rhythm so columns align; never a soft pill.", + "html": "2160p", + "css": ".chip { display: inline-flex; align-items: center; gap: var(--space-1); min-height: 1.375rem; padding: 0 var(--space-2); background: var(--panel-raised); border: 1px solid var(--line); border-radius: var(--radius); font-family: var(--font-readout); font-size: var(--text-xs); color: var(--ink-muted); }" + }, + { + "name": "Verdict chip", + "kind": "custom", + "refersTo": "verdict-eligible", + "description": "Release classification (§9.3): eligible / waived / rejected. Hue + border treatment + the word, so the three buckets survive with colour removed — eligible is a solid tinted border, waived a dashed border (the visible mark of a relaxed rule), rejected is quiet slate and always carries the killing rule's name. Rejected is the policy working, never fault red.", + "html": "waived · pt missing", + "css": ".chip[data-verdict=\"eligible\"] { color: var(--verdict-eligible); border-color: oklch(from var(--verdict-eligible) l c h / 45%); } .chip[data-verdict=\"waived\"] { color: var(--verdict-waived); border-style: dashed; border-color: oklch(from var(--verdict-waived) l c h / 55%); } .chip[data-verdict=\"rejected\"] { color: var(--verdict-rejected); }" + }, + { + "name": "Status chip", + "kind": "custom", + "refersTo": "status-channel", + "description": "Library status (§4.2): airing / incomplete / waiting / complete / ended. Informational, not severity — colour marks activity in one channel-violet family (airing brightest, incomplete, waiting dimmest), satisfaction is neutral (complete bright ink, ended faint). The mono word is always present.", + "html": "airing", + "css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--status-incomplete); } .chip[data-status=\"waiting\"] { color: var(--status-waiting); } .chip[data-status=\"complete\"] { color: var(--status-complete); } .chip[data-status=\"ended\"] { color: var(--status-ended); }" }, { "name": "Module (channel strip)", @@ -170,6 +222,21 @@ "body": "Depth is one inset 1px highlight on a hairline-bordered panel. No drop shadows, no glass, no gradients. The only glow on the board is a lamp's own light.", "section": "elevation" }, + { + "name": "The Verdict Rule", + "body": "Release verdicts are eligible green, waiver amber, rejected slate. Rejected is the policy engine doing its job, so it is quiet neutral — fault red stays reserved for the daemon's own failures. Each bucket also carries a non-colour mark: solid tinted border (eligible), dashed border (waived), the killing rule's name (rejected).", + "section": "colors" + }, + { + "name": "The Status-Is-Not-Severity Rule", + "body": "The five library statuses are informational, never an error/warning/success ramp. Activity lives in one channel-violet family (airing > incomplete > waiting, by lightness and chroma); satisfaction is neutral (complete bright ink, ended faint). Complete is never green; incomplete is never amber.", + "section": "colors" + }, + { + "name": "The Token Rule", + "body": "Components consume the custom properties declared in :root of web/src/style.css — never a literal colour, margin, padding or gap. web/scripts/check-tokens.mjs enforces this in CI; relative-colour derivation from a token (oklch(from var(--x) …)) counts as consuming the token.", + "section": "colors" + }, { "name": "The Dead-Air Rule", "body": "When nothing is flowing, nothing animates. Pulse means probing; data-state=\"off\" is dark and still. Motion that misreports activity is a truth bug.", @@ -180,9 +247,12 @@ "Do lay future surfaces (manual search buckets, queues) out as panels and readouts consuming these tokens; chips in §9.3 are small panels with mono values.", "Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.", "Do keep muted text at or above 4.5:1 on panel ground (--ink-muted floor).", - "Do self-host every asset; the SPA is embedded in the binary and must work with no network." + "Do self-host every asset; the SPA is embedded in the binary and must work with no network.", + "Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals." ], "donts": [ + "Don't colour `complete` green or `incomplete` amber — library status is informational, not a severity ramp.", + "Don't use fault red for a rejected release; red means the daemon itself is broken.", "Don't introduce a second interactive hue or use cyan for a status.", "Don't box content in soft-shadowed rounded cards or add glass/blur.", "Don't put a kicker/eyebrow label above a heading.", diff --git a/Justfile b/Justfile index cc041a1..d2986c2 100644 --- a/Justfile +++ b/Justfile @@ -41,6 +41,7 @@ web-check: pnpm -C web install --frozen-lockfile pnpm -C web exec biome ci . pnpm -C web exec tsc -b --noEmit + pnpm -C web run check-tokens # Build the SPA and the binary that embeds it. build: diff --git a/web/package.json b/web/package.json index e0a11e9..df5f22c 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/scripts/check-tokens.mjs b/web/scripts/check-tokens.mjs new file mode 100644 index 0000000..bfd5f27 --- /dev/null +++ b/web/scripts/check-tokens.mjs @@ -0,0 +1,121 @@ +#!/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. + +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 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(); + if (!SPACING_PROP.test(property)) { + return; + } + const value = declaration.slice(colon + 1).trim(); + 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`); diff --git a/web/src/style.css b/web/src/style.css index 6ce5847..c916b47 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 {