#!/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-` (raw palette, not a token) * inline-hex `color|background|backgroundColor: #...` (inline raw hex) * low-opacity-token `text-/` 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-/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 (60–99). 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 60–99% 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');