- 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 need | iframe + bridge | Shadow DOM render |
|---|---|---|
| Native event capture (wheel, key, drag, selection) | Cross-origin barrier — every interaction round-trips through postMessage | Events bubble normally to parent |
| Contenteditable, drag-drop, mutation observers | Possible via bridge, but each gesture is a 2-hop protocol | Direct DOM, zero protocol surface |
| CSS isolation | Iframe document is fully isolated | Shadow root scopes captured CSS, parent rules don't leak in |
position: fixed/sticky semantics | Honest — iframe is its own viewport, sticky pins to iframe top | Re-anchored to nearest scrolling ancestor in light DOM (canvas) |
| Cross-origin click navigation | Chrome self-navigates iframe to live URL — bridge dies | N/A (no foreign navigation possible; we control the tree directly) |
| Latency on every edit gesture | postMessage roundtrip per keystroke / drag tick | Synchronous |
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 inallPages(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:
contenteditableinjection, 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:
-
URL resolution rewrite.
buildDoccurrently inlines<base href="${origin}/">and relies on browser resolution for every relativesrc/background-image/<a href>in captured HTML. Shadow DOM doesn't honour<base>(it's a document-level element). Every relative URL inrenderNode'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-styleurl()). -
Position context reframing. Captured Mantine's sidebar uses
position: stickyto 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. -
CSS variables and root selectors.
cssVarsfromextractPageTokensare injected as:root{--foo:bar}inside the iframe. In a Shadow DOM:rootmaps 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. -
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.
-
<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.
IntersectionObservercross-frame for visibility-driven editor affordances, orSelectionAPIs 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
| Alternative | Rejected because |
|---|---|
| Keep Chrome self-nav, accept UI zoom over live page | Incompatible with editor — live page is foreign DOM, no editing surface |
| Backend proxy that fetches live URL and injects bridge | Solves 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 CSS | Same 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
dangerouslySetInnerHTMLinside 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: stickyinside 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.