/* Prove a CSS change by diffing computed styles, on the gallery instead of a router. * * The method is docs/css.md's: one DOM, one data set, two stylesheets — load the page, snapshot * getComputedStyle over every element, swap the , snapshot again. Any difference was caused * by the CSS, because nothing else moved. * * What is new here is the SURFACE. On a live router the same sheet twice moves 0.5-1.3% of pixels * (uptime, DHCP leases, signal) and a real regression weighs 0.19% — the noise buries the signal, * which is why screenshots do not work and why a live computed diff still needs a control pass to * learn each page's floor. docs/gallery.html has no clock, no poll and no data: `--control` runs the * SAME stylesheet on both sides and must report exactly 0 differences. That 0 is the threshold, and * it is measured rather than chosen — run `--control` whenever the gallery gains a widget. * * This does not replace the live run. The gallery has every widget and none of the pages: no menu, * no chrome, no third-party sheet. It catches the regression that is IN the stylesheet; owlab * catches the one that is in the page. * * Two viewports run, not one (task 0145): every other gallery gate — this one included, until now — * takes Playwright's 1280 default (`a11y`, `export-tier`) or sets it explicitly (line below), so a * declared width that only drifts once a flex row is narrower than its own content never shows up. * cb1's own bug was exactly that shape: 0 diffs at 1280 with the fix reverted, a `width` diff at 390. * A container query answered by 1280 alone was answered by nothing; VIEWPORTS below is the fix. * * node tools/computed-diff.mjs # worktree vs HEAD, light + dark, both viewports * node tools/computed-diff.mjs --control # the same sheet twice: the threshold, must be 0 * node tools/computed-diff.mjs --against v0.15.0 --full --max 0 */ import { chromium } from 'playwright'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { serveGallery, applyAppearance, matrix, ROOT } from './lib/gallery.mjs'; import { buildCss } from './lib/css.mjs'; const ARGV = process.argv.slice(2); const flag = (n) => ARGV.includes(n); const opt = (n, d) => { const i = ARGV.indexOf(n); return i < 0 ? d : ARGV[i + 1]; }; const CONTROL = flag('--control'); const REF = opt('--against', 'HEAD'); const MAX = opt('--max', null); const POINTS = flag('--full') ? matrix([null]) : [{ palette: 'footstrap', mode: 'light', tint: null }, { palette: 'footstrap', mode: 'dark', tint: null }]; /* Two viewports, not one. 1280 is every page's design width (docs/development.md); 390x844 is the * phone point task 0145's own bug was measured against (styles/theme/60-inputs.css) and the one this * repo already reaches for on a phone-width claim (owrt2512/390x844 elsewhere in that file). The gap * this closes: a caption turns the switch pill into a SECOND, shrinkable flex item only once the row * is narrower than the pill + caption's combined natural width, which never happens at 1280 — so a * declared `width` sitting at exactly `--sw-w` on both sides of a diff there proves nothing, the flex * shrink that broke it never engaged. Measured with the flex-shrink:0 fix reverted in a scratch copy: * cb1's used width holds at 40.00px on both sides at 1280 (0 diffs, the bug invisible), and at 390 it * splits 40.00px (worktree) vs 26.80px (reverted HEAD) — a 13.20px `width` difference the 1280 pass * cannot see at any density. */ const VIEWPORTS = [ { width: 1280, height: 900, label: '1280' }, { width: 390, height: 844, label: '390' }, ]; /* The properties read on every element. This list IS the contract: a property missing from it is a * regression this gate cannot see, so add rather than trim. Layout first, then box, then ink — the * four bugs docs/css.md credits to this method were a font-family, a max-width, a height/padding * pair from two rules, and a lost text-align, one in each group. */ const PROPS = [ 'display', 'position', 'visibility', 'opacity', 'z-index', 'float', 'clear', 'width', 'height', 'max-width', 'max-height', 'min-width', 'min-height', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'border-top-style', 'border-top-color', 'border-bottom-color', 'border-radius', 'box-shadow', 'outline-width', 'outline-color', 'outline-offset', 'color', 'background-color', 'background-image', 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing', 'text-align', 'text-transform', 'text-decoration-line', 'white-space', 'overflow-wrap', 'overflow-x', 'overflow-y', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', 'align-items', 'justify-content', 'gap', 'grid-template-columns', 'grid-auto-flow', 'transform', 'transition-property', 'content', ]; /* Build the reference stylesheet from a git ref. `git archive` gives exactly the two things * build-css.sh needs — itself and styles/ beside it — and it reads neither the index nor the * worktree, so an uncommitted edit cannot leak into the BEFORE side. */ function buildCssAt(ref) { const dir = mkdtempSync(join(process.env.RUNNER_TEMP || tmpdir(), 'fs-css-ref-')); const pkg = 'luci-theme-footstrap'; execFileSync('sh', ['-c', `git archive ${JSON.stringify(ref)} ${pkg}/build-css.sh ${pkg}/styles | tar -x -C ${JSON.stringify(dir)}`], { cwd: ROOT, stdio: ['ignore', 'ignore', 'inherit'] }); const out = join(dir, 'cascade.css'); execFileSync(join(dir, pkg, 'build-css.sh'), [out], { stdio: 'inherit' }); return out; } /* Wait for the sheet AND for font matching. A snapshot taken before matching settles measures * fallback metrics: every width on the page shifts a pixel or two, and 291 false differences on the * firewall page is what that looked like the first time. Neither sheet ships a face now, but a * machine with Manrope installed still resolves one. */ async function settle(page) { await page.evaluate(() => document.fonts.ready); /* And for every running animation. Measured: with fonts alone, a clean tree still reported one * difference against its own HEAD — `span.cbi-tooltip opacity 0.00245647 -> 0`, a fade caught * mid-flight by one snapshot and finished by the other. Waiting on the animations rather than * disabling them keeps `transition-property` honest, which is itself one of the measured * properties. The 2 s cap is for an infinite animation, which never resolves. */ await page.evaluate(() => Promise.race([ Promise.allSettled(document.getAnimations().map((a) => a.finished)), new Promise((r) => setTimeout(r, 2000)), ])); await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); } async function swapSheet(page, href) { await page.evaluate((h) => new Promise((resolve, reject) => { const link = document.querySelector('link[rel="stylesheet"]'); if (!link) { reject(new Error('the gallery has no to swap')); return; } link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', () => reject(new Error('the swapped stylesheet failed to load')), { once: true }); link.href = h; }), href); await settle(page); } const snapshot = (props) => { const els = document.querySelectorAll('*'); const out = new Array(els.length); for (let i = 0; i < els.length; i++) { const cs = getComputedStyle(els[i]); const row = new Array(props.length); for (let p = 0; p < props.length; p++) row[p] = cs.getPropertyValue(props[p]); out[i] = row; } /* A readable name for each element, computed once so the diff pass carries no DOM work. */ const names = new Array(els.length); for (let i = 0; i < els.length; i++) { const e = els[i]; const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2).join('.'); names[i] = e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (cls ? '.' + cls : ''); } window.__fsSnap = { out, names }; return els.length; }; const compare = ({ props, cap }) => { const before = window.__fsSnap; const els = document.querySelectorAll('*'); const diffs = []; if (els.length !== before.out.length) { return { structural: `element count changed: ${before.out.length} -> ${els.length}`, diffs }; } for (let i = 0; i < els.length && diffs.length < cap; i++) { const cs = getComputedStyle(els[i]); for (let p = 0; p < props.length; p++) { const now = cs.getPropertyValue(props[p]); if (now !== before.out[i][p]) { diffs.push({ el: before.names[i], i, prop: props[p], from: before.out[i][p], to: now }); if (diffs.length >= cap) break; } } } return { structural: null, diffs }; }; const CAP = 4000; const cssNow = buildCss(); const cssRef = CONTROL ? cssNow : buildCssAt(REF); const a = await serveGallery(cssRef); const b = await serveGallery(cssNow); const originB = new URL(b.base).origin; const browser = await chromium.launch(); const page = await browser.newPage({ viewport: VIEWPORTS[0] }); let total = 0; const report = []; for (const vp of VIEWPORTS) { await page.setViewportSize({ width: vp.width, height: vp.height }); for (const point of POINTS) { await page.goto(a.base, { waitUntil: 'load' }); await applyAppearance(page, point); await settle(page); /* Swap A onto itself before the FIRST snapshot, so both snapshots are taken in the same state. * Measured: without this, a control pass (the same stylesheet on both sides) reported 28 * differences in light and 0 in dark — every one of them a colour re-serialised across the swap, * `oklab(0.539907 -0.0412925 -0.186042 / 0.4)` becoming * `color(srgb 0.0352941 0.411765 0.854902 / 0.4)` on border-top-color, border-bottom-color and * box-shadow. The values are the same colour; only the serialisation of a computed colour * differs between a sheet parsed with the document and one attached afterwards. Symmetry costs * one extra load and takes the floor to 0, which is what makes a non-zero diff readable as * causal rather than as a number to compare against a remembered baseline. */ await swapSheet(page, `${new URL(a.base).origin}/cascade.css`); const n = await page.evaluate(snapshot, PROPS); await swapSheet(page, `${originB}/cascade.css`); /* The swap does not touch :root, so the axes stamped before it are still in force. Re-stamping * here would be the bug this gate exists to catch, dressed as a fixture. */ const { structural, diffs } = await page.evaluate(compare, { props: PROPS, cap: CAP }); /* The viewport is part of the label, not just the point: a `width` diff that only exists at * 390 and not at 1280 is unreadable as "width: … -> …" alone once two viewports run — task * 0145's own fixture is exactly a diff that is silent at 1280 and loud at 390. */ const label = `${vp.label}/${point.palette}/${point.mode}${point.tint ? `/tint${point.tint}` : ''}`; if (structural) { report.push({ label, structural, diffs: [], n }); total += 1; continue; } report.push({ label, structural: null, diffs, n }); total += diffs.length; } } await browser.close(); a.close(); b.close(); const side = CONTROL ? 'the SAME stylesheet on both sides' : `worktree vs ${REF}`; console.log(`computed-diff: ${side}, ${report[0]?.n ?? 0} elements x ${PROPS.length} properties, ${POINTS.length} appearance point(s) x ${VIEWPORTS.length} viewport(s) (${VIEWPORTS.map((v) => v.label).join(', ')})`); for (const r of report) { if (r.structural) { console.log(` ${r.label}: ${r.structural}`); continue; } console.log(` ${r.label}: ${r.diffs.length} difference(s)${r.diffs.length >= CAP ? ` (capped at ${CAP})` : ''}`); /* Group by property: a real regression is one property over many elements, and printing the * elements one per line buries that shape in a wall of rows. */ const byProp = new Map(); for (const d of r.diffs) { if (!byProp.has(d.prop)) byProp.set(d.prop, []); byProp.get(d.prop).push(d); } for (const [prop, ds] of [...byProp].sort((x, y) => y[1].length - x[1].length)) { const s = ds[0]; console.log(` ${prop}: ${ds.length}x e.g. ${s.el} ${s.from} -> ${s.to}`); } } /* Below this line a real diff count is a JUDGEMENT CALL (`--max` makes it one; plain output is a * report a human reads) — but "the page never rendered" is not a judgement call, it is a broken * harness, and it used to read exactly like a clean pass: a blank gallery snapshots 0 elements on * both sides, `els.length === before.out.length` (0 === 0) skips the structural check, the diff * loop never runs, and "0 difference(s) total" prints — indistinguishable from the real thing this * gate is trusted to prove. Both checks below run UNCONDITIONALLY, independent of --control/--max, * because a run that measured nothing is not a report either — it is nothing to report. * * MEASURED is the current per-point count (853 elements on docs/gallery.html, task-gate-audit); * the floor sits far under it so a widget added or removed never trips it, and only a gallery that * failed to render — 0, or a handful of elements from a bare error page — does. */ const MEASURED_ELEMENTS = 853; const ELEMENTS_FLOOR = 200; const empty = report.filter((r) => (r.n ?? 0) < ELEMENTS_FLOOR); if (empty.length) { console.error(`\nFAIL: measured only ${empty[0].n} element(s) at ${empty[0].label} (last real run: ` + `~${MEASURED_ELEMENTS}) — the gallery did not render. This is not "0 differences"; it is ` + `nothing measured, on ${empty.length}/${report.length} point(s).`); process.exit(1); } const structural = report.filter((r) => r.structural); if (structural.length) { console.error(`\nFAIL: the DOM itself changed shape, which no property list can diff — see the ` + `"element count changed" line(s) above.`); process.exit(1); } if (CONTROL) { if (total === 0) { console.log('control pass: 0 differences. The gallery has no noise floor; any non-zero diff above is causal.'); process.exit(0); } console.error(`control pass FAILED with ${total} difference(s): the same stylesheet disagreed with itself, so the harness is unstable and no diff it reports can be read as causal.`); process.exit(1); } if (MAX !== null) { const max = Number(MAX); if (total > max) { console.error(`computed-diff: ${total} difference(s) over --max ${max}.`); process.exit(1); } } console.log(`computed-diff: ${total} difference(s) total. Read them; this gate reports, it does not judge (pass --max N to make it judge).`);