chore(web): audit-css.mjs — detect true (selector+context+body) duplicates

Extends the script with a strict-duplicate detector that distinguishes
real Pattern G dead code (same selector + same @media context + same
body, where everything but the LAST occurrence in cascade order has no
effect) from descendant-selector counts (which were mostly noise in the
previous report).

The new detector surfaced 29 dead rules in the prior commit (14398af);
it now reports 0 — the cleanup is complete and future regressions will
show up immediately.

Also wires the duplicate-rule data into the --json output for tooling.

Updates docs/ui/UI_AUDIT.md §5 #7 with the consolidation metrics.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin 2026-05-10 09:51:14 +00:00
parent 14398af792
commit 391b3a9fd3
2 changed files with 67 additions and 6 deletions

View File

@ -253,7 +253,11 @@ Quick Actions block fixed in commit `343ffb4`. 28 inline-style blocks remain —
- `bytelyst-trading/grid-needs-minmax` — fires on bare `Nfr` in gridTemplateColumns. Caught + fixed 2 additional violations on first run. - `bytelyst-trading/grid-needs-minmax` — fires on bare `Nfr` in gridTemplateColumns. Caught + fixed 2 additional violations on first run.
- `bytelyst-trading/no-button-with-stacked-children` — fires on `<Button>` with 2+ block children. - `bytelyst-trading/no-button-with-stacked-children` — fires on `<Button>` with 2+ block children.
- All wired as `'warn'`. `npm run lint` reports zero `bytelyst-trading/*` warnings. - All wired as `'warn'`. `npm run lint` reports zero `bytelyst-trading/*` warnings.
7. ✅ **CSS audit script.** `web/scripts/audit-css.mjs` shipped in `c10de34` (extended for hex audit in `063afdb`). Surfaces duplicate selectors + per-class !important counts + hardcoded hex outside token defs. Run via `npm run audit:css`. Top hotspots: `.positions-tab` (22 rules / 26 !imp), `.trade-plans-page` (20 / 25), `.history-tab` (17 / 22), `.trading-sidebar-logo` (9 / 20) — these are the Pattern G consolidation targets for a future pass. 7. ✅ **CSS audit script.** `web/scripts/audit-css.mjs` shipped in `c10de34` (extended for hex audit in `063afdb`, true-duplicate detection in the script-update commit after `14398af`). Surfaces duplicate selectors, per-class !important counts, hardcoded hex outside token defs, and **true duplicates** (same selector + same @media context + same body — pure copy-paste dead code). Run via `npm run audit:css`. Pattern G consolidation pass in commit `14398af` removed 29 dead/duplicate rules and 28 unnecessary `!important` declarations:
- `index.css`: 3082 → 2896 lines (-6%)
- Total `!important`: 183 → 149 (-19%)
- True-duplicate count: 29 → 0
- The 5-times-defined `.trading-sidebar-logo` consolidated into a single canonical version in `layout-fixes.css §22`.
### Medium-term (23 weeks) ### Medium-term (23 weeks)

View File

