@@ -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
|
||||
|
||||
+72
-2
@@ -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": "<span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span>",
|
||||
"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": "<span class=\"chip readout\">2160p</span>",
|
||||
"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": "<span class=\"chip readout\" data-verdict=\"waived\">waived · pt missing</span>",
|
||||
"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": "<span class=\"chip readout\" data-status=\"airing\">airing</span>",
|
||||
"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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user