a ratchet on hardcoded colour, and the docs to match

The three previous attempts at this job died of endlessness, not difficulty. Nothing
stopped the next component arriving with its own `text-emerald-400` while an old one
was being fixed, so the number never fell for long.

`bun run check:theme` counts palette classes per directory against a committed
baseline. It does not care what the number is, only which way it moves. 942 across 21
directories today. Every directory can be paid down independently and none can quietly
regrow — which turns an infinite job into a finite one.

A baseline rather than a ban, deliberately: a rule forbidding palette classes outright
fails on its first run, and a check nobody can make pass is a check everybody turns
off.

Proven by adding one `text-emerald-400` and watching it fail with the directory, the
delta, and what to use instead.

── Documentation, which is part of the work rather than after it ──

`src/workspaces/theme/README.md` is new and is the contract: how to write a theme, all
84 declarations by family, why colours are unwrapped HSL triplets while surfaces are
complete CSS values, why assets are quoted paths, and the rules for components. Start
from Plain, not from Duck — every value you change is then one you decided about.

`plugins/EXTRACTING-A-PLUGIN.md` gains a rule beside the others: a plugin never
hardcodes a colour, and pays its debt down DURING the extraction. The counts are named
there — Soulseek 313, Invoices 95, Wallet 68 — because "some colours" does not convey
that it is two thirds of the platform's total. The kickoff prompt says the same thing
in the form a fresh agent will meet it.

That is what makes the plugin exemption safe rather than a way of losing the work: the
ratchet stops covering a directory the moment it leaves, so the rule has to travel with
it.

