Skip to content

fix(cli): recognize textual motion in sweep checks - #3707

Merged
miguel-heygen merged 7 commits into
mainfrom
magi/fix-sweep-static-text-fingerprint
Oct 2, 2026
Merged

miguel-heygen merged 7 commits into
mainfrom
magi/fix-sweep-static-text-fingerprint

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

What

hyperframes check now recognizes fixed-geometry text, generated content, painted CSS counters, form-control state, and clip-path wipes as real motion instead of failing them with sweep_static, and the frozen-sweep guard and keepsMoving liveness 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 10 to 09 while every recorded field stayed identical, so check concluded 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 in checkPipeline.ts (a motion_frozen finding suppresses sweep_static). On the motivating attr(data-txt) countdown the sweep guard accepted the composition while liveness still reported motion_frozen.

Leading-zero coercion in data-txt remains unchanged because GSAP AttrPlugin owns that behavior; authoring guidance or a lint hint for numeric attribute strings remains separate.

How

  • New 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/::after content, media pixels) plus a composition-level CSS counter channel. window.__hyperframesLayoutGeometry and the liveness signature are both compositionSignature(root); liveness passes quantize: 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.
  • Counter state is folded from every box-generating element and the root's ancestors. Elements the browser does not render — display:none subtrees and skipped content-visibility contents (hidden, or auto while off-screen), decided by element.checkVisibility({ contentVisibilityAuto: true }) where available — generate no painted boxes and are excluded (a content-visibility: hidden host'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 a table-cell host skips its contents and a table-caption host does not; a display: contents host 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 ordinary display: contents host); only declarations that name a counter some painted generated content consumes (counter()/counters() in a ::before/::after/::marker on a non-ignored, paintable host, or the implicit list-item marker) are folded in. A hidden decoy element with a varying counter-reset therefore 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 as motion.
  • Elements under 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 asserted keepsMoving scope — 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 of motion_selector_missing. Explicitly asserted selectors (appearsBy, before, staysInFrame) likewise still report visible.
  • The per-element visibility floor is the same as layout-audit.browser.js: element.checkVisibility({ opacityProperty, visibilityProperty, contentVisibilityAuto }) first, then display / visibility, inherited opacity ≥ 0.2, and a non-empty box.
  • Textual channels are hashed (FNV-1a, length-delimited fields) so raw composition text never leaves the page.
  • Behavior deltas for 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 leaf withinSelector is no longer reported as missing), and media pixels, text, generated content, form state, and counters now count as liveness. Documented in docs/packages/cli.mdx.
  • motion-sample.browser.js throws a named error from __hyperframesMotionSample when 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's evaluate); it reuses the shared round. build-copy.mjs ships the new file.

Test plan

  • motion-signature.browser.test.ts (happy-dom, 30 tests): per-element fake getComputedStyle that 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, pseudo counter-increment, visibility:hidden owners, display:none decoys (direct and nested), unconsumed counters, name-gated decoys next to a list marker, ignored-layer consumers, clip-path, hidden-descendant text, data-layout-ignore motion, and a root that is itself data-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 via HYPERFRAMES_BROWSER_TESTS=1 because the cold launch in the shared Windows package lane exceeds the package hookTimeout — the ubuntu Test job 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's attr() substitution, unsubstituted counter(), ::marker content, 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 under content-visibility: hidden a counter decoy inside off-screen content-visibility: auto contents, a repaint of a content-visibility: hidden canvas, and a value change on a content-visibility: hidden text input (all frozen; the host's box move still counts), plus three still-motion controls: a counter painted only by a display: contents host's pseudo-element (a box-less host stays a counter consumer), text and pseudo content inside an inline content-visibility: hidden host (containment does not apply to non-atomic inlines, so nothing is skipped), and a checkbox toggle on a content-visibility: hidden control (theme paint is box paint), and a keepsMoving scope that is itself data-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; the display: contents, inline-host, and checkbox ones guard against regressions introduced and fixed during review).
  • Follow-up pins from review (Chromium, 4 tests): a counter painted only by a display: contents host's pseudo-element inside content-visibility: hidden contents is frozen (was signed: the box-less host escaped the platform prune and stayed a consumer); a display: table-cell host'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 with checkVisibility probes 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 not visible for motion-sample and that neither the owner's counter-reset nor the hidden pseudo's counter-increment moves 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 (dropping checkVisibility from isVisibleElement, reading real pseudo styles on a skipped host, and paintsContent ignoring skipped). Chromium probes also confirmed that under a content-visibility: hidden host, modal <dialog>, [popover], position: fixed descendants, nested content-visibility: visible, and slotted children are all skipped, so pruning by the parent's verdict removes nothing that paints. Known remaining gap, unchanged: a display: contents child of an off-screen content-visibility: auto host is not pruned (the heuristic only detects hidden); 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 still visible.
  • 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.
  • Documentation: docs/packages/cli.mdx keepsMoving paragraph updated to describe the shared classifier.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@miga-heygen
