From 2ed1ab40022620b72114b7ed3070a61a92e1502e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 8 Aug 2026 06:21:50 +0000 Subject: [PATCH] fix(cli): count font axes as motion in the sweep fingerprint The layout audit fingerprints each element by rect and opacity, plus an 8x8 pixel hash for canvas and video. A composition whose only change over the timeline is font-variation-settings touches none of those, so all six samples hash identically and the audit reports sweep_static on a timeline that is visibly animating. Duplexed variable fonts make it unavoidable rather than unlikely. Recursive holds one advance width across its whole weight axis by design, so even the element rect stays fixed while the letterforms change. Fold the computed font-variation-settings into the per-element fingerprint. It reads normal on every element that does not use the property, so no existing composition's fingerprint changes. This is the third instance of this failure class in the same function, after the canvas-repaint and opacity-reveal cases whose regression tests already sit alongside these. Two more join them: a scene where only the axes move must change the fingerprint, and a scene where nothing moves must hash identically twice. The second is the load-bearing one, since a fingerprint that varies on its own would make sweep_static unfireable and every green layout verdict meaningless. --- .../cli/src/commands/layout-audit.browser.js | 13 +++- .../src/commands/layout-audit.browser.test.ts | 73 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) 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 = `