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
@@ -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)) {
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
const settleTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
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]);
|
||||
|
||||
Reference in New Issue
Block a user