learning_ai_invt_trdg/web/scripts/audit-css.mjs
Devin 063afdbff6 chore(web): hex-color audit + annotate intentional shadows (UI audit #10)
Extends scripts/audit-css.mjs to also report hardcoded hex colors
outside CSS custom-property defs (`--name: #xxx;`) and outside
`var(--token, #fallback)` token-default fallbacks (which are explicitly
allowed per repo design-system rules).

Initial run reports 4 violations — all intentional pre-cascade body
defaults in :root and html.dark that must be literal hex (no var()) so
the page renders correct colors before the cascade resolves the custom
props. Annotated each with an explanatory comment; values intentionally
mirror --foreground / --background. Future drift surfaces immediately.

UI audit doc §5 #10 updated to reflect the sweep is done and the 4 known
violations are documented.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-10 09:33:54 +00:00

172 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
// Audit CSS files in src/ for:
// - Class selectors defined more than once (rule duplication / drift)
// - Per-class !important counts (specificity-fight indicators)
// - Hardcoded hex colors outside CSS custom-property definitions
// and outside `var(--token, #fallback)` token-default fallbacks
// (which are explicitly allowed per repo design-system rules)
//
// Outputs a report to stdout. Exits 0 always; this is informational.
//
// Usage:
// node scripts/audit-css.mjs [path-or-glob...]
// node scripts/audit-css.mjs --threshold 2 # only show classes with >= N defs
// node scripts/audit-css.mjs --json # machine-readable output
//
// See docs/ui/UI_AUDIT.md §5 #7/#10 and Patterns G.
import fs from 'node:fs';
import path from 'node:path';
import { argv, exit } from 'node:process';
const args = argv.slice(2);
const opts = { threshold: 2, json: false, paths: [] };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--threshold' || a === '-t') opts.threshold = parseInt(args[++i], 10);
else if (a === '--json') opts.json = true;
else opts.paths.push(a);
}
if (opts.paths.length === 0) opts.paths = ['src/index.css', 'src/App.css', 'src/layout-fixes.css'];
const SELECTOR_RE = /(^|[\s},])\s*\.([a-zA-Z_][\w-]*)\b/g;
const RULE_BLOCK_RE = /([^{}]+?)\{([^{}]*)\}/gs;
function readSafe(p) {
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
}
function lineOf(content, idx) {
return content.slice(0, idx).split('\n').length;
}
// --- Hex-color audit ----------------------------------------------------
const HEX_RE = /#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})\b/g;
const TOKEN_FALLBACK_RE = /var\([^)]*?#[0-9A-Fa-f]{3,8}[^)]*?\)/g;
const CUSTOM_PROP_DEF_RE = /^\s*--[\w-]+\s*:/;
function findHexViolations(content) {
// Mask out var(--x, #xxx) so hex inside fallbacks doesn't count.
const masked = content.replace(TOKEN_FALLBACK_RE, '___TOKEN___');
const violations = [];
const lines = masked.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const matches = line.match(HEX_RE);
if (!matches) continue;
// Allowed: hex in a CSS custom-property definition (--name: #xxx;)
if (CUSTOM_PROP_DEF_RE.test(line)) continue;
// Allowed: comments
if (/^\s*\*|^\s*\/\//.test(line)) continue;
for (const hex of matches) {
violations.push({ line: i + 1, hex, snippet: line.trim().slice(0, 100) });
}
}
return violations;
}
function audit(filePath) {
const content = readSafe(filePath);
if (content == null) return null;
// For each top-level rule block, collect class selectors and !important counts.
const defs = new Map(); // className -> [{file, line, importants}]
let m;
RULE_BLOCK_RE.lastIndex = 0;
while ((m = RULE_BLOCK_RE.exec(content)) !== null) {
const selector = m[1];
const body = m[2];
const ruleStartIdx = m.index;
const importants = (body.match(/!important/g) || []).length;
// Skip @media / @supports / @keyframes blocks for "duplicate" detection — they're
// legitimately repeated. We track them separately below.
if (/^\s*@/.test(selector)) continue;
// Extract class names from selector list.
const classes = new Set();
let s;
SELECTOR_RE.lastIndex = 0;
while ((s = SELECTOR_RE.exec(selector)) !== null) {
classes.add(s[2]);
}
for (const cls of classes) {
if (!defs.has(cls)) defs.set(cls, []);
defs.get(cls).push({ file: filePath, line: lineOf(content, ruleStartIdx), importants });
}
}
// Total !important
const totalImportant = (content.match(/!important/g) || []).length;
const hexViolations = findHexViolations(content);
return { filePath, defs, totalImportant, hexViolations };
}
const reports = [];
for (const p of opts.paths) {
const abs = path.resolve(p);
const r = audit(abs);
if (r) reports.push(r);
}
// Merge defs across files
const merged = new Map();
let totalImportant = 0;
const allHexViolations = [];
for (const r of reports) {
totalImportant += r.totalImportant;
for (const [cls, locs] of r.defs.entries()) {
if (!merged.has(cls)) merged.set(cls, []);
merged.get(cls).push(...locs);
}
for (const v of r.hexViolations) allHexViolations.push({ file: r.filePath, ...v });
}
// Filter to classes with >= threshold definitions
const dups = [...merged.entries()]
.filter(([, locs]) => locs.length >= opts.threshold)
.sort((a, b) => b[1].length - a[1].length);
const importantPerClass = [...merged.entries()]
.map(([cls, locs]) => [cls, locs.reduce((s, l) => s + l.importants, 0)])
.filter(([, n]) => n > 0)
.sort((a, b) => b[1] - a[1]);
if (opts.json) {
console.log(JSON.stringify({
files: opts.paths,
totalImportant,
duplicateSelectors: dups.map(([cls, locs]) => ({ class: cls, count: locs.length, locations: locs })),
importantByClass: importantPerClass.map(([cls, n]) => ({ class: cls, count: n })),
hexViolations: allHexViolations,
}, null, 2));
exit(0);
}
console.log('=== CSS audit ===');
console.log(`Files scanned: ${opts.paths.join(', ')}`);
console.log(`Total !important declarations: ${totalImportant}`);
console.log('');
console.log(`Class selectors defined ${opts.threshold}+ times (top 30):`);
console.log(' count class');
console.log(' ----- ----------------------------------------');
for (const [cls, locs] of dups.slice(0, 30)) {
const lines = locs.map(l => `${path.basename(l.file)}:${l.line}`).join(', ');
console.log(` ${String(locs.length).padStart(5)} .${cls} (${lines})`);
}
console.log('');
console.log('Top 15 classes by !important count (each declaration is a specificity fight):');
console.log(' count class');
console.log(' ----- ----------------------------------------');
for (const [cls, n] of importantPerClass.slice(0, 15)) {
console.log(` ${String(n).padStart(5)} .${cls}`);
}
console.log('');
console.log('');
console.log(`Hardcoded hex colors outside token defs / fallbacks: ${allHexViolations.length}`);
if (allHexViolations.length > 0) {
console.log(' (allowed: hex inside `--name: #xxx;` defs and `var(--x, #xxx)` fallbacks)');
for (const v of allHexViolations.slice(0, 20)) {
console.log(` ${path.basename(v.file)}:${v.line} ${v.hex} ${v.snippet}`);
}
if (allHexViolations.length > 20) console.log(` ... ${allHexViolations.length - 20} more`);
}
console.log('');
console.log('See docs/ui/UI_AUDIT.md Pattern G + §5 #7/#10 for fix guidance.');