From 10dadce3b2600d0ea206cc4f2ce230491d38b277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 5 Sep 2026 16:39:09 +0000 Subject: [PATCH] fix(cli): fingerprint textual sweep state --- .../cli/src/commands/layout-audit.browser.js | 103 +++++++- .../src/commands/layout-audit.browser.test.ts | 221 +++++++++++++++++- packages/cli/src/utils/checkPipeline.ts | 18 +- packages/cli/src/utils/checkTypes.ts | 6 +- 4 files changed, 326 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 919b7ed59e..b52be2aea8 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1474,17 +1474,18 @@ }; // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample - // fingerprint of every visible element's box + opacity, in DOM order. Node + // fingerprint of every visible element's box, opacity, and rendered text + // state, in DOM order. Node // calls this once per seeked grid point and compares the strings across the // whole run — if every sample produces the identical string, the seek never // actually moved anything and the whole audit run is unreliable. Deliberately // a single opaque string (not a structured array) since Node only ever needs // equality, not per-element diffing. // Pixel-only media motion (a 2D/WebGL canvas repainting or a playing video - // without any element moving) is invisible to a geometry+opacity fingerprint + // without any element moving) is invisible to a DOM-state fingerprint // and false-positives sweep_static. Downsample each visible canvas/video to // 8x8 and fold its pixels into the fingerprint. Tainted, zero-sized, or - // unreadable media hashes to a constant — no worse than geometry-only + // unreadable media hashes to a constant — no worse than DOM-state-only // detection and never a new false negative for DOM-motion compositions. // Media inside iframes is intentionally outside this fingerprint: it lives // in a separate document, and cross-origin frames are inaccessible under SOP. @@ -1509,30 +1510,114 @@ } } + function foldFingerprintField(hash, value) { + hash ^= value.length; + hash = Math.imul(hash, 16777619); + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash; + } + + function cssFingerprintValue(value) { + return value === "none" || value === "normal" ? "" : value || ""; + } + + function foldCounterState(hash, style) { + hash = foldFingerprintField(hash, cssFingerprintValue(style.counterReset)); + hash = foldFingerprintField(hash, cssFingerprintValue(style.counterIncrement)); + return foldFingerprintField(hash, cssFingerprintValue(style.counterSet)); + } + + function counterStateHash(style) { + const reset = cssFingerprintValue(style.counterReset); + const increment = cssFingerprintValue(style.counterIncrement); + const set = cssFingerprintValue(style.counterSet); + if (!reset && !increment && !set) return ""; + let hash = 2166136261; + hash = foldCounterState(hash, style); + return (hash >>> 0).toString(36); + } + + function foldLiveControlState(hash, element) { + if (element.tagName === "INPUT") { + hash = foldFingerprintField(hash, element.type || ""); + hash = foldFingerprintField(hash, element.value || ""); + hash = foldFingerprintField(hash, element.checked ? "1" : "0"); + return foldFingerprintField(hash, element.indeterminate ? "1" : "0"); + } + if (element.tagName === "TEXTAREA") { + return foldFingerprintField(hash, element.value || ""); + } + if (element.tagName !== "SELECT") return foldFingerprintField(hash, ""); + hash = foldFingerprintField(hash, String(element.selectedIndex)); + hash = foldFingerprintField(hash, element.value || ""); + for (let i = 0; i < element.options.length; i++) { + hash = foldFingerprintField(hash, element.options[i].selected ? "1" : "0"); + } + return hash; + } + + function textualStateHash(element, style) { + // Chromium resolves attr() here, so pseudo computed content is the + // platform-owned rendered value rather than a CSS expression to reparse. + const before = getComputedStyle(element, "::before"); + const after = getComputedStyle(element, "::after"); + // Visible descendants are fingerprinted separately; direct nodes prevent + // a hidden descendant's text mutation from masquerading as visible motion. + let directText = ""; + for (const node of directTextNodes(element)) { + directText += node.textContent || ""; + } + let hash = 2166136261; + hash = foldFingerprintField(hash, directText); + hash = foldLiveControlState(hash, element); + hash = foldFingerprintField(hash, cssFingerprintValue(before.content)); + hash = foldFingerprintField(hash, cssFingerprintValue(after.content)); + hash = foldCounterState(hash, style); + hash = foldCounterState(hash, before); + hash = foldCounterState(hash, after); + return (hash >>> 0).toString(36); + } + window.__hyperframesLayoutGeometry = function collectLayoutGeometry() { const root = document.querySelector("[data-composition-id][data-width][data-height]") || document.querySelector("[data-composition-id]") || document.body; - const elements = Array.from(root.querySelectorAll("*")).filter((element) => - isVisibleElement(element), - ); + const allElements = [root, ...root.querySelectorAll("*")]; + const elements = allElements.filter((element) => isVisibleElement(element)); const parts = elements.map((element) => { const rect = toRect(element.getBoundingClientRect()); const opacity = round(opacityChain(element)); // Variable-font axis animation (font-variation-settings) is a real, // visible motion channel that moves no geometry and no opacity, so a - // box+opacity fingerprint reads it as a frozen timeline. Worse in a + // box+opacity+text fingerprint reads it as a frozen timeline. Worse in a // DUPLEXED face (Recursive holds an identical advance width at every // weight by design), where not even the line width shifts — the whole // run then false-positives sweep_static. Fold the computed axis string // in; it is "normal" for every element that does not use it, so this // adds nothing to the fingerprint of an ordinary composition. - const axes = getComputedStyle(element).fontVariationSettings; + const style = getComputedStyle(element); + const axes = style.fontVariationSettings; + const textState = textualStateHash(element, style); return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity},${ axes && axes !== "normal" ? axes : "" - }`; + },${textState}`; }); + const visibleElements = new Set(elements); + for (let ancestor = root.parentElement; ancestor; ancestor = ancestor.parentElement) { + const state = counterStateHash(getComputedStyle(ancestor)); + if (state) parts.push(`c:${state}`); + } + // Counter declarations can live on zero-box owners while a visible + // descendant's ::before/::after paints the resulting value. + for (const element of allElements) { + if (visibleElements.has(element)) continue; + const state = counterStateHash(getComputedStyle(element)); + if (state) parts.push(`c:${state}`); + } for (const media of root.querySelectorAll("canvas, video")) { if (!isVisibleElement(media)) continue; parts.push(`p:${mediaPixelHash(media)}`); diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 783b5e877a..86ef68c73a 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -179,6 +179,214 @@ describe("layout-audit.browser", () => { expect(collect()).toBe(collect()); }); + it("changes the sweep fingerprint when fixed-width text content changes", () => { + document.body.innerHTML = ` +
+ 10 +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + countdown: rect({ left: 280, top: 140, width: 80, height: 48 }), + }); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + document.getElementById("countdown")!.textContent = "09"; + + expect(collect()).not.toBe(before); + }); + + it("changes the sweep fingerprint when an attr-backed data value changes", () => { + document.body.innerHTML = ` + +
+ +
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + countdown: rect({ left: 280, top: 140, width: 80, height: 48 }), + }, + {}, + { + countdown: { + after: { + get content() { + return JSON.stringify(document.getElementById("countdown")!.getAttribute("data-txt")); + }, + } as Partial, + }, + }, + ); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + document.getElementById("countdown")!.setAttribute("data-txt", "09"); + + expect(collect()).not.toBe(before); + }); + + it("changes the sweep fingerprint when pseudo-element content changes", () => { + document.body.innerHTML = ` +
+ +
+ `; + let generatedContent = '"10"'; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + countdown: rect({ left: 280, top: 140, width: 80, height: 48 }), + }, + {}, + { + countdown: { + after: { + get content() { + return generatedContent; + }, + } as Partial, + }, + }, + ); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + generatedContent = '"09"'; + + expect(collect()).not.toBe(before); + }); + + it("changes the sweep fingerprint when CSS counter state changes", () => { + document.body.innerHTML = ` +
+
+
+ `; + let counterReset = "countdown 10"; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + "counter-owner": rect({ left: 0, top: 0, width: 0, height: 0 }), + countdown: rect({ left: 280, top: 140, width: 80, height: 48 }), + }, + { + "counter-owner": { + get counterReset() { + return counterReset; + }, + } as Partial, + }, + { + countdown: { + after: { content: "counter(countdown)" } as Partial, + }, + }, + ); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + counterReset = "countdown 9"; + + expect(collect()).not.toBe(before); + }); + + it("changes the sweep fingerprint when checkbox state changes", () => { + document.body.innerHTML = ` +
+ +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + toggle: rect({ left: 280, top: 140, width: 24, height: 24 }), + }); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + (document.getElementById("toggle") as HTMLInputElement).checked = true; + + expect(collect()).not.toBe(before); + }); + + it("changes the sweep fingerprint when a secondary select option changes", () => { + document.body.innerHTML = ` +
+ +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + choices: rect({ left: 280, top: 140, width: 120, height: 48 }), + }); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + (document.getElementById("choices") as HTMLSelectElement).options[1]!.selected = true; + + expect(collect()).not.toBe(before); + }); + + it("changes the sweep fingerprint when a fixed-width form value changes", () => { + document.body.innerHTML = ` +
+ +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + countdown: rect({ left: 280, top: 140, width: 80, height: 48 }), + }); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + (document.getElementById("countdown") as HTMLInputElement).value = "09"; + + expect(collect()).not.toBe(before); + }); + + it("keeps textual sweep channels identical when the countdown is truly frozen", () => { + document.body.innerHTML = ` +
+ 10 +
+ `; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + countdown: rect({ left: 280, top: 140, width: 80, height: 48 }), + }, + { countdown: { counterReset: "countdown 10" } as Partial }, + { + countdown: { + after: { content: '"10"' } as Partial, + }, + }, + ); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + + expect(collect()).toBe(collect()); + }); + it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { document.body.innerHTML = `
@@ -2487,13 +2695,23 @@ function rangeTextRect(selected: Node | null, rects: Record): D function installGeometry( rects: Record, styleOverrides: Record> = {}, + pseudoStyleOverrides: Record< + string, + { before?: Partial; after?: Partial } + > = {}, ): void { // Style-fixture branching mirrors the audit's per-property reads; splitting // it would scatter one mock across helpers. // fallow-ignore-next-line complexity - vi.spyOn(window, "getComputedStyle").mockImplementation((element) => { + vi.spyOn(window, "getComputedStyle").mockImplementation((element, pseudoElement) => { const el = element as Element; const isBubble = el.id === "bubble"; + const pseudoOverride = + pseudoElement === "::before" + ? pseudoStyleOverrides[el.id]?.before + : pseudoElement === "::after" + ? pseudoStyleOverrides[el.id]?.after + : undefined; return { display: "block", visibility: "visible", @@ -2517,6 +2735,7 @@ function installGeometry( paddingLeft: isBubble ? "16px" : "0px", fontSize: "36px", ...styleOverrides[el.id], + ...pseudoOverride, } as unknown as CSSStyleDeclaration; }); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 3aca7a3758..36d6e4e7c6 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -192,8 +192,8 @@ interface GridSamples { contrastEntries: ContrastAuditEntry[]; screenshots: CheckScreenshot[]; contrastMs: number; - /** One geometry+opacity fingerprint per layout sample (#U10 frozen-sweep guard). */ - geometrySignatures: string[]; + /** One visible-state fingerprint per layout sample (#U10 frozen-sweep guard). */ + layoutStateSignatures: string[]; /** Every rotatable element's geometry at each layout sample; grouped by * selector after the run to detect rotation_pivot_drift. */ rotationSamples: RotationSample[]; @@ -383,7 +383,7 @@ async function collectGridSamples( contrastEntries: [], screenshots: [], contrastMs: 0, - geometrySignatures: [], + layoutStateSignatures: [], rotationSamples: [], indicatorFrames: [], }; @@ -397,7 +397,7 @@ async function collectGridSamples( const layoutIssues = await driver.collectLayout(time, options.tolerance, options.layout); collected.layoutIssues.push(...layoutIssues); issuesAtTime.push(...layoutIssues); - collected.geometrySignatures.push(await driver.collectLayoutGeometry()); + collected.layoutStateSignatures.push(await driver.collectLayoutGeometry()); collected.rotationSamples.push(...(await driver.collectRotationSample(time))); collected.indicatorFrames.push(await driver.collectOffPivotRotationSample(time)); } @@ -481,7 +481,7 @@ const ZERO_LAYOUT_RECT: LayoutRect = { /** * Frozen-sweep guard (#U10): if every layout-grid sample produced the exact - * same geometry+opacity fingerprint (see layout-audit.browser.js), the seek + * same visible-state fingerprint (see layout-audit.browser.js), the seek * never actually advanced the composition's timeline — every other green * verdict from this run is meaningless, not just a missed defect. Skips * short (<3s) compositions, single-sample runs (nothing to compare), and @@ -490,15 +490,15 @@ const ZERO_LAYOUT_RECT: LayoutRect = { */ function detectSweepStatic( duration: number, - geometrySignatures: string[], + layoutStateSignatures: string[], motionIssues: AnchoredLayoutIssue[], hasNoTimelineDeclaration: boolean, ): AnchoredLayoutIssue[] { if (hasNoTimelineDeclaration) return []; if (duration < SWEEP_STATIC_MIN_DURATION_SEC) return []; - if (geometrySignatures.length < 2) return []; + if (layoutStateSignatures.length < 2) return []; if (motionIssues.some((issue) => issue.code === "motion_frozen")) return []; - const [first, ...rest] = geometrySignatures; + const [first, ...rest] = layoutStateSignatures; if (!first || rest.some((signature) => signature !== first)) return []; return [ { @@ -1071,7 +1071,7 @@ export async function runAuditGrid( } const sweepFindings = detectSweepStatic( grid.duration, - collected.geometrySignatures, + collected.layoutStateSignatures, motionIssues, await driver.hasNoTimelineDeclaration(), ); diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 1a3199abeb..eff7ca812e 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -194,9 +194,9 @@ export interface CheckAuditDriver { ): Promise; /** content_overlap only, for the dense re-sampling grid — catches transient text collisions the sparse grid seeks past. */ collectOverlap(time: number): Promise; - /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity - * fingerprint of the current seeked state, for detecting a timeline that - * never advances under seek. See layout-audit.browser.js. */ + /** Frozen-sweep guard (#U10): an opaque fingerprint of the current seeked + * visual state, for detecting a timeline that never advances under seek. + * The method keeps its legacy name for driver compatibility. */ collectLayoutGeometry(): Promise; /** rotation_pivot_drift: every rotatable element's bbox center/size/angle at * the current seeked state. Accumulated across the grid — see checkPipeline. */