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