diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 832d53e4f2..b93e89edfe 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -717,7 +717,8 @@ HTML. Without a sidecar, nothing changes. `keepsMoving` uses the same motion classifier as the frozen-sweep guard: box geometry, opacity, text and generated content, form-control state, painted CSS -counters, clip-path, variable-font axes, and the pixels of visible +counters, clip-path, variable-font axes, SVG stroke dashing +(`stroke-dasharray` / `stroke-dashoffset`), and the pixels of visible canvas/video/img elements all count as motion — so a playing same-origin (or CORS-readable) background video keeps a scope live on its own. Elements under `data-layout-ignore` inside the scope never count; the opt-out does not apply to diff --git a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts index 57af8b9cec..1c41a0821d 100644 --- a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts +++ b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts @@ -355,6 +355,126 @@ describe.skipIf(!RUNS_CHROMIUM)("motion-signature.browser in Chromium", () => { expect(after.liveness).not.toBe(before.liveness); }); + // Blink reports a 290x0 box for this path (object bounding box, no stroke), + // so this also pins that a stroked straight connector counts as visible. + it("sees a stroke-dashoffset draw-in on a straight connector", async () => { + await load( + composition( + "#wire { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; }", + '', + ), + ); + const before = await sample(); + await mutate('document.getElementById("wire").style.strokeDashoffset = "145"'); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).not.toBe(before.liveness); + }); + + // The dash list is half of the channel: a "grow the dashes" reveal that + // keeps the offset fixed is motion for both samplers. + it("sees a stroke-dasharray change at a fixed offset on a straight connector", async () => { + await load( + composition( + "#wire { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 0; }", + '', + ), + ); + const before = await sample(); + await mutate('document.getElementById("wire").style.strokeDasharray = "145 145"'); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).not.toBe(before.liveness); + }); + + // One row per painted-stroke / dash-pattern guard, against Blink's computed + // values: `stroke: transparent` computes to rgba(0, 0, 0, 0); an unpainted + // stroke leaves the 290x0 connector with no visible extent, so it fails the + // visibility floor before any channel runs; an all-zero dash list renders + // solid, so the offset has nothing to shift. + it.each([ + ["stroke-opacity: 0", "stroke-opacity: 0;"], + ["a transparent stroke", "stroke: transparent;"], + ["stroke-width: 0", "stroke-width: 0;"], + ["an all-zero dash list", "stroke-dasharray: 0;"], + ["an all-zero two-entry dash list", "stroke-dasharray: 0 0;"], + ])("ignores stroke-dashoffset motion on a connector with %s", async (_label, css) => { + await load( + composition( + `#wire { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; ${css} }`, + '', + ), + ); + const before = await sample(); + await mutate('document.getElementById("wire").style.strokeDashoffset = "0"'); + const after = await sample(); + + expect(after).toEqual(before); + }); + + // Stroke properties inherit, so the and the both compute the + // dashed stroke; neither is an SVGGeometryElement, and both have a real box + // (the glyph run), so this pins the channel's element filter. + it("ignores stroke-dashoffset motion on non-geometry SVG elements", async () => { + await load( + composition( + "#group { stroke: #000; stroke-width: 2; stroke-dasharray: 20; stroke-dashoffset: 20; font: 32px monospace; }", + 'Q3', + ), + ); + expect(await visible("#label")).toBe(true); + const before = await sample(); + await mutate('document.getElementById("group").style.strokeDashoffset = "0"'); + const after = await sample(); + + expect(after).toEqual(before); + }); + + // Blink already reports an empty box for descendants of every container in + // UNPAINTED_SVG_CONTAINERS (probed for all six); this pins that the platform + // and the classifier agree, not the container rule alone. + it("ignores stroke-dash motion under display:none and inside unpainted SVG containers", async () => { + await load( + composition( + "path { stroke: #000; stroke-width: 4; fill: none; stroke-dasharray: 290; stroke-dashoffset: 290; } #offstage { display: none; }", + ` + + + + + + + + + `, + ), + ); + const before = await sample(); + await mutate( + 'for (const id of ["hidden", "template", "clip", "masked", "instanced", "tiled", "vertex"]) document.getElementById(id).style.strokeDashoffset = "0"', + ); + const after = await sample(); + + expect(after).toEqual(before); + }); + + it("keeps signing content inside an HTML element named ", async () => { + await load( + composition( + "#panel { display: block; } #label { display: inline-block; width: 40px; height: 48px; }", + 'Q3', + ), + ); + const before = await sample(); + await mutate('document.getElementById("label").style.opacity = "0.5"'); + const after = await sample(); + + expect(after.sweep).not.toBe(before.sweep); + expect(after.liveness).not.toBe(before.liveness); + }); + it("sees textarea value and checkbox indeterminate changes", async () => { await load( composition( diff --git a/packages/cli/src/commands/motion-signature.browser.js b/packages/cli/src/commands/motion-signature.browser.js index 5874ccd2d8..fefe5adb45 100644 --- a/packages/cli/src/commands/motion-signature.browser.js +++ b/packages/cli/src/commands/motion-signature.browser.js @@ -19,16 +19,16 @@ // bucketing, while the sweep guard wants exact (0.01) rounding because it asks // whether the seek moved anything at all. // -// Adding a channel (e.g. SVG stroke-dasharray/dashoffset): append one reader +// Adding a channel (e.g. SVG fill-opacity): append one reader // `(element, ctx) => string` to BOX_CHANNELS (reads the element's own box, -// including a control's widget type and checked state) or CONTENT_CHANNELS (reads what the -// element's contents paint — text, pseudo content, control values, media -// pixels — which `content-visibility: hidden` skips). `ctx` -// carries the element's computed style, its ::before/::after styles, its -// inherited opacity, and the quantize flag. A reader returns a string that is -// equal between two samples iff that channel did not visibly change; return "" -// for elements the channel does not apply to so ordinary compositions gain no -// payload. +// including a control's widget type and checked state, or an SVG shape's +// stroke) or CONTENT_CHANNELS (reads what the element's contents paint — text, +// pseudo content, control values, media pixels — which +// `content-visibility: hidden` skips). `ctx` carries the element's computed +// style, its ::before/::after styles, its inherited opacity, and the quantize +// flag. A reader returns a string that is equal between two samples iff that +// channel did not visibly change; return "" for elements the channel does not +// apply to so ordinary compositions gain no payload. // // Signatures are a single opaque string per sample (not a structured array): // Node only ever needs equality, never per-element diffing. Textual channels @@ -37,6 +37,39 @@ (function () { const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]); const MEDIA_TAGS = new Set(["CANVAS", "VIDEO", "IMG"]); + // SVG containers whose content never paints in place: and + // only lend geometry to a referencing element; , and + // content reaches the screen only through the element that + // references it (as a mask buffer, a fill tile, a vertex glyph), and + // only as instances, whose shadow trees querySelectorAll + // cannot reach. This walk visits the referencing element on its own, so + // motion OF that element (its box, opacity, clip-path) is signed; motion of + // the referenced CONTENT (a shape animating inside a or ) + // does change pixels through it but has no channel today. Blink reports an + // empty box for descendants of all six, but a subtree that never paints in + // place should be excluded by rule, not by one engine's bbox behaviour. + // The names match layout-audit's CONNECTOR_SKIP_CONTAINERS (kept in step by + // hand: layout-audit is installed standalone); that selector is not + // namespaced, this check is — SVG tag names are case-preserved + // (`clipPath`), hence the lower-cased match, and an HTML element that merely + // shares a name paints and is not pruned. + const UNPAINTED_SVG_CONTAINERS = new Set([ + "defs", + "clippath", + "mask", + "pattern", + "marker", + "symbol", + ]); + + function isUnpaintedSvgContainer(element) { + return ( + element instanceof SVGElement && UNPAINTED_SVG_CONTAINERS.has(element.tagName.toLowerCase()) + ); + } + // A computed stroke that paints nothing: `none`, or a fully transparent + // colour (`transparent` computes to rgba(0, 0, 0, 0)). + const TRANSPARENT_COLOR = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/; const FNV_OFFSET_BASIS = 2166136261; const FNV_PRIME = 16777619; const LIVENESS_POSITION_BUCKET_PX = 2; @@ -109,17 +142,65 @@ ); } + // Whether `element` starts an unrendered subtree: nothing under it paints + // and nothing under it can feed a painted counter(). `display` is not + // inherited — a child of a display:none parent still computes display:block, + // and a shape inside computes as painted — so compositionSignature + // propagates this to descendants itself. The platform decides where it can + // (checkVisibility: display:none and skipped contents); the fallback is + // display:none. display:contents has no box of its own but its + // pseudo-elements and children render, so it stays an owner — except as + // the child of a host that skips its contents, where its pseudo-elements + // paint nothing either. The platform check cannot tell that from an + // ordinary display:contents host (both have no box), so the parent's + // skipsContents verdict (`parentSkipsContents`) decides; a display:contents + // child of an off-screen `auto` host is not caught (see + // compositionSignature). Without the platform check a skipped host is + // itself unrendered, so the caller never reaches this with a true + // `parentSkipsContents`. An unpainted SVG container + // (UNPAINTED_SVG_CONTAINERS) is excluded by rule: its shapes have layout + // boxes, so checkVisibility cannot know they never reach the screen. + function startsUnrenderedSubtree(element, style, platformDecides, parentSkipsContents) { + if (isUnpaintedSvgContainer(element)) return true; + if (style.display === "contents") return parentSkipsContents; + return platformDecides + ? !element.checkVisibility(RENDERED_BOX_OPTIONS) + : style.display === "none"; + } + + function paintsStroke(style) { + const stroke = cssValue(style.stroke); + return ( + stroke !== "" && + !TRANSPARENT_COLOR.test(stroke) && + Number.parseFloat(style.strokeWidth) > 0 && + Number.parseFloat(style.strokeOpacity) > 0 + ); + } + + // An SVG geometry element (path/circle/ellipse/rect/line/polyline/polygon) + // with a painted stroke. Chromium's getBoundingClientRect for SVG shapes is + // the object bounding box WITHOUT the stroke, so a straight horizontal or + // vertical connector reports 0 height or 0 width regardless of stroke-width + // even though it is plainly on screen. + function isStrokedShape(element, style) { + return element instanceof SVGGeometryElement && paintsStroke(style); + } + // Visibility floor: checkVisibility (as layout-audit.browser.js // isVisibleElement's opacity-floor path; its default path skips it and so // cannot see skipped contents), then display/visibility, then inherited - // opacity, then a non-empty box. Kept local rather than shared because - // layout-audit is also installed and tested on its own; this module owns the - // decision for both motion samplers. The author opt-out (data-layout-ignore / - // data-layout-check=ignore) is NOT applied here: motion-sample reports this - // bit for explicitly asserted selectors, and an assertion naming an element - // outranks a layout-audit opt-out. compositionSignature applies the opt-out - // itself (see there). clip-path is not probed either; it is a channel, so a - // clip-path wipe over a static box counts as motion directly. + // opacity, then a non-empty box — widened by one case: a stroked SVG shape + // whose geometry bbox is degenerate along one axis (see isStrokedShape) is + // on screen even though that floor rejects it. Kept local rather than shared + // because layout-audit is also installed and tested on its own; this module + // owns the decision for both motion samplers. The author opt-out + // (data-layout-ignore / data-layout-check=ignore) is NOT applied here: + // motion-sample reports this bit for explicitly asserted selectors, and an + // assertion naming an element outranks a layout-audit opt-out. + // compositionSignature applies the opt-out itself (see there). clip-path is + // not probed either; it is a channel, so a clip-path wipe over a static box + // counts as motion directly. // fallow-ignore-next-line complexity function isVisibleElement(element, style, opacity) { if (IGNORE_TAGS.has(element.tagName)) return false; @@ -129,10 +210,13 @@ ) { return false; } - if (isHiddenStyle(style || getComputedStyle(element))) return false; + const computed = style || getComputedStyle(element); + if (isHiddenStyle(computed)) return false; if ((opacity === undefined ? opacityChain(element) : opacity) < 0.2) return false; const rect = element.getBoundingClientRect(); - return rect.width > 0.5 && rect.height > 0.5; + if (rect.width > 0.5 && rect.height > 0.5) return true; + // A stroked shape paints along its one non-degenerate axis. + return isStrokedShape(element, computed) && (rect.width > 0.5 || rect.height > 0.5); } function foldField(hash, value) { @@ -190,6 +274,25 @@ return clip ? hashFields([clip]) : ""; } + // `none` and an all-zero list (`0`, `0px 0px`) both render a solid stroke, + // on which the offset has no visible effect. + function dashPattern(style) { + const dashes = cssValue(style.strokeDasharray); + if (!dashes) return ""; + return dashes.split(/[\s,]+/).some((dash) => Number.parseFloat(dash) > 0) ? dashes : ""; + } + + // A "draw the line in" SVG entrance animates stroke-dasharray / + // stroke-dashoffset on a shape whose geometry never changes: no box, no + // opacity, only how much of the stroke is currently dash-visible. Without a + // dash pattern the offset has no visible effect, and without a painted + // stroke neither does, so ordinary shapes stay "". + function strokeDashChannel(element, ctx) { + if (!isStrokedShape(element, ctx.style)) return ""; + const dashes = dashPattern(ctx.style); + return dashes ? hashFields([dashes, ctx.style.strokeDashoffset || ""]) : ""; + } + // Direct text nodes only: descendants are signed separately, and a hidden // descendant's text mutation must not masquerade as visible motion. function textChannel(element) { @@ -271,14 +374,15 @@ } // The element's own box still paints when its contents are skipped - // (content-visibility: hidden) — including a checkbox's check glyph; its - // text, pseudo boxes, control value, and replaced content (a canvas/video/img's - // pixels) do not. + // (content-visibility: hidden) — including a checkbox's check glyph and an + // SVG shape's stroke; its text, pseudo boxes, control value, and replaced + // content (a canvas/video/img's pixels) do not. const BOX_CHANNELS = [ boxChannel, opacityChannel, fontAxesChannel, clipPathChannel, + strokeDashChannel, controlWidgetChannel, ]; const CONTENT_CHANNELS = [ @@ -390,16 +494,11 @@ const quantize = !!(options && options.quantize); const parts = []; const boxOwners = []; - // Unrendered elements (display:none subtrees, skipped contents) paint - // nothing and cannot feed a painted counter(). The platform decides where it - // can; the fallback is display:none and content-visibility:hidden hosts, - // propagated to descendants. display:contents has no box of its own but its - // pseudo-elements and children render, so it stays an owner — except as - // the child of a host that skips its contents, where its pseudo-elements - // paint nothing either. The platform check cannot tell that from an - // ordinary display:contents host (both have no box), so the parent's - // skipsContents verdict decides; a display:contents child of an off-screen - // `auto` host is not caught (see below). + // Unrendered subtrees (see startsUnrenderedSubtree; without the platform + // check, content-visibility:hidden hosts too) paint nothing and cannot feed + // a painted counter(); membership is propagated to descendants here, and + // hosts that skip their contents are remembered so a display:contents + // child of one is pruned as well. const unrenderedBelow = new Set(); const skippedHosts = new Set(); for (const element of [root, ...root.querySelectorAll("*")]) { @@ -407,11 +506,10 @@ const style = getComputedStyle(element); const parent = element.parentElement; const platformDecides = typeof element.checkVisibility === "function"; - const noBox = platformDecides - ? !element.checkVisibility(RENDERED_BOX_OPTIONS) - : style.display === "none"; - const boxlessOwner = style.display === "contents" && !skippedHosts.has(parent); - if (unrenderedBelow.has(parent) || (noBox && !boxlessOwner)) { + if ( + unrenderedBelow.has(parent) || + startsUnrenderedSubtree(element, style, platformDecides, skippedHosts.has(parent)) + ) { unrenderedBelow.add(element); continue; } diff --git a/packages/cli/src/commands/motion-signature.browser.test.ts b/packages/cli/src/commands/motion-signature.browser.test.ts index 7528f094f7..18270c5a9a 100644 --- a/packages/cli/src/commands/motion-signature.browser.test.ts +++ b/packages/cli/src/commands/motion-signature.browser.test.ts @@ -32,6 +32,11 @@ const BASE_STYLE: StyleOverride = { opacity: "1", fontVariationSettings: "normal", clipPath: "none", + stroke: "none", + strokeWidth: "1px", + strokeOpacity: "1", + strokeDasharray: "none", + strokeDashoffset: "0px", counterReset: "none", counterIncrement: "none", counterSet: "none", @@ -117,6 +122,14 @@ const ROOT = { left: 0, top: 0, width: 640, height: 360 }; const COUNTDOWN = { left: 280, top: 140, width: 80, height: 48 }; const ZERO_BOX = { left: 0, top: 0, width: 0, height: 0 }; const COUNTER_CONSUMER = { after: { content: "counter(countdown)" } }; +// The real Chromium bbox of a horizontal stroked path: the object bounding +// box excludes the stroke, so height is 0 regardless of stroke-width. +const FLAT_CONNECTOR = { left: 10, top: 10, width: 290, height: 0 }; +const DASHED_STROKE: StyleOverride = { + stroke: "rgb(0, 0, 0)", + strokeWidth: "4px", + strokeDasharray: "290px", +}; afterEach(() => { vi.restoreAllMocks(); @@ -261,6 +274,281 @@ describe("motion-signature.browser media and geometry channels", () => { expect(collect()).toBe(collect()); }); + // A "draw the line in" SVG entrance (stroke-dashoffset animating on a path + // whose `d` never changes) shares the font-axis blind spot: no box change, + // no opacity change. The connector rect is deliberately degenerate (height + // 0) so the test also pins that a stroked straight line passes the + // visibility gate at all — the common real shape for this animation. + it("changes the sweep fingerprint when only stroke-dashoffset moves on a straight connector", () => { + document.body.innerHTML = ` +
+ +
+ `; + let dashOffset = "290px"; + installFixture({ + rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR }, + styles: { + connector: { + ...DASHED_STROKE, + get strokeDashoffset() { + return dashOffset; + }, + } as StyleOverride, + }, + }); + + const collect = installScript(); + const hidden = collect(); + dashOffset = "145px"; // half drawn in + const half = collect(); + dashOffset = "0px"; // fully revealed + const drawn = collect(); + + expect(half).not.toBe(hidden); + expect(drawn).not.toBe(half); + }); + + // The channel hashes the pattern AND the offset: a wipe that animates the + // dash lengths while the offset stays put (a "grow the dashes" reveal) must + // be motion for the sweep guard and for liveness alike. + it("changes both signatures when only stroke-dasharray moves at a fixed offset", () => { + document.body.innerHTML = ` +
+ +
+ `; + let dashes = "290px"; + installFixture({ + rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR }, + styles: { + connector: { + ...DASHED_STROKE, + strokeDashoffset: "0px", + get strokeDasharray() { + return dashes; + }, + } as StyleOverride, + }, + }); + + const collect = installScript(); + const { compositionSignature, compositionRoot } = + signatureWindow().__hyperframesMotionSignature; + const root = compositionRoot(); + const sweepBefore = collect(); + const livenessBefore = compositionSignature(root, { quantize: true }); + dashes = "145px, 145px"; + + expect(collect()).not.toBe(sweepBefore); + expect(compositionSignature(root, { quantize: true })).not.toBe(livenessBefore); + }); + + it("keeps the sweep fingerprint identical when nothing moves, stroke dash included", () => { + document.body.innerHTML = ` +
+ +
+ `; + installFixture({ + rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR }, + styles: { connector: { ...DASHED_STROKE, strokeDashoffset: "0px" } }, + }); + + const collect = installScript(); + + expect(collect()).toBe(collect()); + }); + + // `display` is not inherited: the fake getComputedStyle reports the child as + // display:block, so only the classifier's ancestor walk can hide it. + it("ignores stroke-dash motion under a display:none ancestor", () => { + document.body.innerHTML = ` +
+ +
+ `; + let dashOffset = "290px"; + installFixture({ + rects: { root: ROOT, diagram: ROOT, offstage: ROOT, connector: FLAT_CONNECTOR }, + styles: { + offstage: { display: "none" }, + connector: { + ...DASHED_STROKE, + get strokeDashoffset() { + return dashOffset; + }, + } as StyleOverride, + }, + }); + + const collect = installScript(); + const before = collect(); + dashOffset = "0px"; + + expect(collect()).toBe(before); + }); + + // Each row defeats one guard of the channel. The first three leave the + // stroke unpainted (paintsStroke), so the dash pattern on it is not motion — + // and the connector must not report as visible on the strength of its + // stroke. The last two paint a stroke whose dash list is all zeros, which + // renders solid (dashPattern), so the offset has nothing to shift. + it.each([ + ["stroke-opacity: 0", { strokeOpacity: "0" }], + ["a transparent stroke", { stroke: "rgba(0, 0, 0, 0)" }], + ["stroke-width: 0", { strokeWidth: "0px" }], + ["an all-zero dash list", { strokeDasharray: "0px" }], + ["an all-zero two-entry dash list", { strokeDasharray: "0px, 0px" }], + ] as const)("ignores stroke-dashoffset motion on a connector with %s", (_label, override) => { + document.body.innerHTML = ` +
+ +
+ `; + let dashOffset = "290px"; + installFixture({ + rects: { root: ROOT, diagram: ROOT, connector: FLAT_CONNECTOR }, + styles: { + connector: { + ...DASHED_STROKE, + ...override, + get strokeDashoffset() { + return dashOffset; + }, + } as StyleOverride, + }, + }); + + const collect = installScript(); + const before = collect(); + dashOffset = "0px"; + + expect(collect()).toBe(before); + }); + + // The channel is scoped to SVGGeometryElement: stroke properties inherit, + // so a and a compute the same dashed stroke as a shape would, + // but neither is a shape the channel signs. Both get a real box so they + // pass the visibility floor and this pins the element filter, not the gate. + it("ignores stroke-dashoffset motion on non-geometry SVG elements", () => { + document.body.innerHTML = ` +
+ Q3 +
+ `; + let dashOffset = "290px"; + const animated = { + ...DASHED_STROKE, + get strokeDashoffset() { + return dashOffset; + }, + } as StyleOverride; + installFixture({ + rects: { + root: ROOT, + diagram: ROOT, + group: { left: 10, top: 10, width: 200, height: 40 }, + label: { left: 10, top: 10, width: 200, height: 40 }, + }, + styles: { group: animated, label: animated }, + }); + + const collect = installScript(); + const before = collect(); + dashOffset = "0px"; + + expect(collect()).toBe(before); + }); + + // Blink reports an empty box for descendants of these containers; the + // fixture deliberately gives them a rendered path's box so this pins the + // container rule itself rather than the box gate. + it("ignores stroke-dash motion inside unpainted SVG containers", () => { + document.body.innerHTML = ` +
+ + + + + + + + +
+ `; + let dashOffset = "290px"; + const animated = { + ...DASHED_STROKE, + get strokeDashoffset() { + return dashOffset; + }, + } as StyleOverride; + installFixture({ + rects: { + root: ROOT, + diagram: ROOT, + defs: ROOT, + reveal: ROOT, + fade: ROOT, + glyph: ROOT, + tile: ROOT, + head: ROOT, + template: FLAT_CONNECTOR, + clip: { left: 10, top: 20, width: 290, height: 0 }, + masked: { left: 10, top: 30, width: 290, height: 0 }, + instanced: { left: 10, top: 40, width: 290, height: 0 }, + tiled: { left: 10, top: 50, width: 290, height: 0 }, + vertex: { left: 10, top: 60, width: 290, height: 0 }, + }, + styles: { + template: animated, + clip: animated, + masked: animated, + instanced: animated, + tiled: animated, + vertex: animated, + }, + }); + + const collect = installScript(); + const before = collect(); + dashOffset = "0px"; + + expect(collect()).toBe(before); + }); + + // The container rule is namespaced: an HTML element that merely shares a + // name with an unpainted SVG container paints its subtree like any other. + it("keeps signing content inside an HTML element named ", () => { + document.body.innerHTML = ` +
+ Q3 +
+ `; + let labelOpacity = "1"; + installFixture({ + rects: { + root: ROOT, + panel: { left: 40, top: 40, width: 200, height: 48 }, + label: { left: 40, top: 40, width: 40, height: 48 }, + }, + styles: { + label: { + get opacity() { + return labelOpacity; + }, + } as StyleOverride, + }, + }); + + const collect = installScript(); + const before = collect(); + labelOpacity = "0.5"; + + expect(collect()).not.toBe(before); + }); + it("changes the sweep fingerprint when only clip-path moves", () => { document.body.innerHTML = `
@@ -836,3 +1124,33 @@ describe("motion-signature.browser counter channel", () => { expect(collect()).toBe(before); }); }); + +// layout-audit.browser.js is installed standalone, so it cannot import this +// module's container list; the two literals are kept in step by hand. This +// pins that they have not drifted. layout-audit spells the names as the SVG +// DOM does (`clipPath`) while this module matches lower-cased tag names, so +// the two spellings of one name compare equal. +describe("motion-signature.browser source parity", () => { + function containerNames(source: string, listPattern: RegExp, file: string): string[] { + const list = listPattern.exec(source)?.[1]; + if (list === undefined) throw new Error(`${file}: container list not found`); + return Array.from(list.matchAll(/[A-Za-z]+/g), (name) => name[0].toLowerCase()).sort(); + } + + it("keeps UNPAINTED_SVG_CONTAINERS equal to layout-audit's CONNECTOR_SKIP_CONTAINERS", () => { + const layoutAudit = readFileSync(join(__dirname, "layout-audit.browser.js"), "utf-8"); + const unpainted = containerNames( + script, + /const UNPAINTED_SVG_CONTAINERS = new Set\(\[([^\]]*)\]\)/, + "motion-signature.browser.js", + ); + const skipped = containerNames( + layoutAudit, + /const CONNECTOR_SKIP_CONTAINERS = "([^"]*)"/, + "layout-audit.browser.js", + ); + + expect(unpainted.length).toBeGreaterThan(0); + expect(unpainted).toEqual(skipped); + }); +});