Fix mobile chip-strip overlap, reveal-replay stagger, scroll-linked text-fill

Three clone-fidelity fixes from manual review of the acceptance screenshots.

Fix 1 — mobile nav chip-strip overlap (generate/css.ts). A horizontally
scrollable flex strip (overflow-x:auto flex <ul>) of nowrap chips collapsed
because the base viewport reported min-width:0px on the chips (a mobile-only
strip that is 0-wide at desktop), so the generator emitted `min-w-0`, letting
the chips shrink below their content instead of overflowing — the nowrap text
then collided. Suppress `min-w-0` for a nowrap flex item whose flex parent
scrolls horizontally; scoped away from legitimate min-w-0 truncation (whose
parent does not scroll-x). Verified on a fresh ooni.com clone: 0 chip overlaps
at 375, scrollWidth 776 > clientWidth 375.

Fix 2 — reveal-replay stagger leaves tiles unpainted (generate/motion.ts).
DittoMotion re-hid each revealed element and replayed its entrance on
scroll-into-view preserving the captured delay/duration, so a fast scroll /
full-page screenshot caught most grid tiles mid-entrance. Cap the replayed
delay (<=300ms) and duration (<=600ms), settle each tile per-element a bounded
time after it enters view, and make the global failsafe settle (not re-animate)
so nothing stays hidden. Validator settle path (__dittoMotionStop) unchanged.
Verified: cropin cotton renders 12/12 crop tiles in a normal full-page pass;
motion gate reveals 24/24.

Fix 3 — scroll-linked text-fill frozen at end state (capture/stabilize.ts,
capture/capture.ts, generate/css.ts). A view-timeline text-fill
(animation-duration resolves to `auto`) was baked filled: the clone has no
scroll timeline, so emitting the animation made it jump to its end keyframe via
fill-mode:both. Capture-side, cancel scroll/view-timeline animations before each
snapshot so the DOM records the at-rest state; generation-side, suppress the
animation-* props when animation-duration is `auto` (we render the at-rest
state, never replay a scroll-linked animation). Teach the motion gate to exclude
these from the static-CSS expectation so the gate stays honest. Verified on a
fresh ooni clone: the "world's no.1 pizza ovens" em is white/gray (rgb 240,240,
240), not filled yellow, at rest; motion gate still passes.

Tests: +7 fixtures (css scroll-strip + scroll-timeline, reveal-replay caps,
motion-gate scroll-timeline exclusion); 123/123 compiler tests green, full test
suite green, typecheck clean. No gate regression on the fresh ooni run vs the
20260704-024247 baseline (all gate pass/fail unchanged; perceptual within
0.0004; motion restored to pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 21:46:28 -07:00
co-authored by Claude Fable 5
parent f3d433c024
commit afab12ff14
8 changed files with 251 additions and 11 deletions
+9 -1
View File
@@ -5,7 +5,7 @@ import { tagElements, captureInteractions, type InteractionCapture } from "./int
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
import {
promoteLazyMedia, settleCarousels, settleScrollReveals, neutralizePreReveal,
forceRevealForShot, restoreRevealForShot,
forceRevealForShot, restoreRevealForShot, neutralizeScrollTimelineAnimations,
} from "./stabilize.js";
import {
enumerateFramesInPage, planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent,
@@ -965,6 +965,14 @@ export async function captureSite(opts: {
await page.waitForTimeout(80);
}
// Scroll-linked animations (animation-timeline: scroll()/view()) are held at their
// end keyframe by `fill-mode:both` after the dwell-scroll pass, even with scroll reset
// to 0 — so the walk would bake the frozen END state (e.g. a text-fill stuck at 100%).
// Cancel them here so the snapshot records the genuine AT-REST (unscrolled, 0%) values.
// Time-based reveals use the default document timeline and are untouched.
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([
+61
View File
@@ -243,6 +243,67 @@ export async function neutralizePreReveal(page: Page): Promise<number> {
}
}
/** Cancel scroll/view-timeline-driven CSS animations so the snapshot records the AT-REST
* (unscrolled) computed style rather than a frozen mid/end-timeline value.
*
* A scroll-linked text-fill (e.g. `animation-timeline: view(); animation-fill-mode: both`)
* progresses with scroll position, not time. The dwell-scroll pass runs the page to the
* bottom, and `fill-mode: both` HOLDS the animation's end keyframe even after we restore
* scroll to 0 — because the element's view-timeline range has already been exited. The DOM
* walk then reads the FROZEN end value (e.g. `background-position: 100%`, fully filled),
* baking the end state into the clone when the live at-rest state is the start (0%).
*
* Fix: before the snapshot, cancel every running CSS animation whose timeline is NOT the
* default (document) timeline — i.e. a ScrollTimeline / ViewTimeline, or (fallback) an
* animation whose resolved effect duration is not finite. Canceling drops the animation's
* fill so getComputedStyle reports the underlying (unanimated) property values.
*
* Scoped to scroll/view timelines only: time-based entrance animations (reveals) use the
* default document timeline with a finite duration and are left untouched. */
export function neutralizeScrollTimelineAnimationsInPage(): number {
let n = 0;
try {
const docTimeline = (document as unknown as { timeline?: unknown }).timeline;
const anims = (document as unknown as { getAnimations?: () => Animation[] }).getAnimations?.() ?? [];
for (const a of anims) {
try {
const tl = a.timeline as unknown;
// Default (document) timeline → time-based; leave it. Anything else (scroll/view
// timeline) or a null timeline with a non-finite effect duration → scroll-linked.
const isDefaultTimeline = tl === docTimeline;
const ctorName: string = (tl ? (tl as { constructor?: { name?: string } }).constructor?.name : "") || "";
const isScrollLinked =
!isDefaultTimeline && /Scroll|View/.test(ctorName);
// Fallback: resolved effect duration is not a finite number (scroll-timeline
// animations report `auto`/non-finite computed duration, e.g. `animation-duration:auto`).
let nonFiniteDuration = false;
try {
const timing = (a.effect as unknown as { getComputedTiming?: () => { duration?: number | string } })?.getComputedTiming?.();
const dur = timing?.duration;
nonFiniteDuration = typeof dur === "number" ? !isFinite(dur) : dur === "auto";
} catch { /* ignore */ }
if (isScrollLinked || (!isDefaultTimeline && nonFiniteDuration)) {
a.cancel();
n++;
}
} catch { /* per-animation errors are non-fatal */ }
}
} catch { /* getAnimations unsupported — no-op */ }
return n;
}
/** Node-side wrapper: bounded + never fatal. */
export async function neutralizeScrollTimelineAnimations(page: Page): Promise<number> {
try {
return await Promise.race([
page.evaluate(neutralizeScrollTimelineAnimationsInPage),
new Promise<number>((res) => setTimeout(() => res(0), 5000)),
]);
} catch {
return 0;
}
}
// ---- Carousel settling (runs in the page) ----
export type CarouselSettleResult = { roots: number; normalized: number; neutralizedAnims: number };