# ADR-028 — Iframe canvas: the one-runtime invariant on the board

**Status:** Accepted

**Date:** 2026-07-12

## Context

Veect's first product invariant is **one runtime**: canvas, preview, isolation, and exported code render the same node tree through the same renderer — "no approximations, no 'roughly how it'll look'". The tech spec strengthens it for repo-native mode: every component instance on the canvas is *the repo's own module compiled by the repo's own framework toolchain* (embedded Vite / managed `next dev`), rendered into the editor through a cross-origin iframe with a pinned-`targetOrigin` postMessage protocol (`runtime.ready`, `fonts.loaded`-gated measurement).

The inherited editor prototype renders frames differently: **NodeRenderer**, a hand-built React component that approximates a 13-type demo kit with inline styles. It is excellent for the Playground fiction it was built for — and it is precisely the "approximation" the invariant forbids for real repos. The product bundle simultaneously says "inherit the editor, do not rebuild" and specifies an architecture in which the canvas interior cannot be NodeRenderer. This collision was unowned by any milestone; it is the hardest UI-architecture problem in the product (selection, hover, drag, hit-testing, and measurement across an origin boundary; React Flow frames containing live iframes; pan/zoom performance).

## Decision

**1. The canvas renders real repo components through iframes served by the workspace runner's preview adapter.** Each frame on the React Flow board hosts a cross-origin iframe whose content is the frame's subtree rendered by the repo's own toolchain inside the runner (ADR-027). What you see on the board *is* the compiled output — the invariant holds by construction, and the checkpoint parity check compares like with like.

**2. An injected agent script provides editor affordances over postMessage.** The adapter serves the frame wrapped with a Veect agent script that implements: node hit-testing and selection reporting, hover highlights, geometry measurement (post-`fonts.loaded`), scroll/viewport sync, and drop-target resolution for drag-from-library. The editor side speaks this **canvas protocol** (part of the runner protocol family, tech spec §9) with pinned `targetOrigin` both ways. Direct manipulation renders selection/hover rings as editor-side overlays positioned from reported geometry — chrome never lives inside the customer's document.

**3. NodeRenderer retires to the Playground** (the no-repo sandbox and landing-page demo funnel), where the base kit is the registry and an in-editor renderer is honest. It may also serve as a boot skeleton while an adapter cold-starts, clearly presented as "starting preview", never as the real render.

**4. Latency budgets are honored by splitting interaction from render.** Board pan/zoom and frame dragging are React Flow transforms on the iframe containers (no round-trip). Tree mutations flow through the doc engine to the adapter via HMR-grade updates; the <16 ms/frame budget applies to direct manipulation, the <500 ms budget (spec §7) to content updates.

## Alternatives considered

- **NodeRenderer-first MVP, iframes later.** Fastest to demo and maximizes prototype inheritance. Rejected: it ships the exact lie the wedge attacks — the trust bar ("an engineer would merge this because preview equals output") cannot be honestly measured against approximated rendering, and the migration rewrites the canvas anyway.
- **Hybrid per node type** (base-kit nodes via NodeRenderer, discovered components via per-component iframe islands, Storybook-composition style). Rejected: two runtimes on one canvas — cross-island layout, double theming, and parity semantics become incoherent; the invariant is violated at every boundary between the two.
- **Server-side screenshot streaming** (render in the runner, stream images). Rejected: kills interaction latency and real hover/focus states; contradicts the "real components with real states for free" promise.

## Consequences

- The canvas protocol (agent script + postMessage surface) becomes a named deliverable of the walking-skeleton and editor-foundation PRDs — it is the technical risk to front-load, exactly as the original spec front-loaded adapter risk in M0.
- The editor rebuild (ADR-029) targets this architecture from the start; no NodeRenderer-era selection/hit-testing code is ported to the board path.
- Per-frame iframes bound the practical frame count per board; the adapter serves multiple frames from one origin with one iframe per frame (or view), and the LRU adapter-pool rules from the spec apply per workspace, not per frame.
- Multi-view frames render as N iframes of the same subtree at different widths — the view-cluster math from the prototype's `engine/views.ts` survives as editor-side layout logic.
- Security: the iframe boundary is also the trust boundary — customer code never executes in the editor's origin; the agent script is Veect-authored and injected by the adapter, never sourced from the repo.

## Related

- ADR-027 (control plane / runner split — the adapter lives in the runner), ADR-029 (editor rebuild)
- Tech spec §1 (invariant), §9 (adapters + protocol), §16 (budgets); UI gap spec §3 (adapter status states, server-component affordances)
- Glossary: **Iframe canvas**, **Preview adapter**, **Canvas protocol** (under runner protocol), **Playground**
