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
+23 -2
View File
@@ -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)) {