107 KiB
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_UPDATEto parent canvas _debugSourceis on every dev-mode fiber but is never read or forwardedPATCH_ELEMENT_STYLE/REMOVE_ELEMENTapply DOM-only mutations — ephemeralSET_DESIGN_TOKENSalready 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_languageexists but has no spec — no schema, no storage, never populated- No
push_intent, noresolve_component, no server-push to agent
@originmain/app — canvas host
- Multi-artboard rendering exists:
Canvas.tsxalready mapsuseArtboards()results to<Artboard>components on a shared transform layer - However, the current implementation lacks: viewport culling, isolation artboard type, device presets, the
artboardIframeMapmessage-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 styleEditQueuestores 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:
<div class="om-viewport"> ← clips to window, receives wheel/drag events
<div class="om-world" ← single transform origin, contains all artboards
style="transform: translate(Xpx, Ypx) scale(Z)">
<ArtboardFrame id="abc" x={0} y={0} w={1440} h={900} route="/dashboard" />
<ArtboardFrame id="def" x={1640} y={0} w={390} h={844} route="/settings" />
<ArtboardFrame id="ghi" x={0} y={1000} w={1440} h={900} route="/onboarding" />
<ArtboardFrame id="iso" x={3400} y={0} w={800} h={600} type="isolation"
component="DashboardCard" file="src/components/DashboardCard.tsx" />
</div>
</div>
Transform state (useCanvasTransform hook in Zustand):
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:
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:
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:
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:
- Routes panel (left sidebar) — lists all
ROUTES_DISCOVEREDroutes. Clicking a route that has no artboard creates one. Routes with artboards show a filled dot. - Artboard Navigator —
+button opens a picker: "New Route Artboard" → route selector; "New Isolation Frame" → component name input. - 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:
export const artboardIframeMap = new Map<string, HTMLIFrameElement>();
DOM references must never be stored in Zustand — they are not serialisable, prevent garbage collection, and break React DevTools. Each <ArtboardFrame> 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:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script type="module">
// Import is generated at request time based on ComponentEntry.isDefaultExport:
// Named export: import { DashboardCard } from '/src/components/DashboardCard.tsx'
// Default export: import DashboardCard from '/src/components/DashboardCard.tsx'
// The CLI reads isDefaultExport from the AST indexer to choose the correct form.
import { DashboardCard } from '/src/components/DashboardCard.tsx'; // ← generated
import { createRoot } from 'react-dom/client';
import React from 'react';
// All Originmain globals use the __OM_ISO_ prefix to minimize collision risk
// with application code. If a collision is detected at runtime (window.__OM_ISO_PROPS__
// already defined), the isolation page logs a warning but proceeds.
const _root = createRoot(document.getElementById('root'));
window.__OM_ISO_RENDER__ = function() {
_root.render(React.createElement(DashboardCard, window.__OM_ISO_PROPS__ || {}));
};
window.__OM_ISO_RENDER__();
</script>
</head>
<body style="margin:0;padding:24px;background:#f5f5f5;"><div id="root"></div></body>
</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<string, unknown> } }
The DOM inspector script (in the iframe) handles this message in its existing window.addEventListener('message', ...) handler:
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
<iframe>mounted - Near (within 800px of viewport): iframe is mounted but
visibility: hidden(keeps HMR connection alive) - Far (beyond 800px): iframe is unmounted, replaced by a
<div class="artboard-thumbnail">showing a static screenshot
The viewport cull is triggered by pan and zoom events and runs on a 100ms debounce — a single setTimeout that resets on each event. This prevents excessive DOM mutations during rapid panning. The debounced handler itself runs synchronously (not via requestAnimationFrame) since it only mounts/unmounts iframes, not repainting.
Thumbnail capture — cross-origin constraint: The canvas host and the CLI proxy (localhost:4170) are different origins. The parent window cannot access the iframe's DOM directly to capture a screenshot. Thumbnails must be captured inside the iframe and sent back via postMessage.
Capture mechanism:
- When an artboard transitions from Active → Near/Far, the canvas sends
{ type: 'CAPTURE_THUMBNAIL' }to that artboard's iframe - The fiber hook / DOM inspector inside the iframe receives this message, runs
html2canvas(document.body)(thehtml2canvaslibrary is injected by the CLI proxy alongside the fiber hook), and posts the result back:post({ type: 'THUMBNAIL_READY', dataUrl: canvas.toDataURL('image/jpeg', 0.6) }) - The canvas receives
THUMBNAIL_READY, stores the data URL inartboardFrames[id].thumbnailDataUrlin Zustand, and uses it to display the placeholder thumbnail_urlin Supabase stores this data URL (or a Supabase Storage URL if the image is uploaded — see §3.8)
Required additions:
CAPTURE_THUMBNAILadded as a valid host message type inprotocol.tsTHUMBNAIL_READYadded as a valid renderer message type inprotocol.tshtml2canvasis bundled inside the CLI package and injected as an inline script (not a CDN URL — CDN dependency would break offline use and introduce a supply-chain risk). The CLI bundleshtml2canvasduring its own build step. Ifhtml2canvasfails (returns a blank canvas, throws, or times out after 3s), the iframe sends{ type: 'THUMBNAIL_READY', dataUrl: null }and the canvas displays a "thumbnail unavailable" placeholder with the artboard label instead. Performance note: html2canvas adds ~250KB to the injected script per artboard tab — it is only injected once per page load and only when the CLI is running in dev mode.- Handler added to
dom-inspector.tsforCAPTURE_THUMBNAIL
3.7 Artboard Navigator Panel (Left Sidebar)
The left panel (currently ArtboardNavigator.tsx) becomes a full sidebar with two tabs:
Artboards tab:
- Lists all artboards with label, route, device icon, and live/idle indicator
- Click → scroll canvas to that artboard, select it
- Drag to reorder (affects auto-arrange row grouping)
- Thumbnail preview on hover
Routes tab:
- Lists all
ROUTES_DISCOVEREDroutes from every live artboard (ROUTES_DISCOVEREDis a renderer→canvas postMessage emitted bydom-inspector.tson init and whenever new<a href>links are detected. Already implemented — see §2 audit. The canvas aggregates routes from all live artboards into a deduplicated list.) - Routes without an artboard show
+to create one - Routes already open show a "live" indicator
3.8 Persistence — Schema Changes
Extend the existing artboards table:
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS route text;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS canvas_x float8 DEFAULT 0;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS canvas_y float8 DEFAULT 0;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS width integer DEFAULT 1440;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS height integer DEFAULT 900;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS device_preset text DEFAULT 'desktop-hd';
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS artboard_type text DEFAULT 'route' CHECK (artboard_type IN ('route', 'isolation', 'static'));
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS isolation_component text;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS isolation_file text;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS isolation_props jsonb DEFAULT '{}';
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS manually_positioned boolean DEFAULT false;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS thumbnail_url text; -- always a Supabase Storage public URL; data URIs are never stored in the DB (see §3.6)
Supabase Storage: Create a artboard-thumbnails bucket (public read, authenticated write). On thumbnail capture, upload the JPEG to artboard-thumbnails/{workspaceId}/{artboardId}.jpg and store the public URL in thumbnail_url. If the upload fails, display a 'thumbnail unavailable' placeholder instead — do not write to the DB.
Canvas transform (pan/zoom) is session-only — stored in Zustand + localStorage. Not persisted to Supabase (users don't expect the exact pan position to survive browser closes).
3.9 Files to Create / Change
| File | Change |
|---|---|
packages/renderer/src/protocol.ts |
Add UPDATE_ISOLATION_PROPS (host message), CAPTURE_THUMBNAIL (host message), THUMBNAIL_READY (renderer message) |
packages/renderer/src/fiber-hook.ts |
Handle CAPTURE_THUMBNAIL (run html2canvas, post THUMBNAIL_READY); handle UPDATE_ISOLATION_PROPS (set window.__OM_ISO_PROPS__ and call window.__OM_ISO_RENDER__()) |
packages/app/src/lib/artboard-iframe-map.ts |
NEW — module-level Map<string, HTMLIFrameElement> singleton; imported by ArtboardFrame and canvas dispatch logic |
packages/app/src/components/canvas/CanvasViewport.tsx |
NEW — pan/zoom world container, wheel+drag handlers, viewport culling |
packages/app/src/components/canvas/ArtboardFrame.tsx |
NEW — single artboard wrapper: label, handles, device outline, iframe |
packages/app/src/components/canvas/IsolationFrame.tsx |
NEW — isolation artboard type; props override panel integration |
packages/app/src/components/canvas/Canvas.tsx |
Replace single-iframe layout with <CanvasViewport> containing <ArtboardFrame> list |
packages/app/src/components/navigator/ArtboardNavigator.tsx |
Add "Routes" tab, thumbnails, create-from-route action |
packages/app/src/components/chrome/AppChrome.tsx |
Add device preset picker to top toolbar for selected artboard |
packages/app/src/store/canvas.ts |
Add canvasTransform, artboardFrames, createArtboard, deleteArtboard, updateArtboardPosition, setArtboardDevicePreset |
packages/app/src/hooks/useArtboards.ts |
Extend to load new schema fields |
packages/cli/src/proxy.ts |
Inject window.__OM_INDEX_URL__ and window.__OM_ISO_BASE__ into proxied HTML; intercept /__om_isolation__ for Vite projects (Next.js isolation pages handled in Phase 3). The boundary: proxy.ts intercepts the request and calls isolationServer.handleRequest(req, res). isolation-server.ts (Phase 3) contains all the HTML generation and framework detection logic. proxy.ts has no HTML generation logic. |
| Database | Migration: alter-artboards-v2.sql with new columns above |
4. Phase 1 — _debugSource Extraction
4.1 What It Is
React's dev build attaches _debugSource: { fileName, lineNumber, columnNumber } to every fiber created by JSX. The same data React DevTools uses to show "Defined in src/components/Card.tsx:12".
Dev builds only: _debugSource is only present when React is compiled with __DEV__ = true. In production builds, the field is absent. Since the CLI proxy is a development-only tool, this is acceptable — but implementers must not expect callSite to be present in staging/production artboards.
4.2 Important Constraint: Usage Site vs Definition File
_debugSource is set at the JSX call site — the file where <Card /> is written — not where the Card function is defined. If page.tsx renders <DashboardCard />, _debugSource on that fiber points to page.tsx, not components/DashboardCard.tsx.
Correct extraction approach: No upward walk is needed. In serializeFiber(), when the current fiber's type is a function (i.e., it is a React component fiber), read fiber._debugSource directly from that fiber. The _debugSource on a component fiber gives the file and line where its JSX tag was written in the parent — that is the call site. This is sufficient: the component name is used to query the AST indexer for the definition file separately.
For the definition file: The AST indexer (Phase 3) is the authoritative source. _debugSource gives the call-site file; componentName is used to query the indexer for the definition file. Both are included in the FiberNode as separate fields.
Fallback when _debugSource is absent: _debugSource is undefined for components rendered imperatively with React.createElement() (not JSX), class components, and some HOC wrappers. In these cases, callSite is omitted from the serialized FiberNode. The Inspector shows "Source location unavailable" and relies solely on the AST indexer for file info. This is expected and not an error condition.
4.3 Protocol Change
export interface FiberNode {
id: string;
name: string;
props: Record<string, unknown>;
children: FiberNode[];
domRect?: DOMRectLike;
// NEW
callSite?: {
fileName: string; // file where <ComponentName /> was written
lineNumber: number;
columnNumber?: number;
};
}
Note: renamed from sourceFile to callSite for accuracy — it is explicitly the usage location, not the definition file. The definition file comes from the AST indexer.
4.4 Files to Change
| File | Change |
|---|---|
packages/renderer/src/protocol.ts |
Add callSite? to FiberNode |
packages/renderer/src/fiber-hook.ts |
Read fiber._debugSource in serializeFiber(), include as callSite |
packages/app/src/components/inspector/Inspector.tsx |
Display call site path + line; label it "used in" not "defined in" |
5. Phase 2 — Figma-style Design Panel
The Inspector panel is replaced with a proper design panel. This is the visual editing core.
5.1 Panel Architecture
The panel has two top-level tabs:
Design tab (default) — CSS-computed values, organized into sections (see §5.2–5.9). Always available.
Props tab — React props for the selected component. Visible only when the AST indexer is running AND the selected element is a capitalized React component. See §5.10.
Code tab — shows the generated diff. The tab is present from Phase 2 as a placeholder (disabled, shows "Preview Code Change requires the CLI indexer"). It becomes fully functional in Phase 4 when the diff generator is wired up.
The panel is driven by selectedComponentStyles (computed CSS) and selectedComponentData (fiber data) from Zustand.
Adaptive sections: Sections expand or collapse based on the selected element's computed styles:
- Layout section: expanded when
displayisflexorgrid - Typography section: expanded when the element has non-empty text content or
font-sizeis set - Constraints section: expanded when
positionisabsoluteorfixed - All other sections: collapsed by default, expand on click
5.2 Section: Frame (Position & Size)
| Input | CSS property | Notes |
|---|---|---|
| X | left (absolute) or layout offset |
Read-only display value when position: static — computed as offsetLeft / offsetTop relative to the nearest positioned ancestor (the artboard root). Does not shift on scroll. Shows a tooltip: "Set position: absolute to edit." Editable when position: absolute or fixed, writing to the left / top CSS property. |
| Y | top (absolute) or layout offset |
Read-only display value when position: static — computed as offsetLeft / offsetTop relative to the nearest positioned ancestor (the artboard root). Does not shift on scroll. Shows a tooltip: "Set position: absolute to edit." Editable when position: absolute or fixed, writing to the left / top CSS property. |
| W | width |
Accepts px, %, rem |
| H | height |
Accepts px, %, rem |
| Lock aspect ratio | — | Derived: W/H ratio locked on resize |
| Rotation | transform: rotate() |
Degrees, –360 to 360 |
| Corner radius | border-radius |
Uniform input + expand to show TL/TR/BR/BL individually |
| Clip content | overflow |
Toggle: visible ↔ hidden |
Unit preservation: When editing W or H, the panel preserves the current unit. If the current value is 50%, the input shows 50 with a % unit badge; typing a new number keeps %. Clicking the unit badge opens a unit picker (px / % / rem / auto). Switching units converts the value numerically at the time of switch: 50% of a 400px parent → 200px. A lock icon next to the unit preserves it on subsequent edits.
5.3 Section: Layout
Shown for all elements. Sub-mode toggles:
Block mode (when display: block or inline-block): The Layout section header is shown but collapsed. Expanding it shows only a Display toggle (Block / Flex / Grid) and padding inputs. Width/height are in the Frame section. This matches the "collapsed by default" rule in §5.1 — the section is always present, never hidden.
Flex mode (when display: flex):
| Input | CSS property |
|---|---|
| Direction | flex-direction (row/column) with icon buttons |
| Wrap | flex-wrap (wrap/nowrap) with icon buttons |
| Align items | align-items (start/center/end/stretch/baseline) with icon buttons |
| Justify content | justify-content (start/center/end/between/around/evenly) with icon buttons |
| Gap | gap — unified input; expand to row-gap / column-gap |
| Padding | padding-top/right/bottom/left — four inputs linked by default; click chain icon to unlink |
Grid mode (when display: grid):
| Input | CSS property |
|---|---|
| Columns | grid-template-columns — text input + visual preview |
| Rows | grid-template-rows — text input + visual preview |
| Column gap | column-gap |
| Row gap | row-gap |
| Align items | align-items |
| Justify items | justify-items |
Display toggle: Block / Flex / Grid buttons at the top of the Layout section. Changing display fires a PATCH_ELEMENT_STYLE immediately and queues the intent.
5.4 Section: Fill
| Control | CSS property | Notes |
|---|---|---|
| Fill type | background-color, background-image, none |
Segmented: None / Solid / Gradient / Image |
| Color picker | background-color |
Full color picker: hex, RGB, HSL inputs + opacity slider |
| Gradient | background-image: linear-gradient(…) |
Gradient bar with draggable stops; each stop has color + position |
| Opacity | opacity |
0–100%, applies to the whole element |
Multiple fills: a + button adds another fill layer (stacked background shorthand). Each fill has a remove × button.
Token integration (Phase 6): When a token match is found for background-color, the hex input is replaced with a token chip (e.g., --color-surface). Click the chip to open the token picker. Click the hex area to enter raw mode.
5.5 Section: Stroke (Border)
| Input | CSS property | Notes |
|---|---|---|
| Color | border-color |
Color picker — linked to token system |
| Width | border-width |
px input; expand to Top/Right/Bottom/Left |
| Style | border-style |
Solid / Dashed / Dotted / None |
| Position | — | Inside / Center / Outside. CSS approximation: "Center" uses border; "Inside" uses box-shadow: inset 0 0 0 Xpx color; "Outside" uses box-shadow: 0 0 0 Xpx color. These modify different CSS properties than a naive border change. The generated IntentChange for Inside/Outside has confidence: 'approximate' (using the existing field from §7.2, not a new approximation boolean). |
| Radius | border-radius |
Mirrors Frame section — shows here too for discoverability |
Box-shadow composition: The Stroke section's Inside/Outside border and the Effects section's drop shadow both use box-shadow. CSS allows multiple comma-separated shadows in one declaration.
interface BoxShadowLayer {
role: 'border' | 'shadow'; // 'border' = Inside/Outside stroke; 'shadow' = Effects drop shadow
x: number; // px
y: number; // px
blur: number; // px
spread: number; // px
color: string; // any valid CSS color string
inset: boolean;
}
The design panel's internal model maintains a boxShadowLayers: BoxShadowLayer[] array that includes both border layers and effect layers, serialized as a single box-shadow declaration. Adding a stroke border adds a layer tagged role: 'border'; adding a drop shadow adds a layer tagged role: 'shadow'. All layers are serialized together: box-shadow: [border layer], [shadow layers...]. This prevents either section from overwriting the other.
5.6 Section: Effects
Drop shadow (maps to box-shadow):
| Input | Notes |
|---|---|
| X offset | px |
| Y offset | px |
| Blur | px |
| Spread | px |
| Color + opacity | Full color picker |
| Inset toggle | switches between drop shadow and inner shadow |
Multiple shadows: + button adds another box-shadow layer. Each shadow has a remove ×.
Blur effects:
| Toggle | CSS property |
|---|---|
| Layer blur | filter: blur(Xpx) — blurs the element itself |
| Background blur | backdrop-filter: blur(Xpx) — frosted glass effect |
5.7 Section: Typography
Shown when the element's childNodes contains at least one TEXT_NODE (type 3) with non-whitespace content — i.e., the element itself directly contains text, not just nested descendants. Check: Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim().length > 0). This prevents the section from appearing on container divs whose only text is in deep children.
Protocol fields required (add to ELEMENT_STYLES renderer message):
hasDirectText: boolean; // true if el has a direct TEXT_NODE child with content
hasParagraphChildren: boolean; // true if el has at least one direct <p> child
The fiber hook computes these inside the iframe and includes them in every ELEMENT_STYLES response. The canvas reads them from selectedComponentStyles to conditionally show this section and the paragraph-spacing row.
Paragraph spacing patch: Editing the paragraph-spacing input sends a new host message PATCH_CHILDREN_STYLE (add to protocol.ts):
{ type: 'PATCH_CHILDREN_STYLE', parentNodeId: string, selector: 'p', property: 'margin-bottom', value: string }
The fiber hook handles this by querying nodeMap[parentNodeId].fiber.stateNode.querySelectorAll(':scope > p') and calling el.style.setProperty(property, value) on each result.
| Input | CSS property |
|---|---|
| Font family | font-family — searchable dropdown; lists fonts detected in the page |
| Font size | font-size — px / rem |
| Font weight | font-weight — numeric (100–900) or keyword dropdown |
| Line height | line-height — unitless multiplier or px |
| Letter spacing | letter-spacing — px / em |
| Paragraph spacing | margin-bottom on the element's direct <p> children, not on the element itself. When this input is edited, PATCH_ELEMENT_STYLE targets each <p> child of the selected element. If no <p> children exist, the input is hidden. Label: "Paragraph spacing (applies to <p> children)." |
| Text align | text-align — Left / Center / Right / Justify icon buttons |
| Text decoration | text-decoration — None / Underline / Strikethrough |
| Text transform | text-transform — None / Uppercase / Lowercase / Capitalize |
| Color | color — full color picker with token integration |
5.8 Section: Constraints
Visible when position is absolute or fixed.
Horizontal constraint (how the element responds when its parent resizes):
- Left —
left: Xpx, width fixed - Right —
right: Xpx, width fixed - Left + Right — both set (element stretches)
- Center —
left: 50%; transform: translateX(-50%) - Scale — width set as
%
Vertical constraint (same pattern for top / bottom).
Visual: a 3×3 grid of constraint anchor icons (identical to Figma's constraints panel).
5.9 Section: Box Model / Spacing
A visual box model diagram (like browser DevTools) showing:
margin(outer ring) — four values, each editable inlineborder(next ring) — mirrors Stroke section valuespadding(inner ring) — four values, each editable inline- content dimensions (W × H from Frame section)
This is a secondary view of the same values in Frame and Layout — but the visual diagram is faster for spacing adjustments.
5.10 Component vs Raw DOM Element Handling
Detection: A component is "named" when selectedComponentData.name starts with a capital letter AND is not one of the known HOC wrapper names: ForwardRef, Memo, Context.Consumer, Context.Provider, Suspense. For namespaced components (e.g., Form.Item), the full dotted name is used as-is. HOC-wrapped components where the inner component's displayName is set will use the displayName (the fiber serializer already prefers displayName || name). A raw element is when it's a lowercase HTML tag (div, button, span, etc.).
Named component: shows a purple Component badge next to the name in the panel header. All design sections available. When generating an intent, the system checks whether the change maps to a known prop (via AST indexer) and uses type: 'prop' if so, type: 'style' otherwise.
Raw DOM element: shows a grey Element badge. All design sections available. When an intent is confirmed, a soft suggestion appears below the diff: "This is a raw <div>. Consider extracting it to a named component for cleaner intent tracking." Not blocking — the intent is valid either way.
Unknown (no fiber data): Panel shows a "Not inspectable" state with instructions — component may be in a production build or outside the fiber hook's scope.
5.11 Props Tab
Available when:
selectedComponentDataexists and name is capitalized, AND- AST indexer is running AND has an entry for that component name
Displays:
- Each prop as a row: name, type badge, current runtime value, input to override
- Read-only props (no default, required): shown with a lock icon
- Overriding a prop: for isolation artboards, sends
UPDATE_ISOLATION_PROPSimmediately (live re-render). For route artboards, prop overrides are queued astype: 'prop'intent changes — they do NOT update the live component (there is no mechanism to inject new props into a running React tree without a code change). The prop value is shown in the input as a "proposed" value (amber indicator), and the Code tab becomes active to preview the required source change. The UI makes clear this is a code change, not a live preview.
When the indexer is not running, the Props tab shows: "Start originmain dev with the CLI to see prop types."
5.12 Files to Create / Change
| File | Change |
|---|---|
packages/app/src/components/inspector/DesignPanel.tsx |
NEW — main panel with tab bar (Design / Props / Code) |
packages/app/src/components/inspector/sections/FrameSection.tsx |
NEW |
packages/app/src/components/inspector/sections/LayoutSection.tsx |
NEW |
packages/app/src/components/inspector/sections/FillSection.tsx |
NEW |
packages/app/src/components/inspector/sections/StrokeSection.tsx |
NEW |
packages/app/src/components/inspector/sections/EffectsSection.tsx |
NEW |
packages/app/src/components/inspector/sections/TypographySection.tsx |
NEW |
packages/app/src/components/inspector/sections/ConstraintsSection.tsx |
NEW |
packages/app/src/components/inspector/sections/BoxModelSection.tsx |
NEW |
packages/app/src/components/inspector/PropsTab.tsx |
NEW |
packages/app/src/components/inspector/Inspector.tsx |
Replace with <DesignPanel>. Phase 1 integration: DesignPanel.tsx must display call-site info in its header: used in {callSite.fileName}:{callSite.lineNumber} (conditional on selectedComponentData?.callSite). Phase 2 does not require Phase 1 to be merged first — implement the field as optional. |
packages/app/src/store/canvas.ts |
Add selectedElementMode: 'component' | 'element' | null |
packages/renderer/src/protocol.ts |
Add hasDirectText, hasParagraphChildren to ElementStylesMessage; add PATCH_CHILDREN_STYLE host message |
packages/renderer/src/fiber-hook.ts |
Compute hasDirectText / hasParagraphChildren in REQUEST_ELEMENT_STYLES handler; handle PATCH_CHILDREN_STYLE |
6. Phase 3 — CLI AST Indexer
6.1 Index Structure
interface ComponentEntry {
name: string;
definitionFile: string; // absolute path to the file where the component is defined
relativeFile: string; // relative to project root: "src/components/Card.tsx"
lineNumber: number; // line of the export declaration
isDefaultExport: boolean;
props: PropEntry[];
tokensUsed: string[]; // CSS custom properties found in file: ["--color-primary"]
lastIndexed: number; // Date.now()
}
interface PropEntry {
name: string;
type: string; // "string | undefined"
optional: boolean;
// Note: default values out of scope for Phase 3 — Phase 5+
}
6.2 AST Parsing Approach
Use TypeScript's compiler API (ts.createSourceFile()) — parse only, no type-checking.
What to extract per file:
- All named and default exported functions/arrow functions starting with a capital letter. Note: functions exported with capital letters that are not React components (e.g.,
GetUserById,FormatCurrency) will appear in the index. The indexer uses a secondary heuristic to filter them: a function is treated as a component only if its return type or return statement contains JSX (detected by scanning the function body for<followed by a capital letter or known HTML tag). Functions with no JSX in the body are excluded from the component index. - Their first parameter's type annotation (the
Propstype) - All string literals matching
/var\(--[-\w]+\)/gin JSX attributes and template literals. The character class[-\w]covers lowercase, uppercase, digits, underscores, and hyphens — the full valid set for CSS custom property names after--.
What to skip:
- Full type resolution (expensive, requires full
tsconfigprogram) - Module graph traversal (index only direct file exports)
node_modules(skip entirely)- Files > 500KB (skip with a warning log)
Parse-only limitation for imported types: ts.createSourceFile() parses the AST but does not resolve type aliases or imports. A component typed as (props: CardProps) => ... where CardProps is imported will yield a single PropEntry { name: "props", type: "CardProps", optional: false } rather than expanded individual prop names. The Props tab shows this as an opaque type badge with a note: "Type CardProps — expand by running the indexer with --full-types (Phase 5+)." Full type expansion via TypeScript's program API is deferred to Phase 5.
6.3 Local HTTP API
CLI second server on port 4171 (or --index-port N):
GET /components → ComponentEntry[] all indexed components
GET /components?name=Card → ComponentEntry[] fuzzy-matched by name
GET /components?file=src/… → ComponentEntry[] all exports from a file
GET /file?path=src/… → { content: string, lines: number } ← NEW
GET /health → { status, indexed, lastScan, projectRoot }
POST /reindex → triggers full rescan
Security on GET /file:
- Validate the
pathparameter is within the project root: resolve to absolute path first usingpath.resolve(projectRoot, requestedPath), then verifyabsolute === projectRoot || absolute.startsWith(projectRoot + path.sep). The+ path.sepsuffix prevents path prefix confusion (e.g.,/home/user/app-secretsstarting with/home/user/app). - Path resolution order: (1) URL-decode the
pathparameter first (decodeURIComponent), (2) resolve to absolute usingpath.resolve(projectRoot, decoded), (3) verify the resolved absolute path is withinprojectRoot + path.sep. Do NOT do a raw string..check before resolution — URL-encoded traversal (%2F..%2F) bypasses raw string checks. - Only serve
.ts,.tsx,.js,.jsx,.css,.scss,.jsonfiles - Never serve:
.env,.env.*,*.pem,*.key,*.p12,*.pfx,*.jks,*.crt,*.cer,*.der,*.secret,*.secrets, anything in.git/,node_modules/.*/, or any file whose name matches/password|secret|credential|token|private/i. - Log all file-read requests
6.4 File Watching
Use chokidar.watch(projectRoot, { ignored: /node_modules/, persistent: true }) — chokidar is required over Node's built-in fs.watch due to well-documented reliability issues with fs.watch on macOS (missed events, high CPU, broken on network mounts). Add chokidar as a dependency of @originmain/cli. On change or add events for .ts or .tsx files:
- Re-index only that file (incremental update)
- Broadcast
{ type: 'INDEX_UPDATED', file }to any SSE subscribers (the canvas subscribes)
Also watch .css, .scss, .less files for changes. On a CSS file change, re-scan for var(--token) references in any component that imports that CSS file (use the import graph tracked in the index). Broadcast { type: 'INDEX_UPDATED', file, reason: 'css-tokens' } to SSE subscribers.
Full rescan on startup. Full rescan on POST /reindex.
SSE Endpoint for index updates:
GET /events → text/event-stream
Events are newline-delimited JSON: data: {"type":"INDEX_UPDATED","file":"src/components/Card.tsx"}\n\n
Authentication: none required (localhost-only). CORS: Access-Control-Allow-Origin: * (localhost). Reconnect: the canvas uses the browser's native EventSource API, which automatically reconnects on disconnect with exponential backoff. The canvas reconnects every 5s if the CLI restarts. On reconnect, the canvas triggers a full panel re-evaluation (deviation check + indexer health check).
6.5 CLI Command Change
originmain dev --target http://localhost:3000 [--port 4170] [--index-port 4171] [--no-index]
--no-index disables the AST indexer. The canvas degrades gracefully: Props tab hidden, diff generation uses component name only (no prop schema).
6.6 Files to Create / Change
| File | Change |
|---|---|
packages/cli/src/indexer.ts |
NEW — AST walker, component map, file watcher |
packages/cli/src/index-server.ts |
NEW — HTTP API on port 4171, GET /file endpoint |
packages/cli/src/isolation-server.ts |
NEW — serves /__om_isolation__ wrapper pages (Vite: inline HTML; Next.js: writes temporary src/app/__om_isolation__/page.tsx to the user's project) |
packages/cli/src/detect-framework.ts |
NEW — canonical framework detection logic; imported by all CLI modules |
packages/cli/src/cli.ts |
Add --index-port / --no-index / isolation handler |
packages/cli/src/proxy.ts |
Inject window.__OM_INDEX_URL__ and window.__OM_ISO_BASE__ into proxied HTML |
7. Phase 4 — Intent Message & Code Diff Preview
7.1 Full Canvas Flow
1. User selects a component → COMPONENT_SELECTED, ELEMENT_STYLES
2. User adjusts a value in the panel → patchStyleEdit() → PATCH_ELEMENT_STYLE → DOM preview
3. User clicks "Preview Code Change" (button is disabled and shows "CLI indexer required" if the indexer is offline — see §5.1 placeholder behavior) → Code tab becomes active, diff generation begins
4. Canvas fetches file from CLI → GET /file?path=src/components/Card.tsx
5. Canvas generates diff client-side → diffLines(originalContent, patchedContent)
6. Diff viewer shown in Code tab → before/after, line-by-line
7. User inspects / approves
8. User clicks "Send to Agent" → IntentMessage built and sent to Agent Bridge
9. Agent reads, edits file, saves → hot reload fires
10. FIBER_TREE_UPDATE arrives → artboard re-renders with real change
11. Intent status updated to IMPLEMENTED
7.2 Intent Message Format
interface IntentMessage {
intentId: string; // uuid
workspaceId: string;
artboardId: string;
component: {
name: string; // "DashboardCard"
nodeId: string; // fiber path ID
callSite?: string; // "src/app/dashboard/page.tsx:34" (from _debugSource)
definitionFile?: string; // "src/components/DashboardCard.tsx" (from indexer)
definitionLine?: number;
props: Record<string, unknown>; // current runtime props
propsSchema?: PropEntry[]; // from AST indexer
};
changes: IntentChange[];
codeDiff?: {
file: string; // "src/components/DashboardCard.tsx"
originalContent: string; // full file content before change
patchedContent: string; // full file content after predicted change
hunks: DiffHunk[]; // structured diff for display
confidence: 'exact' | 'approximate'; // exact = deterministic CSS; approximate = prop change
};
snapshot?: {
before: string; // base64 PNG at time of selection
after?: string; // base64 PNG after DOM preview (captured on confirm)
};
// Snapshot capture mechanism: `CAPTURE_SNAPSHOT` is NOT sent on every component click — it is
// sent only in two situations: (1) when the user has hovered a component for > 200ms (debounced),
// indicating genuine intent to inspect rather than a rapid traverse; and (2) when the user clicks
// 'Preview Code Change', capturing the after-state 100ms after the last DOM patch settles.
// If a snapshot is already in-flight (SNAPSHOT_READY not yet received), any new CAPTURE_SNAPSHOT
// is queued and the previous in-flight capture is cancelled via a new CANCEL_SNAPSHOT host message.
// Add CAPTURE_SNAPSHOT, CANCEL_SNAPSHOT (host messages) and SNAPSHOT_READY (renderer message)
// to protocol.ts. The fiber hook responds to CAPTURE_SNAPSHOT by running
// html2canvas(el, { useCORS: true, timeout: 3000 }) on the selected element's stateNode, then
// posting SNAPSHOT_READY. On timeout or error, it posts SNAPSHOT_READY with dataUrl: null.
designLanguage?: {
tokensUsed: string[];
palette: Record<string, string>; // token key → resolved value
};
createdAt: string; // ISO 8601
}
// Payload size guidance: `codeDiff.originalContent` and `codeDiff.patchedContent` are full file
// contents and may be large (tens of KB). Before storing in `intent_diffs.payload`, the canvas
// should check: if the combined content exceeds 256KB, upload both files to Supabase Storage
// (`intent-diffs/{intentId}/original.ts` and `patched.ts`) and store only the Storage URLs in
// the payload. The agent receives the Storage URLs and downloads them. The `snapshot.before`/`after`
// base64 PNGs should also be stored in Supabase Storage if they exceed 50KB. Add
// `originalContentUrl?` and `patchedContentUrl?` to the `codeDiff` type as alternatives to
// inline content.
interface IntentChange {
type: 'style' | 'prop' | 'layout' | 'remove';
cssProperty?: string;
propName?: string;
from: unknown;
to: unknown;
tokenKey?: string; // e.g. "--radius-md" if value maps to a token (Phase 6)
confidence: 'exact' | 'approximate';
}
interface DiffHunk {
startLine: number;
lines: DiffLine[];
}
interface DiffLine {
type: 'context' | 'added' | 'removed';
content: string;
lineNumber: number;
// Character/word-level inline change ranges — passed to FileDiff options.inlineChanges: 'character'
inlineChanges?: Array<{ start: number; end: number; type: 'added' | 'removed' }>;
}
Line number convention: DiffHunk.startLine and DiffLine.lineNumber are 1-indexed (line 1 is the first line of the file), matching standard editor conventions and the DiffLineAnnotation.line field accepted by @pierre/diffs.
7.3 Client-side Diff Generation
CSS-only changes (type: style) — deterministic, confidence: exact:
Before searching for CSS property values, the generator first checks for CSS module imports (see CSS module detection paragraph below). If a CSS module is found, the search targets that file. If no CSS module is found, search the .tsx file using the strategies below.
- CSS module detection: Before searching the component's
.tsxfile for the CSS value, the diff generator checks if the component imports a.module.cssor.module.scssfile (by scanning the entire file for any import statement matching/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|less)['"]/). The import binding name is extracted (e.g.,styles,classes,cx) — any binding name is valid. The matched CSS module file path is resolved relative to the.tsxfile's directory. This scan must cover the entire file, not just the first N lines, as imports may appear after JSDoc headers or other imports. If a CSS module import is found, the diff generator fetches that CSS file viaGET /file?path=…and searches it first. If a match is found in the CSS file, the diff targets that file instead. ThecodeDiff.filefield reflects whichever file the change was found in. - Fetch file content from
GET /file?path=… - Parse the file: find all occurrences of the CSS property value being changed. Strategy:
- Look for the property in a
style={}JSX prop CSS property names must be converted to camelCase before searchingstyle={{}}JSX attributes:background-color → backgroundColor,border-radius → borderRadius,font-size → fontSize. Use acssPropertyToCamelCase(prop: string): stringutility indiff-generator.ts. When targeting a CSS module file or astyled/csstemplate literal, search using the original kebab-case form. - Look for the property in a
className→ check the CSS modules file if any - Look for the property in a
styledtemplate literal (CSS-in-JS) - Tailwind detection: if
tailwind.config.*is found, the diff generator does NOT attempt class mapping in Phase 4. Instead, it shows an annotation: "⚠ Tailwind project detected — diff is approximate. Agent will determine the correct class replacement." The full Tailwind class mapping (Phase 4.1) is a separate scope item.
- Look for the property in a
- Replace the matching occurrence with the new value
- Generate a line diff (using
diffnpm package — lightweight, already common) - Confidence is
exactif exactly one match was found;approximateif zero or multiple
File not found (404 / 403): If GET /file returns 404 (component from a library such as @radix-ui, shadcn/ui, etc.) or 403 (blocked by security rules), the Code tab shows: 'Source file not in project — this component may be from a third-party library. You can still send a description to the agent.' All changes for that component are marked confidence: 'approximate'. The 'Send to Agent' button changes to 'Send description only' — the intent is sent without codeDiff, containing only the component name, change description, and runtime props.
Prop changes (type: prop) — confidence: approximate:
- Fetch file content
- Find JSX call sites of the component (using the call site from
_debugSource) - Find the prop in question in the JSX attributes
- Suggest the replacement
- Always
approximate— prop values may be expressions, variables, or spread props
When no match is found:
The diff viewer shows: "Could not locate this property in the source file. The agent will search for it." Confidence is approximate. The intent is still valid — the agent has the component name, file path, and change description.
7.4 Diff Viewer UI (Code Tab)
Package: @pierre/diffs (npm, v1.1.20, Apache 2.0). Install:
npm install @pierre/diffs
Peer dependencies: React ≥ 18.3.1 or 19.x, shiki ^3.0.0.
Imports:
// React components
import { FileDiff, MultiFileDiff, Virtualizer } from '@pierre/diffs/react';
// Core utilities (accept/reject API, theming)
import {
diffAcceptRejectHunk,
resolveTheme,
registerCustomCSSVariableTheme,
} from '@pierre/diffs';
@pierre/diffs manages a Shadow DOM internally for CSS isolation — this is completely transparent to React consumers. FileDiff is a standard React component with no extra setup.
Layout
The Code tab renders a split view (side-by-side) by default — left pane shows the original, right pane shows the patched version. A toggle in the tab header switches to stacked (unified) view for narrower panels. The choice is persisted to localStorage per-user.
The layout prop on FileDiff controls this directly:
<FileDiff
diff={diff}
options={{
layout: splitMode ? 'split' : 'stacked', // 'split' | 'stacked'
}}
/>
┌─────────────────────────────────────────────────────────────────────────┐
│ Code ● src/components/DashboardCard.tsx [Split ↔ Unified] │
├──────────────────────────────┬──────────────────────────────────────────┤
│ BEFORE │ AFTER │
├──────────────────────────────┼──────────────────────────────────────────┤
│ 22 │ borderRadius: '8px', │ 22 │ borderRadius: '12px', │
│ │ ^^^^^^^^ │ │ ^^^^^ │
│ │ (red highlight) │ │ (green highlight — char-level) │
│ 23 │ background: 'var(…)', │ 23 │ background: 'var(…)', │
├──────────────────────────────┴──────────────────────────────────────────┤
│ ⚠ line 22 — Approximate match. Agent may adjust. [Accept] [Reject] │
└─────────────────────────────────────────────────────────────────────────┘
│ [✕ Discard all] [Send to Agent →] │
└─────────────────────────────────────────────────────────────────────────┘
Character/word-level highlighting
@pierre/diffs renders inline change ranges within each modified line via options.inlineChanges. Set this to 'character' for the finest granularity — for a change from '8px' to '12px', only those characters are highlighted rather than the whole line. This is the most important improvement over a naive line-diff: users instantly see exactly what value changed without scanning an entire line.
<FileDiff
diff={diff}
options={{
inlineChanges: 'character', // 'character' | 'word' | 'disabled'
}}
/>
The inlineChanges field on each DiffLine (see §7.2) carries the pre-computed character ranges passed through to FileDiff. The library uses these to render sub-line highlights in its Shadow DOM.
Per-change Accept / Reject
@pierre/diffs exposes diffAcceptRejectHunk() for programmatic accept/reject per hunk. This is a pure function — it takes the current diff and returns a new diff with the hunk state toggled. Wire it to your React state:
import { diffAcceptRejectHunk } from '@pierre/diffs';
// In CodeTab.tsx state:
const [diff, setDiff] = useState<Diff>(initialDiff);
// Accept hunk at index i:
setDiff(prev => diffAcceptRejectHunk(prev, i, 'accept'));
// Reject hunk at index i:
setDiff(prev => diffAcceptRejectHunk(prev, i, 'reject'));
Signatures:
function diffAcceptRejectHunk(
diff: Diff,
hunkIndex: number,
action: 'accept' | 'reject'
): Diff;
Semantic behaviour wired in CodeTab.tsx:
- Accept (default for
confidence: 'exact'hunks) — the hunk is included incodeDiff.patchedContentwhen the intent is sent. - Reject — the hunk is excluded; the original value is kept for that position. Useful when the diff generator found the wrong occurrence.
- When all hunks in a file are rejected, the "Send to Agent" button label changes to "Send description only" — the intent is sent without
codeDiff, falling back to the textual change description.
Inline annotations
@pierre/diffs injects annotations adjacent to diff lines via options.annotations. Each annotation is a DiffLineAnnotation object:
interface DiffLineAnnotation {
line: number; // 1-based line number
side: 'before' | 'after'; // which pane the annotation appears in
message: string; // text to display
type?: 'info' | 'warning' | 'error';
}
Usage in CodeTab.tsx:
const annotations: DiffLineAnnotation[] = codeDiff.hunks.flatMap(hunk => {
const results: DiffLineAnnotation[] = [];
if (hunk.confidence === 'approximate') {
results.push({
line: hunk.afterLine,
side: 'after',
message: '⚠ Approximate — agent will verify this location before applying',
type: 'warning',
});
}
if (hunk.tokenKey) {
results.push({
line: hunk.afterLine,
side: 'after',
message: `✦ Token: ${hunk.tokenKey} — exact match from design language`,
type: 'info',
});
}
return results;
});
<FileDiff diff={diff} options={{ annotations }} />
Rendered output for an approximate hunk:
22 │ borderRadius: '12px',
└─ ⚠ Approximate — agent will verify this location before applying
For a token-mapped hunk (Phase 6):
22 │ borderRadius: 'var(--radius-lg)',
└─ ✦ Token: --radius-lg (12px) — exact match from design language
Token hover callbacks
@pierre/diffs fires onTokenEnter, onTokenLeave, and onTokenClick on syntax tokens as identified by the Shiki highlighter (keywords, string literals, numbers, etc.). Each callback receives a TokenPayload:
interface TokenPayload {
text: string; // the raw token text
line: number; // 1-based line number
column: number; // 0-based character offset
side: 'before' | 'after';
element: Element; // the DOM element inside the Shadow DOM (for positioning)
}
<FileDiff
diff={diff}
options={{
onTokenEnter: (payload) => showTokenPopover(payload),
onTokenLeave: () => hideTokenPopover(),
onTokenClick: (payload) => pinTokenPopover(payload),
}}
/>
Custom range detection required for CSS value semantics: onTokenEnter fires on every Shiki syntax token, not specifically CSS property values. To attach design token metadata, CodeTab.tsx must intercept onTokenEnter and apply additional logic:
- Run a regex over the
payload.textto detect CSS value patterns: color functions (rgb(…),hsl(…), hex), pixel/rem values,var(--token-name)references. - Call
resolveValueToToken(payload.text, designLanguage)(from@originmain/design-language) to check for matches. - If a match is found, show a popover positioned relative to
payload.element(usegetBoundingClientRect()on it — it is a real DOM node). - Popover content when
designLanguageis loaded (Phase 6):- Token match → token name, group, raw value, color swatch for color tokens
- Near-miss → "Not in design language. Nearest:
--radius-md(8px)" var(--token-name)→ resolved value + full token metadata
This is additional logic in CodeTab.tsx — @pierre/diffs fires the event, the component decides what to display.
Visual style options
Use full-width background colors (not +/– symbols) via options.diffIndicators: 'background'. This fills the entire line with a red/green tint, giving a clean aesthetic consistent with the rest of the canvas UI and avoiding terminal-style gutter symbols:
<FileDiff
diff={diff}
options={{
diffIndicators: 'background', // 'background' | 'gutter' | 'both' | 'none'
}}
/>
Theming (Shiki-based)
@pierre/diffs uses Shiki for syntax highlighting. It adapts to any Shiki-compatible theme. Register a CSS-variable-driven theme that tracks the canvas's data-theme attribute:
import { registerCustomCSSVariableTheme } from '@pierre/diffs';
// Call once at app startup (e.g. in a layout component or Zustand initializer)
registerCustomCSSVariableTheme({
name: 'originmain-dark',
// Map Shiki token types to CSS variables defined in globals.css
colorReplacements: {
'#000000': 'var(--syntax-bg)',
'#ffffff': 'var(--syntax-fg)',
// ... map other Shiki palette entries to your design tokens
},
});
Then pass the theme name to FileDiff:
<FileDiff
diff={diff}
options={{
theme: theme === 'dark' ? 'originmain-dark' : 'github-light',
}}
/>
Alternatively, call resolveTheme(themeName) to get the resolved Shiki theme object for programmatic use.
Large-file virtualisation
For files exceeding ~500 lines, wrap FileDiff in Virtualizer to avoid rendering every hunk at once. Virtualizer from @pierre/diffs/react provides windowed rendering with the same API surface:
import { FileDiff, Virtualizer } from '@pierre/diffs/react';
<Virtualizer>
<FileDiff diff={diff} options={options} />
</Virtualizer>
Virtualizer measures its container and renders only the visible hunks. Use it unconditionally in CodeTab.tsx — the overhead when the file is small is negligible, and it prevents layout jank on large components.
Full CodeTab.tsx component sketch
import { useState, useMemo } from 'react';
import { FileDiff, Virtualizer } from '@pierre/diffs/react';
import { diffAcceptRejectHunk } from '@pierre/diffs';
import type { DiffLineAnnotation, TokenPayload } from '@pierre/diffs';
export function CodeTab({ codeDiff, artboardId }: CodeTabProps) {
const [diff, setDiff] = useState(codeDiff.fileDiff);
const [layout, setLayout] = useState<'split' | 'stacked'>(() =>
(localStorage.getItem('diffLayout') as 'split' | 'stacked') ?? 'split'
);
const [popover, setPopover] = useState<TokenPopoverState | null>(null);
const { designLanguage } = useCanvas();
const annotations = useMemo<DiffLineAnnotation[]>(() =>
codeDiff.hunks.flatMap(hunk => buildAnnotations(hunk)),
[codeDiff.hunks]
);
const allRejected = diff.hunks.every(h => h.rejected);
function handleAccept(i: number) {
setDiff(prev => diffAcceptRejectHunk(prev, i, 'accept'));
}
function handleReject(i: number) {
setDiff(prev => diffAcceptRejectHunk(prev, i, 'reject'));
}
function handleTokenEnter(payload: TokenPayload) {
if (!designLanguage) return;
const match = resolveValueToToken(payload.text, designLanguage);
if (match) setPopover({ payload, match });
}
return (
<div className="code-tab">
<CodeTabHeader
filePath={codeDiff.filePath}
layout={layout}
onLayoutToggle={() => {
const next = layout === 'split' ? 'stacked' : 'split';
setLayout(next);
localStorage.setItem('diffLayout', next);
}}
/>
<Virtualizer>
<FileDiff
diff={diff}
options={{
layout,
inlineChanges: 'character',
diffIndicators: 'background',
annotations,
onTokenEnter: handleTokenEnter,
onTokenLeave: () => setPopover(null),
onTokenClick: (p) => setPopover(prev => prev?.payload === p ? null : { payload: p, match: resolveValueToToken(p.text, designLanguage) }),
}}
/>
</Virtualizer>
{/* Per-hunk accept/reject controls rendered outside FileDiff, positioned by line */}
<HunkControls
hunks={codeDiff.hunks}
diff={diff}
onAccept={handleAccept}
onReject={handleReject}
/>
{popover && <TokenPopover state={popover} />}
<CodeTabFooter
allRejected={allRejected}
onSend={() => sendToAgent(diff, codeDiff, artboardId)}
onDiscardAll={() => setDiff(codeDiff.fileDiff)}
/>
</div>
);
}
Line selection
Users can click a line number to select it, drag to select a range, or Shift+click to extend. Selected lines define a partial send: only the selected hunks are included in the intent. This is useful when the diff generator correctly found multiple occurrences but the user only wants to change one.
"Send to Agent" button
Builds the full IntentMessage:
- Captures the after-snapshot (100ms delay for DOM to settle)
- Includes only accepted, non-rejected hunks in
codeDiff.patchedContent - Sets
codeDiff.confidenceto'exact'if all included hunks were accepted exact matches;'approximate'otherwise - POSTs to
/api/intent→ Agent Bridgepush_intent - Button changes to a spinner → "Sent ✓ — waiting for agent" → "✓ Applied" on
IMPLEMENTED
Undo queue: styleEditQueue in Zustand maintains a stack. Cmd+Z pops the last item, sends PATCH_ELEMENT_STYLE with the from value to restore the DOM preview, and clears the Code tab.
7.5 Files to Create / Change
| File | Change |
|---|---|
packages/app/src/lib/diff-generator.ts |
NEW — client-side diff logic (CSS, prop, Tailwind strategies) |
packages/app/src/components/inspector/CodeTab.tsx |
NEW — diff viewer, edit mode, Send to Agent button |
packages/app/src/store/canvas.ts |
Add undoStyleEdit() action; add intentStatus map |
packages/app/src/app/api/intent/route.ts |
NEW — POST endpoint for push_intent (proxies to Agent Bridge) |
packages/app/package.json |
Add "@pierre/diffs": "^1.1.20" and "shiki": "^3.0.0" |
8. Phase 5 — Agent Bridge Enhancements
8.1 New Tool: push_intent
Direction: Canvas → Agent Bridge → coding agent
// Input schema
{
workspace_id: z.string().uuid(),
intent: z.object({
intentId: z.string().uuid(),
component: z.object({
name: z.string(),
definitionFile: z.string().optional(),
definitionLine: z.number().optional(),
callSite: z.string().optional(),
}),
changes: z.array(z.object({
type: z.enum(['style', 'prop', 'layout', 'remove']),
cssProperty: z.string().optional(),
propName: z.string().optional(),
from: z.unknown(),
to: z.unknown(),
tokenKey: z.string().optional(),
confidence: z.enum(['exact', 'approximate']),
})),
codeDiff: z.object({
file: z.string(),
originalContent: z.string(),
patchedContent: z.string(),
confidence: z.enum(['exact', 'approximate']),
}).optional(),
snapshot: z.object({
before: z.string(),
after: z.string().optional(),
}).optional(),
designLanguage: z.object({
tokensUsed: z.array(z.string()),
palette: z.record(z.string()),
}).optional(),
}),
}
What the tool does:
- Stores intent in
intent_diffstable with statusEXPORTED - Broadcasts
INTENT_RECEIVEDover WebSocket to connected agent - Returns
{ intentId, status: 'EXPORTED' }
When codeDiff is present with confidence exact, the agent is instructed to apply the diff verbatim. When approximate, the agent uses the diff as guidance and may refine it.
8.2 New Tool: resolve_component
Direction: Coding agent → Agent Bridge → CLI indexer
// Input
{ component_name: z.string() }
// Output
{
name: string;
definitionFile: string;
relativeFile: string;
lineNumber: number;
props: PropEntry[];
tokensUsed: string[];
}
The Agent Bridge proxies GET /components?name=DashboardCard to the registered CLI indexer URL. Returns 404 if no indexer is registered for the workspace session.
8.3 CLI Indexer Registration
originmain dev starts
→ proxy on :4170
→ indexer on :4171
→ POST {AGENT_BRIDGE_URL}/register-indexer
{ workspaceToken, indexerUrl: "http://localhost:4171", ttl: 60 }
→ Agent Bridge stores { indexerUrl, expiresAt: now + ttl } per workspace session
→ resolve_component and file-fetch proxy to indexerUrl
→ CLI sends a heartbeat POST every 30s to refresh the TTL
→ Agent Bridge removes the registration if no heartbeat for > 90s
Agent Bridge URL: The CLI reads ORIGINMAIN_BRIDGE_URL from environment (set in the workspace's .env.local or ~/.originmain/config.json written by the CLI's originmain login command). Default for self-hosted: http://localhost:4172.
Workspace token: Read from ~/.originmain/config.json (written by originmain login). The config stores { workspaceToken, workspaceId, bridgeUrl }. If no token is found, register-indexer is skipped and the CLI logs "Not logged in — agent bridge integration disabled."
Security: The indexerUrl is localhost-only. The Agent Bridge must reject any indexerUrl that is not localhost or 127.0.0.1 — it must never proxy requests to external URLs.
8.4 Protocol Extension — INTENT_RECEIVED Server Push
// Server → agent over WebSocket (not a JSON-RPC request, no id)
{
type: 'INTENT_RECEIVED',
intent: IntentMessage,
}
Claude Code sessions receive this immediately after push_intent is called.
8.4a Existing intent_diffs Table (Reference)
push_intent writes to this existing table. Schema for reference:
-- Existing table (do not recreate — shown for reference only)
CREATE TABLE intent_diffs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid REFERENCES workspaces(id) NOT NULL,
artboard_id uuid REFERENCES artboards(id),
payload jsonb NOT NULL, -- the full IntentMessage JSON
status text NOT NULL DEFAULT 'DRAFT',
-- DiffStatus enum: 'DRAFT' | 'EXPORTED' | 'IMPLEMENTED' | 'BLOCKED'
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
push_intent inserts with status: 'EXPORTED'.
update_diff_status updates the status column.
get_pending_diffs queries WHERE status = 'EXPORTED'.
8.5 Files to Create / Change
| File | Change |
|---|---|
packages/agent-bridge/src/tools.ts |
Add push_intent, resolve_component |
packages/agent-bridge/src/protocol.ts |
Add IntentMessage, IntentChange, INTENT_RECEIVED |
packages/agent-bridge/src/index.ts |
Handle /register-indexer, store per-workspace indexer URL |
packages/agent-bridge/src/adapters/claude-code.ts |
Document new tools in CLAUDE.md section — must explicitly instruct: "After successfully applying an intent diff, call update_diff_status with the intentId from the INTENT_RECEIVED message and status IMPLEMENTED. If the diff could not be applied, call with status BLOCKED and include a reason." |
packages/app/src/app/api/agent-bridge/route.ts |
Wire push_intent and resolve_component |
packages/cli/src/commands/login.ts |
NEW — OAuth flow; on success writes { workspaceToken, workspaceId, bridgeUrl } to ~/.originmain/config.json |
packages/cli/src/cli.ts |
Register originmain login subcommand |
9. Phase 6 — Design Language System
This is the layer that transforms the design panel from a CSS editor into a design-system-aware tool. Every value input becomes token-aware. Deviations are flagged. The agent writes var(--token) instead of raw values.
9.1 Supported Input Formats
Three formats are accepted. The parser auto-detects the format on upload.
Format A — Style Dictionary (most common)
{
"color": {
"primary": { "value": "#0066FF", "type": "color", "description": "Primary brand blue" },
"surface": { "value": "{color.neutral.50}", "type": "color" }
},
"spacing": {
"xs": { "value": "4px", "type": "spacing" },
"sm": { "value": "8px", "type": "spacing" }
},
"borderRadius": {
"sm": { "value": "4px", "type": "borderRadius" },
"md": { "value": "8px", "type": "borderRadius" },
"lg": { "value": "12px", "type": "borderRadius" }
}
}
References use {group.name} syntax. Resolved recursively.
Format B — W3C Design Token Community Group (DTCG / Tokens Studio)
{
"color": {
"primary": { "$value": "#0066FF", "$type": "color" },
"surface": { "$value": "{color.neutral.50}", "$type": "color" }
}
}
Properties prefixed with $. Same reference syntax as Style Dictionary.
Format C — Flat CSS variable map (simplest)
{
"--color-primary": "#0066FF",
"--color-surface": "#FFFFFF",
"--spacing-xs": "4px",
"--spacing-sm": "8px",
"--radius-md": "8px",
"--shadow-card": "0 4px 8px rgba(0,0,0,0.08)"
}
No nested structure, no references, just CSS custom property name → raw value.
Format detection heuristic:
- Any key starts with
--→ Format C - Any value object has
$valueor$type→ Format B - Any value object has
valueortype→ Format A - None match → show "Unknown format" error with guidance
9.2 Normalized Internal Format
All three formats are parsed into a single normalized list:
type TokenType =
| 'color'
| 'spacing'
| 'sizing'
| 'borderRadius'
| 'borderWidth'
| 'fontFamily'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'shadow'
| 'opacity'
| 'other';
interface DesignToken {
key: string; // CSS custom property: "--color-primary"
name: string; // Human label: "Color / Primary"
group: string; // Top-level group: "color", "spacing", etc.
rawValue: string; // Resolved CSS value: "#0066FF"
type: TokenType;
description?: string;
aliasChain?: string[]; // If resolved from an alias: ["--color-brand-500"]
}
CSS custom property key derivation:
- Format A/B:
{ "color": { "primary": ... } }→--color-primary - Format C: key is already the CSS custom property name (used as-is)
- Nested paths:
{ "color": { "brand": { "500": ... } } }→--color-brand-500 - camelCase segments are converted to kebab-case before joining. Each path segment is passed through
segment.replace(/([A-Z])/g, '-$1').toLowerCase()before joining with-. Examples:"borderRadius"→border-radius,"fontSize"→font-size,"boxShadow"→box-shadow. This ensures{ "borderRadius": { "sm": ... } }→--border-radius-sm, not--borderRadius-sm. Add atoKebabCase(s: string): stringutility topackages/design-language/src/parser.ts.
Human label derivation:
Path segments joined with /: "color" + "primary" → "Color / Primary".
Type inference (for Format C which has no explicit type):
- Value matches
#[0-9a-f]{3,8}orrgb(orhsl(oroklch(→color - Value matches
/^\d+(\.\d+)?(px|rem|em|%)$/→ check key name for context:spacing/size/width/height→ respective type - Value matches key containing
radius→borderRadius - Value matches key containing
shadow→shadow - Value matches
/^\d+(\.\d+)?$/and key containsweight→fontWeight - Value matches key containing
font-familyor value is a quoted string →fontFamily - Otherwise →
other
9.3 Token Resolver
The token resolver is the core utility of @originmain/design-language. Given a raw CSS value and a type, it finds matching tokens.
interface TokenMatch {
token: DesignToken;
exact: boolean; // value matches perfectly
distance: number; // 0 = exact; higher = further from match
}
function resolveValueToToken(
value: string,
type: TokenType,
tokens: DesignToken[]
): TokenMatch | null
Resolution strategy per type:
Color:
- Normalize both the input value and all color tokens to OKLCH (perceptually uniform) using
culori(MIT, 9 KB minzipped):import { oklch, parse, differenceCiede2000 } from 'culori'. Addculorias a dependency ofpackages/design-language/only — do not add it to@originmain/appdirectly. - Compute chroma-weighted L²CH distance
- Distance 0 → exact match; distance < 2 → near match (same hue, slight lightness difference); distance < 8 → approximate; > 8 → no match
- If
valueis alreadyvar(--color-primary), extract the key and do a direct lookup
Spacing / sizing / borderRadius / borderWidth / lineHeight:
- Parse the numeric value and unit from both the input and the token. Normalize rem to px using the actual root font size, not a hardcoded 16px. The root font size is sent from the iframe to the canvas in the
READYmessage asrootFontSizePx: number(add this field to theREADYmessage indom-inspector.ts— read it asparseFloat(getComputedStyle(document.documentElement).fontSize)). The canvas stores it per-artboard in Zustand and passes it to the token resolver. - Exact match: same number, same normalized unit
- Near match: within 1px (rounding from rem)
- No match: any other value
Shadow:
- Parse the shadow into components:
{ x, y, blur, spread, color, inset } - Compare component-by-component: all components must match within tolerance for an exact match
FontFamily: String equality after normalizing quotes and whitespace.
FontWeight:
Normalize keywords to numbers: bold → 700, normal → 400. Exact numeric equality.
FontSize / letterSpacing: Same strategy as spacing (numeric + unit normalization).
9.4 Deviation Detection
For every value shown in the design panel, a deviation indicator is computed:
| State | Indicator | Meaning |
|---|---|---|
| Token match — exact | Green chip showing token name | Value is exactly on-system |
| Token match — near | Amber chip with ~ prefix |
Value is close to a token; snap available |
| No token match | Orange dot next to the input | Value deviates from design system |
| No design language loaded | No indicator | Indicators only appear after upload |
Deviation indicators are computed lazily when the panel renders — resolveValueToToken() runs for each displayed value against the loaded token set.
9.5 Snap to Token
When a near-match amber chip is shown, a Snap button appears. Clicking it:
- Replaces the current value with
token.rawValue - Sends
PATCH_ELEMENT_STYLEimmediately (DOM preview) - Updates
styleEditQueuewith the snapped value - Queues an
IntentChangewithtokenKey: token.keyandconfidence: 'exact' - Chip turns green
9.6 Design Panel Integration
Token chip behavior: Every color, spacing, radius, shadow, and typography input in the design panel is "token-aware". The input component has two modes:
Token mode (when a token match is found):
[ ● Color / Primary ▾ ] ← green/amber chip, click to open picker
Raw mode (click the chip, or when no token match):
[ #0066FF ____________ ] ← standard hex input with orange dot if no match
Toggle between modes with a small T icon button on the input.
Token input component interface:
interface TokenAwareInputProps {
cssProperty: string;
value: string;
tokenType: TokenType;
onChange: (value: string, tokenKey?: string) => void;
}
9.7 Token Picker UI
A popover triggered from any token chip or T icon button:
┌───────────────────────────────────┐
│ 🔍 Search tokens... │
├───────────────────────────────────┤
│ COLOR │
│ ● Primary #0066FF ← active│
│ ● Secondary #7C3AED │
│ ● Surface #FFFFFF │
│ ● Destructive #E53E3E │
├───────────────────────────────────┤
│ SPACING │
│ ▪ xs 4px │
│ ▪ sm 8px │
│ ▪ md 16px │
└───────────────────────────────────┘
- Grouped by token type
- Active token (current value) is highlighted
- Color tokens show a color swatch
- Spacing/radius tokens show a scale bar
- Search filters by name, key, or value
- Click applies immediately (DOM preview) and queues intent
9.8 Upload UX & Validation Flow
Upload surface: Workspace Settings → Design Language section.
Three input methods:
- Drag-and-drop a
.jsonfile onto the upload zone - Paste JSON directly into a text area (for quick testing)
- Fetch from URL — enter a URL that returns a JSON token file. The fetch is proxied server-side through
POST /api/design-language/fetch(a new Next.js API route) to avoid CORS restrictions. The API route validates the URL is HTTPS and not a private IP before fetching. The raw JSON is returned to the client for the standard validation pipeline.
Validation pipeline (runs before saving):
Step 1: JSON parse
→ Failure: "Invalid JSON — check for missing commas or brackets"
Step 2: Format detection
→ Failure: "Unknown format. See supported formats: Style Dictionary, W3C DTCG, flat CSS variable map"
Step 3: Token extraction
→ Failure if zero tokens found: "No recognized tokens found in this file"
→ Partial failure (some tokens failed): log failed tokens, continue with valid ones
Step 4: Alias resolution
→ Detect and reject circular references
→ Failure: "Token `--color-primary` has a circular reference: → `--color-brand` → `--color-primary`"
→ Unresolvable aliases: mark token as invalid, skip it, include in partial failure count
Step 5: Type inference (for Format C)
→ Warn if > 20% of tokens are typed as 'other' (may indicate unexpected format)
Step 6: Preview summary
→ "Found 24 colors, 12 spacing values, 8 radii, 4 shadows, 6 typography tokens"
→ Color swatches preview (first 8 color tokens shown as a swatch strip)
→ Spacing scale preview (first 8 spacing tokens shown as a visual scale)
→ Confirm / Cancel buttons
After confirmation, tokens are saved to Supabase and SET_DESIGN_TOKENS is sent to all live artboards.
9.9 Error Handling Reference
| Error | User-facing message |
|---|---|
| Invalid JSON | "Invalid JSON in this file. Check for missing commas, quotes, or brackets." |
| Unknown format | "We couldn't detect the format. Expected: Style Dictionary, W3C DTCG, or flat CSS variable map ({ \"--token\": \"value\" })." |
| Zero tokens found | "No recognized tokens found. Make sure the file contains token definitions, not component config." |
| Circular reference | "Token {key} has a circular reference: {chain}. Remove the cycle before uploading." |
| File too large (> 2MB) | "This file is larger than 2 MB. Most token files are under 100 KB — check you've uploaded the right file." |
| Network error on URL fetch | "Could not fetch from {url}. Check the URL is accessible and returns JSON." |
| Partial parse | "{N} tokens were imported successfully. {M} tokens were skipped due to unresolvable aliases — they will be listed in the import log." |
9.10 Storage — Supabase Schema
CREATE TABLE design_languages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid REFERENCES workspaces(id) NOT NULL UNIQUE,
name text NOT NULL DEFAULT 'Design Language',
raw_json jsonb NOT NULL, -- original uploaded JSON, unchanged
normalized jsonb NOT NULL, -- DesignToken[] after parsing
source_format text NOT NULL, -- 'style-dictionary' | 'dtcg' | 'flat'
token_count integer NOT NULL,
version integer NOT NULL DEFAULT 1,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Version history: keep the last 10 versions
CREATE TABLE design_language_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
design_language_id uuid REFERENCES design_languages(id) NOT NULL,
version integer NOT NULL,
raw_json jsonb NOT NULL,
normalized jsonb NOT NULL,
source_format text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- Enforce max 10 versions via a trigger that deletes the oldest on insert
CREATE INDEX idx_design_languages_workspace ON design_languages(workspace_id);
-- Enforce max 10 versions per design_language — delete oldest on insert
CREATE OR REPLACE FUNCTION prune_design_language_versions()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM design_language_versions
WHERE design_language_id = NEW.design_language_id
AND id NOT IN (
SELECT id FROM design_language_versions
WHERE design_language_id = NEW.design_language_id
ORDER BY version DESC
LIMIT 9
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_prune_dl_versions
AFTER INSERT ON design_language_versions
FOR EACH ROW EXECUTE FUNCTION prune_design_language_versions();
One design language per workspace (not per project). A workspace has at most one active design language row. On update, the existing row is updated and a version row is inserted.
9.11 Runtime Delivery
On canvas session start:
GET /api/design-language?workspaceId=…→ returnsDesignToken[]from Supabase- Stored in Zustand:
designLanguage: DesignToken[] | null - Applied to all currently mounted artboards (Active and Near state per §3.6 viewport culling): for each mounted artboard, send
SET_DESIGN_TOKENSwith the full token map ({ [token.key]: token.rawValue }). Far artboards (iframe unmounted) receive the tokens on next activation — the canvas sendsSET_DESIGN_TOKENSimmediately after mounting an artboard's iframe during the culling transition (before any other message, includingSELECT_COMPONENT), reading the currentdesignLanguagefrom Zustand at that moment.
On design language update (in settings):
- New tokens saved to Supabase
- Zustand store updated in all open canvas tabs via a Supabase Realtime subscription initialised in
loadDesignLanguage():
supabase
.channel('design_language_updates')
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'design_languages',
filter: `workspace_id=eq.${workspaceId}`,
}, (payload) => {
store.setDesignLanguage(payload.new.normalized as DesignToken[]);
})
.subscribe();
The subscription is torn down (.unsubscribe()) when the workspace changes or the canvas unmounts.
3. SET_DESIGN_TOKENS re-sent to all live artboards
4. Design panel re-runs deviation detection for current selection
5. Toast: "Design language updated — {N} tokens changed"
Token resolver location: Runs entirely in the canvas browser process. No server round-trip for resolution. @originmain/design-language exports resolveValueToToken() as a pure function — fast enough to run synchronously per panel render.
9.12 Version History
In Workspace Settings → Design Language:
- List of up to 10 previous versions with timestamp and token count
- "View diff" between any two versions: the diff is computed client-side by comparing two
DesignToken[]arrays keyed ontoken.key. Added = key present in new version only; removed = key present in old version only; changed = key in both with a differentrawValue. Displayed as a three-section table (Added / Removed / Changed) — no external library needed, this is a simple key-based diff. Color tokens show before/after color swatches inline. Spacing, radius, and shadow tokens show the before/after raw value as plain text. - "Restore" button on any version: re-processes that version's
raw_json, saves as a new version (does not delete current)
9.13 Agent Context
When an IntentMessage is built and designLanguage is loaded in Zustand:
intent.designLanguage.tokensUsed= tokens found in the component's file by the AST indexerintent.designLanguage.palette= all tokens from the design language (key → rawValue)intent.changes[n].tokenKey= the token key if the target value maps to a token
This tells the agent: "instead of writing border-radius: 12px, write border-radius: var(--radius-lg)."
9.14 Files to Create / Change
| File | Change |
|---|---|
packages/renderer/src/protocol.ts |
Add rootFontSizePx: number to ReadyMessage |
packages/renderer/src/fiber-hook.ts |
Emit rootFontSizePx: parseFloat(getComputedStyle(document.documentElement).fontSize) in the READY post |
packages/app/src/store/canvas.ts |
Add artboardRootFontSize: Record<string, number>; populate on READY message receipt |
packages/design-language/src/parser.ts |
NEW — parses all 3 formats into DesignToken[] |
packages/design-language/src/resolver.ts |
NEW — resolveValueToToken(), color distance, unit normalization |
packages/design-language/src/validator.ts |
NEW — alias resolution, circular reference detection |
packages/design-language/src/index.ts |
Export all above |
Note: DesignInput.tsx (raw CSS input, no token awareness) is built in Phase 2. It is upgraded to TokenAwareInput.tsx in Phase 6 when the token resolver is available. Phase 2 does not build TokenAwareInput. |
|
packages/app/src/components/inspector/TokenPicker.tsx |
NEW — token picker popover |
packages/app/src/app/settings/design-language/page.tsx |
NEW — upload UI, validation flow, version history |
packages/app/src/app/api/design-language/route.ts |
NEW — GET (load) / POST (save) |
packages/app/src/app/api/design-language/fetch/route.ts |
NEW — server-side proxy for URL fetch; validates HTTPS + non-private IP |
packages/app/src/store/canvas.ts |
Add designLanguage: DesignToken[] | null, loadDesignLanguage() |
packages/app/src/store/canvas.ts |
Subscribe to Supabase realtime for design language changes |
| Database | Migration: create-design-languages.sql |
Note: DesignInput.tsx (no token awareness) is built in Phase 2 (§5.12). Phase 6 builds TokenAwareInput.tsx as a wrapper around DesignInput that adds the token chip, picker, and deviation indicator. |
10. Complete Data Flow
── DEVELOPMENT MACHINE ────────────────────────────────────────────────────────
User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
:3000 :4170 :4171
│ │ │ │
│ GET /dashboard │ │ │
│◄──────────────────│ │ │
│ HTML + HMR │ │ │
│──────────────────►│ inject │ │
│ │ fiber hook │ │
│ │ + index URL │ │
│ │───────────────────────────────────►│
│ │ │ full-scan on │
│ │ │ startup │
│ │ │◄ ─ ─ ─ ─ ─ ─ ─ ─ │ (canvas subscribes
│ │ │ INDEX_UPDATED │ to SSE)
│ │ │──────────────────►│
│ │ │ │
│ │ │ FIBER_TREE_UPDATE │
│ │◄───────────────────────────────────│
│ │ │ user clicks │
│ │ COMPONENT_SELECTED + ELEMENT_STYLES│
│ │◄───────────────────────────────────│
│ │ │ panel renders, │
│ │ │ deviation check │
│ │ │ against tokens │
│ │ │ │
│ │ │ user adjusts │
│ │ PATCH_ELEMENT_STYLE (DOM preview) │
│ │───────────────────────────────────►│
│ │ │ "Preview Code" │
│ │ │ GET /file?path=… │
│ │ │◄──────────────────│
│ │ │ file content │
│ │ │──────────────────►│
│ │ │ diff generated │
│ │ │ Code tab shows │
│ │ │ diff viewer │
│ │ │ │
│ │ │ "Send to Agent" │
── CLOUD ──────────────────────────────────────────────────────────────────────
│ │ │ IntentMessage │
│ │ │ → push_intent ───►│ Agent Bridge
│ │ │ │ stores intent
│ │ │ │ → INTENT_RECEIVED
│ │ │ │──────────────────►
│ │ │ │ Claude Code
│ │ │ │ resolve_component?
│ │ │ GET /components?name=Card
│ │ │◄─────────────────────────────────────│
│ │ │ ComponentEntry │
│ │ │──────────────────────────────────────►│
│ │ │ │ reads file
│ │ │ │ applies diff
│ │ │ │ saves file
│ hot reload │ │ │
│◄──────────────────│ │ │
│ │ FIBER_TREE_UPDATE (post-edit) │
│ │◄───────────────────────────────────│
│ │ │ update_diff_status(IMPLEMENTED)
│ │ │ │◄─────────────────│
│ │ │ canvas shows ✓ │
11. Implementation Phases — Summary
| Phase | Name | Duration | Depends on | Milestone |
|---|---|---|---|---|
| 0 | Infinite Canvas & Multi-Artboard | 5–7 days | — | Multiple iframes on a pannable/zoomable canvas with device presets |
| 1 | _debugSource Extraction |
1–2 days | — (parallel with 0) | Inspector shows "used in src/app/page.tsx:34" |
| 2 | Figma-style Design Panel | 4–6 days | 0, 1 | Full 8-section panel with token-aware inputs (no tokens yet) |
| 3 | CLI AST Indexer | 3–4 days | — (parallel with 2) | GET /components?name=Card returns file + props; GET /file returns content |
| 4 | Code Diff Preview | 3–4 days | 2, 3 | User sees generated diff before sending; can edit it |
| 5 | Agent Bridge push_intent |
2–3 days | 4 | User sends intent; Claude Code receives it and opens the right file |
| 6 | Design Language System | 5–7 days | 2, 4, 5 | Upload JSON, see token chips in panel, deviation flags, agent writes var(--token) (agent context requires Phase 4 IntentMessage + Phase 5 Agent Bridge) |
| 7 | E2E Validation | 2 days | all | Full flow: select → edit → preview diff → send → agent applies → hot reload confirms |
Total: ~25–33 engineering days across all phases.
12. Resolved Decisions
| Decision | Resolution |
|---|---|
| Diff generation: client-side vs agent | Client-side — fast, works offline, deterministic for CSS changes |
| Canvas layout: auto-arrange vs freeform | Both — auto-arrange by default; user can drag to freeform; "Re-arrange all" to reset |
| Design panel: CSS-first vs props-first | CSS-first (Design tab always available) + Props tab when indexer is running |
| Component isolation artboards | Yes — CLI serves /__om_isolation__ wrapper pages |
| Design language: V1 vs V2 | V1 (Phase 6) — it's what distinguishes this from DevTools; already half-wired in protocol |
| Intent confirm: auto-emit vs explicit | Explicit — "Preview Code Change" then "Send to Agent" — matches git mental model |
| Intent storage | Supabase intent_diffs table — integrates with existing diff lifecycle |
| Snapshot capture | Before on selection, after on confirm (100ms delay), both optional |
_debugSource meaning |
Call site (not definition file) — accurately named callSite; definition file from indexer |
13. Remaining Open Questions
Q1: Tailwind class detection in diff generator
If the user's project uses Tailwind, a border-radius change means removing one class and adding another (e.g., rounded → rounded-xl). This requires Tailwind config awareness. Scope: Phase 4 can ship with a "Tailwind detected — diff is approximate" warning; full Tailwind class mapping is Phase 4.1.
Q2: Multi-component selection
Figma supports selecting multiple elements simultaneously. For the canvas this is complex (each artboard is a separate iframe). Phase 0 ships with single-selection only. Multi-select within one artboard can come later.
Q3: Undo after agent applies
Once the agent edits a file and hot-reload fires, the DOM preview change is "real". Should the canvas offer a "Revert intent" button that tells the agent to undo the edit (git checkout the file)? This requires a new Agent Bridge tool. Deferred to post-Phase 7.
Q4: Design language sync from Figma Variables
Figma's Variables API can export the design language directly. A "Sync from Figma" button would call the Figma API and import variables in DTCG format. This is Phase 8+.
Q5: Component prop defaults in AST indexer
Extracting default prop values requires evaluating expressions. Deferred to Phase 5+; Phase 3 extracts names and types only.
14. Non-Goals for This Iteration
- Vue / Svelte support — fiber hook is React-specific; DOM inspector is universal but no AST indexer equivalent
- Production build support —
_debugSourceand the fiber hook require dev builds - Multi-file edits — one intent = one component change; refactors spanning multiple files are out of scope
- Automatic code apply without agent — canvas describes intent; an agent with file access applies it
- Real-time collaboration — multiple users editing the same canvas simultaneously; out of scope
- Figma import / paste — importing Figma designs as artboards; out of scope