desktop-new: build screens from the ramps, cut 54 colour roles to 15

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 <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-07 15:58:41 -07:00
parent ce94bb1599
commit ec918bb75f
10 changed files with 1228 additions and 720 deletions

View File

@@ -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",

View File

@@ -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);

View File

@@ -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}`);
}

View File

@@ -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}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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([]);
});
});

View File

@@ -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 = "<property>-<role>[-<modifier>][-<material>][-<state>]";
/** 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.",
},
];

View File

@@ -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/*"] },

3
pnpm-lock.yaml generated
View File

@@ -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