From f35dfe361a673ee92e245905efdc3b3cd156d084 Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Sun, 13 Sep 2026 00:19:07 +0000 Subject: [PATCH] fix(cli): sweep_static geometry fingerprint now covers SVG stroke/dash motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check's seek-audit fingerprint already folds font-variation-settings into each element's per-element segment and a pixel hash for canvas/video/img, to catch motion channels that move no bbox and no opacity. An SVG stroke-dasharray/stroke-dashoffset "draw the line in" entrance shares that exact blind spot, so a genuinely animating composition using it could get flagged as a frozen timeline. A dedicated pass now walks stroke-capable SVG shapes independently of the per-element loop, gated on paint-visibility rather than bounding-box size — a perfectly straight connector line's raw path geometry has near-zero width or height regardless of stroke-width, so it fails the per-element loop's bbox gate and would otherwise never be reached. Co-Authored-By: Miguel Angel --- .../cli/src/commands/layout-audit.browser.js | 26 ++++++ .../src/commands/layout-audit.browser.test.ts | 81 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 8693f8a678..be43efaa4d 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1699,6 +1699,32 @@ if (!isVisibleElement(media)) continue; parts.push(`p:${mediaPixelHash(media)}`); } + // A "draw the line in" SVG entrance (stroke-dasharray/stroke-dashoffset + // animating on a shape whose geometry never changes) shares the same + // pixel-only-motion blind spot — no bbox change, no opacity change — but + // can't simply join the per-element loop above: a perfectly horizontal or + // vertical connector line has near-zero width or height in its raw path + // geometry regardless of stroke-width, so `isVisibleElement`'s bbox gate + // (`elements`, above) routinely excludes exactly the shapes this fix is + // for. Walk every stroke-capable shape independently, gated only on + // paint-visibility (shaftIsPainted, defined earlier for connector + // detection — its display/visibility/opacity check has nothing + // connector-specific about it) rather than a non-degenerate bounding box. + // Parsed and rounded the same way shaftDashHidden already does, so + // (a) "none" and "0" both settle to the same value (both render as a + // solid, undashed stroke) and (b) float-serialization jitter across + // otherwise-identical samples can't manufacture a false difference. + for (const shape of root.querySelectorAll( + "path, circle, ellipse, rect, line, polyline, polygon", + )) { + if (!shaftIsPainted(shape)) continue; + const shapeStyle = getComputedStyle(shape); + const dashArray = round( + Number.parseFloat(String(shapeStyle.strokeDasharray).split(/[\s,]+/)[0]) || 0, + ); + const dashOffset = round(Number.parseFloat(shapeStyle.strokeDashoffset) || 0); + parts.push(`d:${dashArray},${dashOffset}`); + } return parts.join("|"); }; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index d65dabba00..d686508892 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -217,6 +217,87 @@ describe("layout-audit.browser", () => { expect(collect()).toBe(collect()); }); + // A "draw the line in" SVG entrance (stroke-dasharray/stroke-dashoffset + // animating on a whose `d` never changes) shares the exact same + // blind spot as the font-axis case above: no bbox change (the path's + // geometry is static — only how much of the stroke is dashed-visible + // moves) and no opacity change. `check` false-positived sweep_static on a + // genuinely, visibly animating composition until both dash properties + // joined the fingerprint. The rect is deliberately DEGENERATE (height: 0, + // the real bbox of a perfectly horizontal path regardless of stroke-width) + // to prove the dedicated stroke-dash loop catches this — not the + // per-element loop above, which isVisibleElement's bbox gate would exclude + // this exact shape from. + it("changes the sweep fingerprint when only stroke-dashoffset moves", () => { + document.body.innerHTML = ` +
+ +
+ `; + + let dashOffset = "200"; + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + diagram: rect({ left: 0, top: 0, width: 640, height: 360 }), + connector: rect({ left: 10, top: 10, width: 290, height: 0 }), + }, + { + connector: { + strokeDasharray: "200", + get strokeDashoffset() { + return dashOffset; + }, + } as Partial, + }, + ); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + + const drawStart = collect(); // fully dash-hidden + dashOffset = "100"; // half drawn-in + const drawMid = collect(); + dashOffset = "0"; // fully revealed + const drawEnd = collect(); + + expect(drawMid).not.toBe(drawStart); + expect(drawEnd).not.toBe(drawMid); + }); + + // The other direction, matching the font-axis counter-test above: an + // identical scene, dash properties included, must hash identically — + // otherwise the fingerprint would vary on its own and sweep_static would + // never fire, making every green layout verdict meaningless. + it("keeps the sweep fingerprint identical when nothing moves, stroke dash included", () => { + document.body.innerHTML = ` +
+ +
+ `; + + installGeometry( + { + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + diagram: rect({ left: 0, top: 0, width: 640, height: 360 }), + connector: rect({ left: 10, top: 10, width: 290, height: 0 }), + }, + { + connector: { + strokeDasharray: "200", + strokeDashoffset: "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 = `