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 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 07:26:55 -07:00
co-authored by Claude Fable 5
parent 3f0757dc23
commit d56fbbe45e
4 changed files with 428 additions and 7 deletions
+30 -4
View File
@@ -3375,7 +3375,7 @@ export function collectNodeRules(ir: IR, assetMap: Map<string, string>, includeN
// per line so the line's free space — and thus sibling positions — is unchanged.
const autoWidthFlex = new Set<string>();
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<number> | 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<string, string>, 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<number> | 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<string, string>, 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<string, string>, 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 <html>, so it starts the fluid chain.
walk(ir.root, undefined, true, undefined, undefined, false);
+7
View File
@@ -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: {
+172 -3
View File
@@ -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<number, StyleMap>;
// 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<string, number>();
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<number, RawNode> };
/** 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<number, (RawNode | undefined)[]> = {};
const kidsByVp: Record<number, RawNode[]> = {};
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<number>();
const graftsByAnchor = new Map<number, GraftGroup[]>();
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<RawNode>();
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<RawNode, number>();
aligned[vw]!.forEach((mo, i) => { if (mo) canonIdxOf.set(mo, i); });
let lastCanonIdx = -1;
const newByAnchor = new Map<number, RawNode[]>();
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<RawNode, number>();
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<number, IRNode[]>();
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<number, RawNode | undefined> = {};
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<number, RawNode | undefined> = {};
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<IRDoc["contentDrift"]> = [];
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
+219
View File
@@ -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<string, string> = {}, children: RawChild[] = [], visible = true, computedOver: Record<string, string> = {}): 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<number, RawNode>): 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");
});
});