ADRs
ADR 0032 — Reassembly rendering: iframe+bridge now, Shadow DOM later
  • Date: 2026-06-03
  • Status: Proposed
  • Phase / Feature: Reassembly view evolution toward editor
  • Builds on: ADR 0030 (Page reconstruction), ADR 0031 (Workflow canvas)

Context

ReconstructedPage renders a captured page inside an <iframe sandbox="allow-scripts"> with our IFRAME_BRIDGE_SCRIPT injected into the srcdoc. The bridge forwards wheel/gesture intent to the parent (so artboard zoom is anchored on the cursor) and lets #fragment clicks scroll inside the iframe. The reassembly view is currently read-only.

Roadmap: turn the artboard into an editor. Users will edit text, drag components, change tokens, swap layouts. That changes the constraints on rendering:

Editor neediframe + bridgeShadow DOM render
Native event capture (wheel, key, drag, selection)Cross-origin barrier — every interaction round-trips through postMessageEvents bubble normally to parent
Contenteditable, drag-drop, mutation observersPossible via bridge, but each gesture is a 2-hop protocolDirect DOM, zero protocol surface
CSS isolationIframe document is fully isolatedShadow root scopes captured CSS, parent rules don't leak in
position: fixed/sticky semanticsHonest — iframe is its own viewport, sticky pins to iframe topRe-anchored to nearest scrolling ancestor in light DOM (canvas)
Cross-origin click navigationChrome self-navigates iframe to live URL — bridge diesN/A (no foreign navigation possible; we control the tree directly)
Latency on every edit gesturepostMessage roundtrip per keystroke / drag tickSynchronous

The cross-origin wall is real and unavoidable for self-nav. Self-nav and the editor are mutually exclusive — the moment the iframe loads https://mantine.dev/..., we have read-only access to a foreign document.

Decision

