Files
ditto.site/compiler/src/generate/motion.ts
T
Samraaj BathandClaude Fable 5 f16c3aa4e8 Rotator classification: reject element-bearing containers, non-destructive runtime
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>
2026-07-04 05:33:06 -07:00

328 lines
17 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;
}
/** 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 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: 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;
}
`;