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:
co-authored by
Claude Fable 5
parent
f3d433c024
commit
afab12ff14
@@ -5,7 +5,7 @@ import { tagElements, captureInteractions, type InteractionCapture } from "./int
|
|||||||
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
|
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
|
||||||
import {
|
import {
|
||||||
promoteLazyMedia, settleCarousels, settleScrollReveals, neutralizePreReveal,
|
promoteLazyMedia, settleCarousels, settleScrollReveals, neutralizePreReveal,
|
||||||
forceRevealForShot, restoreRevealForShot,
|
forceRevealForShot, restoreRevealForShot, neutralizeScrollTimelineAnimations,
|
||||||
} from "./stabilize.js";
|
} from "./stabilize.js";
|
||||||
import {
|
import {
|
||||||
enumerateFramesInPage, planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent,
|
enumerateFramesInPage, planForFrameUrl, graftFrameIntoSnapshot, frameHasRenderableContent,
|
||||||
@@ -965,6 +965,14 @@ export async function captureSite(opts: {
|
|||||||
await page.waitForTimeout(80);
|
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
|
// 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.
|
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
|
||||||
const snapshot: PageSnapshot = await Promise.race([
|
const snapshot: PageSnapshot = await Promise.race([
|
||||||
|
|||||||
@@ -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) ----
|
// ---- Carousel settling (runs in the page) ----
|
||||||
|
|
||||||
export type CarouselSettleResult = { roots: number; normalized: number; neutralizedAnims: number };
|
export type CarouselSettleResult = { roots: number; normalized: number; neutralizedAnims: number };
|
||||||
|
|||||||
@@ -2229,6 +2229,18 @@ function declsForViewport(
|
|||||||
const ov = cs.overflowY || cs.overflow || "visible";
|
const ov = cs.overflowY || cs.overflow || "visible";
|
||||||
const parentDisp = parentComputed?.display || "";
|
const parentDisp = parentComputed?.display || "";
|
||||||
const isFlexGridItem = /flex|grid/.test(parentDisp);
|
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 isLeaf = !hasElementChild(node);
|
||||||
const hasText = node.children.some((c) => isTextChild(c) && c.text.trim() !== "");
|
const hasText = node.children.some((c) => isTextChild(c) && c.text.trim() !== "");
|
||||||
const isTextLeaf = isLeaf && hasText && !REPLACED.has(tag) && tag !== "canvas" && !tag.includes("-");
|
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 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) {
|
for (const { prop, def } of GENERIC) {
|
||||||
const value = cs[prop];
|
const value = cs[prop];
|
||||||
@@ -2453,7 +2474,7 @@ function declsForViewport(
|
|||||||
if (!/^(ul|ol|li|menu)$/.test(tag)) continue;
|
if (!/^(ul|ol|li|menu)$/.test(tag)) continue;
|
||||||
// list reset is none; emit whatever the source uses (incl. none).
|
// list reset is none; emit whatever the source uses (incl. none).
|
||||||
} else if (prop === "minWidth") {
|
} 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__") {
|
} else if (def === "__never__") {
|
||||||
// always emit (display/color/fontFamily/fontSize handled below for inherit)
|
// always emit (display/color/fontFamily/fontSize handled below for inherit)
|
||||||
} else if (isDefault(def, value)) {
|
} else if (isDefault(def, value)) {
|
||||||
|
|||||||
@@ -116,6 +116,36 @@ export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTR
|
|||||||
|
|
||||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
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
|
/** Replays captured motion the stylesheet can't express: WAAPI animations (re-issued via
|
||||||
* element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start
|
* element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start
|
||||||
* hidden, transition in when scrolled into view). Starts on mount. Installs
|
* 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> = [];
|
const revealed: Array<(animate: boolean) => void> = [];
|
||||||
let io: IntersectionObserver | null = null;
|
let io: IntersectionObserver | null = null;
|
||||||
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const settleTimers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
for (const w of spec.waapi) {
|
for (const w of spec.waapi) {
|
||||||
const el = byCid(w.cid);
|
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.visibility === "hidden") el.style.visibility = "visible";
|
||||||
if (rv.animationName) {
|
if (rv.animationName) {
|
||||||
if (animate) {
|
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";
|
el.style.animationName = "none";
|
||||||
void el.offsetWidth;
|
void el.offsetWidth;
|
||||||
el.style.animationName = rv.animationName;
|
el.style.animationName = rv.animationName;
|
||||||
el.style.animationDuration = rv.animationDuration || "1s";
|
el.style.animationDuration = clampDuration(rv.animationDuration);
|
||||||
el.style.animationDelay = rv.animationDelay || "0s";
|
el.style.animationDelay = clampDelay(rv.animationDelay);
|
||||||
el.style.animationTimingFunction = rv.animationTiming || "ease";
|
el.style.animationTimingFunction = rv.animationTiming || "ease";
|
||||||
el.style.animationFillMode = "both";
|
el.style.animationFillMode = "both";
|
||||||
el.style.animationIterationCount = "1";
|
el.style.animationIterationCount = "1";
|
||||||
@@ -220,11 +253,22 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
|||||||
shows.set(el, fn);
|
shows.set(el, fn);
|
||||||
revealed.push(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) => {
|
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" });
|
}, { rootMargin: "0px 0px -8% 0px" });
|
||||||
for (const el of shows.keys()) io.observe(el);
|
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 = () => {
|
const stopAll = () => {
|
||||||
@@ -234,6 +278,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
|||||||
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
||||||
if (io) io.disconnect();
|
if (io) io.disconnect();
|
||||||
if (forceTimer) clearTimeout(forceTimer);
|
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
|
for (const f of revealed) f(false); // reveal everything, settled → base CSS graded frame
|
||||||
};
|
};
|
||||||
// Measurement hook: restore the fully-settled/revealed base for grading.
|
// 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);
|
for (const id of intervals) clearInterval(id);
|
||||||
if (io) io.disconnect();
|
if (io) io.disconnect();
|
||||||
if (forceTimer) clearTimeout(forceTimer);
|
if (forceTimer) clearTimeout(forceTimer);
|
||||||
|
for (const t of settleTimers) clearTimeout(t);
|
||||||
try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ }
|
try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
}, [spec]);
|
}, [spec]);
|
||||||
|
|||||||
@@ -64,14 +64,26 @@ export function cssReplayedByReveal(
|
|||||||
return !!revealAnim && names.includes(revealAnim);
|
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[] {
|
export function collectExpectedCss(ir: IR): ExpectedCss[] {
|
||||||
const vp = ir.doc.canonicalViewport;
|
const vp = ir.doc.canonicalViewport;
|
||||||
const out: ExpectedCss[] = [];
|
const out: ExpectedCss[] = [];
|
||||||
const walk = (n: IRNode): void => {
|
const walk = (n: IRNode): void => {
|
||||||
const cs = n.computedByVp[vp];
|
const cs = n.computedByVp[vp];
|
||||||
const an = cs?.animationName;
|
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");
|
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 ?? "") });
|
if (names.length) out.push({ cid: n.id, names, infinite: /infinite/.test(cs!.animationIterationCount ?? "") });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
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 <ul>) 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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { missingHarnessDeps } from "../src/validate/render.js";
|
|||||||
|
|
||||||
// Minimal IRNode factory — collectExpectedCss only reads id, computedByVp[vp].animation*,
|
// Minimal IRNode factory — collectExpectedCss only reads id, computedByVp[vp].animation*,
|
||||||
// and children, so we build just those fields and cast.
|
// 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 {
|
return {
|
||||||
id,
|
id,
|
||||||
tag: "div",
|
tag: "div",
|
||||||
@@ -44,6 +44,20 @@ describe("motion gate — CSS entrance / reveal-replay accounting", () => {
|
|||||||
assert.deepEqual(fade.names, ["fadeInUp"]);
|
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", () => {
|
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
|
// 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.
|
// animation-name is deliberately NOT emitted — it must NOT fail emitted=false.
|
||||||
|
|||||||
@@ -132,4 +132,25 @@ describe("scroll-reveal settling + replay capture (integration)", () => {
|
|||||||
// Validator settle path reveals WITHOUT replaying (no mid-entrance graded frames).
|
// Validator settle path reveals WITHOUT replaying (no mid-entrance graded frames).
|
||||||
assert.match(DITTO_MOTION_TSX, /f\(false\)/);
|
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\)/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user