`THEMES-NOTES.md` rewritten to match reality — it still listed done things as open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:42:56 +00:00
co-authored by Claude Opus 5
parent df92a985d2
commit f977d26fef
7 changed files with 375 additions and 116 deletions
+120
View File
@@ -0,0 +1,120 @@
/**
* A ratchet on hardcoded colour: the count per directory may fall, never rise.
*
* ── Why a baseline instead of a ban ──
*
* A rule that forbids raw palette classes outright fails on the first run — there are hundreds, most of
* them in code that is leaving this repository, and a check nobody can make pass is a check everybody
* turns off. A baseline is enforceable today: it does not care what the number is, only which way it
* moves. Every directory can be paid down independently, and none can quietly regrow.
*
* That is the difference between this and the three previous attempts at the same job. The work was never
* hard, it was endless — because nothing stopped the next component arriving with its own `text-emerald-400`
* while an old one was being fixed.
*
* ── What counts, and what does not ──
*
* Any Tailwind PALETTE colour: `bg-emerald-500`, `text-zinc-400`, `border-red-500/30`, `text-white`,
* `bg-black/60`. Semantic tokens do not count — `bg-destructive`, `text-muted-foreground`, `bg-busy/15` —
* because those follow the theme, which is the entire point.
*
* `text-white` and `bg-black` ARE counted even where they are currently correct (media overlays sitting on
* a photograph rather than on a themed surface). They stay in the baseline rather than being exempted: an
* exemption list is a second thing to keep honest, and a number that cannot rise is enough.
*
* bun scripts/check-theme-tokens.ts # verify against the baseline
* bun scripts/check-theme-tokens.ts --update # accept the current counts (after paying some down)
*/
import { readdirSync, existsSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const BASELINE = 'scripts/theme-baseline.json';
const PALETTE = [
'white', 'black', 'zinc', 'gray', 'slate', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow',
'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia',
'pink', 'rose',
];
const PROPS = [
'bg', 'text', 'border', 'ring', 'from', 'to', 'via', 'fill', 'stroke', 'divide', 'outline',
'decoration', 'accent', 'shadow', 'placeholder', 'caret',
];
const RE = new RegExp(`\\b(${PROPS.join('|')})-(${PALETTE.join('|')})(-\\d{2,3})?(/\\d+)?\\b`, 'g');
/** The directories worth tracking separately, so one can be paid down without hiding another's growth. */
function targets(): string[] {
const out: string[] = [];
const appsRoot = 'src/workspaces/officerdev/src/apps';
if (existsSync(appsRoot)) {
for (const name of readdirSync(appsRoot)) {
const path = join(appsRoot, name);
if (statSync(path).isDirectory()) out.push(path);
}
}
for (const extra of ['src/apps/officer-web/Screens', 'src/workspaces/components', 'src/workspaces/officerdev/src/components']) {
if (existsSync(extra)) out.push(extra);
}
return out.sort();
}
function countIn(dir: string): number {
let total = 0;
const walk = (path: string) => {
for (const entry of readdirSync(path, { withFileTypes: true })) {
const full = join(path, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules') walk(full);
} else if (/\.(tsx|ts)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
total += (readFileSync(full, 'utf-8').match(RE) ?? []).length;
}
}
};
walk(dir);
return total;
}
const counts: Record<string, number> = {};
for (const dir of targets()) {
const n = countIn(dir);
if (n > 0) counts[dir] = n;
}
if (process.argv.includes('--update')) {
writeFileSync(BASELINE, JSON.stringify(counts, null, 2) + '\n');
console.log(` baseline updated: ${Object.keys(counts).length} directories, ${Object.values(counts).reduce((a, b) => a + b, 0)} usages`);
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.log(` no baseline at ${BASELINE} — run with --update to create one`);
process.exit(1);
}
const baseline: Record<string, number> = JSON.parse(readFileSync(BASELINE, 'utf-8'));
const grew: string[] = [];
const shrank: string[] = [];
for (const [dir, n] of Object.entries(counts)) {
const was = baseline[dir] ?? 0;
if (n > was) grew.push(` ${dir}: ${was} -> ${n} (+${n - was})`);
else if (n < was) shrank.push(` ${dir}: ${was} -> ${n}`);
}
if (shrank.length) {
console.log(' paid down:');
for (const line of shrank) console.log(line);
console.log(' run with --update to lock the improvement in.\n');
}
if (grew.length) {
console.log(' Hardcoded colour increased:');
for (const line of grew) console.log(line);
console.log('');
console.log(' Use a semantic token instead of a palette colour: bg-destructive, text-muted-foreground,');
console.log(' bg-busy/15, border-border. They follow the theme; a palette number does not.');
console.log(' See src/workspaces/theme/README.md and docs/design-language-interface.md.');
process.exit(1);
}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
console.log(` no new hardcoded colour (${total} known usages across ${Object.keys(counts).length} directories)`);
+23
View File
@@ -0,0 +1,23 @@
{
"src/apps/officer-web/Screens": 144,
"src/workspaces/components": 47,
"src/workspaces/officerdev/src/apps/AppStore": 6,
"src/workspaces/officerdev/src/apps/Chat": 12,
"src/workspaces/officerdev/src/apps/ChatHistory": 1,
"src/workspaces/officerdev/src/apps/CodeEditor": 5,
"src/workspaces/officerdev/src/apps/Dashboards": 11,
"src/workspaces/officerdev/src/apps/Dav": 7,
"src/workspaces/officerdev/src/apps/FileBrowser": 15,
"src/workspaces/officerdev/src/apps/FileViewer": 38,
"src/workspaces/officerdev/src/apps/Invoices": 95,
"src/workspaces/officerdev/src/apps/Jellyfin": 48,
"src/workspaces/officerdev/src/apps/Photos": 47,
"src/workspaces/officerdev/src/apps/Preview": 4,
"src/workspaces/officerdev/src/apps/QrTransfer": 5,
"src/workspaces/officerdev/src/apps/Soulseek": 313,
"src/workspaces/officerdev/src/apps/SystemMonitor": 1,
"src/workspaces/officerdev/src/apps/Terminal": 10,
"src/workspaces/officerdev/src/apps/Transmission": 43,
"src/workspaces/officerdev/src/apps/Wallet": 68,
"src/workspaces/officerdev/src/components": 22
}