diff --git a/web/package.json b/web/package.json index 97f72c4..ddbca0a 100644 --- a/web/package.json +++ b/web/package.json @@ -21,7 +21,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:viewport": "playwright test e2e/viewport-matrix.spec.ts", - "test:e2e:overflow": "playwright test e2e/horizontal-overflow.spec.ts" + "test:e2e:overflow": "playwright test e2e/horizontal-overflow.spec.ts", + "audit:css": "node scripts/audit-css.mjs" }, "dependencies": { "@bytelyst/api-client": "^0.1.6", diff --git a/web/scripts/audit-css.mjs b/web/scripts/audit-css.mjs new file mode 100644 index 0000000..78e4b4b --- /dev/null +++ b/web/scripts/audit-css.mjs @@ -0,0 +1,129 @@ +#!/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) +// +// 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 and Pattern 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; +} + +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; + return { filePath, defs, totalImportant }; +} + +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; +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); + } +} + +// 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 })), + }, 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('See docs/ui/UI_AUDIT.md Pattern G for fix guidance.');