chore: update audit format - show really different styles

This commit is contained in:
2026-08-30 19:16:18 +05:00
parent 453a5908e6
commit 2d72848e8e
2 changed files with 2383 additions and 7190 deletions
+2036 -7178
View File
File diff suppressed because it is too large Load Diff
+347 -12
View File
@@ -1,9 +1,12 @@
// audit-cdp.mjs
// CDP/DOMSnapshot-based visual audit over multiple routes.
// Visual audit over multiple routes comparing SPECIFIED styles (what the CSS
// actually declares, after cascade + var() resolution + inheritance) instead
// of computed layout values — so sizes driven by differing page content don't
// produce false positives. Geometry (box) is only reported where a dimension
// is explicitly locked by CSS, not where it just follows the content flow.
// Replicates the route→ref mapping from audit-css.mjs (the 4 targets that have
// a corresponding ref HTML file), starts a dev server (unless --no-serve),
// captures each pair, diffs computed styles + geometry, and writes a report to
// web/audit/cdp-audit.txt.
// captures each pair, diffs, and writes a report to web/audit/cdp-audit.txt.
//
// Usage:
// node scripts/audit-cdp.mjs # serve + audit all 4 targets
@@ -113,7 +116,7 @@ async function capture(browser, target) {
});
await page.waitForTimeout(400);
const tree = await page.evaluate((PROPS) => {
const tree = await page.evaluate((props) => {
const NOISE_TAGS = new Set([
"script",
"template",
@@ -121,7 +124,260 @@ async function capture(browser, target) {
"link",
"noscript",
]);
const snap = (el) => {
// Properties that inherit by default — their specified value flows to children.
const INHERITED = new Set([
"color",
"cursor",
"direction",
"font-family",
"font-size",
"font-style",
"font-variant",
"font-weight",
"letter-spacing",
"line-height",
"text-align",
"text-indent",
"text-shadow",
"text-transform",
"visibility",
"white-space",
"word-spacing",
]);
// ---- collect author rules (stylesheets) preserving cascade order ----
const rules = [];
let ruleOrder = 0;
const collect = (list) => {
for (const r of list || []) {
if (r == null) continue;
if (r.type === 1) rules.push({ rule: r, order: ruleOrder++ });
else if (r.type === 3 && r.styleSheet) {
try {
collect(r.styleSheet.cssRules);
} catch {}
} else if (r.type === 4) {
if (r.media?.matches) {
try {
collect(r.cssRules);
} catch {}
}
} else if (r.type === 12) {
try {
if (CSS.supports(r.conditionText)) collect(r.cssRules);
} catch {
try {
collect(r.cssRules);
} catch {}
}
} else if (r.type === 15) {
try {
collect(r.cssRules);
} catch {}
}
}
};
for (const sheet of document.styleSheets) {
try {
collect(sheet.cssRules);
} catch {}
}
// Strip state pseudo-classes that depend on the current pointer/focus state.
const STATE = /:(hover|active|focus|focus-visible|focus-within|visited)\b/g;
// Selector specificity parser (approximation of the CSS algorithm):
// ids, classes, attributes, elements, plus :is/:not/:has (max of args)
// and :where (zero).
const splitTop = (s, sep) => {
const parts = [];
let depth = 0,
cur = "";
const isSep = (ch) =>
sep === "," ? ch === "," : ch === ">" || ch === "+" || ch === "~" || /\s/.test(ch);
for (const ch of s) {
if (ch === "(" || ch === "[") depth++;
else if (ch === ")" || ch === "]") depth--;
if (depth === 0 && isSep(ch)) {
if (cur.trim()) parts.push(cur.trim());
cur = "";
} else cur += ch;
}
if (cur.trim()) parts.push(cur.trim());
return parts;
};
const specCompound = (full) => {
let [A, B, C] = [0, 0, 0];
for (const comp of splitTop(full, " ")) {
const n = comp.length;
let i = 0;
const ident = (ch) => /[\w\u00A0-\uFFFF-]/.test(ch);
const readName = () => {
let o = "";
while (i < n && ident(comp[i])) o += comp[i++];
return o;
};
const endParen = () => {
let d = 1;
i++;
while (i < n) {
if (comp[i] === "(" || comp[i] === "[") d++;
else if (comp[i] === ")") {
d--;
if (!d) return i;
}
i++;
}
return n;
};
while (i < n) {
const ch = comp[i];
if (ch === "#") {
A++;
i++;
readName();
} else if (ch === ".") {
B++;
i++;
readName();
} else if (ch === "*") i++;
else if (ch === "[") {
B++;
while (i < n && comp[i] !== "]") i++;
i++;
} else if (ch === ":") {
if (comp[i + 1] === ":") {
C++;
i += 2;
readName();
} else {
i++;
const name = readName();
if (comp[i] === "(") {
const inner = comp.slice(i + 1, endParen());
if (name !== "where") {
for (const arg of splitTop(inner, ",")) {
const [a, b, c] = specCompound(arg);
A += a;
B += b;
C += c;
}
}
} else B++;
}
} else if (/[a-zA-Z]/.test(ch)) {
C++;
readName();
} else i++;
}
}
return [A, B, C];
};
// All declarations that apply to `el`, in cascade terms.
const matched = (el) => {
const out = [];
for (const { rule, order } of rules) {
const sel = rule.selectorText;
if (!sel) continue;
let ok = false;
try {
ok = el.matches(sel.replace(STATE, ""));
} catch {
try {
ok = el.matches(sel);
} catch {}
}
if (!ok) continue;
const [a, b, c] = specCompound(sel);
const st = rule.style;
for (let k = 0; k < st.length; k++) {
const name = st.item(k);
out.push({
name,
value: st.getPropertyValue(name).trim(),
important: st.getPropertyPriority(name) === "important",
a,
b,
c,
order,
});
}
}
const inline = el.getAttribute("style");
if (inline) {
const st = el.style;
for (let k = 0; k < st.length; k++) {
const name = st.item(k);
out.push({
name,
value: st.getPropertyValue(name).trim(),
important: st.getPropertyPriority(name) === "important",
a: 1e6,
b: 0,
c: 0,
order: 1e6,
});
}
}
return out;
};
const keyOf = (d) => [d.important ? 1 : 0, d.a, d.b, d.c, d.order];
const betterThan = (x, y) => {
const kx = keyOf(x),
ky = keyOf(y);
for (let i = 0; i < kx.length; i++)
if (kx[i] !== ky[i]) return kx[i] > ky[i] ? 1 : -1;
return 0;
};
// Pick the winning declaration per property (inline > !important > cascade).
const resolve = (decls) => {
const map = new Map();
for (const d of decls) {
const cur = map.get(d.name);
if (!cur || betterThan(d, cur) > 0) map.set(d.name, d);
}
return map;
};
// Inherited entries must always lose against a real declaration.
// Custom properties inherit too, so they propagate like inherited props.
const inheritedEntries = (m) => {
const out = [];
for (const [k, v] of m)
if (
(INHERITED.has(k) || k.startsWith("--")) &&
typeof v === "object" &&
v !== null
)
out.push([
k,
{
name: k,
value: v.value ?? "",
important: false,
a: -1,
b: -1,
c: -1,
order: -1,
},
]);
return out;
};
// Substitute var(--name[, fallback]) with the resolved custom property value
// from getComputedStyle (custom props inherit, so per-element lookup works).
const resolveVars = (value, cs, depth = 0) => {
if (depth > 8 || !value.includes("var(")) return value;
return value.replace(
/var\((--[\w-]+)(?:,([^)]*))?\)/g,
(_, name, fb) => {
const v = cs.getPropertyValue(name).trim();
if (v) return resolveVars(v, cs, depth + 1);
return fb ? resolveVars(fb, cs, depth + 1).trim() : "";
},
);
};
const snap = (el, inherited) => {
const tag = el.tagName.toLowerCase();
if (
NOISE_TAGS.has(tag) ||
@@ -129,7 +385,24 @@ async function capture(browser, target) {
el.id === "svelte-announcer"
)
return null;
const specMap = new Map(inheritedEntries(inherited));
for (const [name, d] of resolve(matched(el))) {
const cur = specMap.get(name);
if (!cur || betterThan(d, cur) > 0) specMap.set(name, d);
}
const cs = getComputedStyle(el);
const styles = {};
const raw = {};
const computed = {};
for (const p of props) {
const c = cs.getPropertyValue(p);
computed[p] = c;
const rv = specMap.get(p)?.value ?? "";
raw[p] = rv;
let v = rv;
if (v.includes("var(")) v = resolveVars(v, cs);
styles[p] = v;
}
const r = el.getBoundingClientRect();
return {
tag,
@@ -147,16 +420,21 @@ async function capture(browser, target) {
w: +r.width.toFixed(1),
h: +r.height.toFixed(1),
},
styles: Object.fromEntries(
PROPS.map((p) => [p, cs.getPropertyValue(p)]),
),
styles,
raw,
computed,
children:
tag === "svg"
? []
: [...el.children].map(snap).filter(Boolean),
: [...el.children].map((ch) => snap(ch, specMap)).filter(Boolean),
};
};
return snap(document.body);
const htmlNode = snap(document.documentElement, new Map());
const findBody = (n) =>
n?.tag === "body" ? n : (n?.children ?? []).map(findBody).find(Boolean);
const body = findBody(htmlNode);
if (!body) throw new Error("capture: body not found");
return body;
}, PROPS);
await page.close();
@@ -343,6 +621,43 @@ function diffStyles(a, b) {
`⚠ node without styles (a.tag=${a?.tag}, b.tag=${b?.tag}) — matching bug, inspect manually`,
];
// Layout-sensitive props must never fall back to computed: keeping them
// specified (e.g. "auto") is what makes this audit content-size independent.
const LAYOUT = new Set([
"width",
"height",
"min-width",
"min-height",
"max-width",
"max-height",
"top",
"right",
"bottom",
"left",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"row-gap",
"column-gap",
]);
const DEFAULTISH = (v) =>
v === "" ||
v === "inherit" ||
v === "initial" ||
v === "unset" ||
v === "revert";
// Geometry drift along an axis only matters when that dimension is actually
// locked by CSS; otherwise it is just content flow (text lengths, images).
const flowDriven = (node, k) => {
if (!node?.styles) return true;
if (k === "w") return DEFAULTISH(node.styles.width);
if (k === "h") return DEFAULTISH(node.styles.height);
if (k === "x") return DEFAULTISH(node.styles.left) && DEFAULTISH(node.styles.right);
if (k === "y") return DEFAULTISH(node.styles.top) && DEFAULTISH(node.styles.bottom);
return true;
};
if (!!a.box !== !!b.box)
out.push(
`render: node is rendered only on one side ` +
@@ -351,18 +666,38 @@ function diffStyles(a, b) {
if (a.box && b.box)
for (const k of ["x", "y", "w", "h"]) {
const d = +(b.box[k] - a.box[k]).toFixed(1);
if (Math.abs(d) > PX_TOL)
if (Math.abs(d) > PX_TOL && !(flowDriven(a, k) && flowDriven(b, k)))
out.push(
`${k}: ${a.box[k]}${b.box[k]} (${d > 0 ? "+" : ""}${d}px)`,
);
}
// If a side is declared via CSS variables, append the raw declaration so
// the defining rule is easy to find (e.g. "color: var(--accent)").
const fmtSide = (n, k) => {
const res = n?.styles?.[k] ?? "";
const r = n?.raw?.[k] ?? "";
if (r && r.includes("var(") && r !== res)
return `${res || "—"} [${r}]`;
return res;
};
for (const k of new Set([
...Object.keys(a.styles),
...Object.keys(b.styles),
])) {
const va = a.styles[k] ?? "",
vb = b.styles[k] ?? "";
if (va !== vb) out.push(`${k}: ${va || "—"}${vb || "—"}`);
if (va !== vb) {
// One side relied on a default (UA/inherit) while the other spelled it
// out, but they render identically — not a real difference. Skip for
// content-independent props by comparing the computed values.
if (
!LAYOUT.has(k) &&
(DEFAULTISH(va) || DEFAULTISH(vb)) &&
a.computed?.[k] === b.computed?.[k]
)
continue;
out.push(`${k}: ${fmtSide(a, k) || "—"}${fmtSide(b, k) || "—"}`);
}
}
return out;
}