This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
// scripts/check-dark-text.mjs
|
||||
//
|
||||
// Counts `dark:` + `text-slate-<n>` 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 <dir> --baseline <file>
|
||||
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-<number>`.
|
||||
const VARIANT = String.raw`(?:[a-z][\w-]*:)`;
|
||||
const CLASS_RE = new RegExp(
|
||||
String.raw`(?<![\w./:-])(${VARIANT}*dark:${VARIANT}*text-slate-\d+)(?![\w./-])`,
|
||||
'g',
|
||||
);
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Which files does Tailwind scan?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = (await import(pathToFileURL(CONFIG_PATH).href)).default;
|
||||
} catch (err) {
|
||||
console.error(`dark-text: cannot load ${relative(ROOT, CONFIG_PATH)} — run \`npm --prefix ui install\``);
|
||||
console.error(String(err?.message ?? err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Turn a Tailwind content glob into { dir, test } we can walk. */
|
||||
function compileGlob(pattern) {
|
||||
const clean = pattern.replace(/^\.\//, '');
|
||||
const parts = clean.split('/');
|
||||
const firstMagic = parts.findIndex((p) => /[*?{[]/.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-<n> 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-<n> 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-<n>');
|
||||
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.`);
|
||||
Reference in New Issue
Block a user