// audit-cdp.mjs // 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, and writes a report to web/audit/cdp-audit.txt. // // Usage: // node scripts/audit-cdp.mjs # serve + audit all 4 targets // node scripts/audit-cdp.mjs --no-serve # assume a server is already running // node scripts/audit-cdp.mjs --ours http://127.0.0.1:5173 // node scripts/audit-cdp.mjs --route /preview/demo --ref demo.html import { diffArrays } from "diff"; import { spawn } from "node:child_process"; import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { chromium } from "playwright"; // ==== CLI / config ========================================================= const args = parseCli(); function parseCli() { const a = { viewports: [] }; for (let i = 0; i < process.argv.length; i++) { const v = process.argv[i]; if (v === "--no-serve") a.noServe = true; else if (v === "--ours") a.ours = process.argv[++i]; else if (v === "--ref") a.ref = process.argv[++i]; else if (v === "--route") a.route = process.argv[++i]; else if (v === "--port") a.port = Number(process.argv[++i]); else if (v === "--viewport") a.viewports.push(...(process.argv[++i] ?? "").split(",").filter(Boolean)); } return a; } const WEB = process.cwd(); const PORT = args.port || 5179; const REFS_DIR = resolve(WEB, "../refs-html"); const PX_TOL = 1; // Per-page navigation timeout (ms). Guards against hanging indefinitely when a // served URL is unreachable (e.g. --no-serve with no server actually running). const NAV_TIMEOUT = 20_000; // Requested viewports; repeatable/comma-separated "--viewport WxH". // Deduplicated, preserves order. Defaults to 1440x900 when the flag is absent. const viewports = parseViewports(); const vpLabel = (vp) => `${vp.width}x${vp.height}`; function parseViewports() { const raw = args.viewports.length ? args.viewports : ["1440x900"]; const out = []; for (const s of raw) { const m = /^(\d+)x(\d+)$/.exec(s.trim()); if (!m) { console.error(`[cdp-audit] invalid viewport: ${s} (expected "WxH")`); process.exit(1); } const vp = { width: +m[1], height: +m[2] }; if (!out.some((o) => o.width === vp.width && o.height === vp.height)) out.push(vp); } return out; } // Explicit --viewport means per-resolution files; default run keeps the // backward-compatible single cdp-audit.txt name. const perResFile = args.viewports.length > 0; const reportPath = (vp) => resolve( WEB, "audit", perResFile ? `cdp-audit-${vpLabel(vp)}.txt` : "cdp-audit.txt", ); // Properties snapshot for every node, in both specified and computed form. const PROPS = [ "display", "position", "top", "right", "bottom", "left", "z-index", "float", "flex-direction", "flex-wrap", "justify-content", "align-items", "align-self", "row-gap", "column-gap", "grid-template-columns", "grid-template-rows", "grid-area", "grid-column", "grid-column-start", "grid-column-end", "grid-row", "grid-row-start", "grid-row-end", "margin-top", "margin-right", "margin-bottom", "margin-left", "padding-top", "padding-right", "padding-bottom", "padding-left", "width", "height", "min-width", "min-height", "max-width", "max-height", "box-sizing", "overflow", "opacity", "visibility", "transform", "box-shadow", "border-top-width", "border-top-style", "border-top-color", "border-right-width", "border-right-style", "border-right-color", "border-bottom-width", "border-bottom-style", "border-bottom-color", "border-left-width", "border-left-style", "border-left-color", "border-radius", "background-color", "background-image", "background-size", "background-position", "color", "font-family", "font-size", "font-weight", "font-style", "line-height", "letter-spacing", "text-align", "text-transform", "white-space", "vertical-align", "text-decoration", "text-decoration-line", "text-decoration-style", "text-decoration-color", "text-underline-offset", ]; // Tags/elements never included in the snapshot. const NOISE_TAGS = new Set(["script", "template", "style", "link", "noscript"]); // 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", ]); // ==== capture: open a page and snapshot its tree via getComputedStyle ====== async function capture(browser, target, viewport) { const url = target.startsWith("http") ? target : "file:///" + target.replace(/\\/g, "/"); const page = await browser.newPage({ viewport }); await page.goto(url, { waitUntil: "networkidle", // Fail fast instead of hanging when the served URL is unreachable // (e.g. --no-serve but no server actually running). timeout: NAV_TIMEOUT, }); await page.evaluate(async () => { await document.fonts.ready; }); // Normalize both sides to light theme so we measure real design diffs, // not a theme inversion. await page.evaluate(() => { document.documentElement.dataset.theme = "light"; }); // Get deterministic base state: no element under the cursor and no focused // element, so pointer/keyboard state pseudo-classes (:hover/:focus/:active) // never match. Hover rules are simply not part of a static snapshot. await page.mouse.move(-10, -10); await page.evaluate(() => document.activeElement?.blur?.()); await page.waitForTimeout(400); // Pass Sets as plain arrays (page.evaluate serializes, losing Set-ness) and // rebuild them inside the browser context. const tree = await page.evaluate(snapshotDocument, { PROPS, NOISE_TAGS: [...NOISE_TAGS], INHERITED: [...INHERITED], BOX_GROUPS, }); await page.close(); return tree; } // Browser-side cascade engine. Snapshot the document tree and, for every node, // compute the SPECIFIED value of each requested property — what the CSS // declares after cascade + inheritance + var() resolution — alongside the // computed value from getComputedStyle. Runs inside the page via // page.evaluate, so it must stay self-contained (browser globals + the arg). function snapshotDocument({ PROPS, NOISE_TAGS, INHERITED, BOX_GROUPS }) { // page.evaluate hand-serialized the Sets to plain arrays — restore them. NOISE_TAGS = new Set(NOISE_TAGS); INHERITED = new Set(INHERITED); // ---- collect author rules (stylesheets) preserving cascade order ---- // Recurse into container rules AND native CSS nesting (Svelte emits nested // "& output:where(.svelte-x)" rules whose declarations would otherwise be // invisible), expanding the "&" reference to the parent selector so the // rule still matches via el.matches() and parses for specificity. function collectRules() { const rules = []; let ruleOrder = 0; // Declared cascade-layer order, top-level names only, in declaration // order (from `@layer a, b;` statements and first appearance of blocks). // Unlayered author rules are a separate, always-winning tier. const layerNames = []; const ensureLayer = (name) => { if (name && !layerNames.includes(name)) layerNames.push(name); }; // `layers` is the cascade-layer path enclosing this rule; [] = unlayered. const collect = (list, parentSel = "", layers = []) => { for (const r of list || []) { if (r == null) continue; // Layer rules don't expose a reliable numeric .type in this // Chromium (they report 0), so detect them by constructor. const ctor = r.constructor?.name; if (ctor === "CSSLayerStatementRule") { // @layer a, b; — declares layer order, carries no declarations. for (const n of r.nameList ?? []) ensureLayer(n); continue; } if (ctor === "CSSLayerBlockRule") { // @layer name { ... } — recurse with the layer appended. ensureLayer(r.name); const next = r.name ? [...layers, r.name] : layers; try { collect(r.cssRules, parentSel, next); } catch {} continue; } if (r.type === 1) { let sel = r.selectorText; if (parentSel && sel?.includes("&")) sel = sel.replace(/&/g, parentSel); rules.push({ rule: r, effective: sel, order: ruleOrder++, layers }); if (r.cssRules?.length) collect(r.cssRules, sel ?? parentSel, layers); } else if (r.type === 3 && r.styleSheet) { try { collect(r.styleSheet.cssRules, parentSel, layers); } catch {} } else if (r.type === 4) { if (r.media?.matches) { try { collect(r.cssRules, parentSel, layers); } catch {} } } else if (r.type === 12) { try { if (CSS.supports(r.conditionText)) collect(r.cssRules, parentSel, layers); } catch { try { collect(r.cssRules, parentSel, layers); } catch {} } } else if (r.type === 15) { try { collect(r.cssRules, parentSel, layers); } catch {} } } }; for (const sheet of document.styleSheets) { try { collect(sheet.cssRules); } catch {} } // Layer rank: unlayered rules (empty path) always outrank layered ones; // layered rules win by the position of their OUTERMOST layer in the // declared order (later-declared layer wins over earlier). const rankOf = (layers) => layers.length ? layerNames.indexOf(layers[0]) : Number.MAX_SAFE_INTEGER; for (const rl of rules) rl.layerRank = rankOf(rl.layers); return rules; } // ---- selector specificity (approximation of the CSS algorithm) ---- // ids, classes, attributes, elements, plus :is/:not/:has (max of args) // and :where (zero). function 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; } function 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()); // :is/:not/:has take the most specific argument. if (name !== "where") { let a = 0, b = 0, c = 0; for (const arg of splitTop(inner, ",")) { const [aa, bb, cc] = specCompound(arg); a = Math.max(a, aa); b = Math.max(b, bb); c = Math.max(c, cc); } A += a; B += b; C += c; } } else B++; } } else if (/[a-zA-Z]/.test(ch)) { C++; readName(); } else i++; } } return [A, B, C]; } // ---- cascade resolution ---------------------------------------------- // All declarations that apply to `el`, in cascade terms. // ---- declaration parsing + shorthand expansion ------------------------ // Reading longhands off a rule misses shorthands: e.g. a rule written // `font: 600 14px var(--font-mono)` yields an EMPTY font-size/font-weight/ // font-family on enumeration (getPropertyValue), and the browser even // refuses to expand `font` inline when it contains a var(). So we parse the // raw declaration text and expand shorthands into longhands — font by hand, // everything else via a detached element whose cssText the browser expands. const _tmp = document.createElement("div"); // Split a declaration block ("a:1; b:2") on top-level ';', ignoring braces, // parens and quoted strings (so "content:';'" and var(...) survive). function parseDecls(cssText) { const parts = []; let depth = 0, cur = "", inStr = null; for (const ch of cssText) { if (inStr) { cur += ch; if (ch === inStr) inStr = null; continue; } if (ch === '"' || ch === "'") { inStr = ch; cur += ch; continue; } if (ch === "(" || ch === "[") depth++; else if (ch === ")" || ch === "]") depth--; if (ch === ";" && depth === 0) { if (cur.trim()) parts.push(cur.trim()); cur = ""; } else cur += ch; } if (cur.trim()) parts.push(cur.trim()); return parts; } // font — needs manual parsing: the browser can't expand it when a var() // appears (commonly in the family), so enumerate longhands by substituting // a plain family and reading back the resolved font sub-properties. // Grammar: [