From ed485b1a152bcf1861019fcbbc0de018eda8fea6 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Sat, 2 May 2026 16:28:08 +0100 Subject: [PATCH] made tiny updates --- docs/SOURCE-AWARE-CANVAS.md | 1996 +++++++++++++++++++++++++++++------ 1 file changed, 1688 insertions(+), 308 deletions(-) diff --git a/docs/SOURCE-AWARE-CANVAS.md b/docs/SOURCE-AWARE-CANVAS.md index 5d5f282..c08f971 100644 --- a/docs/SOURCE-AWARE-CANVAS.md +++ b/docs/SOURCE-AWARE-CANVAS.md @@ -1,91 +1,396 @@ -# Source-Aware Canvas & Agent Bridge: Implementation Design +# Source-Aware Canvas & Agent Bridge: Implementation Design v2 -**Status:** Draft — awaiting review before implementation begins -**Scope:** Two-layer system to close the gap between visual intent and real code edits -**Packages touched:** `@originmain/renderer`, `@originmain/cli`, `@originmain/agent-bridge`, `@originmain/app` +**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 the component looks like, what props it accepts, what DOM rect it occupies — but zero **source truth**: which file defines it, what token it consumes, how to write a change back to disk. +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 applied via `PATCH_ELEMENT_STYLE` mutate the live DOM through `el.style.setProperty()`. They vanish on the next hot-reload. That is not an Intent Diff; it is DevTools with a nicer UI. +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 two layers working together: +The corrected architecture requires five layers working together: ``` -┌─────────────────────────────────────────────────────────────┐ -│ LAYER 1 — Runtime (exists today) │ -│ CLI proxy → iframe → fiber hook → FIBER_TREE_UPDATE │ -│ Gives: visual render, component name, DOM rect, live props │ -└───────────────────────────┬─────────────────────────────────┘ - │ component name + _debugSource -┌───────────────────────────▼─────────────────────────────────┐ -│ LAYER 2 — Source (what we are building) │ -│ │ -│ 2a. _debugSource extraction (renderer) │ -│ React dev mode attaches fileName + lineNumber to every │ -│ fiber. We already walk the fiber tree — we just need │ -│ to forward this field. Zero CLI changes required. │ -│ │ -│ 2b. CLI AST Indexer │ -│ Watches .tsx/.ts files, builds component→file map. │ -│ Exposes a local HTTP API: GET /components?name=Card │ -│ Returns: file, line, exported props, design tokens used │ -│ │ -│ 2c. Intent Message protocol │ -│ Canvas emits a structured Intent (not a DOM patch). │ -│ Intent carries: component, file, change, before/after. │ -│ Sent to Agent Bridge → Claude Code / Cursor apply it. │ -└─────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────┐ +│ 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) +## 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 via string injection +- Injects `buildProxyFiberHookScript()` into HTML responses - Passes WebSocket upgrades for HMR -- **No AST indexing. No file watching. No component→file map.** +- **No AST indexing. No file watching. No file-read endpoint.** ### `@originmain/renderer` — fiber hook + DOM inspector -- `buildProxyFiberHookScript()`: hooks `window.__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot` -- Serialises the fiber tree → `FIBER_TREE_UPDATE` to parent canvas -- Sends `COMPONENT_SELECTED` on click, `ELEMENT_STYLES` on request -- Node IDs are stable path strings: `"ComponentName:idx/Child:idx/…"` -- `_debugSource` is present on fiber objects in dev mode but **is never read or forwarded** -- `PATCH_ELEMENT_STYLE` / `REMOVE_ELEMENT` apply DOM-only mutations — no source awareness +- `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` -- Transport: WebSocket (long-running) or HTTP POST (polling) -- Auth: signed workspace token in first message (`AuthRequest`) -- Claude Code adapter generates `CLAUDE.md` section + `.claude/settings.json` block -- **No tool for "I changed this component — apply it to source"** -- **No tool for querying component→file mapping** +- 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 -- Canvas receives `FIBER_TREE_UPDATE`, stores in Zustand (`artboardFiberRoots`) -- Inspector reads `selectedComponentData` (a `FiberNode`) — name + props + domRect -- DOM patches queued in `styleEditQueue`, drained by `LiveArtboard` -- **No intent message emitted. Style changes stop at the DOM.** +- 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. Layer 2a — `_debugSource` Extraction (Quick Win) +## 3. Phase 0 — Infinite Canvas & Multi-Artboard -**What it is:** React's dev build attaches `_debugSource: { fileName, lineNumber, columnNumber }` to every fiber created by JSX. This is the same data React DevTools uses to show "Defined in src/components/Card.tsx:12". +This is the structural foundation. Everything else builds on top of it. -**What we need to do:** -- In `buildProxyFiberHookScript()` (renderer package), read `fiber._debugSource` during `serializeFiber()` -- Forward it as an optional `sourceFile: { fileName, lineNumber }` field on `FiberNode` -- Update the `FiberNode` TypeScript interface to include 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 `maxHeightInPreviousRow + 240px` (using the tallest artboard in the completed row as the row height). 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 4px from its pre-drag position). 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 `