Files
buzz/desktop-new/scripts/token-consumers.mjs
morgmart ec918bb75f 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>
2026-09-07 15:58:41 -07:00

144 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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