Files
waggle-os/scripts/ux-gates/text-color-guard.mjs
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

176 lines
8.0 KiB
JavaScript
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
/**
* ux-gate · text-color-guard (the generation-vector ban)
* ────────────────────────────────────────────────────────────────────────────
* Pillar 4.2(a). Token-pair math (contrast-tokens.mjs) buys one clean round;
* this buys a FLOOR by banning the vectors that regenerate off-token text
* colours. Scans apps/web/src for:
*
* hex-class `text-[#...]` (arbitrary hex text)
* palette-class `text-hive-<n>` (raw palette, not a token)
* inline-hex `color|background|backgroundColor: #...` (inline raw hex)
* low-opacity-token `text-<text-token>/<N>` with N<60 (token dimmed below AA)
*
* It is a RATCHET, not a big-bang: `color-guard-baseline.json` freezes today's
* grandfathered instances; the gate fails only on NEW instances beyond the
* frozen multiset. `text-<token>/N` with 60≤N<100 is reported as a WARNING
* (allowed, listed), never a failure.
*
* Excludes tests, the token source files (index.css / waggle-theme.css), and
* the motion-spec demo page. No dependencies.
*
* Run: npm run ux:color-guard
* Update the frozen baseline (after an intentional, reviewed change):
* node scripts/ux-gates/text-color-guard.mjs --update-baseline
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const SCAN_DIR = path.join(ROOT, 'apps/web/src');
const BASELINE = path.join(ROOT, 'scripts/ux-gates/color-guard-baseline.json');
const OPACITY_FLOOR = 60; // Tailwind opacity % below which a text token is an offense.
const EXTS = new Set(['.tsx', '.ts', '.jsx', '.js']);
const EXCLUDE_FILES = new Set(['index.css', 'waggle-theme.css', 'MotionSpec.tsx']);
const isExcluded = (rel) =>
/(^|\/)(test|__tests__|__mocks__)\//.test(rel) ||
/\.(test|spec)\.(t|j)sx?$/.test(rel) ||
EXCLUDE_FILES.has(path.basename(rel));
// Neutral text-tier tokens whose opacity we police (brand accents excluded — a
// low-opacity honey is a design choice, not a body-legibility violation).
const TEXT_TIERS =
'foreground|muted-foreground|text|text-2|text-muted|text-dim|text-tertiary|text-bright' +
'|card-foreground|popover-foreground|secondary-foreground|accent-foreground';
const DETECTORS = [
{ kind: 'hex-class', re: /text-\[#[0-9a-fA-F]{3,8}\]/g },
{ kind: 'palette-class', re: /\btext-hive-\d{2,3}\b/g },
{ kind: 'inline-hex', re: /\b(?:color|background|backgroundColor)\s*:\s*['"]?#[0-9a-fA-F]{3,8}\b/g },
];
// Opacity detectors capture N so we can split offense (<60) vs warning (6099).
const OPACITY_DETECTORS = [
new RegExp(`\\btext-(?:${TEXT_TIERS})\\/(\\d{1,3})\\b`, 'g'),
/\btext-\[var\(--[\w-]+\)\]\/(\d{1,3})\b/g,
];
function walk(dir, out = []) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === '.git') continue;
const full = path.join(dir, name);
const st = statSync(full);
if (st.isDirectory()) walk(full, out);
else if (EXTS.has(path.extname(name))) out.push(full);
}
return out;
}
const rel = (full) => path.relative(ROOT, full).split(path.sep).join('/');
const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
const normSnippet = (s) => s.trim().replace(/\s+/g, '').replace(/['"]/g, '');
/** Scan the tree → { offenses:[{file,line,kind,snippet}], warnings:[…] }. */
function scan() {
const offenses = [];
const warnings = [];
for (const full of walk(SCAN_DIR)) {
const relFile = rel(full);
if (isExcluded(relFile)) continue;
const text = readFileSync(full, 'utf8');
for (const { kind, re } of DETECTORS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
offenses.push({ file: relFile, line: lineOf(text, m.index), kind, snippet: normSnippet(m[0]) });
}
}
for (const re of OPACITY_DETECTORS) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) {
const n = Number.parseInt(m[1], 10);
const rec = { file: relFile, line: lineOf(text, m.index), snippet: normSnippet(m[0]) };
if (n < OPACITY_FLOOR) offenses.push({ ...rec, kind: 'low-opacity-token' });
else if (n < 100) warnings.push({ ...rec, kind: 'mid-opacity-token' });
}
}
}
const sort = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.snippet.localeCompare(b.snippet);
return { offenses: offenses.sort(sort), warnings: warnings.sort(sort) };
}
const keyOf = (e) => `${e.file}|${e.kind}|${e.snippet}`;
function multiset(entries) {
const m = new Map();
for (const e of entries) m.set(keyOf(e), (m.get(keyOf(e)) ?? 0) + 1);
return m;
}
function loadBaseline() {
if (!existsSync(BASELINE)) return { entries: [] };
try { return JSON.parse(readFileSync(BASELINE, 'utf8')); }
catch { return { entries: [] }; }
}
// ── Run ─────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const { offenses, warnings } = scan();
if (args.includes('--update-baseline')) {
const payload = {
generatedAt: new Date().toISOString().slice(0, 10),
note: 'Frozen grandfathered off-token text colours. Regenerate ONLY after a reviewed, intentional change. The gate fails on NEW instances beyond this multiset.',
floor: OPACITY_FLOOR,
entries: offenses,
};
writeFileSync(BASELINE, JSON.stringify(payload, null, 2) + '\n');
console.log(`ux-gate · text-color-guard — baseline written: ${offenses.length} grandfathered offense(s), ${warnings.length} warning(s).`);
process.exit(0);
}
const baseline = loadBaseline();
const baseCounts = multiset(baseline.entries ?? []);
const curCounts = multiset(offenses);
// NEW = current keys whose count exceeds the frozen baseline count.
const seen = new Map();
const newEntries = [];
for (const e of offenses) {
const k = keyOf(e);
const used = seen.get(k) ?? 0;
if (used >= (baseCounts.get(k) ?? 0)) newEntries.push(e);
seen.set(k, used + 1);
}
// Baseline entries no longer present (fixed) — informational; suggests re-freeze.
let removed = 0;
for (const [k, n] of baseCounts) removed += Math.max(0, n - (curCounts.get(k) ?? 0));
if (args.includes('--json')) {
console.log(JSON.stringify({ offenses, warnings, newEntries, removed }, null, 2));
process.exit(newEntries.length > 0 ? 1 : 0);
}
console.log('\nux-gate · text-color-guard — off-token text-colour ratchet\n');
console.log(` scanned: apps/web/src (${EXTS.size} JS/TS extensions, tests + token files + MotionSpec excluded)`);
console.log(` offenses: ${offenses.length} total · ${baseline.entries?.length ?? 0} frozen in baseline`);
console.log(` warnings: ${warnings.length} (text token at 6099% opacity — allowed)`);
if (removed > 0) console.log(` note: ${removed} baseline offense(s) fixed since freeze — run --update-baseline to tighten the ratchet.`);
if (warnings.length) {
const sample = warnings.slice(0, 8).map((w) => ` ${w.file}:${w.line} ${w.snippet}`).join('\n');
console.log(`\n warning list (first ${Math.min(8, warnings.length)} of ${warnings.length}):\n${sample}`);
}
if (newEntries.length > 0) {
console.error(`\n✗ text-color-guard FAILED — ${newEntries.length} NEW off-token text colour(s):\n`);
for (const e of newEntries) console.error(` ${e.file}:${e.line} [${e.kind}] ${e.snippet}`);
console.error('\n Use a semantic token (--text / --text-2 / --text-muted / --text-tertiary) instead of a raw');
console.error(' hex, palette class, or sub-60% opacity. If this IS intentional and reviewed, re-freeze with');
console.error(' `node scripts/ux-gates/text-color-guard.mjs --update-baseline`.\n');
process.exit(1);
}
console.log('\n✓ text-color-guard PASSED — no new off-token text colours beyond the frozen baseline.\n');