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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ec4e4397cc
commit
f16c3aa4e8
@@ -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: [] };
|
||||
|
||||
@@ -41,12 +41,31 @@ function capToCid(ir: IR): Map<string, string> {
|
||||
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[]) {
|
||||
@@ -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<typeof setInterval>[] = [];
|
||||
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);
|
||||
|
||||
@@ -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(`<!doctype html><html><body>
|
||||
<div id="panel" data-cid-cap="10"></div>
|
||||
<span id="word" data-cid-cap="11">Design</span>
|
||||
<script>
|
||||
var glyphs = ["\\u2b22", "\\u2b21"], gi = 0;
|
||||
var panel = document.getElementById("panel"), rows = 0;
|
||||
// Append real (uncapped) element rows over time, and toggle a glyph inside them.
|
||||
setInterval(function () {
|
||||
if (rows < 4) {
|
||||
var r = document.createElement("div");
|
||||
r.className = "row";
|
||||
r.innerHTML = '<span class="g">' + glyphs[gi % 2] + '</span> line ' + rows;
|
||||
panel.appendChild(r); rows++;
|
||||
} else {
|
||||
gi++;
|
||||
var gs = panel.querySelectorAll(".g");
|
||||
for (var k = 0; k < gs.length; k++) gs[k].textContent = glyphs[gi % 2];
|
||||
}
|
||||
}, 120);
|
||||
// #word: a genuine leaf whose text content cycles.
|
||||
var words = ["Design", "Build", "Ship"], wi = 0, w = document.getElementById("word");
|
||||
setInterval(function () { wi = (wi + 1) % words.length; w.textContent = words[wi]; }, 200);
|
||||
</script>
|
||||
</body></html>`);
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user