diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 8c86391cce..c49a496044 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1517,7 +1517,18 @@ const parts = elements.map((element) => { const rect = toRect(element.getBoundingClientRect()); const opacity = round(opacityChain(element)); - return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`; + // 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 + // 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; + return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity},${ + axes && axes !== "normal" ? axes : "" + }`; }); for (const media of root.querySelectorAll("canvas, video")) { if (!isVisibleElement(media)) continue; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index fbc527f87b..0c1e018f40 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -106,6 +106,79 @@ describe("layout-audit.browser", () => { expect(revealed).not.toBe(fading); }); + // Variable-font axis animation (registry block `weight-wave`): a crest of + // weight travels along a headline by rewriting each character's + // font-variation-settings, and NOTHING else changes — no geometry, no + // opacity, no canvas. A duplexed face makes it total: Recursive holds one + // advance width at every weight by design, so not even the line width + // shifts and all six sweep samples hashed identically until the axis string + // joined the fingerprint. `check` then failed a working composition with + // sweep_static, and the documented remedies (spread the reveal, keep an + // element animating) cannot help — the motion is real, the fingerprint was + // just blind to it. + it("changes the sweep fingerprint when only font-variation-settings moves", () => { + document.body.innerHTML = ` +
+
P
+
+ `; + + let axes = '"wght" 400, "slnt" 0'; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + line: rect({ left: 40, top: 40, width: 560, height: 48 }), + char: rect({ left: 40, top: 40, width: 18, height: 48 }), + }, + { + char: { + get fontVariationSettings() { + return axes; + }, + } as Partial, + }, + ); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + + const rest = collect(); + axes = '"wght" 1000, "slnt" -12'; // the crest arrives over this character + const crest = collect(); + + expect(crest).not.toBe(rest); + }); + + // The other direction, and it guards the more dangerous failure: a + // fingerprint that varies on its own would make sweep_static unfireable and + // every green layout verdict meaningless. Identical scene, axes included, + // must hash identically. + it("keeps the sweep fingerprint identical when nothing moves, font axes included", () => { + document.body.innerHTML = ` +
+
P
+
+ `; + + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + line: rect({ left: 40, top: 40, width: 560, height: 48 }), + char: rect({ left: 40, top: 40, width: 18, height: 48 }), + }, + { + char: { fontVariationSettings: '"wght" 400, "slnt" 0' } 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 = `