# Source-Aware Canvas & Agent Bridge: Implementation Design v2 **Status:** Revised — ready for implementation with known open questions (see §13) **Scope:** Complete design engineering platform — infinite canvas, multi-artboard, source awareness, Figma-style design panel, design language system, agent integration **Packages touched:** `@originmain/renderer`, `@originmain/cli`, `@originmain/agent-bridge`, `@originmain/app`, `@originmain/design-language` **Phases:** 0 → 7 (sequential with noted parallelism opportunities) --- ## 1. Problem Statement The current canvas pipeline gives us **runtime truth** — what a component looks like, what DOM rect it occupies — but zero **source truth** (which file defines it), zero **design system truth** (whether it conforms to the design language), and zero **code writeback** (getting visual changes back to disk). Style patches via `PATCH_ELEMENT_STYLE` mutate the live DOM. They vanish on the next hot reload. That is DevTools with a nicer UI, not a design engineering platform. The corrected architecture requires five layers working together: ``` ┌──────────────────────────────────────────────────────────────────┐ │ LAYER 0 — Infinite Canvas (Phase 0) │ │ Multi-artboard world space. Each artboard = one iframe = one │ │ route. Pan/zoom. Device presets. Component isolation frames. │ └───────────────────────────┬──────────────────────────────────────┘ │ artboardId per iframe ┌───────────────────────────▼──────────────────────────────────────┐ │ LAYER 1 — Runtime (exists today) │ │ CLI proxy → iframe → fiber hook → FIBER_TREE_UPDATE │ │ Gives: visual render, component name, DOM rect, live props │ │ Extended: _debugSource → source file + line per component │ └───────────────────────────┬──────────────────────────────────────┘ │ source file + component name ┌───────────────────────────▼──────────────────────────────────────┐ │ LAYER 2 — Design Panel (Phase 2) │ │ Figma-style inspector: Frame / Layout / Fill / Stroke / │ │ Effects / Typography / Constraints / Box Model │ │ CSS-first (always available) + Props tab (when indexer runs) │ └───────────────────────────┬──────────────────────────────────────┘ │ CSS values + intent changes ┌───────────────────────────▼──────────────────────────────────────┐ │ LAYER 3 — Code Diff & Intent (Phases 3–5) │ │ Client-side diff generation. Diff viewer before agent send. │ │ Confirmed IntentMessage → Agent Bridge → Claude Code applies it │ └───────────────────────────┬──────────────────────────────────────┘ │ token resolution + deviation flags ┌───────────────────────────▼──────────────────────────────────────┐ │ LAYER 4 — Design Language (Phase 6) │ │ Upload Style Dictionary / W3C DTCG / flat JSON. │ │ Token resolver maps raw CSS values → token names. │ │ Deviation flags + snap-to-token + agent writes var(--token). │ └──────────────────────────────────────────────────────────────────┘ ``` --- ## 2. What Exists Today (Audit) ### `@originmain/cli` — proxy only - Reverse-proxies the user's dev server through port 4170 - Strips `X-Frame-Options` / CSP so the iframe can load - Injects `buildProxyFiberHookScript()` into HTML responses - Passes WebSocket upgrades for HMR - **No AST indexing. No file watching. No file-read endpoint.** ### `@originmain/renderer` — fiber hook + DOM inspector - `buildProxyFiberHookScript()` hooks `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot` - Serialises fiber tree → `FIBER_TREE_UPDATE` to parent canvas - `_debugSource` is on every dev-mode fiber but **is never read or forwarded** - `PATCH_ELEMENT_STYLE` / `REMOVE_ELEMENT` apply DOM-only mutations — ephemeral - `SET_DESIGN_TOKENS` already applies CSS custom properties on `:root` — used in Phase 6 ### `@originmain/agent-bridge` — MCP over JSON-RPC 2.0 - 5 tools: `get_pending_diffs`, `get_artboard_context`, `ask_design_agent`, `update_diff_status`, `get_design_language` - **`get_design_language` exists but has no spec — no schema, no storage, never populated** - No `push_intent`, no `resolve_component`, no server-push to agent ### `@originmain/app` — canvas host - Multi-artboard rendering exists: `Canvas.tsx` already maps `useArtboards()` results to `` components on a shared transform layer - However, the current implementation lacks: viewport culling, isolation artboard type, device presets, the `artboardIframeMap` message-routing pattern, and the auto-arrange algorithm - Phase 0 **extends** this existing foundation rather than replacing it from scratch - Inspector reads `selectedComponentData` — name + props + domRect - `styleEditQueue` stores DOM patches — never generates code diffs - No design panel sections, no token-aware inputs ### `@originmain/design-language` — package exists, largely empty - No token resolver, no format parser, no deviation detection --- ## 3. Phase 0 — Infinite Canvas & Multi-Artboard This is the structural foundation. Everything else builds on top of it. ### 3.1 Canvas World Model The canvas becomes a true 2D viewport using a world-space transform: ```
← clips to window, receives wheel/drag events
``` **Transform state** (`useCanvasTransform` hook in Zustand): ```ts interface CanvasTransform { x: number; // world pan offset X (pixels) y: number; // world pan offset Y (pixels) scale: number; // zoom level (0.1 → 4.0) } ``` Pan: `mousedown` + drag on empty canvas (or `Space` + drag, or middle-click drag). Zoom: `Ctrl+scroll` or trackpad pinch. Scale clamped to `[0.1, 4.0]`. **Zoom-to-cursor math:** Zoom must centre on the cursor position, not the element's `transform-origin`. On each wheel event, before updating `scale`, compute the new translate so the world point under the cursor stays fixed: ```ts const newScale = clamp(scale * factor, 0.1, 4.0); const newX = cursorX - (cursorX - x) * (newScale / scale); const newY = cursorY - (cursorY - y) * (newScale / scale); // apply { x: newX, y: newY, scale: newScale } ``` Set `transform-origin: 0 0` on `.om-world` so the translate and scale compose correctly. `cursorX` and `cursorY` must be relative to the viewport element, not the window. Compute them as: ```ts const rect = viewportEl.getBoundingClientRect(); const cursorX = e.clientX - rect.left; const cursorY = e.clientY - rect.top; ``` **Pan vs select event hierarchy:** `.om-world` has `pointer-events: none` by default; pointer events are only received by artboard frames and the `.om-viewport` element directly. When `Space` is held or the middle mouse button is down, `.om-viewport` sets `pointer-events: all` on itself and `pointer-events: none` on all `.artboard-frame` children, capturing all drag events for panning. On `Space` release or mouse-up, pointer events are restored. This prevents artboard clicks from interfering with pan gestures. **Keyboard shortcuts:** | Key | Action | |---|---| | `Space + drag` | Pan | | `Cmd + =` / `Cmd + -` | Zoom in / out | | `Cmd + 0` | Fit all artboards in view | | `Cmd + 1` | Reset to 100% at selected artboard | | `Cmd + Shift + H` | Fit artboard height to viewport | **"Fit all" algorithm (`Cmd+0`):** Compute the axis-aligned bounding box of all artboards in world space. Apply padding of 80px on all sides. Then: ```ts const pad = 80; // Guard: if canvas is empty, reset to 100% at origin if (artboards.length === 0) { applyTransform({ x: 0, y: 0, scale: 1 }); return; } const scale = Math.max( 0.1, // never go below minimum zoom Math.min( (vpWidth - pad * 2) / totalWidth, (vpHeight - pad * 2) / totalHeight, 4.0 // never exceed maximum zoom ) ); const x = pad - bbox.minX * scale + (vpWidth - pad * 2 - totalWidth * scale) / 2; const y = pad - bbox.minY * scale + (vpHeight - pad * 2 - totalHeight * scale) / 2; ``` ### 3.2 Artboard Types Three types of artboard: **Route artboard** — renders a full page route of the user's app. `{ type: 'route', route: '/dashboard' }` **Component isolation artboard** *(available from Phase 3 — requires CLI AST indexer)* — renders a single component in a CLI-served wrapper page. `{ type: 'isolation', component: 'DashboardCard', file: 'src/components/DashboardCard.tsx' }` `// always a project-root-relative path, matching ComponentEntry.relativeFile` See §3.5 for the isolation server. **Static artboard** *(future)* — a placeholder frame with no live iframe, for annotating or wireframing. ### 3.3 Artboard Lifecycle **Creating an artboard:** Three entry points, all result in a `createArtboard()` dispatch: 1. **Routes panel** (left sidebar) — lists all `ROUTES_DISCOVERED` routes. Clicking a route that has no artboard creates one. Routes with artboards show a filled dot. 2. **Artboard Navigator** — `+` button opens a picker: "New Route Artboard" → route selector; "New Isolation Frame" → component name input. 3. **Duplicate** — right-click any artboard → Duplicate. Creates a copy at a different device size (opens device preset picker). **Auto-arrange (default layout):** New artboards snap to a horizontal row with `gap: 200px`. When a row exceeds 3 artboards or total width > 6000px, a new row begins below. The vertical gap between rows is 240px. The Y position of a new row is `previousRowStartY + maxHeightInPreviousRow + 240px` (using the tallest artboard in the completed row as the row height — accumulated across all previous rows, not just the last one). The layout algorithm runs on `createArtboard()` only — it computes a suggested `(x, y)` position and assigns it. The user can drag the artboard away from that position at any time; subsequent auto-arrange calls do not move manually-positioned artboards (a `manuallyPositioned: boolean` flag on each artboard record prevents re-calculation). The flag is set to `true` on `pointerup` at the end of a successful artboard drag (when the user has moved the artboard at least **10 world-space pixels** from its pre-drag position — convert screen delta to world delta by dividing by `canvasTransform.scale` before comparing). It is never set by the auto-arrange algorithm's own writes to `canvas_x`/`canvas_y`. **"Re-arrange all"** button in the Artboard Navigator context menu resets all positions to the auto-grid (after a confirmation prompt, since it discards freeform layout). **Deleting an artboard:** Right-click → Delete. Removes iframe from DOM, removes Supabase row (the `intent_diffs.artboard_id` foreign key must have `ON DELETE SET NULL` — not `CASCADE` — to preserve diff history even when the artboard is deleted), removes from Zustand, and calls `artboardIframeMap.delete(artboardId)` to release the DOM reference. **Selecting an artboard:** Click the artboard label (above the iframe) to select the frame itself (shows frame handles, shows device preset picker in the top bar). Click inside the iframe to select a component within it (activates design panel). **Multi-artboard message routing:** Component selection sets both `selectedArtboardId` and `selectedComponentData` in Zustand simultaneously. All outgoing DOM messages (`PATCH_ELEMENT_STYLE`, `REQUEST_ELEMENT_STYLES`, `DESELECT`, `SET_DESIGN_TOKENS` to a single artboard) are always dispatched to the iframe referenced by `selectedArtboardId`. `artboardIframeMap` is a **module-level singleton** defined outside Zustand in `packages/app/src/lib/artboard-iframe-map.ts`: ```ts export const artboardIframeMap = new Map(); ``` DOM references must never be stored in Zustand — they are not serialisable, prevent garbage collection, and break React DevTools. Each `` registers its `iframeRef.current` on mount (`artboardIframeMap.set(id, el)`) and removes it on unmount (`artboardIframeMap.delete(id)`). The canvas dispatches messages via `artboardIframeMap.get(selectedArtboardId)?.contentWindow.postMessage(envelope, '*')`. On `deleteArtboard(id)`, both the Zustand action and the `ArtboardFrame` unmount path call `artboardIframeMap.delete(id)` to release the DOM reference. ### 3.4 Device Presets Each artboard has a `width × height` that sets the iframe's `width` and `height` CSS properties directly. The dev server sees the correct viewport for responsive breakpoints. | Preset key | Label | Width | Height | |---|---|---|---| | `desktop-hd` | Desktop HD | 1440 | 900 | | `desktop-lg` | Desktop Large | 1280 | 800 | | `laptop` | Laptop | 1024 | 768 | | `tablet-landscape` | Tablet Landscape (iPad Pro) | 1366 | 1024 | | `tablet-portrait` | Tablet Portrait | 768 | 1024 | | `mobile-iphone-14` | iPhone 14 | 390 | 844 | | `mobile-iphone-se` | iPhone SE | 375 | 667 | | `mobile-android` | Android | 360 | 800 | | `custom` | Custom | user-defined | user-defined | Changing the preset: top toolbar shows the current device preset for the selected artboard. A dropdown lists presets. Choosing one resizes the iframe immediately; the dev server's CSS responds to the new viewport. ### 3.5 Component Isolation Artboards A component isolation artboard renders a single React component in isolation. The mechanism differs by framework: **Vite-based projects:** The CLI intercepts `/__om_isolation__` requests and generates a minimal HTML wrapper. Because Vite handles arbitrary `.tsx` module imports natively, this works without any changes to the user's project: ```html
``` **Export type handling:** The CLI queries `GET /components?name=DashboardCard` before generating the isolation page to determine `isDefaultExport`. If the indexer is not yet running, the CLI generates both import forms and tries them in order using a dynamic import wrapper. **Next.js projects:** Next.js does not serve arbitrary source files. The CLI detects Next.js (presence of `next.config.*`) and instead writes a temporary page file to the user's project. For App Router projects: check for `src/app/` first, then `app/` at the project root. Write the temp page to whichever exists: `{appDir}/__om_isolation__/page.tsx`. If neither `src/app/` nor `app/` is found, fall back to the Pages Router check below. For Pages Router projects (`pages/` exists): `pages/__om_isolation__.tsx`. If neither directory is found, falls back to the Vite approach (bare module import). The temporary page is deleted when the CLI stops (registered via `process.on('exit', cleanup)` and `process.on('SIGINT', cleanup)`). **Startup cleanup:** On `originmain dev` start, the CLI scans for and deletes any pre-existing `__om_isolation__` directories in both `src/app/` and `app/` before creating new ones — this recovers from previous unclean exits. **`.gitignore` injection:** The CLI appends the following to the project's `.gitignore` if not already present (idempotent check before writing): ``` # Originmain component isolation frame (auto-deleted on CLI stop) __om_isolation__/ ``` This page uses a dynamic import based on the `component` and `file` query params passed as `searchParams`. **Framework detection:** The CLI checks `package.json` for `"next"` in dependencies to detect Next.js; otherwise defaults to the Vite approach. **Canonical framework detection logic (used everywhere in the CLI):** (1) Check `package.json` `dependencies`/`devDependencies` for `"next"` → Next.js. (2) Check for `vite.config.*` in the project root → Vite. (3) Check for `remix.config.*` → Remix. (4) Otherwise → generic Vite-compatible. This logic lives in `packages/cli/src/detect-framework.ts` (NEW file, listed in Phase 3 files). All CLI modules import from this single source. **`UPDATE_ISOLATION_PROPS` protocol:** When the user edits props in the Props tab of the design panel, the canvas sends this message to the isolation artboard's iframe: ```ts // Canvas → isolation iframe (via artboardIframeMap) { source: HOST_SOURCE, artboardId, message: { type: 'UPDATE_ISOLATION_PROPS', props: Record } } ``` The DOM inspector script (in the iframe) handles this message in its existing `window.addEventListener('message', ...)` handler: ```js else if(m.type==='UPDATE_ISOLATION_PROPS'){ window.__OM_ISO_PROPS__=m.props; if(typeof window.__OM_ISO_RENDER__==='function')window.__OM_ISO_RENDER__(); } ``` This triggers a synchronous React re-render with the new props — no full page reload. **Required protocol addition (Phase 0):** Add `UPDATE_ISOLATION_PROPS` to `dom-inspector.ts`'s message handler. Add `UPDATE_ISOLATION_PROPS` to `protocol.ts` as a valid host message type. ### 3.6 Viewport Culling Rendering more than ~4 live iframes simultaneously is expensive. The strategy: - **Active** (within viewport bounds + 200px margin): full live `