Files
ditto.site/compiler/src/generate/motion.ts
T
Samraaj BathandClaude Fable 5 afab12ff14 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>
2026-07-03 21:46:28 -07:00

297 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { MotionCapture, WaapiAnim, RotatorSpec, RevealSpec, MarqueeSpec } from "../capture/motion.js";
/**
* Stage 5 motion controller. CSS @keyframes motion is reproduced declaratively in
* ditto.css (the animation plays on load with no JS). The two families that the
* stylesheet can't express are reproduced by one fixed `'use client'` component,
* `DittoMotion`, parameterized by captured specs:
* - **WAAPI** — re-issues `element.animate(keyframes, timing)` on the cid'd node.
* - **rotating text** — cycles the captured text values on an interval.
*
* Unlike DittoWire (which applies nothing on mount to keep the base frame untouched),
* DittoMotion DOES start motion on mount — the clone is meant to REPLAY motion on load.
* The validator measures the settled base by cancelling Web Animations and calling
* `window.__dittoMotionStop()` (which DittoMotion installs) to restore rotator text, so
* gates 06 still grade the static frame. The motion gate drives it un-frozen to verify.
*/
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
// Reveal families: transition (hidden via opacity/transform, revealed by the element's own
// transition) and visibility+entrance-class (hidden via visibility, revealed with a named
// keyframe animation — Elementor/WOW/AOS; the @keyframes ship in the page CSS).
export type RTReveal = {
cid: string; opacity: string; transform: string; transition: string;
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
};
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
/** Map every IR node's capture-id → cid (the rendered identity). */
function capToCid(ir: IR): Map<string, string> {
const m = new Map<string, string>();
const walk = (n: IRNode): void => {
const cap = n.attrs["data-cid-cap"];
if (cap !== undefined) m.set(cap, n.id);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return m;
}
/** Resolve cap-keyed motion specs to cids that survived into this IR (optionally
* scoped by an include filter, for multi-route body/chrome splitting). Specs whose
* element was pruned are dropped (left static). */
export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec {
if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] };
const map = capToCid(ir);
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
const waapi: RTWaapi[] = [];
for (const w of motion.waapi as WaapiAnim[]) {
const cid = map.get(w.cap);
if (!ok(cid)) continue;
waapi.push({ cid, keyframes: w.keyframes, duration: w.duration, delay: w.delay, easing: w.easing, iterations: w.iterations, direction: w.direction, fill: w.fill });
}
const rotators: RTRotator[] = [];
for (const r of motion.rotators as RotatorSpec[]) {
const cid = map.get(r.cap);
if (!ok(cid)) continue;
rotators.push({ cid, texts: r.texts, intervalMs: r.intervalMs });
}
const reveals: RTReveal[] = [];
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
const cid = map.get(rv.cap);
if (!ok(cid)) continue;
reveals.push({
cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition,
...(rv.visibility === "hidden" ? { visibility: rv.visibility } : {}),
...(rv.animationName ? {
animationName: rv.animationName,
animationDuration: rv.animationDuration,
animationDelay: rv.animationDelay,
animationTiming: rv.animationTiming,
} : {}),
});
}
const marquees: RTMarquee[] = [];
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
const cid = map.get(m.cap);
if (!ok(cid)) continue;
marquees.push({ cid, pxPerSec: m.pxPerSec, periodPx: m.periodPx });
}
return { waapi, rotators, reveals, marquees };
}
export function motionHasContent(spec: MotionSpec): boolean {
return spec.waapi.length > 0 || spec.rotators.length > 0 || spec.reveals.length > 0 || spec.marquees.length > 0;
}
/** Relative import path from a route page at the given app-segment depth to the shared
* DittoMotion (single page / entry route: depth 0 → "./ditto/DittoMotion"). */
export function dittoMotionImportPath(depth: number): string {
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DittoMotion";
}
/** JSX for the motion controller, rendered at the end of a page fragment. "" when empty. */
export function motionWireJsx(spec: MotionSpec, indent: number): string {
if (!motionHasContent(spec)) return "";
const pad = " ".repeat(indent);
return `${pad}<DittoMotion spec={${JSON.stringify(spec)}} />`;
}
/** The fixed DittoMotion client component, written once per generated app. */
export const DITTO_MOTION_TSX = `"use client";
import { useEffect } from "react";
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
type RTReveal = {
cid: string; opacity: string; transform: string; transition: string;
visibility?: "hidden"; animationName?: string; animationDuration?: string; animationDelay?: string; animationTiming?: string;
};
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
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
* window.__dittoMotionStop, and honors window.__dittoMotionStopped, so the validator can
* restore the fully-settled/revealed base for grading — gates 06 measure the static frame.
* The stopped FLAG (set by the validator even before this mounts) makes a late mount skip
* applying any motion, closing the hydration race that could otherwise leave content hidden. */
export default function DittoMotion({ spec }: { spec: MotionSpec }) {
useEffect(() => {
if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing
const intervals: ReturnType<typeof setInterval>[] = [];
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
const anims: Animation[] = [];
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
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);
if (!el) continue;
try {
anims.push(el.animate(w.keyframes, {
duration: w.duration || 0, delay: w.delay || 0, easing: w.easing || "linear",
iterations: w.iterations < 0 ? Infinity : (w.iterations || 1),
direction: (w.direction as PlaybackDirection) || "normal", fill: (w.fill as FillMode) || "none",
}));
} catch { /* unsupported keyframe shape — leave static */ }
}
// Marquees: rAF-driven continuous tickers, reconstructed as an infinite linear translateX
// loop over one duplicated copy (periodPx). Leftward (pxPerSec<0): 0 -> -period; rightward:
// -period -> 0. Cancelled by stopAll so the graded frame shows the element's base transform.
for (const m of spec.marquees) {
const el = byCid(m.cid);
if (!el || !m.periodPx || !m.pxPerSec) continue;
const left = m.pxPerSec < 0;
const a = "translateX(0px)", z = "translateX(-" + m.periodPx + "px)";
const durationMs = Math.max(1000, Math.round((m.periodPx / Math.abs(m.pxPerSec)) * 1000));
try {
anims.push(el.animate([{ transform: left ? a : z }, { transform: left ? z : a }], {
duration: durationMs, iterations: Infinity, easing: "linear",
}));
} catch { /* leave static */ }
}
for (const r of spec.rotators) {
const el = byCid(r.cid);
if (!el || r.texts.length < 2) continue;
const original = el.textContent;
const start = r.texts.findIndex((t) => t === (original || "").replace(/\\s+/g, " ").trim());
let i = start < 0 ? 0 : start;
rotators.push({ el, original });
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
}
// Scroll reveals: re-hide each element (JS-applied on mount, so non-JS/SSR still shows
// the content), then reveal when it scrolls into view. Two families:
// - transition — hide via opacity/transform, reveal by transitioning to full;
// - visibility+entrance-class (Elementor/WOW/AOS) — hide via visibility with the baked
// entrance animation suppressed, reveal by restarting the captured @keyframes
// (they ship in the page CSS under the captured animation-name).
// A force-reveal timer guarantees nothing stays hidden if the observer misses.
if (spec.reveals.length) {
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
// animate=false (validator settle path) jumps straight to the settled frame so no
// measurement can catch a mid-entrance value.
const show = (el: HTMLElement, rv: RTReveal, animate: boolean) => {
el.style.opacity = "1"; el.style.transform = "none";
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. 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 = clampDuration(rv.animationDuration);
el.style.animationDelay = clampDelay(rv.animationDelay);
el.style.animationTimingFunction = rv.animationTiming || "ease";
el.style.animationFillMode = "both";
el.style.animationIterationCount = "1";
} else {
el.style.animationName = "none"; // settled frame: keep the entrance suppressed
}
}
};
const shows = new Map<Element, (animate: boolean) => void>();
for (const rv of spec.reveals) {
const el = byCid(rv.cid);
if (!el) continue;
if (rv.visibility === "hidden") {
el.style.visibility = "hidden";
el.style.animationName = "none"; // don't burn the baked entrance while hidden
} else {
el.style.transition = rv.transition;
el.style.opacity = rv.opacity;
if (rv.transform !== "none") el.style.transform = rv.transform;
}
const fn = (animate: boolean) => show(el, rv, animate);
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) 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);
// 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 = () => {
(window as any).__dittoMotionStopped = true;
for (const id of intervals) clearInterval(id);
for (const r of rotators) r.el.textContent = r.original;
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.
(window as any).__dittoMotionStop = stopAll;
return () => {
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]);
return null;
}
`;