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 0–6 still grade the static frame. The motion gate drives it un-frozen to verify. */ export type RTWaapi = { cid: string; keyframes: Array>; 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 { const m = new Map(); 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}`; } /** 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>; 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 0–6 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[] = []; 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 | null = null; const settleTimers: ReturnType[] = []; 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 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; } `;