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: {