Files
zeekayandHanzo Dev c3c87437c6 Count the hues the shell can actually name
The palette gate reads utility classes and literal values. The shell is written
in gui's `$` tokens, so `color="$red10"` was invisible to it — in the one file
the convention was written for. A hue is a hue whichever vocabulary reaches for
it, so `$<hue><n>` counts against the same budget. Verified by adding one and
watching the gate rise to 178 and fail.

Two bugs the second pattern exposed: a file naming a hue both ways reported only
the later count, because the per-file tally assigned instead of accumulating;
and the by-hue breakdown looked for `-green-`, so a token hue landed in the
total and in no hue's column.

What it found: an error line in Welcome, and the node indicator and fault
banner I had just written in green and red. `status.ts` has the answer for all
three — luminance carries degree, inversion is spent once on the state a person
has something to do about, and inline text takes the brightest step plus weight
because a monochrome palette cannot make one sentence redder than another.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 05:40:31 -07:00

179 lines
7.6 KiB
JavaScript
Executable File
Raw Permalink 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.
#!/usr/bin/env node
/**
* THE PALETTE GATE — is every colour on this surface the design system's?
*
* The brand is monochrome, and `libs/hanzo-ui/src/styles/styles.css` says so in
* the one place that can: `--color-brand` is `hsla(0, 0%, 100%)`, the gray ramp
* is pure neutral, and the cyan, green, amber and red scales are commented out.
*
* That intent was not enforceable, which is the whole reason this exists.
* Tailwind 4's `@import 'tailwindcss'` brings its own theme, and an `@theme`
* block OVERRIDES tokens rather than replacing the set — so commenting a scale
* out leaves Tailwind's default in place. Measured in the shipped bundle:
* `--color-cyan-400:#00d2ef` is defined and `.text-cyan-400{color:var(--color-cyan-400)}`
* is emitted. The theme said monochrome and the build painted cyan.
*
* So this reads the SOURCE and reports every colour a component names for
* itself. Two kinds, and they are different findings:
*
* chromatic a hue the brand does not have — cyan, emerald, purple, amber.
* Every one is a defect: the mark in the sidebar was drawn
* `text-cyan-400`, which is the green H nobody ordered.
*
* literal a hex, rgb or hsl written into a component — `bg-[#0a0c10]`.
* Not a hue question: a value with no name cannot be themed, so
* a literal defeats @hanzo/appearance whatever colour it is.
*
* Neutral utilities (`text-white/40`, `bg-black`) are NOT reported. They are
* achromatic, so they cannot break the monochrome intent — but they are not
* themeable either, so they are counted separately and shown, without failing
* the gate. Naming them as defects alongside a cyan gradient would bury the
* finding that matters in four hundred that do not.
*
* The gate is a RATCHET, not a threshold: `palette.json` records what was here,
* and the run fails when a number goes UP. A repo with 466 findings cannot be
* gated at zero without turning the gate off, and a gate nobody can satisfy is
* a gate nobody runs.
*/
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
const SRC = join(ROOT, 'src');
const BUDGET = join(ROOT, 'bin', 'palette.json');
/** The hues the brand does not have. `gray`, `neutral`, `zinc`, `stone`, `slate`
* and `white`/`black` are absent on purpose — those are the ramp. */
const CHROMATIC = [
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal',
'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose',
];
/** Where a colour can be named in a utility class. */
const SLOTS = [
'text', 'bg', 'border', 'from', 'via', 'to', 'ring', 'shadow', 'fill',
'stroke', 'decoration', 'outline', 'accent', 'caret', 'divide', 'placeholder',
];
const chromatic = new RegExp(
`\\b(?:${SLOTS.join('|')})-(?:${CHROMATIC.join('|')})-\\d{2,3}\\b`,
'g',
);
/**
* The same hue, named the other way this app can name one.
*
* The shell is written in gui's `$` tokens, not utility classes, so a
* `color="$red10"` was invisible here — which is the one file the convention
* was written for. A hue is a hue whichever vocabulary reaches for it, so it
* counts against the same budget.
*/
const token = new RegExp(`\\$(?:${CHROMATIC.join('|')})\\d{1,2}\\b`, 'g');
/** A colour value written out: `#0a0c10`, `rgb(…)`, `rgba(…)`, `hsl(…)`. */
const literal = /#[0-9a-fA-F]{3,8}\b|\brgba?\([^)]*\)|\bhsla?\([^)]*\)/g;
/** Achromatic and unthemeable: counted, never failed. */
const achromatic = new RegExp(
`\\b(?:${SLOTS.join('|')})-(?:white|black|gray|neutral|zinc|stone|slate)(?:-\\d{2,3})?(?:/\\d{1,3})?\\b`,
'g',
);
/**
* The code, with its comments taken out.
*
* A colour NAMED IN PROSE is not a colour a component paints. Explaining that
* `--color-brand` is `hsla(0, 0%, 100%)` is exactly the note a reader of this
* palette wants, and counting it as a defect makes the gate argue against
* documenting the thing it is enforcing — which it did, on its own first run,
* against a comment written to explain a conversion.
*
* `//` is only a comment when it is not the `//` in a url, so a `:` before it
* disqualifies it.
*/
function code(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
function sources(dir, out = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name.startsWith('.')) continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) sources(path, out);
else if (/\.(tsx?|css)$/.test(name)) out.push(path);
}
return out;
}
const found = { chromatic: {}, literal: {}, achromatic: {} };
const hues = {};
for (const path of sources(SRC)) {
const name = relative(ROOT, path);
const text = code(readFileSync(path, 'utf8'));
for (const [kind, pattern] of [
['chromatic', chromatic],
['chromatic', token],
['literal', literal],
['achromatic', achromatic],
]) {
const hits = text.match(pattern);
if (!hits) continue;
// Two patterns report the same kind, so this accumulates. Assigning would
// let a file that names a hue both ways report only the second count.
found[kind][name] = (found[kind][name] ?? 0) + hits.length;
if (kind === 'chromatic') {
for (const hit of hits) {
const hue = CHROMATIC.find((h) => hit.includes(`-${h}-`) || hit.startsWith(`$${h}`));
if (hue) hues[hue] = (hues[hue] ?? 0) + 1;
}
}
}
}
const total = (kind) => Object.values(found[kind]).reduce((a, b) => a + b, 0);
const now = {
chromatic: total('chromatic'),
literal: total('literal'),
achromatic: total('achromatic'),
};
const write = process.argv.includes('--write');
const was = existsSync(BUDGET) ? JSON.parse(readFileSync(BUDGET, 'utf8')) : null;
console.log('PALETTE — every colour on this surface, and where it came from\n');
for (const kind of ['chromatic', 'literal']) {
const worst = Object.entries(found[kind]).sort((a, b) => b[1] - a[1]).slice(0, 8);
const before = was?.[kind];
const move = before === undefined ? '' : now[kind] === before ? ' (unchanged)'
: now[kind] < before ? ` (was ${before}, −${before - now[kind]})`
: ` (was ${before}, +${now[kind] - before})`;
console.log(`${kind.padEnd(11)} ${String(now[kind]).padStart(4)}${move}`);
for (const [file, count] of worst) console.log(` ${String(count).padStart(4)} ${file}`);
console.log();
}
console.log(`achromatic ${String(now.achromatic).padStart(4)} — neutral, so it cannot break the brand; unthemeable, so it is not the answer either`);
console.log(`\nby hue ${Object.entries(hues).sort((a, b) => b[1] - a[1]).map(([h, n]) => `${h} ${n}`).join(' ')}`);
if (write) {
writeFileSync(BUDGET, JSON.stringify(now, null, 2) + '\n');
console.log(`\nrecorded ${relative(ROOT, BUDGET)}`);
process.exit(0);
}
if (!was) {
console.log('\nno budget recorded — run with --write to set the ratchet');
process.exit(0);
}
const risen = ['chromatic', 'literal'].filter((k) => now[k] > was[k]);
if (risen.length) {
console.log(`\nFAIL — ${risen.map((k) => `${k} rose to ${now[k]} from ${was[k]}`).join('; ')}`);
console.log('A colour written into a component cannot be themed. Name a token instead:');
console.log(' text-text-default text-text-secondary text-brand');
console.log(' bg-bg-default bg-bg-secondary bg-bg-tertiary');
console.log(' border-divider border-border border-border-input');
process.exit(1);
}
const fell = ['chromatic', 'literal'].filter((k) => now[k] < was[k]);
console.log(fell.length ? '\nPASS — and it fell. Run with --write to lower the ratchet.' : '\nPASS');