diff --git a/compiler/src/capture/motion.ts b/compiler/src/capture/motion.ts index c35b5b2..5edfe7f 100644 --- a/compiler/src/capture/motion.ts +++ b/compiler/src/capture/motion.ts @@ -482,8 +482,11 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log const cap = el && (el as Element).getAttribute?.("data-cid-cap"); if (!cap) continue; const elem = el as Element; - // only track small text-bearing elements (a rotating word/phrase, not a big block) - if (elem.querySelector("[data-cid-cap]")) continue; // has element children → not a leaf word + // only track small text-bearing elements (a rotating word/phrase, not a big block). + // Reject ANY element child, capped or not: rows injected at runtime (after cid-cap + // tagging, so uncapped) would otherwise defeat a capped-only check and let a + // multi-element panel pass as a "leaf word". + if (elem.firstElementChild) continue; // has element children → not a leaf word const txt = norm(elem.textContent || ""); if (!txt || txt.length > 80) continue; const e = changes.get(cap) ?? { texts: [], times: [] }; diff --git a/compiler/src/generate/motion.ts b/compiler/src/generate/motion.ts index 967e74f..4701a90 100644 --- a/compiler/src/generate/motion.ts +++ b/compiler/src/generate/motion.ts @@ -41,12 +41,31 @@ function capToCid(ir: IR): Map { return m; } +/** Map every IR node's cid → node, so an emission guard can inspect the resolved target. */ +function cidToNode(ir: IR): Map { + const m = new Map(); + 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[]) { @@ -58,6 +77,11 @@ export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, inclu 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[] = []; @@ -157,7 +181,7 @@ 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 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> = []; @@ -196,8 +220,15 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { 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()); + // 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))); @@ -274,7 +305,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) { 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 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); diff --git a/compiler/test/rotatorGuard.test.ts b/compiler/test/rotatorGuard.test.ts new file mode 100644 index 0000000..eed2b52 --- /dev/null +++ b/compiler/test/rotatorGuard.test.ts @@ -0,0 +1,128 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chromium, type Browser, type Page } from "playwright"; +import type { IR, IRNode } from "../src/normalize/ir.js"; +import type { MotionCapture } from "../src/capture/motion.js"; +import { captureMotion } from "../src/capture/motion.js"; +import { buildMotionSpec, DITTO_MOTION_TSX } from "../src/generate/motion.js"; + +// A rotator cycles the text of a single LEAF word/phrase. A container that bears element +// children must never be treated as a rotator: the runtime replaces its content on each swap, +// so classifying a structural panel as a rotator would flatten its whole subtree to one text +// node. This regression guards all three defense layers against that misclassification. + +// ---- Minimal IR factory (only the fields buildMotionSpec reads: id, attrs, children) ---- +function node(id: string, cap: string | null, children: IRNode[] = [], text?: string): IRNode { + const kids = text !== undefined ? ([{ text }] as unknown as IRNode[]) : children; + return { + id, + tag: "div", + attrs: cap !== null ? { "data-cid-cap": cap } : {}, + visibleByVp: {}, + bboxByVp: {}, + computedByVp: {}, + children: kids, + } as unknown as IRNode; +} + +function ir(root: IRNode): IR { + return { doc: { canonicalViewport: 1280 } as unknown as IR["doc"], root }; +} + +function motionWith(rotators: MotionCapture["rotators"]): MotionCapture { + return { waapi: [], rotators, reveals: [], marquees: [] }; +} + +describe("rotator misclassification guard — layer 1 (emission)", () => { + it("emits a rotator whose target is a genuine text leaf (no element children)", () => { + const tree = ir(node("n0", null, [node("n1", "5", [], "Design")])); + const spec = buildMotionSpec(tree, motionWith([{ cap: "5", texts: ["Design", "Build"], intervalMs: 900 }])); + assert.equal(spec.rotators.length, 1, "leaf rotator survives emission"); + assert.equal(spec.rotators[0]!.cid, "n1"); + }); + + it("DROPS a rotator whose target IR node has element children (structural container)", () => { + // n1 is capped and was classified as a rotator, but it holds real element rows (n2, n3). + const container = node("n1", "5", [ + node("n2", null, [], "row one"), + node("n3", null, [], "row two"), + ]); + const tree = ir(node("n0", null, [container])); + const spec = buildMotionSpec(tree, motionWith([{ cap: "5", texts: ["a", "b"], intervalMs: 240 }])); + assert.equal(spec.rotators.length, 0, "element-bearing container is not emitted as a rotator"); + }); + + it("keeps genuine leaf rotators while dropping a sibling container in the same spec", () => { + const tree = ir(node("n0", null, [ + node("n1", "5", [], "Design"), // genuine leaf + node("n2", "6", [node("n3", null, [], "row")]), // container → dropped + ])); + const spec = buildMotionSpec(tree, motionWith([ + { cap: "5", texts: ["Design", "Build"], intervalMs: 900 }, + { cap: "6", texts: ["x", "y"], intervalMs: 240 }, + ])); + assert.equal(spec.rotators.length, 1); + assert.equal(spec.rotators[0]!.cid, "n1"); + }); +}); + +describe("rotator misclassification guard — layer 3 (non-destructive runtime)", () => { + it("saves & restores the target's child NODES, never a flattened textContent string", () => { + // Save path: the original child nodes are cloned (structure preserved), not read as a string. + assert.match(DITTO_MOTION_TSX, /Array\.from\(el\.childNodes\)\.map\(\(n\) => n\.cloneNode\(true\)\)/); + // Restore path: rebuild via replaceChildren with cloned nodes — no `textContent = r.original`. + assert.match(DITTO_MOTION_TSX, /r\.el\.replaceChildren\(\.\.\.r\.original\.map\(\(n\) => n\.cloneNode\(true\)\)\)/); + assert.doesNotMatch(DITTO_MOTION_TSX, /r\.el\.textContent = r\.original/, "no lossy textContent restore remains"); + }); + + it("refuses to install a rotator on an element that has element children at runtime", () => { + assert.match(DITTO_MOTION_TSX, /if \(el\.childElementCount > 0\) continue;/); + }); +}); + +describe("rotator misclassification guard — layer 2 (capture leaf guard)", () => { + let browser: Browser; + let page: Page; + before(async () => { + browser = await chromium.launch(); + page = await browser.newPage(); + }); + after(async () => { + await browser.close(); + }); + + it("does not record an element-bearing container (runtime-injected uncapped rows) as a rotator", async () => { + // #panel is capped BEFORE its rows are injected (mirrors cid-cap tagging preceding the site's + // late row appends). Its rows are uncapped. #word is a genuine capped leaf cycling text. + await page.setContent(` +
+ Design + + `); + // tsx/esbuild names the evaluated function; provide the helper it references in-page. + await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);"); + const motion = await captureMotion(page, { observeMs: 1600 }); + const caps = new Set(motion.rotators.map((r) => r.cap)); + assert.ok(!caps.has("10"), "element-bearing panel is NOT recorded as a rotator"); + assert.ok(caps.has("11"), "genuine leaf word IS recorded as a rotator"); + }); +});