@ -65,11 +65,35 @@ function findHexViolations(content) {
return violations; return violations;
} }
// Find the @media (or @supports etc) context for a given byte offset in CSS.
// Walks backward through the source counting unmatched braces.
function findEnclosingAtRule(content, idx) {
let depth = 0;
for (let i = idx - 1; i >= 0; i--) {
const c = content[i];
if (c === '}') depth++;
else if (c === '{') {
if (depth === 0) {
// Find selector-like text before this `{`
let j = i - 1;
while (j > 0 && content[j] !== '{' && content[j] !== '}') j--;
const snippet = content.slice(j + 1, i).trim().replace(/\s+/g, ' ');
if (snippet.startsWith('@')) return snippet;
return null;
}
depth--;
}
}
return null;
}
function audit(filePath) { function audit(filePath) {
const content = readSafe(filePath); const content = readSafe(filePath);
if (content == null) return null; if (content == null) return null;
// For each top-level rule block, collect class selectors and !important counts. // For each top-level rule block, collect class selectors and !important counts.
const defs = new Map(); // className -> [{file, line, importants}] const defs = new Map(); // className -> [{file, line, importants}]
// Same-selector + same-context + same-body groups (true Pattern G dead code).
const trueDups = new Map(); // key -> [{file, line, selector, context}]
let m; let m;
RULE_BLOCK_RE.lastIndex = 0; RULE_BLOCK_RE.lastIndex = 0;
while ((m = RULE_BLOCK_RE.exec(content)) !== null) { while ((m = RULE_BLOCK_RE.exec(content)) !== null) {
@ -77,10 +101,21 @@ function audit(filePath) {
const body = m[2]; const body = m[2];
const ruleStartIdx = m.index; const ruleStartIdx = m.index;
const importants = (body.match(/!important/g) || []).length; 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; if (/^\s*@/.test(selector)) continue;
// Extract class names from selector list. const normSel = selector.trim().replace(/\s+/g, ' ');
const normBody = body.trim().replace(/\s+/g, ' ');
const context = findEnclosingAtRule(content, ruleStartIdx);
if (normBody) {
const key = `${normSel}::${context || 'top'}::${normBody}`;
if (!trueDups.has(key)) trueDups.set(key, []);
trueDups.get(key).push({
file: filePath,
line: lineOf(content, ruleStartIdx),
selector: normSel,
context: context || 'top-level',
});
}
// Also track loose "class touched by N rules" for the duplicate-class report
const classes = new Set(); const classes = new Set();
let s; let s;
SELECTOR_RE.lastIndex = 0; SELECTOR_RE.lastIndex = 0;
@ -92,10 +127,9 @@ function audit(filePath) {
defs.get(cls).push({ file: filePath, line: lineOf(content, ruleStartIdx), importants }); defs.get(cls).push({ file: filePath, line: lineOf(content, ruleStartIdx), importants });
} }
} }
// Total !important
const totalImportant = (content.match(/!important/g) || []).length; const totalImportant = (content.match(/!important/g) || []).length;
const hexViolations = findHexViolations(content); const hexViolations = findHexViolations(content);
return { filePath, defs, totalImportant, hexViolations }; return { filePath, defs, totalImportant, hexViolations, trueDups };
} }
const reports = []; const reports = [];
@ -109,6 +143,7 @@ for (const p of opts.paths) {
const merged = new Map(); const merged = new Map();
let totalImportant = 0; let totalImportant = 0;
const allHexViolations = []; const allHexViolations = [];
const allTrueDups = new Map(); // key -> occurrences across files
for (const r of reports) { for (const r of reports) {
totalImportant += r.totalImportant; totalImportant += r.totalImportant;
for (const [cls, locs] of r.defs.entries()) { for (const [cls, locs] of r.defs.entries()) {
@ -116,7 +151,12 @@ for (const r of reports) {
merged.get(cls).push(...locs); merged.get(cls).push(...locs);
} }
for (const v of r.hexViolations) allHexViolations.push({ file: r.filePath, ...v }); for (const v of r.hexViolations) allHexViolations.push({ file: r.filePath, ...v });
for (const [k, occs] of r.trueDups.entries()) {
if (!allTrueDups.has(k)) allTrueDups.set(k, []);
allTrueDups.get(k).push(...occs);
} }
}
const trueDupGroups = [...allTrueDups.values()].filter(g => g.length >= 2);
// Filter to classes with >= threshold definitions // Filter to classes with >= threshold definitions
const dups = [...merged.entries()] const dups = [...merged.entries()]
@ -135,6 +175,12 @@ if (opts.json) {
duplicateSelectors: dups.map(([cls, locs]) => ({ class: cls, count: locs.length, locations: locs })), duplicateSelectors: dups.map(([cls, locs]) => ({ class: cls, count: locs.length, locations: locs })),
importantByClass: importantPerClass.map(([cls, n]) => ({ class: cls, count: n })), importantByClass: importantPerClass.map(([cls, n]) => ({ class: cls, count: n })),
hexViolations: allHexViolations, hexViolations: allHexViolations,
trueDuplicates: trueDupGroups.map(g => ({
selector: g[0].selector,
context: g[0].context,
count: g.length,
locations: g.map(o => `${path.basename(o.file)}:${o.line}`),
})),
}, null, 2)); }, null, 2));
exit(0); exit(0);
} }
@ -158,6 +204,17 @@ for (const [cls, n] of importantPerClass.slice(0, 15)) {
console.log(` ${String(n).padStart(5)} .${cls}`); console.log(` ${String(n).padStart(5)} .${cls}`);
} }
console.log(''); console.log('');
console.log('');
console.log(`True duplicate rules (same selector + same @media + same body — pure dead code): ${trueDupGroups.reduce((s, g) => s + g.length - 1, 0)}`);
if (trueDupGroups.length > 0) {
console.log(' (these are strict copy-paste duplicates; the LAST one in source order wins, the rest are dead code and can be deleted)');
for (const g of trueDupGroups.slice(0, 10)) {
console.log(` ${g.length}x ${g[0].selector} [${g[0].context}]`);
for (const o of g) console.log(` ${path.basename(o.file)}:${o.line}`);
}
if (trueDupGroups.length > 10) console.log(` ... ${trueDupGroups.length - 10} more groups`);
}
console.log(''); console.log('');
console.log(`Hardcoded hex colors outside token defs / fallbacks: ${allHexViolations.length}`); console.log(`Hardcoded hex colors outside token defs / fallbacks: ${allHexViolations.length}`);
if (allHexViolations.length > 0) { if (allHexViolations.length > 0) {