Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/commands/motion-signature.browser.chromium.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }",
'<svg width="640" height="360"><path id="wire" d="M 10 10 L 300 10"/></svg>',
),
);
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; }",
'<svg width="640" height="360"><path id="wire" d="M 10 10 L 300 10"/></svg>',
),
);
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} }`,
'<svg width="640" height="360"><path id="wire" d="M 10 10 L 300 10"/></svg>',
),
);
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 <g> and the <text> 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; }",
'<svg width="640" height="360"><g id="group"><text id="label" x="10" y="100">Q3</text></g></svg>',
),
);
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; }",
`<svg width="640" height="360">
<g id="offstage"><path id="hidden" d="M 10 10 L 300 10"/></g>
<defs><path id="template" d="M 10 20 L 300 20"/></defs>
<clipPath id="reveal"><path id="clip" d="M 10 30 L 300 30"/></clipPath>
<mask id="fade"><path id="masked" d="M 10 40 L 300 40"/></mask>
<symbol id="glyph"><path id="instanced" d="M 10 50 L 300 50"/></symbol>
<pattern id="tile"><path id="tiled" d="M 10 60 L 300 60"/></pattern>
<marker id="head"><path id="vertex" d="M 10 70 L 300 70"/></marker>
<rect id="anchor" x="10" y="100" width="200" height="50" fill="#f00"/>
</svg>`,
),
);
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 <defs>", async () => {
await load(
composition(
"#panel { display: block; } #label { display: inline-block; width: 40px; height: 48px; }",
'<defs id="panel"><span id="label">Q3</span></defs>',
),
);
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(
Expand Down
172 changes: 135 additions & 37 deletions packages/cli/src/commands/motion-signature.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: <defs> and <clipPath>
// only lend geometry to a referencing element; <mask>, <pattern> and
// <marker> content reaches the screen only through the element that
// references it (as a mask buffer, a fill tile, a vertex glyph), and
// <symbol> only as <use> 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 <mask> or <pattern>)
// 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;
Expand Down Expand Up @@ -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 <defs> 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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -390,28 +494,22 @@
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("*")]) {
if (IGNORE_TAGS.has(element.tagName)) continue;
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;
}
Expand Down
Loading
Loading