Short term — iframe + bridge with custom in-artboard navigation.

  • Drop Chrome self-navigation. Bridge intercepts <a> clicks, posts the href to the parent. Parent normalizes the URL, looks it up in allPages (project's captured pages), and either swaps the active captured page (in-artboard) or opens a new tab (miss).
  • Iframe always shows our srcdoc; bridge is always alive. Zoom over the artboard always works through the same path as canvas-margin wheel — no overlay hacks, no focus tricks, no window.focus() heuristics.
  • Editor primitives bolt onto the bridge: contenteditable injection, click-to-select postMessages, drag handle events. Each interaction roundtrips through postMessage. Acceptable for v1.

Medium term — migrate to Shadow DOM rendering when editor latency becomes user-visible (e.g. drag jitter, contenteditable lag, mutation observers we can't bridge cleanly). Concrete trigger: any editor gesture where the postMessage round-trip is felt on a mid-spec laptop.

Why not Shadow DOM right now

The architectural endpoint is Shadow DOM (or even regular React rendering with scoped CSS via CSS Modules / nested-tree class isolation). But the migration is a serious refactor with risk of fidelity regression. Specific work that has to happen before Shadow DOM ships:

  1. URL resolution rewrite. buildDoc currently inlines <base href="${origin}/"> and relies on browser resolution for every relative src/background-image/<a href> in captured HTML. Shadow DOM doesn't honour <base> (it's a document-level element). Every relative URL in renderNode's leaf HTML has to become absolute at render time — that's hundreds of nodes across the existing five verified captures (Mantine, GitHub, Stripe, react.dev, vercel), each with its own quirks (data-URIs, srcset, inline-style url()).

  2. Position context reframing. Captured Mantine's sidebar uses position: sticky to pin to the page viewport. Inside the iframe today, that "viewport" is the iframe itself, behaviour matches the live site. In a Shadow DOM, sticky resolves against the nearest scrolling ancestor in the light DOM tree — likely .rp-canvas. The shipping pattern probably works but visually subtle bugs are likely on every capture with sticky/fixed elements. Need to walk each captured tree, find positioned ancestors, decide whether to keep or reframe, test against the snapshot.

  3. CSS variables and root selectors. cssVars from extractPageTokens are injected as :root{--foo:bar} inside the iframe. In a Shadow DOM :root maps to the shadow host, and the inheritance model changes (CSS custom properties still inherit from host, but some selectors won't match the same elements). Need to re-target the rule set and verify token resolution on every captured tree.

  4. Fidelity verification cost. Capture-v3 P1+P2 (animation pause + opacity drop + position-context restore + ground-truth tokens — see ADR 0030 §B-structure) was tuned against the iframe rendering pipeline. Shadow DOM is a different rendering substrate; every fidelity win is potentially at risk and has to be re-measured. The Mantine reassembly is currently 9/10 fidelity — easy to drop a point on a CSS edge case during the migration.

  5. <head> semantics. Google Fonts <link>, BYOF <style>, and captured <style> blocks live in the iframe's <head>. Shadow DOM has no <head> — these have to be repositioned inside the shadow root, and cascade order may shift. Font loading specifically interacts with FOIT/FOUT timing and may surface different visual artefacts than today's iframe.

Path #2 (custom nav, iframe stays) is ~30 minutes of code that's already been written and verified (commit a00f88b on this branch, reverted). Shadow DOM migration is a deliberate sprint with explicit fidelity re-verification on each captured site. Doing it now would couple the editor unlock to a fidelity regression risk we don't need to take.

Migration trigger to Shadow DOM

Open this ADR for revision when any of the following lands:

  • First editor gesture (drag, type, select) where the postMessage round-trip is visibly laggy on a 2024-era MacBook Air. Current bridge round-trip is <1 frame, fine for wheel/click; sustained 60Hz drag may not be.
  • We hit a feature that fundamentally needs synchronous DOM access the bridge can't fake — e.g. IntersectionObserver cross-frame for visibility-driven editor affordances, or Selection APIs that need to coordinate across multiple captured trees.
  • Bridge code grows past ~500 LOC of protocol/forwarder boilerplate. At that point the iframe is paying for itself in protocol weight, not isolation.

Until one of those fires, the iframe stays.

Consequences

Accepted:

  • Editor v1 will round-trip every interaction through postMessage. Acceptable for v1 latency budgets (<50ms perceived).
  • Non-captured links open in a new tab instead of navigating the iframe in-place. Acceptable for an editor product — the artboard is a controlled artifact, not a browser.
  • Live external pages cannot be embedded in the artboard. Acceptable — we are an editor of captured snapshots, not a web browser.

Trade-off documented elsewhere:

  • Mitigates the "UI zoom over post-nav iframe" gap that drove ~8 iterations on this branch. Bridge always alive = no post-nav state = no leak.

Alternatives considered

AlternativeRejected because
Keep Chrome self-nav, accept UI zoom over live pageIncompatible with editor — live page is foreign DOM, no editing surface
Backend proxy that fetches live URL and injects bridgeSolves zoom + nav but doesn't help editor (proxied content is still a snapshot we now have to fetch on every click). Significant infra cost for marginal UX gain.
Skip iframe entirely now, render direct React + scoped CSSSame migration cost as Shadow DOM without the isolation guarantee. Captured page CSS would leak into Arno chrome on the first edge case.

Implementation notes (when Shadow DOM lands)

  • Use attachShadow({ mode: 'open' }) so React DevTools can still inspect.
  • Render captured tree via dangerouslySetInnerHTML inside a div with the shadow root attached after mount — or migrate to React-Shadow / react-shadow-root if maturity is sufficient at that time.
  • Move <style> / <link rel="stylesheet"> injection into the shadow root; verify font loading still hits the right scope.
  • Walk captured tree pre-render, rewrite all relative URLs against sourceUrl's origin (replacing the <base> strategy).
  • For position: sticky inside captured tree, either accept canvas-anchored behaviour or rewrite captured-positioning to be artboard-scoped.
  • Re-verify fidelity on the five canonical captures (Mantine, GitHub, Stripe, react.dev, vercel) before merging.