From c10de34a115edf211989f7cb844c1389471557db Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 09:24:07 +0000 Subject: [PATCH] chore(web): add CSS audit script for duplicate selectors (UI audit #7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds web/scripts/audit-css.mjs — surfaces classes that appear in many rules (likely targets of style drift) and per-class !important counts (specificity-fight indicators). Implements the script suggested by docs/ui/UI_AUDIT.md §5 #7 / Pattern G. Run: pnpm --filter @bytelyst/trading-web run audit:css (or: cd web && npm run audit:css) Initial run on src/index.css + src/App.css + src/layout-fixes.css exposes the top hotspots: - .positions-tab 22 rules, 26 !important - .trade-plans-page 20 rules, 25 !important - .history-tab 17 rules, 22 !important - .trading-sidebar-logo 9 rules, 20 !important These are the targets for the future Pattern G consolidation pass. The script supports --threshold N and --json for tooling. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- web/package.json | 3 +- web/scripts/audit-css.mjs | 129 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 web/scripts/audit-css.mjs 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.');