// scripts/check-dark-text.mjs // // Counts `dark:` + `text-slate-` class names and fails when there are more // than the baseline records. // // WHY // --- // ui/src/index.css INVERTS the slate ramp for the dark theme: `--slate-200` is // a pale grey in light and a dark near-ground tone (#2f2f37) in dark, `--slate-700` is // #334155 in light and a pale grey in dark. So a bare `text-slate-700` is // already the right ink in BOTH themes. Adding `dark:text-slate-200` on top // flips it a second time and the text lands on the dark theme's background // colour — measured at 1.17:1 to 1.97:1 on the five spots fixed in #1388. // // WHY "DON'T ADD MORE" AND NOT "NEVER" // ------------------------------------ // The pattern is not always wrong. Where the surface stays light in the dark // theme -- a literal `bg-white` card, for instance -- dark ink IS the correct // choice, and PromptCoachPanel.tsx has exactly such a case. So the existing // uses stay in the baseline and only new ones fail. // // WHAT THIS CHECK DOES NOT SEE // ---------------------------- // - It does not measure contrast. An element's real background comes from // its ancestors, and that cannot be decided from static text. A check that // pretended to measure it would be worse than no check at all. // - It therefore also stops correct code: on a surface that stays light in // the dark theme (a literal `bg-white`), dark ink is right and this check // still counts it. Baseline it deliberately, with a comment saying why. // - The scan is plain text, like Tailwind's own class extractor, so a class // name written inside a comment or a test fixture counts too. One such // mention is in the baseline on purpose (CacheWorkerCards.tsx documents // the trap). To name the pattern in prose without being counted, write // `dark:text-slate-*` with a star instead of a number. // - It only looks at `slate`. `dark:text-gray-*`, `dark:text-zinc-*` and // every other ramp are invisible to it -- `gray` is inverted in // ui/src/index.css too, so the same trap exists there and is not guarded. // - Variant chains around `dark:` are counted (`dark:hover:text-slate-300`, // `group-hover:dark:text-slate-300`), but a colour set from JS (a ternary // building the class name from pieces) is not. // - It counts occurrences per file, not per element. Moving a use from one // file to another reads as one addition plus one removal, and the addition // fails until the baseline is refreshed. // // Usage: // node scripts/check-dark-text.mjs # verify against baseline // node scripts/check-dark-text.mjs --update # rewrite the baseline // node scripts/check-dark-text.mjs --ui --baseline import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { dirname, resolve, relative, join, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); /** `--flag value` / `--flag=value`; returns undefined when absent. */ function argValue(flag) { const argv = process.argv.slice(2); const i = argv.indexOf(`--${flag}`); if (i !== -1 && argv[i + 1]) return argv[i + 1]; const inline = argv.find((a) => a.startsWith(`--${flag}=`)); return inline ? inline.slice(flag.length + 3) : undefined; } // `--ui` / `--baseline` exist so the check can be exercised against a fixture // tree (see scripts/check-dark-text.test.ts). Normal runs take neither. const UI_DIR = resolve(ROOT, argValue('ui') ?? 'ui'); const CONFIG_PATH = resolve(UI_DIR, 'tailwind.config.js'); const BASELINE_PATH = argValue('baseline') ? resolve(ROOT, argValue('baseline')) : resolve(__dirname, 'dark-text-baseline.json'); const UPDATE = process.argv.includes('--update'); // --------------------------------------------------------------------------- // 1. What to look for // --------------------------------------------------------------------------- // A variant chain (`dark:`, `dark:hover:`, `md:dark:`, `group-hover:dark:`) // with `dark` somewhere in it, ending in `text-slate-`. const VARIANT = String.raw`(?:[a-z][\w-]*:)`; const CLASS_RE = new RegExp( String.raw`(? /[*?{[]/.test(p)); const staticPrefix = parts.slice(0, firstMagic === -1 ? parts.length : firstMagic).join('/'); const rx = globToRegExp(clean); return { dir: resolve(UI_DIR, staticPrefix), test: (rel) => rx.test(rel) }; } /** Minimal glob -> RegExp: supports `**` `*` `?` and `{a,b}`. */ function globToRegExp(glob) { let out = ''; for (let i = 0; i < glob.length; i += 1) { const c = glob[i]; if (c === '*') { if (glob[i + 1] === '*') { i += 1; if (glob[i + 1] === '/') { i += 1; out += '(?:[^/]+/)*'; } else { out += '.*'; } } else { out += '[^/]*'; } } else if (c === '?') { out += '[^/]'; } else if (c === '{') { const end = glob.indexOf('}', i); if (end === -1) { out += escapeRe(c); } else { out += `(?:${glob.slice(i + 1, end).split(',').map(escapeRe).join('|')})`; i = end; } } else { out += escapeRe(c); } } return new RegExp(`^${out}$`); } function walk(dir, out = []) { if (!existsSync(dir)) return out; if (!statSync(dir).isDirectory()) { out.push(dir); return out; } for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; const full = join(dir, entry.name); if (entry.isDirectory()) walk(full, out); else if (entry.isFile()) out.push(full); } return out; } const contentPatterns = (Array.isArray(config.content) ? config.content : config.content?.files ?? []).filter( (p) => typeof p === 'string', ); if (contentPatterns.length === 0) { console.error('dark-text: tailwind.config.js has no string `content` globs to scan.'); process.exit(1); } const files = new Set(); for (const pattern of contentPatterns) { const { dir, test } = compileGlob(pattern); for (const file of walk(dir)) { const relToUi = relative(UI_DIR, file).split(sep).join('/'); if (test(relToUi)) files.add(file); } } // --------------------------------------------------------------------------- // 3. Scan // --------------------------------------------------------------------------- /** key -> { file, class, count, lines[] } */ const found = new Map(); for (const file of [...files].sort()) { const relPath = relative(ROOT, file).split(sep).join('/'); const lines = readFileSync(file, 'utf8').split('\n'); lines.forEach((line, i) => { for (const match of line.matchAll(CLASS_RE)) { const cls = match[1]; const key = `${relPath} ${cls}`; const hit = found.get(key) ?? { file: relPath, class: cls, count: 0, lines: [] }; hit.count += 1; hit.lines.push(i + 1); found.set(key, hit); } }); } const entries = [...found.values()] .sort((a, b) => a.file.localeCompare(b.file) || a.class.localeCompare(b.class)) .map(({ file, class: cls, count }) => ({ file, class: cls, count })); const total = entries.reduce((n, e) => n + e.count, 0); // --------------------------------------------------------------------------- // 4. Compare with the baseline (or write it) // --------------------------------------------------------------------------- if (UPDATE) { const payload = { _comment: [ 'Known `dark:` + text-slate- class names (see scripts/check-dark-text.mjs).', 'ui/src/index.css inverts the slate ramp for the dark theme, so a bare', 'text-slate- is already right in both themes and the dark: variant flips it', 'a second time onto the background. Some of these are still correct — on a', 'surface that stays light in the dark theme, dark ink is what you want.', 'This file exists so the check fails on NEW ones only. Do not add to it by', 'hand: fix the class, then run `npm run check:dark-text -- --update`.', ], total, files: entries.length === 0 ? 0 : new Set(entries.map((e) => e.file)).size, classes: entries.length === 0 ? 0 : new Set(entries.map((e) => e.class)).size, entries, }; writeFileSync(BASELINE_PATH, `${JSON.stringify(payload, null, 2)}\n`); console.log(`dark-text: baseline written — ${total} uses in ${payload.files} files (${payload.classes} distinct classes).`); process.exit(0); } if (!existsSync(BASELINE_PATH)) { console.error(`dark-text: ${relative(ROOT, BASELINE_PATH)} is missing. Run \`npm run check:dark-text -- --update\`.`); process.exit(1); } const baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); const baselineCounts = new Map((baseline.entries ?? []).map((e) => [`${e.file} ${e.class}`, e.count])); const added = []; for (const [key, hit] of found) { const allowed = baselineCounts.get(key) ?? 0; if (hit.count > allowed) added.push({ ...hit, allowed }); } const removed = []; for (const [key, count] of baselineCounts) { const current = found.get(key)?.count ?? 0; if (current < count) removed.push({ key, count, current }); } if (added.length > 0) { console.error('dark-text: new `dark:` text-slate class — the dark theme inverts this ramp, so this flips the ink twice.\n'); for (const hit of added) { const where = hit.lines.map((n) => `${hit.file}:${n}`).join(', '); const extra = hit.allowed > 0 ? ` (baseline allows ${hit.allowed}, found ${hit.count})` : ''; console.error(` ${hit.class}${extra}\n ${where}`); } console.error(`\n${added.length} new use(s). ui/src/index.css maps slate onto CSS variables and INVERTS the ramp for`); console.error('the dark theme (--slate-200 is #e2e8f0 in light and #2f2f37 in dark). A bare text-slate-'); console.error('is therefore already the right ink in both themes; the dark: variant flips it a second time'); console.error('and the text lands on the background. Drop the variant:'); console.error(' text-slate-700 dark:text-slate-200 -> text-slate-700'); console.error('\nIf the surface under this text stays LIGHT in the dark theme (a literal bg-white card),'); console.error('the dark ink is correct — pin the colour so it cannot flip, e.g. text-[#1e293b], or'); console.error('record it with `npm run check:dark-text -- --update` and say in a comment why it belongs.'); console.error('\nIf the surface is a hard-coded dark colour in both themes (bg-[#0b1020]), pin the ink to a'); console.error('literal light value instead of any slate class, e.g. text-[#f1f5f9].'); console.error('\nThis check does not measure contrast — see the header of scripts/check-dark-text.mjs.'); process.exit(1); } if (removed.length > 0) { console.log(`dark-text: ${removed.length} baselined use(s) are gone — thanks. Refresh with:`); console.log(' npm run check:dark-text -- --update'); for (const r of removed) console.log(` ${r.key} (baseline ${r.count}, now ${r.current})`); } console.log(`dark-text: OK — ${total} known use(s), no new ones.`);