diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts index 1257259..4b92679 100644 --- a/compiler/src/capture/capture.ts +++ b/compiler/src/capture/capture.ts @@ -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([ diff --git a/compiler/src/capture/stabilize.ts b/compiler/src/capture/stabilize.ts index e500549..a3ffed2 100644 --- a/compiler/src/capture/stabilize.ts +++ b/compiler/src/capture/stabilize.ts @@ -243,6 +243,67 @@ export async function neutralizePreReveal(page: Page): Promise { } } +/** 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 { + try { + return await Promise.race([ + page.evaluate(neutralizeScrollTimelineAnimationsInPage), + new Promise((res) => setTimeout(() => res(0), 5000)), + ]); + } catch { + return 0; + } +} + // ---- Carousel settling (runs in the page) ---- export type CarouselSettleResult = { roots: number; normalized: number; neutralizedAnims: number }; diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index 5a9058a..2730540 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -2229,6 +2229,18 @@ function declsForViewport( const ov = cs.overflowY || cs.overflow || "visible"; const parentDisp = parentComputed?.display || ""; const isFlexGridItem = /flex|grid/.test(parentDisp); + // A chip in a horizontally-scrollable flex strip (an overflow-x:auto/scroll flex parent) relies on + // the CSS default `min-width:auto` to stay at its content width so the strip scrolls. The base + // viewport can report `min-width:0px` on such an item (e.g. a mobile-only strip collapsed to 0 at + // desktop), which would emit `min-w-0` and let the item shrink below its content — collapsing the + // strip and colliding its nowrap text. Suppress `min-w-0` for a nowrap flex item whose flex parent + // scrolls horizontally. This is scoped away from legitimate `min-w-0` truncation (overflow:hidden + + // ellipsis on the item itself, whose parent does NOT scroll-x), so it is safe. + const parentOverflowX = parentComputed ? (parentComputed.overflowX || parentComputed.overflow || "visible") : "visible"; + const inScrollXFlexStrip = + /flex/.test(parentDisp) && + /^(auto|scroll)$/.test(parentOverflowX) && + (cs.whiteSpace || "normal") === "nowrap"; const isLeaf = !hasElementChild(node); const hasText = node.children.some((c) => isTextChild(c) && c.text.trim() !== ""); const isTextLeaf = isLeaf && hasText && !REPLACED.has(tag) && tag !== "canvas" && !tag.includes("-"); @@ -2417,7 +2429,16 @@ function declsForViewport( } const hasTransform = cs.transform && cs.transform !== "none"; - const hasAnimation = cs.animationName && cs.animationName !== "none"; + // A scroll/view-timeline-driven animation reports its resolved `animation-duration` as + // `auto` (the duration is derived from the timeline range, not a time). The clone has no + // scroll timeline, so emitting such an animation makes it jump straight to its END keyframe + // (`fill-mode:both` + a 0s time-based duration), freezing the element at the fully-progressed + // state (e.g. a scroll-linked text-fill stuck 100% filled). We do NOT replay scroll-linked + // animations; the goal is the correct AT-REST render. So suppress the animation-* props for + // an animation whose duration is `auto`, and let the captured static properties (now recorded + // at-rest, scroll reset + timeline animations canceled before the snapshot) stand. + const isScrollTimelineAnim = (cs.animationDuration || "").split(",").some((d) => d.trim() === "auto"); + const hasAnimation = cs.animationName && cs.animationName !== "none" && !isScrollTimelineAnim; for (const { prop, def } of GENERIC) { const value = cs[prop]; @@ -2453,7 +2474,7 @@ function declsForViewport( if (!/^(ul|ol|li|menu)$/.test(tag)) continue; // list reset is none; emit whatever the source uses (incl. none). } else if (prop === "minWidth") { - if (value === "auto" || (value === "0px" && !isFlexGridItem)) continue; + if (value === "auto" || (value === "0px" && !isFlexGridItem) || (value === "0px" && inScrollXFlexStrip)) continue; } else if (def === "__never__") { // always emit (display/color/fontFamily/fontSize handled below for inherit) } else if (isDefault(def, value)) { diff --git a/compiler/src/generate/motion.ts b/compiler/src/generate/motion.ts index d1dabbb..967e74f 100644 --- a/compiler/src/generate/motion.ts +++ b/compiler/src/generate/motion.ts @@ -116,6 +116,36 @@ export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTR const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]'); +// Reveal-replay pacing caps. On the live site an entrance stagger plays ONCE, on first load, +// while the whole group is already in view. Our replay re-hides each element and replays its +// entrance on scroll-into-view, preserving the captured per-element delay AND duration. A grid +// of tiles then either staggers over a long window (captured delays) or each tile plays a long +// (e.g. 1.25s) entrance as it scrolls in — so a fast scroll / full-page screenshot catches most +// tiles mid-entrance, unpainted. Cap the replayed delay AND duration so each tile paints promptly +// after it enters the viewport, keeping relative order. (The validator settles via +// __dittoMotionStop and is unaffected — this only bounds the live, un-stopped replay.) +const REVEAL_MAX_DELAY_MS = 300; +const REVEAL_MAX_DURATION_MS = 600; +const parseTimeMs = (raw: string | undefined): number => { + if (!raw) return 0; + const first = String(raw).split(",")[0]!.trim(); + const m = /^(-?[0-9.]+)(ms|s)?$/.exec(first); + if (!m) return 0; + let ms = parseFloat(m[1]!); + if (m[2] !== "ms") ms *= 1000; + return isFinite(ms) ? ms : 0; +}; +const clampDelay = (raw: string | undefined): string => { + const ms = parseTimeMs(raw); + if (ms <= 0) return "0s"; + return Math.min(ms, REVEAL_MAX_DELAY_MS) + "ms"; +}; +const clampDuration = (raw: string | undefined): string => { + const ms = parseTimeMs(raw); + if (ms <= 0) return raw && String(raw).trim() ? String(raw) : "1s"; + return Math.min(ms, REVEAL_MAX_DURATION_MS) + "ms"; +}; + /** Replays captured motion the stylesheet can't express: WAAPI animations (re-issued via * element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start * hidden, transition in when scrolled into view). Starts on mount. Installs @@ -133,6 +163,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { const revealed: Array<(animate: boolean) => void> = []; let io: IntersectionObserver | null = null; let forceTimer: ReturnType | null = null; + const settleTimers: ReturnType[] = []; for (const w of spec.waapi) { const el = byCid(w.cid); @@ -190,12 +221,14 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { if (rv.visibility === "hidden") el.style.visibility = "visible"; if (rv.animationName) { if (animate) { - // restart the entrance from t=0: none -> name starts a fresh animation + // restart the entrance from t=0: none -> name starts a fresh animation. Cap the + // delay + duration so the tile paints promptly after entering view (a long captured + // stagger/entrance would otherwise leave it blank through a fast scroll / screenshot). el.style.animationName = "none"; void el.offsetWidth; el.style.animationName = rv.animationName; - el.style.animationDuration = rv.animationDuration || "1s"; - el.style.animationDelay = rv.animationDelay || "0s"; + el.style.animationDuration = clampDuration(rv.animationDuration); + el.style.animationDelay = clampDelay(rv.animationDelay); el.style.animationTimingFunction = rv.animationTiming || "ease"; el.style.animationFillMode = "both"; el.style.animationIterationCount = "1"; @@ -220,11 +253,22 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { shows.set(el, fn); revealed.push(fn); } + // Per-element settle: a bounded time after a tile is animated in, force it to its settled + // frame (animate=false) so it can't be caught mid-entrance by a fast scroll / screenshot, + // regardless of when it entered view. The clamped delay+duration keep this window short. + const settleAfter = REVEAL_MAX_DELAY_MS + REVEAL_MAX_DURATION_MS + 100; io = new IntersectionObserver((entries) => { - for (const e of entries) if (e.isIntersecting) { const f = shows.get(e.target); if (f) { f(true); io!.unobserve(e.target); } } + for (const e of entries) if (e.isIntersecting) { + const f = shows.get(e.target); if (!f) continue; + f(true); io!.unobserve(e.target); + settleTimers.push(setTimeout(() => f(false), settleAfter)); + } }, { rootMargin: "0px 0px -8% 0px" }); for (const el of shows.keys()) io.observe(el); - forceTimer = setTimeout(() => { for (const f of revealed) f(true); }, 4000); + // Global failsafe: settle EVERYTHING (animate=false) so any element the observer never fired + // for (never scrolled into view, or a missed callback) is painted, not left hidden. Per-mount + // is sufficient because it jumps straight to the settled frame with no residual delay. + forceTimer = setTimeout(() => { for (const f of revealed) f(false); }, 4000); } const stopAll = () => { @@ -234,6 +278,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } } if (io) io.disconnect(); if (forceTimer) clearTimeout(forceTimer); + for (const t of settleTimers) clearTimeout(t); for (const f of revealed) f(false); // reveal everything, settled → base CSS graded frame }; // Measurement hook: restore the fully-settled/revealed base for grading. @@ -242,6 +287,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { for (const id of intervals) clearInterval(id); if (io) io.disconnect(); if (forceTimer) clearTimeout(forceTimer); + for (const t of settleTimers) clearTimeout(t); try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ } }; }, [spec]); diff --git a/compiler/src/validate/motionGate.ts b/compiler/src/validate/motionGate.ts index 0796dec..cffbc71 100644 --- a/compiler/src/validate/motionGate.ts +++ b/compiler/src/validate/motionGate.ts @@ -64,14 +64,26 @@ export function cssReplayedByReveal( return !!revealAnim && names.includes(revealAnim); } -/** CSS-animated nodes expected from the IR (computed animation-name ≠ none). */ +/** A node's animation is scroll/view-timeline-driven when its computed animation-duration is + * `auto` (the duration is resolved from the timeline range, not a time). The generator does NOT + * emit such animations — replaying a scroll-linked animation is out of scope; the clone reproduces + * the correct AT-REST state instead — so these are excluded from the static-CSS expectation rather + * than failing `emitted=false`. Distinct from time-based reveals (finite duration, default timeline). */ +export function isScrollTimelineAnim(cs: IRNode["computedByVp"][number] | undefined): boolean { + const dur = cs?.animationDuration; + return !!dur && dur.split(",").some((d) => d.trim() === "auto"); +} + +/** CSS-animated nodes expected from the IR (computed animation-name ≠ none). Scroll/view-timeline + * animations (animation-duration:auto) are excluded — the clone renders their at-rest state, not + * a replay, so there is no static decl to expect. */ export function collectExpectedCss(ir: IR): ExpectedCss[] { const vp = ir.doc.canonicalViewport; const out: ExpectedCss[] = []; const walk = (n: IRNode): void => { const cs = n.computedByVp[vp]; const an = cs?.animationName; - if (an && an !== "none") { + if (an && an !== "none" && !isScrollTimelineAnim(cs)) { const names = an.split(",").map((x) => x.trim()).filter((x) => x && x !== "none"); if (names.length) out.push({ cid: n.id, names, infinite: /infinite/.test(cs!.animationIterationCount ?? "") }); } diff --git a/compiler/test/css.test.ts b/compiler/test/css.test.ts index ad68257..8a9b293 100644 --- a/compiler/test/css.test.ts +++ b/compiler/test/css.test.ts @@ -233,3 +233,60 @@ describe("generateCss hidden-node banded geometry", () => { assert.ok(!mobile.includes("left:10px"), `ancestor-hidden child must not emit geometry, got: ${mobile}`); }); }); + +// Fix 1 — mobile nav chip-strip overlap. A horizontally-scrollable flex strip (overflow-x:auto +// flex
    ) holds nowrap chips; the base viewport can report `min-width:0px` on those flex-item +// chips (e.g. a mobile-only strip collapsed to 0 at desktop). Emitting `min-w-0` lets the chips +// compress below their content width instead of overflowing, collapsing the scroll strip so the +// nowrap chip text collides ("Pizza OvenSpiral Mixe…"). The emitter must suppress `min-w-0` for a +// nowrap flex item inside an overflow-x:auto/scroll flex parent. +describe("generateCss scroll-strip chip min-width", () => { + it("suppresses min-w-0 on a nowrap chip inside an overflow-x:auto flex strip", () => { + const chip = node("n2", "li", computed({ display: "list-item", minWidth: "0px", whiteSpace: "nowrap" })); + const ul = node("n1", "ul", computed({ display: "flex", overflowX: "auto", columnGap: "8px" }), [chip]); + const root = node("n0", "body", computed(), [ul]); + const css = generateCss(irWith(root), new Map()); + assert.ok(!baseRule(css, "n2").includes("min-width"), `chip must not carry min-width:0 in a scroll strip, got: ${baseRule(css, "n2")}`); + }); + + it("still emits min-w-0 for a nowrap flex item whose parent does NOT scroll horizontally", () => { + // A truncation/ellipsis flex child (parent overflow-x:visible) legitimately needs min-w-0 to + // shrink below content — the fix is scoped to overflow-x:auto/scroll parents, so this is kept. + const item = node("n2", "div", computed({ minWidth: "0px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" })); + const row = node("n1", "div", computed({ display: "flex" }), [item]); + const root = node("n0", "body", computed(), [row]); + const css = generateCss(irWith(root), new Map()); + assert.ok(baseRule(css, "n2").includes("min-width:0"), `non-scrolling flex row keeps min-w-0, got: ${baseRule(css, "n2")}`); + }); +}); + +// Fix 3 — scroll-linked text-fill frozen at end state. A scroll/view-timeline animation reports +// its resolved `animation-duration` as `auto`. The clone has no scroll timeline, so emitting the +// animation-* props makes it jump straight to its END keyframe (fill-mode:both + a 0s time-based +// duration), freezing e.g. a text-fill 100% filled at rest. The emitter must suppress the +// animation-* longhands when animation-duration is `auto`, leaving the captured at-rest statics. +describe("generateCss scroll-timeline animation suppression", () => { + it("drops animation-* props for an animation-duration:auto (scroll-timeline) node", () => { + const em = node("n1", "em", computed({ + animationName: "fillAnimation", animationDuration: "auto", + animationTimingFunction: "linear", animationFillMode: "both", + backgroundClip: "text", + })); + const root = node("n0", "body", computed(), [em]); + const css = generateCss(irWith(root), new Map()); + const rule = baseRule(css, "n1"); + assert.ok(!rule.includes("animation-name"), `scroll-timeline node must not emit animation-name, got: ${rule}`); + assert.ok(!rule.includes("animation-duration"), `scroll-timeline node must not emit animation-duration, got: ${rule}`); + }); + + it("still emits animation-* for a normal time-based (finite duration) animation", () => { + const el = node("n1", "div", computed({ + animationName: "fadeInUp", animationDuration: "1.25s", + animationTimingFunction: "ease", animationFillMode: "both", + })); + const root = node("n0", "body", computed(), [el]); + const css = generateCss(irWith(root), new Map()); + const rule = baseRule(css, "n1"); + assert.ok(rule.includes("animation-name:fadeInUp"), `time-based reveal keeps its animation, got: ${rule}`); + }); +}); diff --git a/compiler/test/motionGate.test.ts b/compiler/test/motionGate.test.ts index 5761ff5..e847fb9 100644 --- a/compiler/test/motionGate.test.ts +++ b/compiler/test/motionGate.test.ts @@ -9,7 +9,7 @@ import { missingHarnessDeps } from "../src/validate/render.js"; // Minimal IRNode factory — collectExpectedCss only reads id, computedByVp[vp].animation*, // and children, so we build just those fields and cast. -function node(id: string, anim: { animationName?: string; animationIterationCount?: string } | null, children: IRNode[] = []): IRNode { +function node(id: string, anim: { animationName?: string; animationIterationCount?: string; animationDuration?: string } | null, children: IRNode[] = []): IRNode { return { id, tag: "div", @@ -44,6 +44,20 @@ describe("motion gate — CSS entrance / reveal-replay accounting", () => { assert.deepEqual(fade.names, ["fadeInUp"]); }); + it("collectExpectedCss EXCLUDES a scroll/view-timeline animation (animation-duration:auto)", () => { + // Fix 3: the ooni text-fill em (animation-timeline:view → computed animation-duration:auto) + // is intentionally NOT emitted (we render the at-rest state, not a scroll replay). It must be + // excluded from the static-CSS expectation, not counted as an owed-but-missing animation. + const tree = ir( + node("n0", null, [ + node("n358", { animationName: "fillAnimation", animationDuration: "auto", animationIterationCount: "1" }), + node("n4", { animationName: "spin", animationDuration: "1s", animationIterationCount: "infinite" }), + ]), + ); + const got = collectExpectedCss(tree); + assert.deepEqual(got.map((e) => e.cid), ["n4"], "scroll-timeline node n358 must be excluded"); + }); + it("cssReplayedByReveal excludes a captured entrance the clone replays via a reveal", () => { // The cropin regression: n464's fadeInUp is driven by a DittoMotion reveal, so the static // animation-name is deliberately NOT emitted — it must NOT fail emitted=false. diff --git a/compiler/test/reveal.test.ts b/compiler/test/reveal.test.ts index 533a093..d173a9b 100644 --- a/compiler/test/reveal.test.ts +++ b/compiler/test/reveal.test.ts @@ -132,4 +132,25 @@ describe("scroll-reveal settling + replay capture (integration)", () => { // Validator settle path reveals WITHOUT replaying (no mid-entrance graded frames). assert.match(DITTO_MOTION_TSX, /f\(false\)/); }); + + // Fix 2 — reveal-replay stagger must not leave tiles unpainted. A long captured entrance + // delay/duration replayed on scroll-into-view means a fast scroll / full-page screenshot + // catches most tiles mid-entrance. The replay caps the delay + duration and force-settles + // each tile shortly after it enters view, so tiles paint promptly and none stay hidden. + it("caps the replayed entrance delay and duration", () => { + // The animate path uses the clamp helpers rather than the raw captured values. + assert.match(DITTO_MOTION_TSX, /el\.style\.animationDuration = clampDuration\(rv\.animationDuration\)/); + assert.match(DITTO_MOTION_TSX, /el\.style\.animationDelay = clampDelay\(rv\.animationDelay\)/); + // Caps are bounded (delay <= 300ms, duration <= 600ms). + assert.match(DITTO_MOTION_TSX, /REVEAL_MAX_DELAY_MS = 300/); + assert.match(DITTO_MOTION_TSX, /REVEAL_MAX_DURATION_MS = 600/); + }); + + it("force-settles each revealed tile per-element and settles everything at the failsafe", () => { + // Per-element settle timer jumps a tile to its settled frame a bounded time after it animates + // in (so it can't be caught mid-entrance regardless of WHEN it scrolled into view). + assert.match(DITTO_MOTION_TSX, /settleTimers\.push\(setTimeout\(\(\) => f\(false\), settleAfter\)\)/); + // Global failsafe settles (animate=false) rather than re-animating — nothing stays hidden. + assert.match(DITTO_MOTION_TSX, /forceTimer = setTimeout\(\(\) => \{ for \(const f of revealed\) f\(false\); \}, 4000\)/); + }); });