Repository navigation
fix(cli): recognize textual motion in sweep checks - #3707
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
Textual sweep fingerprint extension is thorough — direct text, pseudo content, CSS counters, and form state all hashed correctly with FNV-1a. Eight new test cases cover every channel. — Miga
## What
`check`'s sweep_static guard false-positives on a composition that swaps between equal-size, equal-position opaque `<img>` elements — a common authoring pattern for revealing frame N of a still sequence from a paused GSAP cursor. The render itself is correct; only `check`'s verdict is wrong.
## Why
The sweep guard fingerprints every visible element's box + opacity + font-variation-settings per seeked sample. That's deliberately blind to pixel-only motion (a canvas repainting, a video playing) with no element moving, so an existing carve-out downsamples each visible `canvas`/`video` to 8x8 and folds its pixels into the fingerprint specifically to catch that class of motion.
That carve-out's element selector (`root.querySelectorAll("canvas, video")`) never included `img`. An img src/visibility swap between equal-size opaque images moves zero geometry and zero opacity, so it stays outside both the base fingerprint and the pixel-hash carve-out — the whole-run fingerprint reads byte-identical across every sample and `sweep_static` fires on an animating composition.
## How
Widened the selector to `canvas, video, img`. No other change was needed: `mediaPixelHash` already handles `img` correctly — `drawImage` accepts any `CanvasImageSource`, and its width/height detection already falls back to the element's bounding rect the same way it does for `canvas`/`video`.
**Scope note:** this repo has an open PR (#3707) touching the same function (`collectLayoutGeometry` in this same file) for a different, unrelated bug (text/counter fingerprinting). This change is deliberately isolated to the `canvas, video` → `canvas, video, img` selector line and a new comment above it — verified against #3707's current diff that neither touches this exact loop, so the two PRs shouldn't conflict regardless of merge order.
## Testing
Added a test mirroring the existing "changes the sweep fingerprint when visible video pixels advance" test, using an `<img>` element instead of `<video>` with the same pixel-mock approach.
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed
- Full CLI suite (excluding known-broken browser-launch tests unrelated to this change): 217 test files / 3023 tests passing
One local-environment caveat, disclosed for transparency: `layout-audit.browser.test.ts` (the file the new test lives in) can't execute in my local sandbox — it fails identically with or without this change (`No such built-in module: node:`, a happy-dom + Vite externalization issue affecting every test file in this repo that imports Node builtins at the top under `@vitest-environment happy-dom`, not specific to this change). I verified the new test's logic and mocking approach are structurally identical to the existing, CI-passing video test it's modeled on, and will confirm via this PR's CI run rather than a local one.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
10dadce to
a329d21
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Second-pass quality review at head a329d21e (isolated worktree; rebase checked byte-for-byte against the PR file list, 326/22 over 4 files; browser-audit 128/128, check pipeline 59/59, tsc --noEmit clean, oxlint and oxfmt clean; 7 mutations; 10 probes in real Chromium via the repo's puppeteer-core). Three findings I consider blocking.
Blocking
- B1 — the hidden-element counter loop admits every non-visible element.
packages/cli/src/commands/layout-audit.browser.js:1770-1776iteratesallElementsminus the visible set, sodisplay:nonesubtrees are included, although the comment above it scopes the loop to zero-box owners whose visible descendants paint the counter. Because extra channels can only suppresssweep_static, one decoydisplay:noneelement with a varyingcounter-resetmakes a fully frozen composition read as live. Reproduced in Chromium with the actual audit script. Restrict the loop to elements that own a counter consumed by a visible descendant's pseudo-element, or at least excludedisplay:nonesubtrees. - B2 — "what counts as motion" now has two owners that disagree.
__hyperframesLayoutGeometryreads nine channels after this PR;livenessSignature(packages/cli/src/commands/motion-sample.browser.js:66,elementSignature) is still box + opacity only. They meet in one decision atcheckPipeline.ts:519. Proved with the PR's own motivating case: anattr(data-txt)countdown yieldssweepDetectsMotion: true, livenessDetectsMotion: false, somotion_frozenstill fires on the composition this PR exists to accept. One classifier, consumed by both samplers. - B3 — four new branches are pinned by no test. The ancestor counter loop (
:1766), pseudo counter folding (:1735-1736),TEXTAREA/indeterminate(:1704-1708) and root inclusion (:1745) can each be deleted with all 128 tests green. Tests are jsdom with a mockedgetComputedStyle, so theattr()test asserts the mock rather than the platform; one real-browser case per branch would pin them.
Non-blocking: the new pseudo-element style reads cost a measured 1.6–3.0× per sample with no cap (the sibling classifier notes its own cap explicitly); the description's test counts (92/52) do not match the actual 128/59, and the "four frozen-sweep guard controls" it cites are pre-existing tests that stub out all the new code; the unchecked typecheck box is in fact clean in a fresh install.
Holds: Blink substitutes attr() in computed pseudo content ("10" → "09") and does not substitute counter(), so both mechanisms in the diff are justified. Mutations on the two landed detectors go red (6 and 1 tests).
Verdict: COMMENT — needs fixes before merge.
Reasoning: a decoy hidden counter defeats the frozen-sweep check, the liveness sampler still rejects the exact composition the PR accepts, and the new branches have no coverage.
Review by Miga
The frozen-sweep guard (sweep_static) and keepsMoving liveness (motion_frozen) each owned their own definition of "what counts as motion": the sweep fingerprint in layout-audit.browser.js read nine channels while motion-sample.browser.js compared box + opacity only. They meet in one decision in checkPipeline.ts, so a fixed-width attr() countdown was accepted by the sweep guard and still reported frozen by liveness. Move the classifier into motion-signature.browser.js, injected before both samplers, and make it the single owner: - Per-element channels (box, opacity, font-variation axes, clip-path, direct text, form-control state, ::before/::after content, media pixels) as a reader list so further channels (e.g. SVG dash) plug in. - Liveness consumes the same signature with 2px / 0.08 bucketing; the sweep guard consumes it exact. motion-sample throws at install if the shared script is missing. - Counter state is a composition-level channel over box-generating elements and root ancestors. display:none subtrees generate no boxes and are excluded, and only declarations naming a counter that some painted generated content (::before/::after/::marker on a non-ignored, paintable host) consumes are folded in, so a decoy hidden counter cannot make a frozen composition read as live. - clip-path is a channel, replacing the fingerprint's former reliance on the clipped-away visibility probe, so a wipe over a static box still counts. - data-layout-ignore layers are excluded from the signature but not from the per-selector `visible` bit reported for explicit motion assertions. - Root resolution for the "*" liveness scope now matches the sweep guard and the root element is part of its own signature. Tests: motion-signature.browser.test.ts (happy-dom, per-element fake computed style so ancestor handling comes from the classifier) and motion-signature.browser.chromium.test.ts (real Chromium via puppeteer-core; skips without a system browser) pin every branch: attr() substitution, display:none decoys, named-counter gating, ::marker, zero-box hosts, ancestor counters, textarea/indeterminate, root text, clip-path, ignore layers, and sweep/liveness agreement on the countdown fixture. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
Addressed in fbec4c6. |
miga-heygen
left a comment
There was a problem hiding this comment.
Second-pass quality review at head fbec4c60 (isolated worktree; 172/172 across the four touched suites including the real-Chromium suite, 11 mutations, 3 Chromium probes). Two of the three blockers from my review at a329d21e are closed; the third is closed for display:none but not for the neighbouring case, and the new suite turns a required check red.
Closed and pinned. One owner of the signature: compositionSignature (packages/cli/src/commands/motion-signature.browser.js:320) maps ELEMENT_CHANNELS (:226) for both consumers — the sweep exact at :359, the liveness sampler quantised via the same call at motion-sample.browser.js:66 — and the quantise buckets (:38-39) reproduce the deleted 2px / 0.08 thresholds exactly. elementSignature / livenessSignature are gone and __hyperframesLayoutGeometry is defined once. Removing each of the eight channels in turn goes red (box 2, opacity 1, fontAxes 1, clipPath 2, text 3, control 6, generatedContent 4, mediaPixel 2); reverting liveness to its own list → 4 red including the attr() countdown; dropping the hidden-subtree walk → 2 red.
Blocking
- The required
Tests on windows-latestcheck is red at this head because of the new suite.motion-signature.browser.chromium.test.ts:59— thebeforeAllbrowser launch exceeds the repo-widehookTimeout: 30_000(vitest.config.ts:39) in thestudio-engine-clilane:Hook timed out in 30000ms, all 20 tests skipped, lane failed, aggregator failed. The lane was green ata329d21eand10dadce3. This is the first real-browser launch inside@hyperframes/cli(the producer's lives behind its own script), so it wants its own lane or an opt-in flag on Windows rather than a global timeout bump. content-visibility: hiddensubtrees still admit unpainted motion.motion-signature.browser.js:329prunes onlydisplay:none. Proved in Chromium: a text mutation, or a counter decoy, inside acontent-visibility: hiddenwrapper changes__hyperframesLayoutGeometry(). The copiedisVisibleElement(:82) droppedlayout-audit.browser.js:180-190'selement.checkVisibility({ contentVisibilityAuto: true })while its comment at:74still claims the same visibility floor; that API is exactly what rejects this case.- A
keepsMovingscope that is itselfdata-layout-ignorenow reportsmotion_selector_missing.:339excludes the scope and all descendants, so the signature is empty every frame andmotionAudit.ts:171says the selector matched no element for an element that exists (proved: empty vs a 44-character signature without the attribute). That contradicts the file's own rule at:74-78, which the diff honours forsampleElement's visible bit but not for asserted-scope liveness, and it is a behaviour change from main.
Non-blocking: the install-time ordering throw never fails anything — addScriptTag resolves on load, so it degrades to a page error and then an opaque __hyperframesMotionSample is not a function; duplicated round(); mediaPixelChannel readbacks now sit on the 300-sample liveness path; the counter ancestor walk escapes a scoped liveness root; paragraph merge at docs/packages/cli.mdx:718. No missed injection site (checkBrowser.ts:438-440, layout.ts:393-394, build-copy.mjs:107 are the only three). The dash-only SVG case is still frozen, which is now consistent between both samplers rather than a disagreement.
Verdict: COMMENT — needs fixes before merge.
Reasoning: the single-owner refactor is right and well pinned, but the head fails a required check and two hidden-content cases still leak into the signature.
Review by Miga
…ure asserted scopes The shared motion classifier pruned only display:none subtrees, so text, pseudo content, media pixels, and counter decoys inside skipped content-visibility contents changed the frozen-sweep and liveness signatures although nothing painted. Pruning is now platform-owned where checkVisibility exists (display:none, skipped hidden/auto contents; a display:contents host keeps its painting pseudo-elements), with the computed-style fallback for happy-dom. A content-visibility:hidden host signs its own box, opacity, clip-path, font axes, and widget state, but not its text, pseudo content, control value, or media pixels; hosts where size containment does not apply (non-atomic inline, display:contents, table and internal table boxes, ruby) skip nothing. isVisibleElement regains the checkVisibility floor layout-audit uses on its opacity-floor path, so an asserted selector inside skipped contents reports hidden. The layout-audit opt-out no longer applies to the element passed as the signature root: a keepsMoving scope that is itself data-layout-ignore is measured (an assertion naming an element outranks the opt-out) instead of producing an empty signature that motionAudit reports as a missing selector. Opt-outs inside the scope still apply. motion-sample defers the missing-classifier error to the first call so the driver's evaluate surfaces it instead of an opaque "is not a function" page error; round() is shared. The real-Chromium suite is opt-in on Windows (HYPERFRAMES_BROWSER_TESTS=1): in the shared Windows package lane the runner's Chrome did not launch within the package hookTimeout, and the suite already runs on Linux CI where the system Chrome is found. The launch hook has its own 120s ceiling. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
|
Addressed in bfaf6eb. |
miga-heygen
left a comment
There was a problem hiding this comment.
Reviewed head bfaf6eb9 in an isolated worktree (one commit on fbec4c60, 6 files). Follow-up to my review at fbec4c60.
Strengths
motion-signature.browser.js:326-331—isOptedOut(element, root)makes the asserted scope authoritative: the root's and its ancestors'data-layout-ignoreno longer suppress measurement, while a descendant opt-out still skips. Pinned in both happy-dom (:309) and Chromium (chromium.test.ts:180).motion-signature.browser.js:432— acontent-visibility:hiddenhost signsBOX_CHANNELSonly; each of the nine channels lives in exactly one list, andskipsContentsis the single owner of the css-contain-2 exemption table.chromium.test.ts:36-68—describe.skipIf(!RUNS_CHROMIUM)with the 120s launch ceiling clears the Windows lane; the ubuntu Test job log at this head shows the suite ran (30 tests, not skipped).
Prior blockers
- Windows lane timeout: fixed (
Tests on windows-latestand thestudio-engine-clilane pass). content-visibility:hiddenleak: fixed for the general case viacheckVisibilitypruning at:398-404plus the box-only host signature; one residual below.- Opted-out asserted scope: fixed (
:326-331).
Important
motion-signature.browser.js:402—noBox && style.display !== "contents"exemptsdisplay:contentsfrom the platform's no-box verdict unconditionally, so adisplay:contentselement whose parent skips its contents stays a box owner with a live pseudo. Chromium 152 probe:#wrap{content-visibility:hidden} #h{display:contents} #h::after{content:counter(c)}— changing the owner'scounter-resetchanges__hyperframesLayoutGeometry()although nothing paints. Fix: when the platform decides, also treatdisplay:contentsas unrendered when its parent is inunrenderedBeloworskipsContents(parent); add a Chromium pin.motion-signature.browser.js:73-74—NOT_CONTAINABLE_DISPLAYdisagrees with Chromium 152 on two table rows. A child inside acontent-visibility:hiddentable-cellreportscheckVisibility({contentVisibilityAuto:true}) === false(Chromium skips cell contents; the regex says not containable, so the host over-counts its own text and pseudo). A child inside a hiddentable-captionreportstrue(Chromium does not skip caption contents; the regex and the comment say containable, so the caption's own text drops out). Suggesttable-(?!cell$)[a-z-]+plustable-captionin the exempt set, with two Chromium pins. Rare in compositions.
Nits / test gaps
- Dropping the
checkVisibilitycall inisVisibleElement(:122-127), keeping a real pseudo on a skipped host, and lettingpaintsContentignoreskippedeach leave all 68 tests green. The code is right; a fixture "ignores a counter consumed only by acontent-visibility:hiddenhost's pseudo" would pin all three. - The Chromium suite is silently skipped on any Linux runner without a system Chrome; consider hard-failing when
CIis set and no binary is found. - Deferred install throw (
motion-sample.browser.js:10-13,65-70): same message, surfaces at first call; both injection sites inject the signature first, so this is defensive only.
Verified: single visibility predicate; visibility:hidden parent with visibility:visible child still counts; opacity:0 unchanged from before; content-visibility:auto on-screen counts; a skipped host's counter-increment still reaches siblings; inline-block correctly absent from the skip list; round shared via __hyperframesMotionSignature.round. Local: 68/68 across the three touched suites (Chromium via system binary), motionAudit + layout-audit 131/131, tsc --noEmit clean for cli.
CI: every required check is green at bfaf6eb9 except Render on windows-latest (run 34915456761): the downloaded chrome-headless-shell --version probe timed out and the step reported "Chrome cannot start". This PR touches no workflow or browser-manager files and every sibling windows-render run in the same window is green, so it reads as a runner flake; it needs a rerun before approval.
Verdict: COMMENT
Reasoning: All three prior blockers are closed and pinned; two narrow correctness gaps remain in the containment table and the display:contents exemption, and the required Windows render check is red on an infra failure that a rerun should clear.
Review by Miga
…s, caption paints A display:contents child of a content-visibility:hidden host has no box of its own, so the platform check could not tell it apart from an ordinary display:contents owner and it stayed a counter consumer: a change of the owner's counter-reset altered the sweep signature although nothing paints. The parent's skipsContents verdict now prunes it; boxed descendants keep being decided by the platform. NOT_CONTAINABLE_DISPLAY had table-cell and table-caption inverted versus Chromium 152, where a table-cell host skips its contents and a table-caption host paints them. Four Chromium pins: the display:contents-in-skipped-contents counter, a table-cell host's own text and pseudo content (frozen), a table-caption host's own text and pseudo content (motion), and a counter reachable only through a skipped host's hidden ::marker / ::after (frozen; also asserts the skipped child is not visible for motion-sample). Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
|
Addressed in 09418ae. |
miga-heygen
left a comment
There was a problem hiding this comment.
Reviewed head 09418ae7 in an isolated worktree.
Re-pass of the one commit on bfaf6eb9 addressing the two importants from my previous review. Both closed:
motion-signature.browser.js:405-415—skippedHostsplusboxlessOwner = display === "contents" && !skippedHosts.has(parent): a display:contents child of a host that skips its contents is now pruned. The direct-parent set is enough because the first such child lands inunrenderedBelowand deeper display:contents nodes are pruned throughunrenderedBelow.has(parent)(Chromium oracle: wrap[cv:hidden] > mid[contents] > host[contents]::after counter() leaves the signature unchanged on owner mutation). Boxed descendants still go through the platform check, unchanged.:77—table-(?!cell$): Chromium 152 oracle agrees on every case (div display:table-cell, real td/th under content-visibility:hidden skip their contents; div display:table-caption and real caption paint them; table / inline-table / inline / ruby paint). The comment at:68-76now describes the platform, and correctly notes the spec would have both the other way.
Mutations, each caught by the new Chromium tests: drop the !skippedHosts.has(parent) term, revert the regex to caption, never populate the set, drop the table-* branch, bypass the set when the platform decides. 64/64 across the Chromium and happy-dom files locally.
Nit — :419-421 says a skipped host's own counter-increment / counter-set still reach a sibling's painted counter(). In Chromium 152 that is state-dependent: a host skipped from first layout does not contribute (sibling paints 10 with body reset 10 and host increment 5 / set 7 pending); after one unskipped layout the value is retained through later skips (15). Keeping the declarations remains the accepted conservative over-count, so no code change; suggest wording "may still reach … once the host has been laid out unskipped".
Verdict: APPROVE
Reasoning: Both prior importants are fixed with platform-verified behaviour and mutation-pinned tests; the only remaining item is a comment wording nit.
— Miga
Edit accuracy: accurate 1216 (base branch 1216), smooth 1004 of thoseThe gate passes. Quarantined, measured but not gated (1)
|
What
hyperframes checknow recognizes fixed-geometry text, generated content, painted CSS counters, form-control state, and clip-path wipes as real motion instead of failing them withsweep_static, and the frozen-sweep guard andkeepsMovingliveness share one definition of "what counts as motion" so they can no longer disagree. A genuinely frozen composition still fails the guard.Why
The sweep guard compared box geometry, opacity, variable-font axes, and media pixels across seek samples. A fixed-width countdown could visibly change from
10to09while every recorded field stayed identical, socheckconcluded the timeline never advanced.Widening only the sweep fingerprint would have left a second owner:
motion-sample.browser.js's liveness signature still compared box + opacity, and the two meet in one decision incheckPipeline.ts(amotion_frozenfinding suppressessweep_static). On the motivatingattr(data-txt)countdown the sweep guard accepted the composition while liveness still reportedmotion_frozen.Leading-zero coercion in
data-txtremains unchanged because GSAP AttrPlugin owns that behavior; authoring guidance or a lint hint for numeric attribute strings remains separate.How
motion-signature.browser.js, injected before both samplers, owns the per-element channel list, split into box channels (box, opacity, font-variation axes, clip-path, checkbox checked/indeterminate) and content channels (direct text, control value/selection, resolved::before/::aftercontent, media pixels) plus a composition-level CSS counter channel.window.__hyperframesLayoutGeometryand the liveness signature are bothcompositionSignature(root); liveness passesquantize: true(2px / 0.08 buckets), the sweep guard reads exact. Channels are a reader list so a further channel (e.g. SVG dash) plugs in as one function.display:nonesubtrees and skippedcontent-visibilitycontents (hidden, orautowhile off-screen), decided byelement.checkVisibility({ contentVisibilityAuto: true })where available — generate no painted boxes and are excluded (acontent-visibility: hiddenhost's own box and a checkbox's checked/indeterminate state still count; its text, pseudo content, control value, and media pixels do not; hosts where size containment does not apply — non-atomic inline,display: contents, table boxes other than a cell, and ruby boxes — skip nothing and stay fully signed, matching Chromium 152 where atable-cellhost skips its contents and atable-captionhost does not; adisplay: contentshost has no box but its pseudo-elements paint, so it stays a counter owner/consumer — except as the child of a host that skips its contents, where the parent's verdict prunes it because the platform check cannot tell that box-less case from an ordinarydisplay: contentshost); only declarations that name a counter some painted generated content consumes (counter()/counters()in a::before/::after/::markeron a non-ignored, paintable host, or the implicitlist-itemmarker) are folded in. A hidden decoy element with a varyingcounter-resettherefore cannot make a frozen composition read as live.clip-pathis a channel, replacing the fingerprint's former reliance on the clipped-away visibility probe, so a wipe over a static box still counts as motion.data-layout-ignore/data-layout-check="ignore"inside the measured root are excluded from the signature (a decorative layer must not prove the timeline advanced). The root itself — the composition root or an assertedkeepsMovingscope — is always measured, even when it or an ancestor carries the opt-out: an assertion naming an element outranks the layout-audit opt-out, and an ignored scope therefore reports a real signature instead ofmotion_selector_missing. Explicitly asserted selectors (appearsBy,before,staysInFrame) likewise still reportvisible.layout-audit.browser.js:element.checkVisibility({ opacityProperty, visibilityProperty, contentVisibilityAuto })first, thendisplay/visibility, inherited opacity ≥ 0.2, and a non-empty box.keepsMoving, all in the more-sensitive direction: the"*"scope resolves its root like the sweep guard ([data-composition-id][data-width][data-height]first), the scope element is part of its own signature (a leafwithinSelectoris no longer reported as missing), and media pixels, text, generated content, form state, and counters now count as liveness. Documented indocs/packages/cli.mdx.motion-sample.browser.jsthrows a named error from__hyperframesMotionSamplewhen the shared script is missing (an install-time throw would only surface as a page error and an opaque "is not a function" from the driver'sevaluate); it reuses the sharedround.build-copy.mjsships the new file.Test plan
motion-signature.browser.test.ts(happy-dom, 30 tests): per-element fakegetComputedStylethat reports only each element's own declared style, so hidden-subtree and ancestor-counter handling has to come from the classifier. Covers media pixels, opacity reveal, font axes, quantization buckets, text/root text/attr()/pseudo content, checkbox/indeterminate/select/input/textarea, zero-box and ancestor counter owners, pseudocounter-increment,visibility:hiddenowners,display:nonedecoys (direct and nested), unconsumed counters, name-gated decoys next to a list marker, ignored-layer consumers, clip-path, hidden-descendant text,data-layout-ignoremotion, and a root that is itselfdata-layout-ignore(measured, while opted-out layers inside it are not).motion-signature.browser.chromium.test.ts(real Chromium via puppeteer-core, 34 tests, skips without a system browser; opt-in on Windows viaHYPERFRAMES_BROWSER_TESTS=1because the cold launch in the shared Windows package lane exceeds the packagehookTimeout— the ubuntuTestjob finds the runner's Chrome and runs it; the browser launch has its own 120s hook timeout): the same branches against the platform with the exact injected scripts — Blink'sattr()substitution, unsubstitutedcounter(),::markercontent,counters()/counter(x, upper-roman)/counter-set, zero-box hosts with absolutely positioned pseudo-elements, sweep/liveness agreement on the countdown fixture, text / counter-decoy / host-own-text-and-pseudo cases undercontent-visibility: hiddena counter decoy inside off-screencontent-visibility: autocontents, a repaint of acontent-visibility: hiddencanvas, and a value change on acontent-visibility: hiddentext input (all frozen; the host's box move still counts), plus three still-motion controls: a counter painted only by adisplay: contentshost's pseudo-element (a box-less host stays a counter consumer), text and pseudo content inside an inlinecontent-visibility: hiddenhost (containment does not apply to non-atomic inlines, so nothing is skipped), and a checkbox toggle on acontent-visibility: hiddencontrol (theme paint is box paint), and akeepsMovingscope that is itselfdata-layout-ignore(non-empty signature, inner opt-out still excluded). Removing any one of the new branches turns at least one test red (16 mutations checked; seven of the ten new Chromium tests are red against the previous classifier; thedisplay: contents, inline-host, and checkbox ones guard against regressions introduced and fixed during review).display: contentshost's pseudo-element insidecontent-visibility: hiddencontents is frozen (was signed: the box-less host escaped the platform prune and stayed a consumer); adisplay: table-cellhost's own text and pseudo content are frozen and a<caption>host's own text and pseudo content are motion (the containability regex had the two inverted versus Chromium 152 — verified withcheckVisibilityprobes on the child and screenshot diffs: cell contents skip, caption contents paint); and a fixture whose only counter consumers are a skipped list-item host's hidden::marker/::after, asserting the skipped child is notvisiblefor motion-sample and that neither the owner'scounter-resetnor the hidden pseudo'scounter-incrementmoves the signature. The first three are red against the previous classifier; the fourth is red under each of three mutations that survived the existing suites (droppingcheckVisibilityfromisVisibleElement, reading real pseudo styles on a skipped host, andpaintsContentignoringskipped). Chromium probes also confirmed that under acontent-visibility: hiddenhost, modal<dialog>,[popover],position: fixeddescendants, nestedcontent-visibility: visible, and slotted children are all skipped, so pruning by the parent's verdict removes nothing that paints. Known remaining gap, unchanged: adisplay: contentschild of an off-screencontent-visibility: autohost is not pruned (the heuristic only detectshidden); noted in the source comment.motion-sample.browser.test.ts(8 tests): install without the classifier and a named error on first call, liveness agreement with the sweep fingerprint on the attr() countdown, leaf scope signature, ignored asserted selector stillvisible.check.test.ts: 59 passed, including the four pre-existing frozen-sweep guard controls (driver-mocked, unchanged).tsc --noEmit, oxlint, oxfmt, and the repo's fallow gate pass on the changed files.docs/packages/cli.mdxkeepsMovingparagraph updated to describe the shared classifier.