From ec918bb75f7ca0528dbc3cc6b329167fbd40d2a0 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:58:41 -0700 Subject: [PATCH] desktop-new: build screens from the ramps, cut 54 colour roles to 15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers all eight palette ramps as Tailwind classes, so a screen writes `bg-purple-9` or `text-red-12` directly. This reverses the rule the colour guard used to enforce, and the reversal turns on one fact: every step in this palette is authored per mode, so `neutral-4` is one value in light and another in dark and a component naming it behaves correctly in both. That is what makes a raw step safe here and unsafe in stock Tailwind, where `neutral-200` is a single literal. Once a step is mode-aware, a role whose light and dark values are the same step is a name in front of a number, and a name in front of a number hides the decision instead of recording it. `bg-accent` was `purple-9`; `text-error` was `red-12`. Nineteen roles were exactly that. The fifteen that remain each earn the name: the four structural surfaces take a different step per mode, so no class can express them; the emphasis roles enforce a rule a ramp cannot state, which is that there are three levels of text and one border weight; the glass materials are a bundled treatment; and `--text-on-accent` follows its fill rather than the mode. Also fixes a namespace collision that had shipped a real defect. `--color-x` is not one utility, it is all of them — one declaration defines `bg-x`, `text-x`, `border-x` and `ring-x` from a single value — so `text-danger` resolved to the saturated fill (red-9) instead of the text step. Three error messages rendered at APCA Lc 34 on a dark panel against a target of 60, and `check-contrast` passed the whole time because it measured `--text-danger`, a token no class could reach. Text roles now register as `--text-color-*`, matching the `--border-color-*` split that fixed the same bug for borders. Guards updated to enforce the new rule rather than the old one: `check-color` now rejects a role whose two modes agree and names the class to write instead, and `check-contrast` measures the ramp steps screens actually use, so deleting a role cannot leave it auditing a name nobody types. `registry.test.ts` binds the documented token list to the stylesheet, and `pnpm census` reports every role's readers without failing a build. Signed-off-by: Morgan Martin Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop-new/package.json | 4 +- desktop-new/scripts/check-color.mjs | 197 ++-- desktop-new/scripts/check-contrast.mjs | 102 +- desktop-new/scripts/token-consumers.mjs | 143 +++ desktop-new/src/shared/styles/tokens.css | 935 +++++++++++------- .../src/shared/tokens/registry.test.ts | 142 +++ desktop-new/src/shared/tokens/registry.ts | 417 ++++---- .../ui/{SegmentedNavigation.tsx => Tabs.tsx} | 0 desktop-new/tsconfig.json | 5 + pnpm-lock.yaml | 3 + 10 files changed, 1228 insertions(+), 720 deletions(-) create mode 100644 desktop-new/scripts/token-consumers.mjs create mode 100644 desktop-new/src/shared/tokens/registry.test.ts rename desktop-new/src/shared/ui/{SegmentedNavigation.tsx => Tabs.tsx} (100%) diff --git a/desktop-new/package.json b/desktop-new/package.json index b9b58f3b95..21d0d279ed 100644 --- a/desktop-new/package.json +++ b/desktop-new/package.json @@ -16,7 +16,8 @@ "test": "vitest run src", "test:e2e": "playwright test", "tauri:dev": "node ../desktop/scripts/tauri-command.mjs dev --config ../desktop-new/tauri.conf.json --config ../desktop/src-tauri/tauri.dev.conf.json", - "check:color": "node scripts/check-color.mjs" + "check:color": "node scripts/check-color.mjs", + "census": "node scripts/token-consumers.mjs" }, "dependencies": { "@base-ui/react": "1.7.0", @@ -37,6 +38,7 @@ "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@tanstack/router-plugin": "^1.167.12", + "@types/node": "25.6.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.0", diff --git a/desktop-new/scripts/check-color.mjs b/desktop-new/scripts/check-color.mjs index 9338762f1f..385a7b37dc 100644 --- a/desktop-new/scripts/check-color.mjs +++ b/desktop-new/scripts/check-color.mjs @@ -22,8 +22,8 @@ * shadow values that bake alpha into a literal. * * It also audits the layering itself: that the palette is the only place a - * literal lives, that a family step references a palette step rather than - * holding its own value, and that two tokens doing the same job resolve to the + * literal lives, that an identity role references a palette step rather than + * holding its own value, and that two roles doing the same job resolve to the * same step rather than merely to the same value today. That last one is not * hypothetical — `accent-2` and `tint-purple` were the same purple in light mode * and two different purples in dark, and nothing caught it because both were @@ -87,13 +87,42 @@ const CSS_MIXERS = [ { pattern: /\bhsla?\([^)]*[,/]\s*0?\.\d+\s*\)/g, what: "hsl() with alpha" }, ]; +/** Every hue in the palette. `neutral` is a hue like any other. */ +const PALETTE_HUES = [ + "neutral", + "purple", + "red", + "green", + "amber", + "blue", + "cyan", + "orange", +]; + /** - * A component may not name a palette or family token directly. Only the role - * layer is public — the palette is where values live and families are the jobs - * a hue does, but a screen is built from what a thing *is*. + * **Palette steps are public now. Only glass is private.** + * + * This rule used to reject `var(--neutral-4)` in a component, on the reasoning + * that a screen should say what a thing *is* rather than which colour it takes. + * That reasoning was imported from Tailwind, where `neutral-800` is a single + * literal and naming it really does break dark mode. + * + * It does not hold here. **Every step in this palette is authored per mode**, so + * `neutral-4` is one value in light and another in dark and a component naming it + * behaves correctly in both. Once that is true, a role whose light and dark + * values are the same step is a name in front of a number — and a name in front + * of a number hides the decision rather than recording it. Nineteen roles were + * exactly that. + * + * Glass stays private for a different reason, which has nothing to do with + * naming: a glass fill without its blur, rim, and lift is not glass. It is + * reachable only through the `glass-primary` / `glass-secondary` utilities, which + * carry the whole material. That is enforced below. + * + * Hues are enumerated rather than matched as `[a-z]+` because a palette step and + * a non-colour token are the same shape: `--neutral-4` and `--space-4`. */ -const PRIVATE_TOKEN = - /var\(\s*--(palette-[a-z]+-\d+|(?:accent|danger|success|warning|info)-(?:tint|tint-hover|border|fill|text)|neutral-\d+|glass-\d+)\s*\)/g; +const PRIVATE_TOKEN = /var\(\s*--glass-\d+\s*\)/g; /** * Files exempt from the private-token rule, with the reason. @@ -104,6 +133,10 @@ const PRIVATE_TOKEN = */ const PRIVATE_TOKEN_ALLOWED = new Map([ ["shared/styles/tokens.css", "Defines the layers it references."], + [ + "shared/styles/globals.css", + "The glass materials live here; pairing each fill with its blur and rim is their job.", + ], [ "shared/tokens/registry.ts", "Documents the ramps; the token names are its content.", @@ -179,7 +212,7 @@ function check(file) { failures.push({ at, found: match[0], - why: "Palette and family tokens are private. Build screens from role tokens.", + why: "A glass fill on its own is not glass. Use the `glass-primary` or `glass-secondary` utility, which carries the blur, rim, and lift with it.", }); } } @@ -219,23 +252,43 @@ function auditLayers() { const read = (block, name) => new RegExp(`^\\s*${name}:\\s*([^;]+);`, "m").exec(block)?.[1].trim(); - const HUES = [ - "gray", - "purple", - "red", - "green", - "amber", - "blue", - "cyan", - "orange", + const HUES = PALETTE_HUES; + // Backdrop scenes are intentionally mode-specific at the palette layer, but + // their semantic gradient slot must be complete and paired. A slot with a + // missing counterpart would silently turn an appearance preference into an + // invalid state on a mode change, so verify the production CSS seam here. + const BACKDROP_PAIRS = [ + ["--gradient-1", "--gradient-sky-field", "--gradient-night-garden"], + ["--gradient-2", "--gradient-peach-field", "--gradient-signal-flare"], + ["--gradient-3", "--gradient-blue-hour", "--gradient-electric-dusk"], + ["--gradient-4", "--gradient-orchid-field", "--gradient-ultraviolet"], ]; - const FAMILIES = ["accent", "danger", "success", "warning", "info"]; - const JOBS = ["tint", "tint-hover", "border", "fill", "text"]; + for (const [slot, lightScene, darkScene] of BACKDROP_PAIRS) { + for (const [mode, scene] of [ + ["light", lightScene], + ["dark", darkScene], + ]) { + if (!read(modes[mode], scene)) { + failures.push({ + at: `${rel} (${mode})`, + found: scene, + why: "Missing named backdrop treatment for this semantic gradient slot.", + }); + } + if (read(modes[mode], slot) !== `var(${scene})`) { + failures.push({ + at: `${rel} (${mode})`, + found: `${slot}: ${read(modes[mode], slot) ?? "missing"}`, + why: `Must pair this slot with ${scene} in ${mode} mode.`, + }); + } + } + } // 1. Every palette step exists in both modes and holds a literal. for (const hue of HUES) { for (let step = 1; step <= 12; step += 1) { - const name = `--palette-${hue}-${step}`; + const name = `--${hue}-${step}`; for (const [mode, block] of Object.entries(modes)) { const value = read(block, name); if (!value) { @@ -255,63 +308,55 @@ function auditLayers() { } } - // 2. A family step references the palette; it never holds a value. - for (const family of FAMILIES) { - for (const job of JOBS) { - const name = `--${family}-${job}`; - for (const [mode, block] of Object.entries(modes)) { - const value = read(block, name); - // Absent in dark is correct: it inherits through the palette. - if (!value) continue; - if (!/^var\(--palette-[a-z]+-\d+\)$/.test(value)) { - failures.push({ - at: `${rel} (${mode})`, - found: `${name}: ${value}`, - why: "A family step must reference a palette step, not hold a value.", - }); - } - } - } - } - - // 3. No two family steps resolve to the same palette step. + // 2. THE ROLES THAT REMAIN MUST BE MODE-ASYMMETRIC. // - // The generalised form of the bug that motivated this layer. Two tokens that - // hold the same value are either one job with two names — which drifts the - // moment someone edits one — or two jobs that will become indistinguishable - // on screen. Either way it wants a person to look. This catches it for any - // future family without naming pairs, which the previous version did and - // which went silently vacuous when those tokens were deleted. - const seen = new Map(); - for (const family of FAMILIES) { - for (const job of JOBS) { - const name = `--${family}-${job}`; - const value = read(modes.light, name); - if (!value) continue; - const existing = seen.get(value); - if (existing) { - failures.push({ - at: rel, - found: `${existing} and ${name} both resolve to ${value}`, - why: "Two family steps on one palette step. Either it is one job with two names, or two jobs that will look identical. Give the second its own step.", - }); - } else { - seen.set(value, name); - } - } - } + // This replaces four checks that audited the identity families — that each + // referenced a palette step, that none was missing, that no two shared a step, + // that dark never restated one. All four are gone because their subject is: + // nineteen roles were deleted once palette steps became reachable as classes, + // and the ones left are the four surfaces plus emphasis. + // + // The invariant now worth enforcing is the TEST FOR WHETHER A ROLE IS EARNED. + // A surface role exists precisely because light and dark take *different* ramp + // steps, so no single class can express it. If someone adds a surface role + // whose two modes agree, the name is doing nothing and a class would say it — + // that is the mistake this catches, in the same shape it already happened. + // + // The emphasis roles are deliberately exempt: they are the same step in both + // modes and stay because the *name* enforces a rule a ramp cannot state, which + // is that there are three levels of text and one border weight. + const NAME_ENFORCES_A_RULE = new Set([ + "--text-primary", + "--text-secondary", + "--text-tertiary", + "--text-disabled", + "--border-primary", + "--text-on-accent", + ]); - // 4. Dark restates the palette, never the families. - for (const family of FAMILIES) { - for (const job of JOBS) { - if (read(modes.dark, `--${family}-${job}`)) { - failures.push({ - at: `${rel} (dark)`, - found: `--${family}-${job}`, - why: "Restated in dark. A family inherits through the palette, which dark already redefines; restating it reopens the drift surface.", - }); - } - } + const roleNames = [ + ...new Set( + [...modes.light.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm)].map( + (m) => m[1], + ), + ), + ]; + + for (const name of roleNames) { + if (NAME_ENFORCES_A_RULE.has(name)) continue; + // Glass materials are a bundled treatment, not a surface step, and `bg-app` + // swaps a whole gradient rather than a step. + if (name.includes("glass") || name === "--bg-app") continue; + + const light = read(modes.light, name); + const dark = read(modes.dark, name); + if (dark && dark !== light) continue; // earns its name + + failures.push({ + at: rel, + found: `${name}: ${light}`, + why: `Same value in both modes, so this name is in front of a number. Write ${light?.replace(/var\(--(.+)\)/, "$1") ?? "the step"} where it is used, or add it to NAME_ENFORCES_A_RULE with the rule it enforces.`, + }); } } @@ -335,6 +380,6 @@ for (const { at, found, why } of failures) { console.error(` ${why}\n`); } console.error( - "Add the step to the palette in tokens.css, or add a documented override\nin scripts/check-color.mjs with a reason. See DESIGN.md § Colour.", + "Add the step to the palette in tokens.css, or add a documented override\nin scripts/check-color.mjs with a reason. See DESIGN.md § Colour discipline.", ); process.exit(1); diff --git a/desktop-new/scripts/check-contrast.mjs b/desktop-new/scripts/check-contrast.mjs index 7cd81f171d..d4fdb9306c 100644 --- a/desktop-new/scripts/check-contrast.mjs +++ b/desktop-new/scripts/check-contrast.mjs @@ -34,6 +34,11 @@ const TARGET_META = 45; * * An exception must name a role and say why, so the list stays short and * arguable rather than becoming a place to hide failures. + * + * A key is either a role — exempt on every surface — or `role on surface`, + * exempt for that one pairing. Prefer the narrow form: a role-wide exemption + * for a shortfall on one surface also silences a genuine failure on the next + * surface that role reaches, which is the failure mode this list must not have. */ const EXCEPTIONS = new Map([ [ @@ -45,38 +50,67 @@ const EXCEPTIONS = new Map([ /** Roles measured at the meta target rather than the body target. */ const META_ROLES = new Set(["--text-tertiary"]); -/** Neutral surfaces any text role may sit on. */ -const SURFACES = ["--bg-panel", "--bg-float", "--bg-inset", "--bg-hover"]; +/** + * Neutral surfaces any text may sit on. + * + * `--bg-hover` was a role and is now written as `--neutral-4`, so it is named by + * its step here. It still belongs in this list: a row under the cursor is a + * surface text sits on, whatever it is called. + */ +const SURFACES = ["--bg-panel", "--bg-float", "--bg-inset", "--neutral-4"]; -/** Text roles that must be readable on every neutral surface. */ +/** + * Text that must be readable on every neutral surface. + * + * **The palette steps in this list are the point, not an oversight.** Once a + * screen may write `text-red-12` directly, the guard has to measure the step a + * screen actually uses — otherwise it audits names nobody types. `--text-error` + * and `--text-accent` used to be here; deleting the roles without moving their + * steps into this list would have left the guard passing while measuring + * nothing, which is precisely the failure that shipped error text at Lc 34. + * + * A step joins this list when a screen starts using it for text. `red-12` is + * here because three error messages use it; `red-11` is not, because nothing + * does — and it is the step that fails, at 59.7 on a dark panel. + */ const TEXT_ROLES = [ "--text-primary", "--text-secondary", "--text-tertiary", "--text-disabled", - "--text-accent", - "--text-danger", - "--text-success", - "--text-warning", - "--text-info", -]; - -/** Paired text and the one fill it is defined against. */ -const PAIRS = [ - ["--text-on-accent", "--bg-accent"], - ["--text-on-inverse", "--bg-inverse"], - ["--text-on-danger", "--bg-danger"], - ["--text-on-success", "--bg-success"], - ["--text-on-warning", "--bg-warning"], - ["--text-on-info", "--bg-info"], + "--purple-12", // accent text: links, active nav, chip labels + "--red-12", // error text: failed session start, rejected form ]; /** - * The families with a tint surface. DESIGN.md: "tint carries a meaning and takes - * coloured text", so each family's `text-*` must be readable on both its tint - * and that tint's hover — the hover state is the harder one and was the gap. + * Paired text and the fills it is defined against — including each fill's hover. + * + * The hover was the gap. A solid button had no `bg-*-hover`, so hovering one + * fell through to the neutral `bg-hover` and painted white paired text on light + * grey — Lc 13, invisible, and this guard could not see it because it only + * measured the resting fill. A paired text colour is only correct if it clears + * every fill it can actually sit on, and hover is one of them. */ -const TINT_FAMILIES = ["accent", "danger", "success", "warning", "info"]; +const PAIRS = [ + ["--text-on-accent", "--purple-9"], + ["--text-on-accent", "--purple-10"], + // `bg-neutral-11` with `text-neutral-1` — the inverse pair, written as steps + // now that the roles are gone. Still measured as a pair, because the text + // follows the fill: move the fill and this has to be re-measured. + ["--neutral-1", "--neutral-11"], +]; + +/** + * Tint surfaces and the coloured text that sits on them, as steps. + * + * DESIGN.md: "tint carries a meaning and takes coloured text". The accent tint + * and its hover are both real surfaces — a chip at rest and a chip under the + * cursor — and the hover is the harder one, which is where the gap was. + */ +const TINT_PAIRS = [ + ["--purple-12", "--purple-3"], + ["--purple-12", "--purple-4"], +]; const css = readFileSync(TOKENS, "utf8"); @@ -118,6 +152,12 @@ function resolve(map, name, depth = 0) { const modes = declarationsByMode(); const failures = []; const skipped = []; +/** + * Pairing-scoped exceptions this run actually needed. An exception nobody hits + * is either fixed or wrong, and either way should not sit in the list + * unchallenged — same reasoning as the type guard's stale-override report. + */ +const claimedExceptions = new Set(); for (const [mode, map] of Object.entries(modes)) { const check = (textRole, surfaceRole) => { @@ -132,7 +172,12 @@ for (const [mode, map] of Object.entries(modes)) { const lc = Math.abs(apcaContrast(text, surface)); const target = META_ROLES.has(textRole) ? TARGET_META : TARGET_BODY; if (lc >= target) return; + // Role-wide first, then the narrow `role on surface` form. if (EXCEPTIONS.has(textRole)) return; + if (EXCEPTIONS.has(`${textRole} on ${surfaceRole}`)) { + claimedExceptions.add(`${textRole} on ${surfaceRole}`); + return; + } failures.push({ mode, textRole, @@ -149,10 +194,7 @@ for (const [mode, map] of Object.entries(modes)) { for (const surface of SURFACES) check(role, surface); } for (const [role, fill] of PAIRS) check(role, fill); - for (const family of TINT_FAMILIES) { - check(`--text-${family}`, `--bg-${family}-tint`); - check(`--text-${family}`, `--bg-${family}-tint-hover`); - } + for (const [text, tint] of TINT_PAIRS) check(text, tint); } // Guard the maths itself: if these drift, every verdict above is wrong. @@ -198,3 +240,11 @@ console.log("✓ Contrast: every text role clears its APCA target in both modes" for (const [role, why] of EXCEPTIONS) { console.log(` (exception) ${role} — ${why.split(";")[0]}`); } + +const stale = [...EXCEPTIONS.keys()].filter( + (key) => key.includes(" on ") && !claimedExceptions.has(key), +); +if (stale.length > 0) { + console.log("\nℹ Pairing exceptions no longer needed — delete them:"); + for (const key of stale) console.log(` ${key}`); +} diff --git a/desktop-new/scripts/token-consumers.mjs b/desktop-new/scripts/token-consumers.mjs new file mode 100644 index 0000000000..fc81abd9f4 --- /dev/null +++ b/desktop-new/scripts/token-consumers.mjs @@ -0,0 +1,143 @@ +/** + * Census: who actually reads each colour role? + * + * Not a guard — it never fails a build, and it is not in `pnpm check`. Run it by + * hand when deciding whether a role earns its name: + * + * node scripts/token-consumers.mjs + * + * It is deliberately advisory. "Zero consumers" is an argument for deleting a + * role, not a verdict: a token can be legitimately unused for a week because the + * screen that needs it is half-built. What it does do is make the argument + * checkable, which is how the role layer went from 54 names to 29 — eighteen had + * no reader anywhere, and six were read only by the pages documenting them. + * + * It counts two kinds of reader, because a role can be reached two ways: + * + * 1. `var(--bg-info)` in a stylesheet + * 2. the Tailwind utility the role registers as — `bg-info` in a component + * + * Documentation is counted separately from product code, and that separation is + * the point: a /design page rendering a swatch of `bg-info` proves the token + * exists and nothing more. Nineteen of the twenty deleted status roles were + * "used" in exactly that sense. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname; +const SRC = join(ROOT, "src"); +const TOKENS = join(SRC, "shared/styles/tokens.css"); + +/** Files that describe the system rather than use it. */ +const isDocs = (path) => + path.includes("features/design-system/") || + path.includes("shared/styles/design-components.css") || + path.includes("shared/tokens/registry.ts"); + +function walk(dir, out = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.(css|tsx?|ts)$/.test(full)) out.push(full); + } + return out; +} + +const css = readFileSync(TOKENS, "utf8"); + +// Role declarations live outside `@theme`; registrations live inside it. +const themeStart = css.indexOf("@theme inline {"); +const roleBlock = css.slice(0, themeStart); +const themeBlock = css.slice(themeStart); + +const roles = new Set( + [...roleBlock.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm)].map( + (m) => m[1], + ), +); + +// What Tailwind class does each role register as? `--color-info: var(--bg-info)` +// means `bg-info` / `text-info` reach `--bg-info`. +// Order matters: `--text-color-error` must match the `text-color` namespace, not +// `text` with the suffix `color-error`. Longest prefix first. +const NAMESPACES = ["text-color", "border-color", "color"]; +const registrations = new Map(); // role -> [{ ns, suffix }] +for (const [, ns, suffix, role] of themeBlock.matchAll( + new RegExp( + String.raw`^\s*--(${NAMESPACES.join("|")})-([a-z0-9-]+):\s*var\((--[a-z0-9-]+)\)`, + "gm", + ), +)) { + if (!registrations.has(role)) registrations.set(role, []); + registrations.get(role).push({ ns, suffix }); +} + +const files = walk(SRC).filter((f) => f !== TOKENS); +const counts = new Map([...roles].map((r) => [r, { product: [], docs: [] }])); + +for (const file of files) { + const text = readFileSync(file, "utf8"); + const rel = relative(SRC, file); + const bucket = isDocs(file) ? "docs" : "product"; + + for (const role of roles) { + const hits = []; + if (text.includes(`var(${role})`)) hits.push("var()"); + + for (const { ns, suffix } of registrations.get(role) ?? []) { + // `--color-info` is reachable as bg-info, text-info, border-info… + const prefixes = + ns === "color" + ? [ + "bg", + "text", + "border", + "fill", + "stroke", + "ring", + "outline", + "shadow", + "from", + "to", + "via", + ] + : ns === "border-color" + ? ["border"] + : ["text"]; // text-color + for (const p of prefixes) { + // Word-bounded so `bg-info` does not match `bg-info-tint`. + if (new RegExp(`\\b(?:[a-z-]+:)?${p}-${suffix}\\b(?!-)`).test(text)) { + hits.push(`${p}-${suffix}`); + break; + } + } + } + + if (hits.length) + counts + .get(role) + [bucket].push(`${rel} (${[...new Set(hits)].join(", ")})`); + } +} + +const dead = []; +const docsOnly = []; +const live = []; +for (const [role, { product, docs }] of counts) { + if (product.length) live.push([role, product]); + else if (docs.length) docsOnly.push([role, docs]); + else dead.push(role); +} + +console.log(`\n${roles.size} roles declared.\n`); +console.log(`── Zero readers anywhere (${dead.length}) ──`); +for (const role of dead.sort()) console.log(` ${role}`); +console.log(`\n── Read only by the docs (${docsOnly.length}) ──`); +for (const [role, where] of docsOnly.sort()) + console.log(` ${role} ← ${where.join("; ")}`); +console.log(`\n── Read by product code (${live.length}) ──`); +for (const [role, where] of live.sort((a, b) => a[1].length - b[1].length)) { + console.log(` ${role} ×${where.length}`); + for (const w of where) console.log(` ${w}`); +} diff --git a/desktop-new/src/shared/styles/tokens.css b/desktop-new/src/shared/styles/tokens.css index ed83889bcc..014fe41357 100644 --- a/desktop-new/src/shared/styles/tokens.css +++ b/desktop-new/src/shared/styles/tokens.css @@ -3,10 +3,10 @@ * * Four layers, and only the role layer is ever used when building a screen: * - * LAYER 0 palette --palette-purple-3 every hue, twelve steps, both modes - * LAYER 1 families --accent-tint the five jobs a hue does - * LAYER 2 roles --bg-panel what a thing IS in the interface - * LAYER 3 components bg-panel Tailwind utilities + * LAYER 0 palette --purple-3, --gradient-sky-field raw visual values + * LAYER 1 semantics --accent-tint, --gradient-1 stable selections + * LAYER 2 roles --bg-panel what a thing IS + * LAYER 3 components bg-panel Tailwind utilities * * The palette exists because the layer above it was 114 hand-picked hex values. * Nothing enforced that two tokens doing the same job agreed, and they drifted: @@ -32,7 +32,10 @@ * shade. Reach for a different step. `scripts/check-color.mjs` enforces this. * - Transparency for genuine see-through — glass — is a separate axis, and * lives baked into its own values. - * - Every token holds a light and a dark value under the same name. + * - Roles and semantic selections hold a light and a dark value under the + * same name. A named palette treatment is deliberately mode-specific when + * its paired dark scene is a different visual idea; `sky-field` does not + * pretend it is `night-garden` after dark mode switches on. * * Palette values are Radix Colors (MIT), transcribed rather than depended on. * Radix is not on Block's Tech Radar; this is a values-only copy, no package. @@ -51,230 +54,206 @@ :root { /* gray */ - --palette-gray-1: #fcfcfc; /* app background */ - --palette-gray-2: #f9f9f9; /* subtle background */ - --palette-gray-3: #f0f0f0; /* component background */ - --palette-gray-4: #e8e8e8; /* component hover */ - --palette-gray-5: #e0e0e0; /* component selected */ - --palette-gray-6: #d9d9d9; /* subtle border */ - --palette-gray-7: #cecece; /* border */ - --palette-gray-8: #bbbbbb; /* border hover */ - --palette-gray-9: #8d8d8d; /* solid fill */ - --palette-gray-10: #838383; /* solid fill hover */ - --palette-gray-11: #646464; /* low-contrast text */ - --palette-gray-12: #202020; /* high-contrast text */ /* purple */ - --palette-purple-1: #fefcfe; - --palette-purple-2: #fbf7fe; - --palette-purple-3: #f7edfe; - --palette-purple-4: #f2e2fc; - --palette-purple-5: #ead5f9; - --palette-purple-6: #e0c4f4; - --palette-purple-7: #d1afec; - --palette-purple-8: #be93e4; - --palette-purple-9: #8e4ec6; - --palette-purple-10: #8347b9; - --palette-purple-11: #8145b5; - --palette-purple-12: #402060; + --purple-1: #fefcfe; + --purple-2: #fbf7fe; + --purple-3: #f7edfe; + --purple-4: #f2e2fc; + --purple-5: #ead5f9; + --purple-6: #e0c4f4; + --purple-7: #d1afec; + --purple-8: #be93e4; + --purple-9: #8e4ec6; + --purple-10: #8347b9; + --purple-11: #8145b5; + --purple-12: #402060; /* red */ - --palette-red-1: #fffcfc; - --palette-red-2: #fff7f7; - --palette-red-3: #feebec; - --palette-red-4: #ffdbdc; - --palette-red-5: #ffcdce; - --palette-red-6: #fdbdbe; - --palette-red-7: #f4a9aa; - --palette-red-8: #eb8e90; - --palette-red-9: #e5484d; - --palette-red-10: #dc3e42; - --palette-red-11: #ce2c31; - --palette-red-12: #641723; + --red-1: #fffcfc; + --red-2: #fff7f7; + --red-3: #feebec; + --red-4: #ffdbdc; + --red-5: #ffcdce; + --red-6: #fdbdbe; + --red-7: #f4a9aa; + --red-8: #eb8e90; + --red-9: #e5484d; + --red-10: #dc3e42; + --red-11: #ce2c31; + --red-12: #641723; /* green */ - --palette-green-1: #fbfefc; - --palette-green-2: #f4fbf6; - --palette-green-3: #e6f6eb; - --palette-green-4: #d6f1df; - --palette-green-5: #c4e8d1; - --palette-green-6: #adddc0; - --palette-green-7: #8eceaa; - --palette-green-8: #5bb98b; - --palette-green-9: #30a46c; - --palette-green-10: #2b9a66; - --palette-green-11: #218358; - --palette-green-12: #193b2d; + --green-1: #fbfefc; + --green-2: #f4fbf6; + --green-3: #e6f6eb; + --green-4: #d6f1df; + --green-5: #c4e8d1; + --green-6: #adddc0; + --green-7: #8eceaa; + --green-8: #5bb98b; + --green-9: #30a46c; + --green-10: #2b9a66; + --green-11: #218358; + --green-12: #193b2d; /* amber */ - --palette-amber-1: #fefdfb; - --palette-amber-2: #fefbe9; - --palette-amber-3: #fff7c2; - --palette-amber-4: #ffee9c; - --palette-amber-5: #fbe577; - --palette-amber-6: #f3d673; - --palette-amber-7: #e9c162; - --palette-amber-8: #e2a336; - --palette-amber-9: #ffc53d; - --palette-amber-10: #ffba18; - --palette-amber-11: #ab6400; - --palette-amber-12: #4f3422; + --amber-1: #fefdfb; + --amber-2: #fefbe9; + --amber-3: #fff7c2; + --amber-4: #ffee9c; + --amber-5: #fbe577; + --amber-6: #f3d673; + --amber-7: #e9c162; + --amber-8: #e2a336; + --amber-9: #ffc53d; + --amber-10: #ffba18; + --amber-11: #ab6400; + --amber-12: #4f3422; /* blue */ - --palette-blue-1: #fbfdff; - --palette-blue-2: #f4faff; - --palette-blue-3: #e6f4fe; - --palette-blue-4: #d5efff; - --palette-blue-5: #c2e5ff; - --palette-blue-6: #acd8fc; - --palette-blue-7: #8ec8f6; - --palette-blue-8: #5eb1ef; - --palette-blue-9: #0090ff; - --palette-blue-10: #0588f0; - --palette-blue-11: #0d74ce; - --palette-blue-12: #113264; + --blue-1: #fbfdff; + --blue-2: #f4faff; + --blue-3: #e6f4fe; + --blue-4: #d5efff; + --blue-5: #c2e5ff; + --blue-6: #acd8fc; + --blue-7: #8ec8f6; + --blue-8: #5eb1ef; + --blue-9: #0090ff; + --blue-10: #0588f0; + --blue-11: #0d74ce; + --blue-12: #113264; /* cyan */ - --palette-cyan-1: #fafdfe; - --palette-cyan-2: #f2fafb; - --palette-cyan-3: #def7f9; - --palette-cyan-4: #caf1f6; - --palette-cyan-5: #b5e9f0; - --palette-cyan-6: #9ddde7; - --palette-cyan-7: #7dcedc; - --palette-cyan-8: #3db9cf; - --palette-cyan-9: #00a2c7; - --palette-cyan-10: #0797b9; - --palette-cyan-11: #107d98; - --palette-cyan-12: #0d3c48; + --cyan-1: #fafdfe; + --cyan-2: #f2fafb; + --cyan-3: #def7f9; + --cyan-4: #caf1f6; + --cyan-5: #b5e9f0; + --cyan-6: #9ddde7; + --cyan-7: #7dcedc; + --cyan-8: #3db9cf; + --cyan-9: #00a2c7; + --cyan-10: #0797b9; + --cyan-11: #107d98; + --cyan-12: #0d3c48; /* orange */ - --palette-orange-1: #fefcfb; - --palette-orange-2: #fff7ed; - --palette-orange-3: #ffefd6; - --palette-orange-4: #ffdfb5; - --palette-orange-5: #ffd19a; - --palette-orange-6: #ffc182; - --palette-orange-7: #f5ae73; - --palette-orange-8: #ec9455; - --palette-orange-9: #f76b15; - --palette-orange-10: #ef5f00; - --palette-orange-11: #cc4e00; - --palette-orange-12: #582d1d; + --orange-1: #fefcfb; + --orange-2: #fff7ed; + --orange-3: #ffefd6; + --orange-4: #ffdfb5; + --orange-5: #ffd19a; + --orange-6: #ffc182; + --orange-7: #f5ae73; + --orange-8: #ec9455; + --orange-9: #f76b15; + --orange-10: #ef5f00; + --orange-11: #cc4e00; + --orange-12: #582d1d; } .dark { /* gray */ - --palette-gray-1: #111111; /* app background */ - --palette-gray-2: #191919; /* subtle background */ - --palette-gray-3: #222222; /* component background */ - --palette-gray-4: #2a2a2a; /* component hover */ - --palette-gray-5: #313131; /* component selected */ - --palette-gray-6: #3a3a3a; /* subtle border */ - --palette-gray-7: #484848; /* border */ - --palette-gray-8: #606060; /* border hover */ - --palette-gray-9: #6e6e6e; /* solid fill */ - --palette-gray-10: #7b7b7b; /* solid fill hover */ - --palette-gray-11: #b4b4b4; /* low-contrast text */ - --palette-gray-12: #eeeeee; /* high-contrast text */ /* purple */ - --palette-purple-1: #18111b; - --palette-purple-2: #1e1523; - --palette-purple-3: #301c3b; - --palette-purple-4: #3d224e; - --palette-purple-5: #48295c; - --palette-purple-6: #54346b; - --palette-purple-7: #664282; - --palette-purple-8: #8457aa; - --palette-purple-9: #8e4ec6; - --palette-purple-10: #9a5cd0; - --palette-purple-11: #d19dff; - --palette-purple-12: #ecd9fa; + --purple-1: #18111b; + --purple-2: #1e1523; + --purple-3: #301c3b; + --purple-4: #3d224e; + --purple-5: #48295c; + --purple-6: #54346b; + --purple-7: #664282; + --purple-8: #8457aa; + --purple-9: #8e4ec6; + --purple-10: #9a5cd0; + --purple-11: #d19dff; + --purple-12: #ecd9fa; /* red */ - --palette-red-1: #191111; - --palette-red-2: #201314; - --palette-red-3: #3b1219; - --palette-red-4: #500f1c; - --palette-red-5: #611623; - --palette-red-6: #72232d; - --palette-red-7: #8c333a; - --palette-red-8: #b0484f; - --palette-red-9: #e5484d; - --palette-red-10: #ec5d5e; - --palette-red-11: #ff9592; - --palette-red-12: #ffd1d9; + --red-1: #191111; + --red-2: #201314; + --red-3: #3b1219; + --red-4: #500f1c; + --red-5: #611623; + --red-6: #72232d; + --red-7: #8c333a; + --red-8: #b0484f; + --red-9: #e5484d; + --red-10: #ec5d5e; + --red-11: #ff9592; + --red-12: #ffd1d9; /* green */ - --palette-green-1: #0e1512; - --palette-green-2: #121b17; - --palette-green-3: #132d21; - --palette-green-4: #113b29; - --palette-green-5: #174933; - --palette-green-6: #20573e; - --palette-green-7: #28714f; - --palette-green-8: #2f7c57; - --palette-green-9: #30a46c; - --palette-green-10: #33b074; - --palette-green-11: #3dd68c; - --palette-green-12: #b1f1cb; + --green-1: #0e1512; + --green-2: #121b17; + --green-3: #132d21; + --green-4: #113b29; + --green-5: #174933; + --green-6: #20573e; + --green-7: #28714f; + --green-8: #2f7c57; + --green-9: #30a46c; + --green-10: #33b074; + --green-11: #3dd68c; + --green-12: #b1f1cb; /* amber */ - --palette-amber-1: #16120c; - --palette-amber-2: #1d180f; - --palette-amber-3: #302008; - --palette-amber-4: #3f2700; - --palette-amber-5: #4d3000; - --palette-amber-6: #5c3d05; - --palette-amber-7: #714f19; - --palette-amber-8: #8f6424; - --palette-amber-9: #ffc53d; - --palette-amber-10: #ffd60a; - --palette-amber-11: #ffca16; - --palette-amber-12: #ffe7b3; + --amber-1: #16120c; + --amber-2: #1d180f; + --amber-3: #302008; + --amber-4: #3f2700; + --amber-5: #4d3000; + --amber-6: #5c3d05; + --amber-7: #714f19; + --amber-8: #8f6424; + --amber-9: #ffc53d; + --amber-10: #ffd60a; + --amber-11: #ffca16; + --amber-12: #ffe7b3; /* blue */ - --palette-blue-1: #0d1520; - --palette-blue-2: #111927; - --palette-blue-3: #0d2847; - --palette-blue-4: #003362; - --palette-blue-5: #004074; - --palette-blue-6: #104d87; - --palette-blue-7: #205d9e; - --palette-blue-8: #2870bd; - --palette-blue-9: #0090ff; - --palette-blue-10: #3b9eff; - --palette-blue-11: #70b8ff; - --palette-blue-12: #c2e6ff; + --blue-1: #0d1520; + --blue-2: #111927; + --blue-3: #0d2847; + --blue-4: #003362; + --blue-5: #004074; + --blue-6: #104d87; + --blue-7: #205d9e; + --blue-8: #2870bd; + --blue-9: #0090ff; + --blue-10: #3b9eff; + --blue-11: #70b8ff; + --blue-12: #c2e6ff; /* cyan */ - --palette-cyan-1: #0b161a; - --palette-cyan-2: #101b20; - --palette-cyan-3: #082c36; - --palette-cyan-4: #003848; - --palette-cyan-5: #004558; - --palette-cyan-6: #045468; - --palette-cyan-7: #12677e; - --palette-cyan-8: #11809c; - --palette-cyan-9: #00a2c7; - --palette-cyan-10: #23afd0; - --palette-cyan-11: #4ccce6; - --palette-cyan-12: #b6ecf7; + --cyan-1: #0b161a; + --cyan-2: #101b20; + --cyan-3: #082c36; + --cyan-4: #003848; + --cyan-5: #004558; + --cyan-6: #045468; + --cyan-7: #12677e; + --cyan-8: #11809c; + --cyan-9: #00a2c7; + --cyan-10: #23afd0; + --cyan-11: #4ccce6; + --cyan-12: #b6ecf7; /* orange */ - --palette-orange-1: #17120e; - --palette-orange-2: #1e160f; - --palette-orange-3: #331e0b; - --palette-orange-4: #462100; - --palette-orange-5: #562800; - --palette-orange-6: #66350c; - --palette-orange-7: #7e451d; - --palette-orange-8: #a35829; - --palette-orange-9: #f76b15; - --palette-orange-10: #ff801f; - --palette-orange-11: #ffa057; - --palette-orange-12: #ffe0c2; + --orange-1: #17120e; + --orange-2: #1e160f; + --orange-3: #331e0b; + --orange-4: #462100; + --orange-5: #562800; + --orange-6: #66350c; + --orange-7: #7e451d; + --orange-8: #a35829; + --orange-9: #f76b15; + --orange-10: #ff801f; + --orange-11: #ffa057; + --orange-12: #ffe0c2; } /* ============================================================ @@ -289,67 +268,18 @@ really are a scale, and every step is in use. Step 1 stays pure white rather than the palette's off-white, because the app background is a deliberate choice, not a grey. */ - --neutral-1: #ffffff; /* app background — not palette-gray-1 */ - --neutral-2: var(--palette-gray-2); /* subtle background */ - --neutral-3: var(--palette-gray-3); /* component background */ - --neutral-4: var(--palette-gray-4); /* component hover */ - --neutral-5: var(--palette-gray-5); /* component selected */ - --neutral-6: var(--palette-gray-6); /* subtle border */ - --neutral-7: var(--palette-gray-7); /* border */ - --neutral-8: var(--palette-gray-8); /* border hover */ - --neutral-9: var(--palette-gray-9); /* solid fill, tertiary text */ - /* Steps 10-12 deliberately do NOT track palette gray 10-12. - Radix's 11 is "low-contrast text", sized to pass on its own step-2 - background. This system points --text-secondary at step 10 and asks it to - stay readable on bg-inset and bg-hover, which are darker — the guard - measured palette-gray-10 at Lc 56.5 there against a 60 target. So - secondary text takes a darker step than the palette's naming suggests, - and the inverse-fill steps keep the near-black they were sized for. */ - --neutral-10: var(--palette-gray-11); /* secondary text */ - --neutral-11: #262626; /* dark fill — sized against bg-inverse */ - --neutral-12: #0a0a0a; /* high-contrast text */ - - /* --- Accent. A slot, not a colour. ----------------------- - Nothing above this family knows the hue. Point these five at - a different palette hue to change the accent; every role - below follows. Current hue: purple. - - Text takes step 12, not step 11. Radix sizes step 11 for a - WCAG 4.5:1 target; this system judges with APCA at Lc 60, and - every hue's step 11 measured Lc 55-61 on the surfaces these - roles must clear — legal by WCAG, short of our target. Step 12 - clears every pairing in both modes with room to spare. The - guard found this; do not lower it back to 11. */ - --accent-tint: var(--palette-purple-3); /* tinted surface */ - --accent-tint-hover: var(--palette-purple-4); /* tint hover */ - --accent-border: var(--palette-purple-8); /* border, focus ring */ - --accent-fill: var(--palette-purple-9); /* solid fill */ - --accent-text: var(--palette-purple-12); /* accent text */ - - /* --- Status. The same five jobs, per family. -------------- */ - --danger-tint: var(--palette-red-3); - --danger-tint-hover: var(--palette-red-4); - --danger-border: var(--palette-red-8); - --danger-fill: var(--palette-red-9); - --danger-text: var(--palette-red-12); - - --success-tint: var(--palette-green-3); - --success-tint-hover: var(--palette-green-4); - --success-border: var(--palette-green-8); - --success-fill: var(--palette-green-9); - --success-text: var(--palette-green-12); - - --warning-tint: var(--palette-amber-3); - --warning-tint-hover: var(--palette-amber-4); - --warning-border: var(--palette-amber-8); - --warning-fill: var(--palette-amber-9); - --warning-text: var(--palette-amber-12); - - --info-tint: var(--palette-blue-3); - --info-tint-hover: var(--palette-blue-4); - --info-border: var(--palette-blue-8); - --info-fill: var(--palette-blue-9); - --info-text: var(--palette-blue-12); + --neutral-1: #ffffff; /* app background */ + --neutral-2: #f9f9f9; /* subtle background */ + --neutral-3: #f0f0f0; /* component background */ + --neutral-4: #e8e8e8; /* component hover */ + --neutral-5: #e0e0e0; /* component selected */ + --neutral-6: #d9d9d9; /* subtle border */ + --neutral-7: #cecece; /* border */ + --neutral-8: #bbbbbb; /* border hover */ + --neutral-9: #8d8d8d; /* solid fill, tertiary text */ + --neutral-10: #646464; /* secondary text */ + --neutral-11: #262626; /* dark fill */ + --neutral-12: #0a0a0a; /* high-contrast text */ /* --- Glass. A translucency ramp of fills. ---------------- */ --glass-1: #ffffff5c; /* barely there */ @@ -358,10 +288,23 @@ --glass-4: #ffffffc7; /* glass hovered */ --glass-5: #ffffffe0; /* nearly solid */ - /* --- Gradients. Numbered background treatments. ---------- */ - --gradient-1: - radial-gradient(at 85% 88%, #eef2d4 0%, transparent 62%), - linear-gradient(158deg, #f5f7f8 0%, #dfeaf3 52%, #e9efe0 100%); + /* --- Backdrop treatments. Named visual values, not colours. + These treatments only exist in the mode where their names are true. The + semantic pairings below provide the stable cross-mode selection. -------- */ + --gradient-sky-field: + radial-gradient(at 88% 84%, #f5e99e 0%, transparent 60%), + linear-gradient(148deg, #f8fbff 0%, #c6e8fb 50%, #d9f0d5 100%); + --gradient-peach-field: + radial-gradient(at 8% 85%, #fac8d3 0%, transparent 60%), + linear-gradient(150deg, #fff9f7 0%, #f5d5df 48%, #d5eafa 100%); + --gradient-blue-hour: + radial-gradient(at 12% 14%, #bfeefa 0%, transparent 56%), + radial-gradient(at 88% 86%, #ddd0fa 0%, transparent 61%), + linear-gradient(153deg, #f6f9ff 0%, #d6e4fb 51%, #eee4f7 100%); + --gradient-orchid-field: + radial-gradient(at 14% 87%, #fac8e8 0%, transparent 57%), + radial-gradient(at 85% 14%, #d9d4ff 0%, transparent 57%), + linear-gradient(154deg, #fbf8ff 0%, #e9ddf8 50%, #dce8fb 100%); /* --- Texture. Separate from colour and gradient. --------- */ --texture-dots-color: #0a0a0a0f; @@ -440,29 +383,110 @@ --glass-4: #1c1c1cc7; --glass-5: #1c1c1ce0; - --gradient-1: - radial-gradient(at 85% 88%, #23261a 0%, transparent 62%), - linear-gradient(158deg, #15171b 0%, #172231 52%, #191d19 100%); + /* Dark treatments are their own scenes, not dimmed versions of their light + counterparts. Their scene pairing lives in the semantic layer below. */ + --gradient-night-garden: + radial-gradient( + ellipse 110% 100% at 52% 50%, + transparent 34%, + rgba(2, 11, 13, 0.42) 100% + ), + radial-gradient(at 88% 16%, rgba(52, 211, 153, 0.56) 0%, transparent 42%), + radial-gradient(at 12% 86%, rgba(56, 189, 248, 0.52) 0%, transparent 46%), + radial-gradient(at 52% 48%, rgba(6, 95, 70, 0.3) 0%, transparent 78%), + linear-gradient(153deg, #071311 0%, #0a2929 51%, #081722 100%); + --gradient-signal-flare: + radial-gradient( + ellipse 110% 100% at 52% 50%, + transparent 34%, + rgba(16, 4, 7, 0.42) 100% + ), + radial-gradient(at 14% 14%, rgba(245, 158, 11, 0.6) 0%, transparent 40%), + radial-gradient(at 88% 88%, rgba(244, 63, 94, 0.56) 0%, transparent 47%), + radial-gradient(at 52% 48%, rgba(154, 52, 18, 0.3) 0%, transparent 78%), + linear-gradient(158deg, #160e09 0%, #351315 52%, #190915 100%); + --gradient-electric-dusk: + radial-gradient( + ellipse 110% 100% at 52% 50%, + transparent 34%, + rgba(3, 6, 16, 0.4) 100% + ), + radial-gradient(at 12% 12%, rgba(34, 211, 238, 0.58) 0%, transparent 42%), + radial-gradient(at 88% 92%, rgba(124, 58, 237, 0.63) 0%, transparent 48%), + radial-gradient(at 48% 40%, rgba(37, 99, 235, 0.3) 0%, transparent 78%), + linear-gradient(158deg, #0a0d16 0%, #121d38 50%, #180d2b 100%); + --gradient-ultraviolet: + radial-gradient( + ellipse 110% 100% at 52% 50%, + transparent 34%, + rgba(8, 3, 16, 0.42) 100% + ), + radial-gradient(at 14% 88%, rgba(236, 72, 153, 0.57) 0%, transparent 43%), + radial-gradient(at 86% 14%, rgba(99, 102, 241, 0.62) 0%, transparent 44%), + radial-gradient(at 48% 52%, rgba(109, 40, 217, 0.28) 0%, transparent 79%), + linear-gradient(155deg, #100914 0%, #21113c 52%, #081622 100%); --texture-dots-color: #ffffff14; } /* ============================================================ - LAYER 2 — PUBLIC ROLES - The only layer a screen may use. Every value below points at - a ramp step, except the documented exceptions at the bottom. + LAYER 1 — SEMANTIC BACKDROP CHOICES + Numbered slots pair one named light treatment with one named + dark treatment. They are the appearance-preference seam: a + person selects `gradient-1`, not a light-only scene name. ============================================================ */ :root { - /* --- Structural surfaces. Named by region. --------------- - Ask one question: is it behind, on, above, or in? */ - --bg-app: var(--gradient-1); - --bg-panel: var(--neutral-1); - --bg-float: var(--neutral-1); - --bg-inset: var(--neutral-3); - --bg-hover: var(--neutral-4); + --gradient-1: var(--gradient-sky-field); + --gradient-2: var(--gradient-peach-field); + --gradient-3: var(--gradient-blue-hour); + --gradient-4: var(--gradient-orchid-field); +} - /* --- Emphasis. One ramp shared by text and borders. ------ */ +.dark { + --gradient-1: var(--gradient-night-garden); + --gradient-2: var(--gradient-signal-flare); + --gradient-3: var(--gradient-electric-dusk); + --gradient-4: var(--gradient-ultraviolet); +} + +/* ============================================================ + LAYER 2 — PUBLIC ROLES + The only layer a screen may use. Every value below points at + a ramp step or semantic backdrop choice, except the documented + exceptions at the bottom. + ============================================================ */ + +:root { + /* --- Structural surfaces. --------------------------------- + THE ROLES THAT EXIST BECAUSE A RAMP STEP CANNOT SAY THEM. + Each one takes a DIFFERENT step per mode, so no class like + `bg-neutral-1` is correct in both. That is the whole test for + whether a name is earned; everything that passed it is here. + + `bg-app` qualifies for the same reason in a different shape: + the gradient itself is swapped per mode. */ + --bg-app: var(--gradient-1); + --bg-panel: var(--neutral-1); /* dark: neutral-3 */ + --bg-float: var(--neutral-1); /* dark: neutral-5 */ + --bg-inset: var(--neutral-3); /* dark: neutral-2 */ + + /* `bg-hover` was here and is gone. It was neutral-4 in both modes, so + `bg-neutral-4` says it exactly. Its five call sites now do. + + Worth knowing before recreating it: hover means "one step more + contrast than whatever is underneath", which is a relationship, not + a value — hover on a panel and hover on an inset are different + numbers. A single token could never express that, so the name was + promising something it did not deliver. */ + + /* --- Emphasis. ------------------------------------------- + These are the exception to the rule above: every one is the + same step in both modes, so a class would work. They stay + because the NAME enforces something — there are deliberately + three levels of text and one border weight, and a ramp cannot + say "three". Reaching for `text-neutral-11` because it looks + right is how a fourth level appears without anyone deciding. */ --text-primary: var(--neutral-12); --text-secondary: var(--neutral-10); --text-tertiary: var(--neutral-9); @@ -472,62 +496,86 @@ information here that a person needs. */ --text-disabled: var(--neutral-8); - --border-primary: var(--neutral-7); - --border-secondary: var(--neutral-6); - --border-tertiary: var(--neutral-5); + /* One border, not three. `primary` names the sole deliberate weight, + and neutral-4 keeps it at the threshold of perception over both the + panel and the atmospheric backdrop. A second weight arrives only with + a design that proves a different boundary needs one. */ + --border-primary: var(--neutral-4); - /* --- Identity. Two arrangements only: SOLID fill with - paired text on it, or TINT fill with coloured text. */ - --bg-accent: var(--accent-fill); - --bg-accent-tint: var(--accent-tint); - --bg-accent-tint-hover: var(--accent-tint-hover); - --text-accent: var(--accent-text); - --border-accent: var(--accent-border); + /* --- Identity: deliberately absent. ---------------------- + There were six accent roles and every one held the same purple + step in both modes, so each was a name in front of a number — + `bg-accent` was `purple-9` and nothing else. The screens now say + `bg-purple-9`, `bg-purple-3`, `text-purple-12`, `border-purple-8`. - --bg-inverse: var(--neutral-11); - --bg-inverse-hover: var(--neutral-10); + `bg-inverse` went the same way (`neutral-11`), and so did its + paired text (`neutral-1`). - /* --- Status. Identical shape, four times over. ----------- */ - --bg-danger: var(--danger-fill); - --bg-danger-tint: var(--danger-tint); - --bg-danger-tint-hover: var(--danger-tint-hover); - --text-danger: var(--danger-text); - --border-danger: var(--danger-border); + The argument FOR naming them was that accent is a slot whose hue + could change. It is a real argument and it lost to a simpler fact: + the hue already changed once — the accent moved to Tailwind purple + — and it was five values in the ramp, not a rename. The ramp is + the slot. A role in front of it only adds a hop. - --bg-success: var(--success-fill); - --bg-success-tint: var(--success-tint); - --bg-success-tint-hover: var(--success-tint-hover); - --text-success: var(--success-text); - --border-success: var(--success-border); + When a repeated accent PATTERN appears — not a colour, a pattern, + like a tinted callout that turns up on four screens — that earns a + name, and it will be named for the pattern. */ - --bg-warning: var(--warning-fill); - --bg-warning-tint: var(--warning-tint); - --bg-warning-tint-hover: var(--warning-tint-hover); - --text-warning: var(--warning-text); - --border-warning: var(--warning-border); + /* --- Status: deliberately absent. ------------------------ + There were twenty roles here — danger, success, warning, info, five + each, the accent's shape copied four times. Nineteen of the twenty + had no reader outside the /design pages that displayed them, and the + one real consumer was a 8px presence dot. - --bg-info: var(--info-fill); - --bg-info-tint: var(--info-tint); - --bg-info-tint-hover: var(--info-tint-hover); - --text-info: var(--info-text); - --border-info: var(--info-border); + They were invented by symmetry rather than by need, and the symmetry + was actively misleading: the red ramp alone offers red-3, red-8, + red-9 and red-12 for "an error", and a set of pre-made names hid + that choice instead of making it. `text-danger` shipped pointing at + the wrong red for months (see the registration note below) and + nothing noticed, because no design had ever looked at it. + + Status colour gets designed when there is a screen to design it on. + Until then the ramps are right there: an error message picks its + step, and once two screens pick the same one it earns a name. The + hues stay in the palette — nothing was deleted from layer 0. + + Two things briefly had names here — `text-error` and + `bg-agent-running` — and they went too, for the same reason as the + accent: both were one step, identical in both modes. Error text is + `text-red-12` and the running dot is `bg-green-9`, written where + they are used. + + The measurement that chose red-12 is the part worth keeping, and it + lives in DESIGN.md rather than in a token: red-11 is the more + obviously red choice and fails the Lc 60 body target on a dark panel + (59.7) and the dark composer (57.5). */ /* --- Material. Translucency and blur live inside the value. - A component never assembles them. */ - --bg-chrome-glass: var(--glass-3); - --bg-chrome-glass-hover: var(--glass-4); - --bg-chrome-selected: var(--neutral-1); - --bg-panel-glass: var(--glass-2); - --bg-float-glass: var(--glass-3); + A component never assembles them. - /* --- Focus. ---------------------------------------------- - Legible on every neutral surface (WCAG 3.0–3.5:1), but it is an accent - against accent: on `bg-accent` it measures 1.1:1 and disappears. A focused - primary button therefore needs the ring separated from the fill — an offset - in the surface colour, not a different ring hue, so one focus treatment - works everywhere. Whatever component layer introduces focus owns that - offset; there is no second ring token. */ - --ring-focus: var(--accent-border); + Named by STACKING DEPTH, not by the component that uses it. + `primary` sits on the backdrop; `secondary` sits over something + already glass. Elevation reads as less translucency, so the + higher it sits the more opaque it gets — DESIGN.md § Surface + and depth. The `glass-primary` / `glass-secondary` utilities in + globals.css are the only legitimate readers: they pair each fill + with its blur, rim, and lift so a material cannot arrive in + pieces. */ + --bg-glass-primary: var(--glass-2); + --bg-glass-primary-hover: var(--glass-3); + --bg-glass-secondary: var(--glass-4); + --bg-glass-secondary-hover: var(--glass-5); + --bg-chrome-selected: var(--neutral-1); + + /* --- Focus: `purple-8`, written directly. ----------------- + Same step in both modes, so it was a name in front of a number too. + The judgement that survives is not the value: purple-8 is legible on + every neutral surface (WCAG 3.0–3.5:1) but measures 1.1:1 on + `purple-9` and disappears. **A focused primary button needs the ring + separated from the fill** — an offset in the surface colour, not a + different ring hue, so one focus treatment works everywhere. That + belongs to whichever component layer introduces focus. It is in + DESIGN.md, which is where a rule lives; a token could not state it. */ /* --- Geometry. Repeated relationships from Messages. ----- */ --space-workspace-inset: var(--space-4); @@ -535,6 +583,7 @@ --space-panel-inset: var(--space-5); --space-control-inset: var(--space-3); --space-row-gap: var(--space-2); + --space-section-gap: var(--space-4); /* An inline chip sits in a line of text, so its inset and gap are tighter than a control's and are its own repeated relationship. Both are below the 4px spacing floor because a chip hugs its label — the design draws 3px of @@ -568,23 +617,21 @@ purple fill and unreadable on yellow or lime. Until the computation lands, these hold literals. */ --text-on-accent: #ffffff; - --text-on-inverse: var(--neutral-1); - /* The status families need their paired text for the same reason accent does: - `bg-danger` is a saturated fill, and text on it must be the paired value - rather than whatever it inherits. The registry documented these four before - they were declared, so `text-on-danger` resolved to nothing and text on a - danger button fell back to near-black on red. The token table found it. - Literals for now, on the same exception as text-on-accent — they become - computed when the lightness rule lands. */ - --text-on-danger: #ffffff; - --text-on-success: #ffffff; - /* A literal, not `var(--neutral-12)`: the warning fill is the same amber in - both modes, so its paired text must be dark in both. neutral-12 flips to - near-white in dark mode, which put #f5f5f5 on #eab308 — APCA Lc 34, WCAG - 1.76. A paired-text token follows its fill, never the mode. */ - --text-on-warning: #0a0a0a; - --text-on-info: #ffffff; + /* `text-on-inverse` was `var(--neutral-1)` in both modes and went with + `bg-inverse`; its two call sites pair `bg-neutral-11` with + `text-neutral-1` directly. It is listed here rather than silently + dropped because the paired-text RULE below still governs that pair: + the text follows the fill, so if the fill moves off neutral-11 the + text is re-measured, not inherited. */ + + /* The four status paired-text roles went with their fills. Keep the finding + they produced, because it is the rule and not a bug report: a paired-text + token follows its FILL, never the mode. `--text-on-warning` was + `var(--neutral-12)`, which flips to near-white in dark mode and put #f5f5f5 + on the same amber — APCA Lc 34. The amber is identical in both modes, so its + text had to be dark in both. Whatever names status ends up with, they take + literals or a computed lightness, never a neutral step. */ /* 2. The glass rim. A directional light effect, not a solid line: bright along the lit edge, dimmer on the opposite @@ -620,65 +667,189 @@ @theme inline { --color-*: initial; + /* --------------------------------------------------------------- + THE RAMPS, REACHABLE DIRECTLY. + + `bg-neutral-4`, `text-red-12`, `border-purple-8`. This is the layer + a screen is built from now, and it is a deliberate reversal: the + colour guard used to reject exactly these classes. + + The reason it is safe here and was not safe in Tailwind's own + palette is that **every step is authored per mode.** `neutral-11` + is one value in light and another in dark, so a component naming a + step still behaves in both. Tailwind's `neutral-800` is a single + literal, which is why reaching for it used to break dark mode and + why a semantic name was the only correct move. + + What a semantic role is still FOR is the case a step cannot + express — see the four surface roles above, where light and dark + take different steps. Everything else was a name in front of a + number, and a name in front of a number hides the choice instead + of recording it. + --------------------------------------------------------------- */ + /* neutral */ + --color-neutral-1: var(--neutral-1); + --color-neutral-2: var(--neutral-2); + --color-neutral-3: var(--neutral-3); + --color-neutral-4: var(--neutral-4); + --color-neutral-5: var(--neutral-5); + --color-neutral-6: var(--neutral-6); + --color-neutral-7: var(--neutral-7); + --color-neutral-8: var(--neutral-8); + --color-neutral-9: var(--neutral-9); + --color-neutral-10: var(--neutral-10); + --color-neutral-11: var(--neutral-11); + --color-neutral-12: var(--neutral-12); + + /* purple */ + --color-purple-1: var(--purple-1); + --color-purple-2: var(--purple-2); + --color-purple-3: var(--purple-3); + --color-purple-4: var(--purple-4); + --color-purple-5: var(--purple-5); + --color-purple-6: var(--purple-6); + --color-purple-7: var(--purple-7); + --color-purple-8: var(--purple-8); + --color-purple-9: var(--purple-9); + --color-purple-10: var(--purple-10); + --color-purple-11: var(--purple-11); + --color-purple-12: var(--purple-12); + + /* red */ + --color-red-1: var(--red-1); + --color-red-2: var(--red-2); + --color-red-3: var(--red-3); + --color-red-4: var(--red-4); + --color-red-5: var(--red-5); + --color-red-6: var(--red-6); + --color-red-7: var(--red-7); + --color-red-8: var(--red-8); + --color-red-9: var(--red-9); + --color-red-10: var(--red-10); + --color-red-11: var(--red-11); + --color-red-12: var(--red-12); + + /* green */ + --color-green-1: var(--green-1); + --color-green-2: var(--green-2); + --color-green-3: var(--green-3); + --color-green-4: var(--green-4); + --color-green-5: var(--green-5); + --color-green-6: var(--green-6); + --color-green-7: var(--green-7); + --color-green-8: var(--green-8); + --color-green-9: var(--green-9); + --color-green-10: var(--green-10); + --color-green-11: var(--green-11); + --color-green-12: var(--green-12); + + /* amber */ + --color-amber-1: var(--amber-1); + --color-amber-2: var(--amber-2); + --color-amber-3: var(--amber-3); + --color-amber-4: var(--amber-4); + --color-amber-5: var(--amber-5); + --color-amber-6: var(--amber-6); + --color-amber-7: var(--amber-7); + --color-amber-8: var(--amber-8); + --color-amber-9: var(--amber-9); + --color-amber-10: var(--amber-10); + --color-amber-11: var(--amber-11); + --color-amber-12: var(--amber-12); + + /* blue */ + --color-blue-1: var(--blue-1); + --color-blue-2: var(--blue-2); + --color-blue-3: var(--blue-3); + --color-blue-4: var(--blue-4); + --color-blue-5: var(--blue-5); + --color-blue-6: var(--blue-6); + --color-blue-7: var(--blue-7); + --color-blue-8: var(--blue-8); + --color-blue-9: var(--blue-9); + --color-blue-10: var(--blue-10); + --color-blue-11: var(--blue-11); + --color-blue-12: var(--blue-12); + + /* cyan */ + --color-cyan-1: var(--cyan-1); + --color-cyan-2: var(--cyan-2); + --color-cyan-3: var(--cyan-3); + --color-cyan-4: var(--cyan-4); + --color-cyan-5: var(--cyan-5); + --color-cyan-6: var(--cyan-6); + --color-cyan-7: var(--cyan-7); + --color-cyan-8: var(--cyan-8); + --color-cyan-9: var(--cyan-9); + --color-cyan-10: var(--cyan-10); + --color-cyan-11: var(--cyan-11); + --color-cyan-12: var(--cyan-12); + + /* orange */ + --color-orange-1: var(--orange-1); + --color-orange-2: var(--orange-2); + --color-orange-3: var(--orange-3); + --color-orange-4: var(--orange-4); + --color-orange-5: var(--orange-5); + --color-orange-6: var(--orange-6); + --color-orange-7: var(--orange-7); + --color-orange-8: var(--orange-8); + --color-orange-9: var(--orange-9); + --color-orange-10: var(--orange-10); + --color-orange-11: var(--orange-11); + --color-orange-12: var(--orange-12); + --color-app: var(--bg-app); --color-panel: var(--bg-panel); --color-float: var(--bg-float); --color-inset: var(--bg-inset); - --color-hover: var(--bg-hover); - --color-primary: var(--text-primary); - --color-secondary: var(--text-secondary); - --color-tertiary: var(--text-tertiary); - --color-disabled: var(--text-disabled); + /* TEXT COLOUR REGISTERS IN ITS OWN NAMESPACE, like borders below. + Not `--color-*`. - /* Borders register in their OWN namespace, not `--color-*`. - Text and borders share the emphasis names — primary, secondary, tertiary — - but hold different values: text is the dark end of the neutral ramp - (12/10/9), borders the light end (7/6/5). `--color-*` is a single namespace, - so registering borders there would make `border-primary` resolve to the text - colour and draw every hairline at near-black. It did exactly that until - these three lines existed. */ + `--color-x` is shared by every colour prefix at once: it makes `bg-x`, + `text-x`, `border-x`, `ring-x` and the rest, all pointing at one value. So + the moment two roles differ only by which prefix uses them, that namespace + picks one and silently drops the other. + + It had already happened twice. Borders were the first, fixed below. Text + colour was the second and shipped: `--color-danger: var(--bg-danger)` also + defined `text-danger`, so error text rendered in red-9 — the saturated fill + — instead of red-12. On a dark panel that measured APCA Lc 34 against a + target of 60, and `check-contrast` passed the whole time because it audited + `--text-danger`, a token no class could reach. + + A guard measuring a token nothing resolves to is worse than no guard, so + every text role now registers as `--text-color-*` and is what it says. */ + --text-color-primary: var(--text-primary); + --text-color-secondary: var(--text-secondary); + --text-color-tertiary: var(--text-tertiary); + --text-color-disabled: var(--text-disabled); + --text-color-on-accent: var(--text-on-accent); + + /* Borders register in their OWN namespace for the same reason — this is where + the collision was found first. Text and borders shared the emphasis names, + holding different values: text the dark end of the neutral ramp, borders the + light end. Registered under `--color-*`, `border-primary` resolved to the + text colour and drew every hairline at near-black. It did exactly that until + this line existed. */ --border-color-primary: var(--border-primary); - --border-color-secondary: var(--border-secondary); - --border-color-tertiary: var(--border-tertiary); - --color-accent: var(--bg-accent); - --color-accent-tint: var(--bg-accent-tint); - --color-accent-tint-hover: var(--bg-accent-tint-hover); - --color-on-accent: var(--text-on-accent); + /* `--color-*` now registers backgrounds only, so `bg-accent` is the one + utility each of these makes. Text colour is `--text-color-*` above. */ - --color-inverse: var(--bg-inverse); - --color-inverse-hover: var(--bg-inverse-hover); - --color-on-inverse: var(--text-on-inverse); - - --color-danger: var(--bg-danger); - --color-danger-tint: var(--bg-danger-tint); - --color-danger-tint-hover: var(--bg-danger-tint-hover); - --color-on-danger: var(--text-on-danger); - --color-success: var(--bg-success); - --color-success-tint: var(--bg-success-tint); - --color-success-tint-hover: var(--bg-success-tint-hover); - --color-on-success: var(--text-on-success); - --color-on-warning: var(--text-on-warning); - --color-on-info: var(--text-on-info); - --color-warning: var(--bg-warning); - --color-warning-tint: var(--bg-warning-tint); - --color-warning-tint-hover: var(--bg-warning-tint-hover); - --color-info: var(--bg-info); - --color-info-tint: var(--bg-info-tint); - --color-info-tint-hover: var(--bg-info-tint-hover); - - --color-chrome-glass: var(--bg-chrome-glass); - --color-chrome-glass-hover: var(--bg-chrome-glass-hover); + /* The glass fills are deliberately NOT registered as Tailwind colour + utilities. A `bg-glass-primary` class would be the fill without its + blur, rim, or lift — the exact failure the materials exist to prevent. + Use the `glass-primary` / `glass-secondary` utilities instead. */ --color-chrome-selected: var(--bg-chrome-selected); - --color-panel-glass: var(--bg-panel-glass); - --color-float-glass: var(--bg-float-glass); --spacing-workspace-inset: var(--space-workspace-inset); --spacing-panel-gap: var(--space-panel-gap); --spacing-panel-inset: var(--space-panel-inset); --spacing-control-inset: var(--space-control-inset); --spacing-row-gap: var(--space-row-gap); + --spacing-section-gap: var(--space-section-gap); --spacing-chip-inset: var(--space-chip-inset); --spacing-chip-gap: var(--space-chip-gap); } diff --git a/desktop-new/src/shared/tokens/registry.test.ts b/desktop-new/src/shared/tokens/registry.test.ts new file mode 100644 index 0000000000..9e2237e37d --- /dev/null +++ b/desktop-new/src/shared/tokens/registry.test.ts @@ -0,0 +1,142 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { PALETTE, RAMPS, ROLE_GROUPS } from "./registry"; + +/** + * `tokens.css` as text. + * + * Read with `node:fs` rather than Vite's `import.meta.glob(..., "?raw")`, which + * the sibling component-registry test uses for `.tsx` sources. On a `.css` file + * that returns an **empty string** — Vite hands it to the CSS pipeline and the + * raw query yields nothing, with no error. Every assertion below would have + * passed vacuously against empty text, which is the exact failure mode this file + * exists to catch, so the length is asserted before anything else. + */ +const TOKENS = readFileSync( + new URL("../styles/tokens.css", import.meta.url), + "utf8", +); + +/** Declarations outside `@theme`: the role and palette layers. */ +const DECLARATIONS = TOKENS.slice(0, TOKENS.indexOf("@theme inline {")); +/** Inside `@theme`: what Tailwind turns into utilities. */ +const THEME = TOKENS.slice(TOKENS.indexOf("@theme inline {")); + +const declares = (variable: string) => + new RegExp(`^\\s*${variable}:`, "m").test(DECLARATIONS); + +/** + * The /design pages are rendered from this registry, so a role described here + * but absent from the stylesheet is a page confidently documenting something + * that does not exist — and the reader has no way to tell. + * + * This is not hypothetical. `bg-float-glass` was described as "a floating + * surface that should let content through", rendered a swatch on /design/color, + * and was never declared anywhere: the swatch painted nothing and the row read + * as a real token. It survived a census of consumers too, because a token no + * stylesheet declares also has no consumers to count. + */ +describe("token registry — every documented role exists", () => { + it("actually read the stylesheet", () => { + // Guards the guard: see the note on TOKENS. Empty text would make every + // other assertion here pass while checking nothing. + expect(TOKENS.length).toBeGreaterThan(1000); + expect(TOKENS).toContain("@theme inline {"); + }); + + it("declares every role the registry describes", () => { + const missing: string[] = []; + for (const group of ROLE_GROUPS) { + for (const role of group.roles) { + if (!declares(role.variable)) + missing.push(`${group.id}: ${role.variable}`); + } + } + expect(missing).toEqual([]); + }); + + it("declares every palette step and ramp step it lists", () => { + const missing: string[] = []; + for (const hue of PALETTE) { + for (const step of hue.steps) { + if (!declares(step.variable)) missing.push(step.variable); + } + } + for (const ramp of RAMPS) { + for (const step of ramp.steps) { + if (!declares(step.variable)) missing.push(step.variable); + } + } + expect(missing).toEqual([]); + }); +}); + +/** + * The namespace rule, bound to the file rather than to a reviewer's memory. + * + * `--color-x` is shared by every colour prefix at once — it defines `bg-x`, + * `text-x`, `border-x` and the rest from one value. So when two roles differ + * only by which prefix uses them, that namespace silently picks one. + * + * It shipped twice. Borders first: `border-primary` resolved to the text colour + * and drew every hairline at near-black. Then text: `--color-danger` also + * defined `text-danger`, so error text rendered in red-9, the saturated fill, + * instead of red-12 — APCA Lc 34 on a dark panel, while `check-contrast` passed + * because it measured `--text-danger`, a token no class could reach. + * + * Both fixes were the same move, so the invariant is one line: a text or border + * role registers in its own namespace, never the shared one. + */ +describe("token registry — colour namespaces do not collide", () => { + const registrations = (namespace: string) => + [ + ...THEME.matchAll( + new RegExp( + `^\\s*--${namespace}-([a-z0-9-]+):\\s*var\\((--[a-z0-9-]+)\\)`, + "gm", + ), + ), + ].map(([, suffix, role]) => ({ suffix, role })); + + it("registers no text or border role under the shared --color-* namespace", () => { + const shared = registrations("color").filter( + ({ role }) => role.startsWith("--text-") || role.startsWith("--border-"), + ); + expect( + shared.map(({ suffix, role }) => `--color-${suffix} points at ${role}`), + ).toEqual([]); + }); + + it("registers every border role it exposes as --border-color-*", () => { + const roles = [ + ...new Set( + ROLE_GROUPS.flatMap((group) => group.roles) + .map((role) => role.variable) + .filter((variable) => variable.startsWith("--border-")), + ), + ]; + const registered = new Set( + registrations("border-color").map(({ role }) => role), + ); + expect(roles.filter((role) => !registered.has(role))).toEqual([]); + }); + + it("registers every text role it exposes as --text-color-*", () => { + // Every `--text-*` role that is not a size — sizes share the spelling and + // register in Tailwind's own `--text-*` namespace, which is the collision + // typography.css documents. + const roles = [ + ...new Set( + ROLE_GROUPS.flatMap((group) => group.roles) + .map((role) => role.variable) + .filter((variable) => variable.startsWith("--text-")), + ), + ]; + const registered = new Set( + registrations("text-color").map(({ role }) => role), + ); + expect(roles.filter((role) => !registered.has(role))).toEqual([]); + }); +}); diff --git a/desktop-new/src/shared/tokens/registry.ts b/desktop-new/src/shared/tokens/registry.ts index f58f341e10..f040f23dfa 100644 --- a/desktop-new/src/shared/tokens/registry.ts +++ b/desktop-new/src/shared/tokens/registry.ts @@ -21,8 +21,6 @@ export interface RampStep { job: string; /** The CSS custom property, without `var()`. */ variable: string; - /** The palette step this resolves to, when the ramp is a family. */ - palette?: string; } /** A private ramp. */ @@ -64,7 +62,13 @@ export interface RoleGroup { PRIVATE RAMPS ============================================================ */ -const NEUTRAL_JOBS = [ +/** + * What each of the twelve steps is *for*. The same twelve jobs in every hue — + * which is the whole point of a twelve-step scale, and what makes the + * step-to-role mapping deterministic: a role picks a step number, not a colour. + * Named from the neutral ramp because that is where every step is in use. + */ +const STEP_JOBS = [ "app background", "subtle background", "component background", @@ -79,67 +83,46 @@ const NEUTRAL_JOBS = [ "high-contrast text", ]; -/** - * The five jobs every coloured family does, and the palette step each takes. - * - * Named rather than numbered, because a number invited reading `--accent-2` as - * "a light purple" when it means "the surface an accent tint sits on". The step - * numbers are the palette position, shown so the ramp relationship stays legible. - * - * Text takes step 12, not the 11 Radix names "low-contrast text": Radix sizes 11 - * for WCAG 4.5:1, and every hue's step 11 measured Lc 55-61 against this - * system's Lc 60 target on the surfaces these roles must clear. - */ -const FAMILY_JOBS: Array<[string, number, string]> = [ - ["tint", 3, "tinted surface"], - ["tint-hover", 4, "tint hover / selected"], - ["border", 8, "border, focus ring"], - ["fill", 9, "solid fill"], - ["text", 12, "text on a tint or a neutral surface"], -]; - -function identityRamp( - id: string, - name: string, - description: string, - hue: string, -): Ramp { - return { - id, - name, - description, - steps: FAMILY_JOBS.map(([job, step, use]) => ({ - step, - job: `${job} — ${use}`, - variable: `--${id}-${job}`, - palette: `--palette-${hue}-${step}`, - })), - }; -} - /** A palette hue: twelve steps, authored per mode. The bottom layer. */ export interface PaletteHue { id: string; - /** Which family, if any, currently draws from this hue. */ + /** Which identity, if any, currently draws from this hue. */ usedBy?: string; - steps: Array<{ step: number; variable: string }>; + steps: Array<{ step: number; variable: string; job: string }>; +} + +/** + * A private, named backdrop treatment. + * + * Unlike a colour ramp, a treatment is a complete visual composition: several + * colours plus their positions, falloff, and sometimes a vignette. Its name is + * deliberately mode-specific — Night garden is the dark counterpart of Sky + * field, not Sky field with its brightness turned down. + */ +export interface BackdropTreatment { + id: string; + name: string; + variable: string; + mode: "light" | "dark"; + pairedWith: string; + description: string; } /** * Every hue in the palette. * - * The layer beneath the families, and the only place a literal colour lives. + * The bottom layer, and the only place a literal colour lives. * Values are Radix Colors (MIT), transcribed rather than depended on — Radix is * not on Block's Tech Radar, so this is a values-only copy with no package. * - * It exists for two reasons. The families above it were 114 hand-picked hex + * It exists for two reasons. The tokens above it were 114 hand-picked hex * values with nothing enforcing that two tokens doing the same job agreed, and * they drifted. And a hue's dark steps are not its light steps dimmed: reaching * for a subtler dark purple by writing `purple-950/50` in a component put a real * colour decision somewhere it could not be named, paired, or measured. */ export const PALETTE: PaletteHue[] = [ - { id: "gray", usedBy: "neutral", steps: [] }, + { id: "neutral", usedBy: "structure", steps: [] }, { id: "purple", usedBy: "accent", steps: [] }, { id: "red", usedBy: "danger", steps: [] }, { id: "green", usedBy: "success", steps: [] }, @@ -151,37 +134,112 @@ export const PALETTE: PaletteHue[] = [ ...hue, steps: Array.from({ length: 12 }, (_, i) => ({ step: i + 1, - variable: `--palette-${hue.id}-${i + 1}`, + variable: `--${hue.id}-${i + 1}`, + job: STEP_JOBS[i], })), })); -export const RAMPS: Ramp[] = [ +export const BACKDROP_TREATMENTS: BackdropTreatment[] = [ { - id: "neutral", - name: "Neutral", - description: - "Twelve steps, each step a fixed job. The number means a job, not a brightness — which is what lets a role encode a position that stays true across every palette.", - steps: NEUTRAL_JOBS.map((job, i) => ({ - step: i + 1, - job, - variable: `--neutral-${i + 1}`, - })), + id: "sky-field", + name: "Sky field", + variable: "--gradient-sky-field", + mode: "light", + pairedWith: "night-garden", + description: "Blue sky, fresh green, and a sunlit yellow edge.", }, - identityRamp( - "accent", - "Accent — a slot, not a colour", - "Nothing above this family knows the hue, so the accent can change, become a person's preference, or vary per theme without a single component changing. Point the five steps at a different palette hue and every role follows.", - "purple", - ), - identityRamp( - "danger", - "Danger", - "The same five jobs as accent. Learning one family teaches all of them.", - "red", - ), - identityRamp("success", "Success", "The same five jobs as accent.", "green"), - identityRamp("warning", "Warning", "The same five jobs as accent.", "amber"), - identityRamp("info", "Info", "The same five jobs as accent.", "blue"), + { + id: "peach-field", + name: "Peach field", + variable: "--gradient-peach-field", + mode: "light", + pairedWith: "signal-flare", + description: "A bright peach wash with a cool blue edge.", + }, + { + id: "blue-hour", + name: "Blue hour", + variable: "--gradient-blue-hour", + mode: "light", + pairedWith: "electric-dusk", + description: "Cyan, periwinkle, and a quiet lavender horizon.", + }, + { + id: "orchid-field", + name: "Orchid field", + variable: "--gradient-orchid-field", + mode: "light", + pairedWith: "ultraviolet", + description: "Lilac, periwinkle, and a low orchid-pink bloom.", + }, + { + id: "night-garden", + name: "Night garden", + variable: "--gradient-night-garden", + mode: "dark", + pairedWith: "sky-field", + description: "Deep teal atmosphere, emerald high light, and blue below.", + }, + { + id: "signal-flare", + name: "Signal flare", + variable: "--gradient-signal-flare", + mode: "dark", + pairedWith: "peach-field", + description: + "A warm ember field with orange high light and a rose horizon.", + }, + { + id: "electric-dusk", + name: "Electric dusk", + variable: "--gradient-electric-dusk", + mode: "dark", + pairedWith: "blue-hour", + description: "Cyan and ultraviolet light over a deep blue atmosphere.", + }, + { + id: "ultraviolet", + name: "Ultraviolet", + variable: "--gradient-ultraviolet", + mode: "dark", + pairedWith: "orchid-field", + description: "A deep violet field, blue high light, and a low rose flare.", + }, +]; + +/** The four stable appearance slots, each pairing one light and dark scene. */ +export const BACKDROP_CHOICES = [ + { + token: "gradient-1", + variable: "--gradient-1", + lightTreatment: "sky-field", + darkTreatment: "night-garden", + use: "The default app backdrop: Sky field in light mode, Night garden in dark.", + }, + { + token: "gradient-2", + variable: "--gradient-2", + lightTreatment: "peach-field", + darkTreatment: "signal-flare", + use: "A warm paired app backdrop: Peach field in light mode, Signal flare in dark.", + }, + { + token: "gradient-3", + variable: "--gradient-3", + lightTreatment: "blue-hour", + darkTreatment: "electric-dusk", + use: "A cool paired app backdrop: Blue hour in light mode, Electric dusk in dark.", + }, + { + token: "gradient-4", + variable: "--gradient-4", + lightTreatment: "orchid-field", + darkTreatment: "ultraviolet", + use: "A violet paired app backdrop: Orchid field in light mode, Ultraviolet in dark.", + }, +] as const; + +export const RAMPS: Ramp[] = [ { id: "glass", name: "Glass", @@ -202,76 +260,37 @@ export const RAMPS: Ramp[] = [ PUBLIC ROLES ============================================================ */ -function identityGroup( - id: string, - label: string, - description: string, -): RoleGroup { - return { - id, - name: label, - description, - roles: [ - { - token: `bg-${id}`, - variable: `--bg-${id}`, - pointsAt: `${id} fill`, - use: "The solid fill: primary buttons, active toggles. Takes its paired text.", - status: "core", - }, - { - token: `text-on-${id}`, - variable: `--text-on-${id}`, - pointsAt: "computed from the fill's lightness", - use: `Text sitting on bg-${id}.`, - status: "core", - exception: - "Computed rather than fixed: white is readable on a blue or purple fill and unreadable on yellow or lime.", - }, - { - token: `text-${id}`, - variable: `--text-${id}`, - pointsAt: `${id} text`, - use: "Coloured text on a neutral background: links, labels.", - status: "core", - }, - { - token: `bg-${id}-tint`, - variable: `--bg-${id}-tint`, - pointsAt: `${id} tint`, - use: "A tinted surface carrying meaning: chips, callouts, selected rows. Takes coloured text.", - status: "core", - }, - { - token: `bg-${id}-tint-hover`, - variable: `--bg-${id}-tint-hover`, - pointsAt: `${id} tint-hover`, - use: "That tinted surface hovered or selected.", - status: "core", - }, - { - token: `border-${id}`, - variable: `--border-${id}`, - pointsAt: `${id} border`, - use: "Focus rings and active borders.", - status: "core", - }, - ], - }; -} +/** + * There is no identity-group generator any more, and that is the point. + * + * A helper here built five roles from one line of a hue lookup — `bg-accent`, + * `bg-accent-tint`, `text-accent`, and so on — which is exactly how twenty status + * roles came to exist without anyone designing them. Every one of those roles + * held the same palette step in both modes, so each was a name in front of a + * number. + * + * Screens now write the step: `bg-purple-9`, `bg-purple-3`, `text-purple-12`. + * Safe here and not in Tailwind because **every step is authored per mode**, so a + * class still behaves in light and dark. The step-to-role mapping in DESIGN.md + * survives as guidance for *which* step to reach for; it is no longer a + * generator. + * + * A name comes back when a repeated accent PATTERN appears — a tinted callout on + * four screens — and it will be named for the pattern, not the colour. + */ export const ROLE_GROUPS: RoleGroup[] = [ { id: "surfaces", name: "Structural surfaces", description: - "Named and closed, because there are only a few right answers and guessing produces a broken interface. Ask one question: is it behind, on, above, or in?", + "The roles that exist because a ramp step cannot say them: each takes a different step in light and dark, so no single class like `bg-neutral-1` is correct in both. That is the whole test for whether a colour earns a name. Ask one question: is it behind, on, above, or in? (`bg-hover` was here and is now written as `bg-neutral-4` \u2014 it was the same step in both modes. Hover is a *relationship*, one step more contrast than whatever is underneath, which no single token could express anyway.)", roles: [ { token: "bg-app", variable: "--bg-app", - pointsAt: "gradient-1", - use: "The backdrop everything sits on.", + pointsAt: "gradient-1 (Sky field light / Night garden dark)", + use: "The backdrop everything sits on. It takes the first paired appearance choice by default.", status: "core", }, { @@ -295,13 +314,6 @@ export const ROLE_GROUPS: RoleGroup[] = [ use: "Anything pushed in: inputs, code blocks, quotes.", status: "core", }, - { - token: "bg-hover", - variable: "--bg-hover", - pointsAt: "neutral 4", - use: "Any neutral row or item under the cursor. Region-less — it works on any neutral surface, which is why there is only one of them.", - status: "core", - }, ], }, { @@ -341,118 +353,44 @@ export const ROLE_GROUPS: RoleGroup[] = [ { token: "border-primary", variable: "--border-primary", - pointsAt: "neutral 7", - use: "The default visible line.", - status: "core", - }, - { - token: "border-secondary", - variable: "--border-secondary", - pointsAt: "neutral 6", - use: "A lighter separator inside a group.", - status: "core", - }, - { - token: "border-tertiary", - variable: "--border-tertiary", - pointsAt: "neutral 5", - use: "The faintest line.", + pointsAt: "neutral 4", + use: "Every deliberate line: panel boundaries, dividers, separators. One quiet weight; add another only when a design proves a different boundary needs it.", status: "core", }, ], }, - identityGroup( - "accent", - "Accent", - "Two arrangements and no third: a solid fill with paired text on it, or a tint fill with coloured text on it. There is deliberately nothing between them.", - ), - { - id: "inverse", - name: "Inverse", - description: - "A high-contrast fill for surfaces that must stand apart from everything: tooltips, toasts.", - roles: [ - { - token: "bg-inverse", - variable: "--bg-inverse", - pointsAt: "neutral 11", - use: "High-contrast fill: tooltips, toasts.", - status: "core", - }, - { - token: "bg-inverse-hover", - variable: "--bg-inverse-hover", - pointsAt: "neutral 10", - use: "Its hover. Step 10 sits on the light side of step 11 in the light ramp and the dark side in the dark ramp, so the hover inverts direction with no special-casing.", - status: "core", - }, - { - token: "text-on-inverse", - variable: "--text-on-inverse", - pointsAt: "computed from the fill's lightness", - use: "Text sitting on bg-inverse.", - status: "core", - exception: "Computed rather than fixed.", - }, - ], - }, - identityGroup("danger", "Danger", "Destructive actions and error states."), - identityGroup("success", "Success", "Confirmation and healthy states."), - identityGroup("warning", "Warning", "Caution that is not yet an error."), - identityGroup("info", "Info", "Neutral information and guidance."), { id: "material", name: "Material", description: - "A translucent blurred surface is the same region in a different material. Translucency and blur live inside the value; a component never assembles them from a fill, a transparency, and a blur amount.", + "Glass is applied as a whole material — a fill, a blur, a rim, and sometimes a lift — through the `glass-primary` and `glass-secondary` utilities. The fills below are what those utilities read; they are deliberately not registered as Tailwind colour utilities, because a bare `bg-glass-primary` class would be the fill without the rest, which is the failure the materials exist to prevent. Named by stacking depth: primary sits on the backdrop, secondary sits over something already glass. See the glass page.", roles: [ { - token: "bg-chrome-glass", - variable: "--bg-chrome-glass", - pointsAt: "glass 3 + blur-md", - use: "The top bar and floating chrome.", + token: "glass-primary", + variable: "--bg-glass-primary", + pointsAt: "glass 2 + blur-md + rim", + use: "Glass sitting directly on the backdrop: chrome, nav, a region in glass. The most translucent, because the backdrop is the thing worth seeing through to.", status: "core", }, { - token: "bg-chrome-glass-hover", - variable: "--bg-chrome-glass-hover", - pointsAt: "glass 4 + blur-md", - use: "A chrome control under the cursor. A glass hover moves one step up the ramp, changing opacity and never blur.", + token: "glass-secondary", + variable: "--bg-glass-secondary", + pointsAt: "glass 4 + blur-lg + rim + shadow-sm", + use: "Glass over something already glass: popovers, menus, a modal over a panel. Less translucent so it separates, and a real shadow because it is genuinely above.", status: "core", }, { token: "bg-chrome-selected", variable: "--bg-chrome-selected", pointsAt: "neutral 1 light / neutral 5 dark", - use: "The selected item inside chrome. Carries no material suffix because omitting it means opaque — on glass, elevation reads as less translucency, not a lighter colour.", + use: "The selected item inside chrome. Opaque rather than a glass step, because on glass elevation reads as less translucency, not a lighter colour.", status: "core", }, { - token: "bg-panel-glass", - variable: "--bg-panel-glass", - pointsAt: "glass 2 + blur-lg", - use: "A panel that should let the backdrop through.", - status: "core", - }, - { - token: "bg-float-glass", - variable: "--bg-float-glass", - pointsAt: "glass 3 + blur-md", - use: "A floating surface that should let content through.", - status: "core", - }, - ], - }, - { - id: "focus", - name: "Focus", - description: "Focus is part of the design, never an artefact to suppress.", - roles: [ - { - token: "ring-focus", - variable: "--ring-focus", - pointsAt: "accent border", - use: "The keyboard focus ring.", + token: "glass-primary-interactive", + variable: "--bg-glass-primary-hover", + pointsAt: "glass 3 on hover", + use: "Primary glass you can click: chrome buttons, topbar controls. The hover moves one step up the ramp and never changes blur — re-blurring a large surface every frame is expensive enough to feel.", status: "core", }, ], @@ -469,12 +407,15 @@ export const ROLE_GROUPS: RoleGroup[] = [ export const VOCABULARY: Array<{ group: string; words: string[] }> = [ { group: "property", words: ["bg", "text", "border", "ring"] }, { group: "region", words: ["app", "panel", "float", "chrome", "inset"] }, - { group: "emphasis", words: ["primary", "secondary", "tertiary"] }, + { group: "emphasis", words: ["primary", "secondary", "tertiary", "default"] }, { group: "state", words: ["hover", "selected", "disabled"] }, { group: "material", words: ["glass"] }, { group: "modifier", words: ["tint"] }, { group: "identity", words: ["accent", "inverse"] }, - { group: "status", words: ["success", "warning", "danger", "info"] }, + // Not a status vocabulary: `error` names one situation that needed a colour, + // and `agent-running` names another. A tier vocabulary is what produced four + // undesigned identities, so these stay concrete until a pattern repeats. + { group: "situation", words: ["error", "agent-running"] }, { group: "paired", words: ["on-accent", "on-inverse"] }, ]; @@ -482,7 +423,7 @@ export const GRAMMAR = "-[-][-][-]"; /** Fixed order, so there is only one correct spelling. */ export const GRAMMAR_EXAMPLES = { - legal: ["bg-chrome-glass-hover", "bg-accent-tint-hover", "text-secondary"], + legal: ["bg-accent-tint-hover", "text-primary", "bg-chrome-selected"], illegal: ["bg-chrome-hover-glass", "bg-hover-chrome"], }; @@ -722,7 +663,7 @@ export const SPACE_ROLES = [ token: "space-workspace-inset", variable: "--space-workspace-inset", pointsAt: "space 4", - use: "The distance between the window edge and its workspace panels.", + use: "The distance between the window edge and its panels.", }, { token: "space-panel-gap", @@ -748,6 +689,12 @@ export const SPACE_ROLES = [ pointsAt: "space 2", use: "The gap between an icon, label, and trailing metadata in one row.", }, + { + token: "space-section-gap", + variable: "--space-section-gap", + pointsAt: "space 4", + use: "The gap between adjacent navigator sections. Larger than the 1px between rows inside one, because that difference is the only thing saying where a group ends.", + }, ]; export const RADII = [ @@ -840,7 +787,7 @@ export const EXCEPTIONS = [ why: "The glass rim is a directional light effect, not a solid line. It is not on the glass ramp — that is a ramp of fills, and in dark mode the fill is translucent near-black while the rim stays translucent white. It is not one of the numbered gradients either: those are background treatments, this is a material detail.", }, { - name: "gradient-1, texture-dots", - why: "Not colours in the ramp sense.", + name: "backdrop treatments and gradient-1…4, texture-dots", + why: "Not colours in the ramp sense. A named backdrop treatment is a complete visual composition; gradient-1…4 pair one light scene and one dark scene for a stable appearance choice.", }, ]; diff --git a/desktop-new/src/shared/ui/SegmentedNavigation.tsx b/desktop-new/src/shared/ui/Tabs.tsx similarity index 100% rename from desktop-new/src/shared/ui/SegmentedNavigation.tsx rename to desktop-new/src/shared/ui/Tabs.tsx diff --git a/desktop-new/tsconfig.json b/desktop-new/tsconfig.json index c41c555c93..84af4ddc7e 100644 --- a/desktop-new/tsconfig.json +++ b/desktop-new/tsconfig.json @@ -3,6 +3,11 @@ "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "ES2022", "DOM", "DOM.Iterable"], + /* Node types are for the tests only — `src/shared/tokens/registry.test.ts` + reads `tokens.css` as text to assert the registry documents tokens that + actually exist. It cannot use Vite's `?raw`, which returns an empty + string for a `.css` file. No app code imports a Node module. */ + "types": ["node", "vite/client"], "module": "ESNext", "skipLibCheck": true, "paths": { "@/*": ["./src/*"] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f58e886a33..b829e9e10f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,6 +359,9 @@ importers: '@tanstack/router-plugin': specifier: ^1.167.12 version: 1.168.10(@tanstack/react-router@1.170.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0)) + '@types/node': + specifier: 25.6.0 + version: 25.6.0 '@types/react': specifier: ^19.1.8 version: 19.2.18