A progressively-growing feed panel (rows injected after cap tagging) passed the rotator leaf guard - which only rejected containers with CAPPED children - and the rotator runtime's textContent writes then flattened 51 statically-emitted descendants to one text node. Three layers: emission drops rotators whose IR target has element children; the capture leaf guard rejects any element child (capped or not); the DittoMotion runtime saves/restores cloned child nodes via replaceChildren so even a misclassified rotator can't destroy structure. 425 tests pass (6 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
328 lines
17 KiB
TypeScript
328 lines
17 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
/** Map every IR node's cid → node, so an emission guard can inspect the resolved target. */
|
||
function cidToNode(ir: IR): Map<string, IRNode> {
|
||
const m = new Map<string, IRNode>();
|
||
const walk = (n: IRNode): void => {
|
||
m.set(n.id, n);
|
||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||
};
|
||
walk(ir.root);
|
||
return m;
|
||
}
|
||
|
||
/** True when an IR node has ≥1 element child. A genuine rotating word/phrase is a text leaf
|
||
* (no element children); a container captured as a rotator would have the runtime flatten its
|
||
* whole subtree to one text node, destroying every descendant element. */
|
||
function hasElementChild(n: IRNode | undefined): boolean {
|
||
return !!n && n.children.some((c) => !isTextChild(c));
|
||
}
|
||
|
||
/** 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 nodeByCid = cidToNode(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;
|
||
// A rotator cycles the text of a single leaf word/phrase. If the resolved target has element
|
||
// children, capture misclassified a structural container (e.g. one whose rows were injected at
|
||
// runtime, uncapped, after cid-cap tagging) as a rotator. Emitting it would let the runtime
|
||
// flatten every descendant element to one text node; drop it so the static subtree survives.
|
||
if (hasElementChild(nodeByCid.get(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 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<typeof setInterval>[] = [];
|
||
const rotators: Array<{ el: HTMLElement; original: Node[] }> = [];
|
||
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;
|
||
// Defense in depth: a genuine rotating word is a text leaf. If the target has element
|
||
// children, it was misclassified (its subtree would be destroyed by a text write) — skip it
|
||
// so the static structure survives even if the emission guard was bypassed.
|
||
if (el.childElementCount > 0) continue;
|
||
// Save the ORIGINAL child nodes (cloned) rather than a flattened textContent string, so the
|
||
// settle/restore path rebuilds the exact subtree via replaceChildren — never a lossy text
|
||
// node. For a real text leaf this is just the single text node round-tripped intact.
|
||
const original = Array.from(el.childNodes).map((n) => n.cloneNode(true));
|
||
const start = r.texts.findIndex((t) => t === (el.textContent || "").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.replaceChildren(...r.original.map((n) => n.cloneNode(true)));
|
||
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;
|
||
}
|
||
`;
|