From d56fbbe45e8d78319696a02b3e30dcf7df6939bd Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Sat, 4 Jul 2026 07:26:55 -0700 Subject: [PATCH] Per-viewport DOM divergence: graft non-canonical-only children, drift stand-in policy The IR's canonical tree came from one viewport, so DOM that exists only at other widths (mobile-only pagination bullets, per-viewport product sets) either vanished or left empty display:none shells. - Unmatched children at non-canonical viewports graft into the IR at deterministic anchored positions with per-viewport data scoped to their source widths; emission gives them a base hide plus a reveal band (hidden max-[...]:block), capped per parent, invisible subtrees skipped - Whole-set identity drift (a container serving disjoint same-family child sets per viewport) is detected by mutual mismatch; canonical content stands in at the drift viewport instead of an empty shell, and manifest.json records the divergence - Per-item divergence resolves naturally via grafting: each viewport shows exactly its captured content, mutually exclusive by banding 468 tests pass (8 new), typecheck clean, double-build byte-identical. Co-Authored-By: Claude Fable 5 --- compiler/src/generate/css.ts | 34 ++++- compiler/src/generate/manifest.ts | 7 + compiler/src/normalize/ir.ts | 175 +++++++++++++++++++++- compiler/test/viewportGraft.test.ts | 219 ++++++++++++++++++++++++++++ 4 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 compiler/test/viewportGraft.test.ts diff --git a/compiler/src/generate/css.ts b/compiler/src/generate/css.ts index 92e3c58..650ed50 100644 --- a/compiler/src/generate/css.ts +++ b/compiler/src/generate/css.ts @@ -3375,7 +3375,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN // per line so the line's free space — and thus sibling positions — is unchanged. const autoWidthFlex = new Set(); - const walk = (node: IRNode, parentNode: IRNode | undefined, parentFluid: boolean, layoutParent: IRNode | undefined, cbAncestor: IRNode | undefined, parentDefiniteHeight: boolean): void => { + const walk = (node: IRNode, parentNode: IRNode | undefined, parentFluid: boolean, layoutParent: IRNode | undefined, cbAncestor: IRNode | undefined, parentDefiniteHeight: boolean, standInVps: ReadonlySet | null = null): void => { // Full-bleed fluidity propagates as a CONTIGUOUS chain from the root: a node is fluid // only if its containing block is also fluid — otherwise width:auto fills a fixed-width // ancestor instead of the viewport (a full-bleed row inside a fixed 1280 wrapper would @@ -3618,7 +3618,23 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN // gated to a breakpoint owns the transform only where it runs, but freezes a mid-scroll offset // at the OTHER bands — suppress those frozen deltas at every width). const animOwned = animOwnedProps(node, [baseVp, ...bands.map((b) => b.vp)]); - const baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform, sourceFixedHeight), tokenResolver); + let baseDecls = finalizeDecls(declsForViewport(node, parentNode?.computedByVp[baseVp], baseVp, assetMap, centeredBase, colorVar, ir.doc.perViewport[baseVp]?.scrollHeight, widthPlan, gridColsByVp?.get(baseVp), gridRowsByVp?.get(baseVp), flowH, dropInsets, leftPct, heightFill, geometry, dropGridRows, dropViewportMaxWidth, nowrapText, keepIdentityTransform, sourceFixedHeight), tokenResolver); + // A node GRAFTED into the IR from a non-canonical capture (it does not exist in the canonical + // DOM, so it has no computed style at the base viewport → declsForViewport yields nothing). + // Its base rule must take it out of layout entirely; each band where it WAS observed then + // emits its full per-viewport decls as the delta (display is always emitted, so the band + // override both reveals and lays it out — the inverse of the canonical-only display:none band). + const absentAtBase = !node.computedByVp[baseVp]; + if (absentAtBase) baseDecls = new Map([["display", "none"]]); + // Viewports where this node STANDS IN for divergent source content (content-identity drift on + // an ancestor): it has no captured data there, but the drift policy shows the canonical subtree + // instead of an empty shell — so its per-band hide must be skipped at those widths, and the + // stand-in state propagates to its descendants (they lack per-viewport data there too). + let driftStandIn: Set | null = standInVps ? new Set([...standInVps].filter((vp) => !node.computedByVp[vp])) : null; + for (const vp of parentNode?.childDriftVps ?? []) { + if (!node.computedByVp[vp]) (driftStandIn ??= new Set()).add(vp); + } + if (driftStandIn && driftStandIn.size === 0) driftStandIn = null; const nr: NodeRule = { base: baseDecls, bands: [] }; // Per-band overrides (delta vs base), using the parent's value AT THAT viewport. @@ -3626,7 +3642,17 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN if (!b.media) continue; // Node was not in the observed DOM at this width (responsive conditional // rendering): hide it so the clone matches the source at this viewport. - if (!node.computedByVp[b.vp]) { nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); continue; } + if (!node.computedByVp[b.vp]) { + // Grafted node: the base rule is already display:none — no per-band hide needed. + if (absentAtBase) continue; + // Content-identity drift (an ancestor's children were a wholly different set at this + // width): banding every canonical child display:none would render an EMPTY shell where + // the source showed content. Faithful-at-canonical: skip the hide and let the base + // (canonical) subtree stand in for the divergent set (recorded in doc.contentDrift). + if (driftStandIn?.has(b.vp)) continue; + nr.bands.push({ media: b.media, decls: new Map([["display", "none"]]) }); + continue; + } // The node isn't PAINTED at this width. HOW it is hidden decides what to emit, because only // `display:none` takes the box out of layout — a `visibility:hidden` box still occupies space // and can extend the scrollable area. The base rule bakes CANONICAL geometry unconditionally, @@ -3772,7 +3798,7 @@ export function collectNodeRules(ir: IR, assetMap: Map, includeN } rules.set(node.id, nr); - for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight); + for (const c of node.children) if (!isTextChild(c)) walk(c, node, childFluid, childLayoutParent, childCb, childDefiniteHeight, driftStandIn); }; // The root's containing block is the viewport-filling , so it starts the fluid chain. walk(ir.root, undefined, true, undefined, undefined, false); diff --git a/compiler/src/generate/manifest.ts b/compiler/src/generate/manifest.ts index 2eb8071..5c65b48 100644 --- a/compiler/src/generate/manifest.ts +++ b/compiler/src/generate/manifest.ts @@ -56,6 +56,13 @@ export function buildManifest(args: { fallback: fontGraph.entries.filter((f) => f.status === "fallback").length, }, components: { count: componentCount }, + // Fidelity note: containers whose children were a DIFFERENT SET at some band viewport(s) + // (content-identity drift — the source deterministically served other content there). The + // clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead + // of an empty shell; a perceptual delta against the source at those widths is expected. + divergence: { + contentDrift: (ir.doc.contentDrift ?? []).map((d) => ({ id: d.id, tag: d.tag, viewports: d.viewports })), + }, // Stage 2: capture-sanity audit — what overlays were dismissed, whether any // still covered the page, video stills materialized, and per-viewport quiescence. capture: { diff --git a/compiler/src/normalize/ir.ts b/compiler/src/normalize/ir.ts index 5842604..8c72bcb 100644 --- a/compiler/src/normalize/ir.ts +++ b/compiler/src/normalize/ir.ts @@ -6,7 +6,10 @@ import type { InteractionCapture } from "../capture/interactions.js"; /** * Normalized Render IR. Merges the per-viewport capture snapshots into a single * tree whose structure comes from the canonical (1280) capture; each node carries - * per-viewport computed styles, bounding boxes, and visibility. This is the single + * per-viewport computed styles, bounding boxes, and visibility. Children that exist + * ONLY at non-canonical viewports are grafted in as siblings (visible only in their + * source bands); a container whose children are a wholly different set at some width + * is recorded as content drift instead (see childDriftVps). This is the single * source of truth for section/token inference, generation, and validation. */ @@ -40,6 +43,14 @@ export type IRNode = { // ::placeholder computed style (input/textarea with placeholder text) — emitted as a // `::placeholder` rule so form controls keep their authored placeholder color. placeholderByVp?: Record; + // Band viewports where this container's element children were a DIFFERENT SET than the + // canonical capture's (mutual whole-set mismatch — the source deterministically served + // different content at that width, e.g. a rotator picking other items per breakpoint). + // Policy: FAITHFUL-AT-CANONICAL — emission shows the canonical children there (it skips + // the display:none band it would otherwise emit for a child with no counterpart) instead + // of rendering an empty shell; the other set is NOT grafted (no duplication). Surfaced in + // the manifest via doc.contentDrift. + childDriftVps?: number[]; children: IRChild[]; }; @@ -75,6 +86,11 @@ export type IRDoc = { // re-emit the keyframes that the per-node `animation-name` declarations reference — // without these the animation properties are inert (the half-plumbed pre-Stage-5 gap). keyframes: string[]; + // Containers whose children diverged as a WHOLE SET at some band viewport(s) (see + // IRNode.childDriftVps). Recorded post-renumber so ids are final; carried into the + // generated manifest as a fidelity note ("the source served different content there"). + // Omitted when no drift was detected. + contentDrift?: Array<{ id: string; tag: string; viewports: number[] }>; }; export type IR = { @@ -419,6 +435,39 @@ function alignChildren(canon: RawNode[], other: RawNode[]): (RawNode | undefined return out; } +/** True when this raw node or any element descendant painted at its capture viewport. */ +function rawSubtreeVisible(n: RawNode): boolean { + if (n.visible) return true; + for (const c of elementChildren(n)) if (rawSubtreeVisible(c)) return true; + return false; +} + +/** Most frequent tag in a sibling group (ties broken alphabetically — deterministic). */ +function dominantTag(nodes: RawNode[]): string { + const counts = new Map(); + for (const n of nodes) counts.set(n.tag, (counts.get(n.tag) ?? 0) + 1); + let best = "", bestC = -1; + for (const [t, c] of [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) { + if (c > bestC) { best = t; bestC = c; } + } + return best; +} + +// A sibling appearing ONLY at non-canonical viewport(s) is grafted into the IR (visible only in +// its source band(s)). Cap per parent per viewport so a pathological capture (a virtualized list +// re-keying hundreds of rows) can't balloon the tree; the whole-set drift path below covers the +// legitimate large-mismatch case. +const GRAFT_MAX_PER_VP = 24; +// Whole-set content-identity drift: at least this many children UNMATCHED on BOTH sides before a +// container is treated as "the source served different content at this width" (vs a couple of +// responsive-only extras, which are grafted instead). +const DRIFT_MIN_UNMATCHED = 3; + +/** A per-parent graft group: one non-canonical-only child, matched across the non-canonical + * viewports it appears at. `rep` is the raw node from the LOWEST viewport (structural identity + * for aligning later viewports into the group). */ +type GraftGroup = { rep: RawNode; rawByVp: Record }; + /** Capture-ids of recognized-pattern panels/regions to force-keep through pruning * (they are display:none in the inactive/collapsed base state). Empty when no * interactions were captured, so non-interactive runs prune exactly as before. */ @@ -449,6 +498,9 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? } const canonical = viewports.includes(1280) ? 1280 : viewports[Math.floor(viewports.length / 2)]!; const canonSnap = snapshots[canonical]!; + // Deterministic iteration order for the cross-viewport graft grouping below. + const vpsAsc = [...viewports].sort((a, b) => a - b); + const bandVpSet = new Set(bandVps); // Stage 4: recognized interactive panels (tabs/accordion) are often display:none // at base (the inactive/collapsed state). Their subtrees must survive pruning so @@ -519,25 +571,131 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? const canonKids = elementChildren(raw); // Align this node's canonical element children to each viewport's children. const aligned: Record = {}; + const kidsByVp: Record = {}; for (const vw of viewports) { if (vw === canonical) continue; const m = matched[vw]; const vwKids = m && m.tag === raw.tag ? elementChildren(m) : []; + kidsByVp[vw] = vwKids; aligned[vw] = alignChildren(canonKids, vwKids); } + + // ---- Per-viewport DOM divergence (canonical-rooted subtrees only) ---- + // Two inverse gaps closed here: + // • GRAFT: a child that exists ONLY at non-canonical viewport(s) (destroyed/never built at + // the canonical width) would otherwise never enter the IR — the clone then misses it at + // the widths where the source shows it (e.g. mobile-only carousel pagination bullets). + // Graft it as a sibling at its source position, carrying per-viewport data ONLY for the + // viewports it appeared at; emission hides it at base and reveals it in its band(s). + // • DRIFT: when a container's children at some viewport are a DIFFERENT SET on BOTH sides + // (mutual whole-set mismatch — the source deterministically served other content there), + // grafting would duplicate the component. Prefer faithful-at-canonical: record the drift + // so emission shows the canonical children there instead of banding them all display:none + // (an empty shell). Recorded in childDriftVps → doc.contentDrift → the manifest. + const driftVps = new Set(); + const graftsByAnchor = new Map(); + if (matched[canonical]) { + for (const vw of vpsAsc) { + if (vw === canonical) continue; + const vwKids = kidsByVp[vw]!; + if (vwKids.length === 0) continue; + const matchedOther = new Set(); + for (const mo of aligned[vw]!) if (mo) matchedOther.add(mo); + const unmatchedCanon = canonKids.filter((_, i) => !aligned[vw]![i]); + const unmatchedOther = vwKids.filter((k) => !matchedOther.has(k)); + // Whole-set identity drift: many unmatched on BOTH sides, few matched, and both leftover + // groups are the same kind of element (same dominant tag — one component family serving + // different items, not a structurally different widget swapped in at this width). + if ( + unmatchedCanon.length >= DRIFT_MIN_UNMATCHED && + unmatchedOther.length >= DRIFT_MIN_UNMATCHED && + matchedOther.size * 2 < Math.min(canonKids.length, vwKids.length) && + dominantTag(unmatchedCanon) === dominantTag(unmatchedOther) + ) { + driftVps.add(vw); + continue; // faithful-at-canonical: do NOT graft the other set (no duplication) + } + if (unmatchedOther.length === 0 || unmatchedOther.length > GRAFT_MAX_PER_VP) continue; + // Graft position: each unmatched child anchors AFTER the last canonical child matched + // before it in this viewport's sibling order (-1 = before every canonical child). + const canonIdxOf = new Map(); + aligned[vw]!.forEach((mo, i) => { if (mo) canonIdxOf.set(mo, i); }); + let lastCanonIdx = -1; + const newByAnchor = new Map(); + for (const k of vwKids) { + const ci = canonIdxOf.get(k); + if (ci !== undefined) { lastCanonIdx = ci; continue; } + let list = newByAnchor.get(lastCanonIdx); + if (!list) newByAnchor.set(lastCanonIdx, (list = [])); + list.push(k); + } + // Merge this viewport's unmatched children into the anchor's existing graft groups by + // structural signature (the same node present at several widths becomes ONE graft with + // per-viewport data), preserving sibling order; leftovers open new groups in order. + for (const [anchor, newcomers] of [...newByAnchor.entries()].sort((a, b) => a[0] - b[0])) { + const groups = graftsByAnchor.get(anchor) ?? []; + const al = alignChildren(groups.map((g) => g.rep), newcomers); + const groupOfNew = new Map(); + al.forEach((r, idx) => { if (r) groupOfNew.set(r, idx); }); + const merged: GraftGroup[] = []; + let gi = 0; + for (const k of newcomers) { + const gIdx = groupOfNew.get(k); + if (gIdx !== undefined) { + while (gi <= gIdx) merged.push(groups[gi++]!); + groups[gIdx]!.rawByVp[vw] = k; + } else { + merged.push({ rep: k, rawByVp: { [vw]: k } }); + } + } + while (gi < groups.length) merged.push(groups[gi++]!); + graftsByAnchor.set(anchor, merged); + } + } + } + // Materialize graft groups → IR nodes. Keep only groups that actually PAINT at a band + // viewport (a graft visible solely at a dense sample width could never be shown — bands are + // emitted only at the standard breakpoints — so it would be dead markup). + const graftedByAnchor = new Map(); + for (const [anchor, groups] of [...graftsByAnchor.entries()].sort((a, b) => a[0] - b[0])) { + const out: IRNode[] = []; + for (const g of groups) { + const graftVps = Object.keys(g.rawByVp).map(Number).sort((a, b) => a - b); + if (!graftVps.some((vw) => bandVpSet.has(vw) && rawSubtreeVisible(g.rawByVp[vw]!))) continue; + const gm: Record = {}; + for (const vw of viewports) gm[vw] = g.rawByVp[vw]; + const gn = convert(g.rawByVp[graftVps[0]!]!, gm); + if (gn) out.push(gn); + } + if (out.length) graftedByAnchor.set(anchor, out); + } + if (driftVps.size) { + const bandDrift = [...driftVps].filter((v) => bandVpSet.has(v)).sort((a, b) => a - b); + if (bandDrift.length) node.childDriftVps = bandDrift; + } + let ei = 0; + let pushedLeading = false; + const pushGrafts = (anchor: number): void => { + for (const gn of graftedByAnchor.get(anchor) ?? []) node.children.push(gn); + }; for (const c of raw.children) { if ((c as IRTextNode).text !== undefined) { node.children.push({ text: (c as IRTextNode).text }); continue; } + if (!pushedLeading) { pushGrafts(-1); pushedLeading = true; } const child = c as RawNode; const childMatched: Record = {}; - for (const vw of viewports) childMatched[vw] = vw === canonical ? child : aligned[vw]![ei]; - ei++; + // A grafted subtree has no canonical counterpart: its own children must not be treated as + // canonical-matched either (they carry data only at the graft's source viewports). + for (const vw of viewports) childMatched[vw] = vw === canonical ? (matched[canonical] ? child : undefined) : aligned[vw]![ei]; const converted = convert(child, childMatched); if (converted) node.children.push(converted); + pushGrafts(ei); + ei++; } + if (!pushedLeading) pushGrafts(-1); } return node; }; @@ -667,6 +825,16 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? }; renumber(root); + // Content-identity drift notes (containers whose children were a different set at some band + // viewport — see childDriftVps). Collected AFTER renumbering so the recorded ids are final; + // deterministic (pre-order walk of the pruned tree). + const contentDrift: NonNullable = []; + const collectDrift = (n: IRNode): void => { + if (n.childDriftVps?.length) contentDrift.push({ id: n.id, tag: n.tag, viewports: [...n.childDriftVps] }); + for (const c of n.children) if (!isTextChild(c)) collectDrift(c); + }; + collectDrift(root); + const perViewport: IRDoc["perViewport"] = {}; for (const vw of viewports) { const d = snapshots[vw]!.doc; @@ -696,6 +864,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion? // benchmark + all multi-page, which don't capture motion) ⇒ none emitted, so the // clone stays byte-identical to the pre-Stage-5 frozen output. keyframes: opts?.motion ? [...new Set(canonSnap.keyframes ?? [])].sort() : [], + ...(contentDrift.length ? { contentDrift } : {}), }; // Repair transient root scroll-locks. A site that locks scrolling during an intro diff --git a/compiler/test/viewportGraft.test.ts b/compiler/test/viewportGraft.test.ts new file mode 100644 index 0000000..c55cf98 --- /dev/null +++ b/compiler/test/viewportGraft.test.ts @@ -0,0 +1,219 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RawNode, RawChild } from "../src/capture/walker.js"; +import { buildIR, isTextChild, type IR, type IRNode } from "../src/normalize/ir.js"; +import { collectNodeRules } from "../src/generate/css.js"; + +// Per-viewport DOM divergence handling: +// • GRAFT — a child that exists ONLY at non-canonical viewports enters the IR as a sibling at +// its source position, carrying per-viewport data only for the widths it appeared at, and is +// emitted display:none at base + revealed in its band(s). +// • DRIFT — a container whose children are a wholly DIFFERENT SET at some viewport (content +// identity drift) is NOT grafted (no duplication); the canonical children stand in at that +// width (the display:none banding is skipped) and the divergence is recorded in +// doc.contentDrift for the manifest. + +function raw(tag: string, attrs: Record = {}, children: RawChild[] = [], visible = true, computedOver: Record = {}): RawNode { + return { + tag, attrs, + computed: { display: visible ? "block" : "none", position: "static", visibility: "visible", ...computedOver }, + bbox: { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 }, + visible, + children, + }; +} + +function snapshot(vp: number, root: RawNode): object { + return { + doc: { + url: "https://example.test/page", title: "Fixture", + head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" }, + lang: "en", charset: "UTF-8", viewportWidth: vp, viewportHeight: 800, + scrollWidth: vp, scrollHeight: 800, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", + bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial", metaViewport: "width=device-width, initial-scale=1", + nodeCount: 10, truncated: false, + }, + root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [], + }; +} + +/** Build an IR from a DIFFERENT raw body tree per viewport. */ +function buildDivergentIR(rootByVp: Record): IR { + const vps = Object.keys(rootByVp).map(Number).sort((a, b) => a - b); + const sourceDir = mkdtempSync(join(tmpdir(), "ditto-ir-graft-")); + mkdirSync(join(sourceDir, "capture"), { recursive: true }); + for (const vp of vps) { + writeFileSync(join(sourceDir, "capture", `dom-${vp}.json`), JSON.stringify(snapshot(vp, rootByVp[vp]!))); + } + return buildIR(sourceDir, vps); +} + +function findByTag(node: IRNode, tag: string): IRNode | null { + if (node.tag === tag) return node; + for (const c of node.children) { + if (isTextChild(c)) continue; + const hit = findByTag(c, tag); + if (hit) return hit; + } + return null; +} + +function elemChildren(node: IRNode): IRNode[] { + return node.children.filter((c): c is IRNode => !isTextChild(c)); +} + +// ---- fixtures ---- + +/** Carousel whose pagination (ul + 3 bullet li) exists ONLY below the canonical width. */ +function carouselFixture(): { small: RawNode; large: RawNode } { + const track = (): RawNode => raw("div", { id: "track" }); + const pagination = (): RawNode => + raw("ul", { id: "pagination" }, [ + raw("li", {}, [raw("button", { type: "button" }, [{ text: "1" }])]), + raw("li", {}, [raw("button", { type: "button" }, [{ text: "2" }])]), + raw("li", {}, [raw("button", { type: "button" }, [{ text: "3" }])]), + ]); + const small = raw("body", {}, [raw("section", { id: "carousel" }, [track(), pagination()])]); + const large = raw("body", {}, [raw("section", { id: "carousel" }, [track()])]); + return { small, large }; +} + +/** Container serving a DIFFERENT item set per viewport (content identity drift). */ +function driftFixture(): { small: RawNode; large: RawNode } { + const item = (id: string): RawNode => raw("li", { id }, [raw("span", {}, [{ text: `Item ${id}` }])]); + const small = raw("body", {}, [raw("ul", { id: "rotator" }, [item("b1"), item("b2"), item("b3"), item("b4")])]); + const large = raw("body", {}, [raw("ul", { id: "rotator" }, [item("a1"), item("a2"), item("a3"), item("a4")])]); + return { small, large }; +} + +describe("IR grafts non-canonical-only children", () => { + it("grafts a subtree present only at 375 into the canonical tree at its source position", () => { + const { small, large } = carouselFixture(); + const ir = buildDivergentIR({ 375: small, 1280: large }); + + const section = findByTag(ir.root, "section")!; + const kids = elemChildren(section); + assert.deepEqual(kids.map((k) => k.tag), ["div", "ul"], "pagination grafted after the matched track"); + + const ul = kids[1]!; + assert.equal(ul.attrs.id, "pagination"); + // Per-viewport data ONLY at the source viewport — nothing invented at canonical. + assert.deepEqual(Object.keys(ul.computedByVp), ["375"]); + assert.equal(ul.visibleByVp[375], true); + assert.equal(ul.computedByVp[1280], undefined); + assert.equal(ul.bboxByVp[1280], undefined); + // The grafted subtree's descendants carry the same viewport-scoped data. + const bullets = elemChildren(ul); + assert.equal(bullets.length, 3); + for (const li of bullets) { + assert.deepEqual(Object.keys(li.computedByVp), ["375"]); + const btn = elemChildren(li)[0]!; + assert.equal(btn.tag, "button"); + assert.deepEqual(Object.keys(btn.computedByVp), ["375"]); + } + // No drift recorded — this is an additive responsive difference, not a set swap. + assert.equal(section.childDriftVps, undefined); + assert.equal(ir.doc.contentDrift, undefined); + }); + + it("merges the same non-canonical-only child across several viewports into ONE grafted node", () => { + const { small, large } = carouselFixture(); + const ir = buildDivergentIR({ 375: small, 768: structuredClone(small), 1280: large }); + + const section = findByTag(ir.root, "section")!; + const uls = elemChildren(section).filter((k) => k.tag === "ul"); + assert.equal(uls.length, 1, "one grafted node, not one per viewport"); + assert.deepEqual(Object.keys(uls[0]!.computedByVp).map(Number).sort((a, b) => a - b), [375, 768]); + }); + + it("is deterministic: two builds from the same capture are byte-identical", () => { + const { small, large } = carouselFixture(); + const a = buildDivergentIR({ 375: small, 1280: large }); + const b = buildDivergentIR({ 375: structuredClone(small), 1280: structuredClone(large) }); + assert.equal(JSON.stringify(a), JSON.stringify(b)); + }); + + it("does not graft a child that never paints at any band viewport", () => { + const { small, large } = carouselFixture(); + // Make the whole pagination subtree invisible at 375. + const section = small.children[0] as RawNode; + const pag = (section.children as RawNode[]).find((c) => c.tag === "ul")!; + const markInvisible = (n: RawNode): void => { + n.visible = false; n.computed.display = "none"; n.bbox = { x: 0, y: 0, width: 0, height: 0 }; + for (const c of n.children) if ((c as RawNode).tag) markInvisible(c as RawNode); + }; + markInvisible(pag); + const ir = buildDivergentIR({ 375: small, 1280: large }); + assert.equal(findByTag(ir.root, "ul"), null, "invisible-everywhere subtree is not grafted"); + }); +}); + +describe("IR whole-set content drift falls back to faithful-at-canonical", () => { + it("records drift instead of grafting when both sides mutually mismatch", () => { + const { small, large } = driftFixture(); + const ir = buildDivergentIR({ 375: small, 1280: large }); + + const rotator = findByTag(ir.root, "ul")!; + const kids = elemChildren(rotator); + // Canonical set only — the divergent 375 set is NOT grafted (no duplication). + assert.deepEqual(kids.map((k) => k.attrs.id), ["a1", "a2", "a3", "a4"]); + assert.deepEqual(rotator.childDriftVps, [375]); + // Canonical children carry no invented data at the drift viewport. + for (const k of kids) assert.equal(k.computedByVp[375], undefined); + // Surfaced for the manifest, with the final (post-renumber) id. + assert.deepEqual(ir.doc.contentDrift, [{ id: rotator.id, tag: "ul", viewports: [375] }]); + }); + + it("does not misread an additive difference as drift", () => { + // Canonical set fully matches at 375; 375 merely has extras → graft path, no drift. + const item = (id: string): RawNode => raw("li", { id }); + const small = raw("body", {}, [raw("ul", { id: "list" }, [item("x1"), item("x2"), item("e1"), item("e2"), item("e3")])]); + const large = raw("body", {}, [raw("ul", { id: "list" }, [item("x1"), item("x2")])]); + const ir = buildDivergentIR({ 375: small, 1280: large }); + const list = findByTag(ir.root, "ul")!; + assert.equal(list.childDriftVps, undefined); + assert.deepEqual(elemChildren(list).map((k) => k.attrs.id), ["x1", "x2", "e1", "e2", "e3"]); + assert.deepEqual(Object.keys(elemChildren(list)[2]!.computedByVp), ["375"]); + }); +}); + +describe("emission of grafted and drift nodes", () => { + it("emits a grafted node as display:none at base with a full reveal band at its viewport", () => { + const { small, large } = carouselFixture(); + const ir = buildDivergentIR({ 375: small, 1280: large }); + const ul = findByTag(ir.root, "ul")!; + const rules = collectNodeRules(ir, new Map()); + const nr = rules.get(ul.id)!; + assert.deepEqual([...nr.base.entries()], [["display", "none"]], "hidden at the canonical base"); + assert.equal(nr.bands.length, 1, "exactly one band: the reveal at its source viewport"); + const band = nr.bands[0]!; + assert.match(band.media, /max-width/); + assert.equal(band.decls.get("display"), "block", "the band reveals AND lays out the node"); + }); + + it("skips the display:none band for drift stand-ins (and their descendants), keeps it otherwise", () => { + const { small, large } = driftFixture(); + // Control: a genuinely canonical-only sibling (absent at 375, NOT under a drift container) + // must still be banded display:none at 375. + (large.children as RawNode[]).push(raw("aside", { id: "desktop-only" }, [{ text: "Desktop" }])); + const ir = buildDivergentIR({ 375: small, 1280: large }); + const rules = collectNodeRules(ir, new Map()); + + const rotator = findByTag(ir.root, "ul")!; + for (const li of elemChildren(rotator)) { + const nr = rules.get(li.id)!; + assert.equal(nr.bands.some((b) => b.decls.get("display") === "none"), false, `stand-in ${li.attrs.id} not hidden at the drift viewport`); + // Descendants of the stand-in subtree are not hidden either. + const span = elemChildren(li)[0]!; + const snr = rules.get(span.id)!; + assert.equal(snr.bands.some((b) => b.decls.get("display") === "none"), false, "stand-in descendant not hidden"); + } + + const aside = findByTag(ir.root, "aside")!; + const anr = rules.get(aside.id)!; + assert.equal(anr.bands.some((b) => b.decls.get("display") === "none"), true, "unrelated canonical-only node still hidden per band"); + }); +});