miga-heygen enabled auto-merge (squash) September 8, 2026 18:09
miguel-heygen pushed a commit that referenced this pull request Sep 11, 2026
## 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)
@miga-heygen
miga-heygen force-pushed the magi/fix-sweep-static-text-fingerprint branch from 10dadce to a329d21 Compare September 13, 2026 03:57

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-1776 iterates allElements minus the visible set, so display:none subtrees 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 suppress sweep_static, one decoy display:none element with a varying counter-reset makes 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 exclude display:none subtrees.
  • B2 — "what counts as motion" now has two owners that disagree. __hyperframesLayoutGeometry reads 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 at checkPipeline.ts:519. Proved with the PR's own motivating case: an attr(data-txt) countdown yields sweepDetectsMotion: true, livenessDetectsMotion: false, so motion_frozen still 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 mocked getComputedStyle, so the attr() 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>
@mintlify

mintlify Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Oct 2, 2026, 12:25 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@miga-heygen

Copy link
Copy Markdown
Contributor

Addressed in fbec4c6.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. The required Tests on windows-latest check is red at this head because of the new suite. motion-signature.browser.chromium.test.ts:59 — the beforeAll browser launch exceeds the repo-wide hookTimeout: 30_000 (vitest.config.ts:39) in the studio-engine-cli lane: Hook timed out in 30000ms, all 20 tests skipped, lane failed, aggregator failed. The lane was green at a329d21e and 10dadce3. 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.
  2. content-visibility: hidden subtrees still admit unpainted motion. motion-signature.browser.js:329 prunes only display:none. Proved in Chromium: a text mutation, or a counter decoy, inside a content-visibility: hidden wrapper changes __hyperframesLayoutGeometry(). The copied isVisibleElement (:82) dropped layout-audit.browser.js:180-190's element.checkVisibility({ contentVisibilityAuto: true }) while its comment at :74 still claims the same visibility floor; that API is exactly what rejects this case.
  3. A keepsMoving scope that is itself data-layout-ignore now reports motion_selector_missing. :339 excludes the scope and all descendants, so the signature is empty every frame and motionAudit.ts:171 says 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 for sampleElement'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>
@miga-heygen

Copy link
Copy Markdown
Contributor

Addressed in bfaf6eb.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-ignore no 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 — a content-visibility:hidden host signs BOX_CHANNELS only; each of the nine channels lives in exactly one list, and skipsContents is 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-latest and the studio-engine-cli lane pass).
  • content-visibility:hidden leak: fixed for the general case via checkVisibility pruning at :398-404 plus the box-only host signature; one residual below.
  • Opted-out asserted scope: fixed (:326-331).

Important

  1. motion-signature.browser.js:402 — noBox && style.display !== "contents" exempts display:contents from the platform's no-box verdict unconditionally, so a display:contents element 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's counter-reset changes __hyperframesLayoutGeometry() although nothing paints. Fix: when the platform decides, also treat display:contents as unrendered when its parent is in unrenderedBelow or skipsContents(parent); add a Chromium pin.
  2. motion-signature.browser.js:73-74 — NOT_CONTAINABLE_DISPLAY disagrees with Chromium 152 on two table rows. A child inside a content-visibility:hidden table-cell reports checkVisibility({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 hidden table-caption reports true (Chromium does not skip caption contents; the regex and the comment say containable, so the caption's own text drops out). Suggest table-(?!cell$)[a-z-]+ plus table-caption in the exempt set, with two Chromium pins. Rare in compositions.

Nits / test gaps

  • Dropping the checkVisibility call in isVisibleElement (:122-127), keeping a real pseudo on a skipped host, and letting paintsContent ignore skipped each leave all 68 tests green. The code is right; a fixture "ignores a counter consumed only by a content-visibility:hidden host's pseudo" would pin all three.
  • The Chromium suite is silently skipped on any Linux runner without a system Chrome; consider hard-failing when CI is 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>
@miga-heygen

Copy link
Copy Markdown
Contributor

Addressed in 09418ae.

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — skippedHosts plus boxlessOwner = 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 in unrenderedBelow and deeper display:contents nodes are pruned through unrenderedBelow.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-76 now 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

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 1216 (base branch 1216), smooth 1004 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (1)

@miguel-heygen
miguel-heygen merged commit e8659cb into main Oct 2, 2026
96 checks passed
@miguel-heygen
miguel-heygen deleted the magi/fix-sweep-static-text-fingerprint branch October 2, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants