'use client'; import { useState, useCallback, useRef, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useCanvas } from '@/store/canvas'; import { useViewport } from '@/store/viewport'; import { useDiffs } from '@/hooks/useDiffs'; import { LiveArtboard } from './LiveArtboard'; import { IsolationFrame } from './IsolationFrame'; import { SelectionOverlay } from './SelectionOverlay'; import type { FiberNode } from '@originmain/renderer'; interface ArtboardProps { id: string; label: string; x: number; y: number; width: number; height: number; renderUrl?: string; /** Route path appended to renderUrl so each artboard can show a different screen. */ route?: string; /** * Artboard type (spec Phase 0 §3.3). * route — (default) renders a URL in an iframe * isolation — renders a single component via the CLI's /__om_isolation__ page * static — static screenshot; no iframe */ artboard_type?: 'route' | 'isolation' | 'static'; /** Component name for isolation artboards (artboard_type === 'isolation'). */ isolation_component?: string | null; /** Workspace-relative file path for isolation artboards. */ isolation_file?: string | null; /** Current prop overrides forwarded to the isolation iframe. */ isolation_props?: Record | null; /** Called when the live app reports discoverable routes — Canvas handles creation. */ onRoutesDiscovered?: (sourceId: string, routes: Array<{ path: string; label: string }>) => void; /** * Viewport culling classification (spec Phase 0 §3.2). * active — overlaps the current viewport → render full LiveArtboard iframe * near — within 1 viewport margin of the visible area → keep iframe alive * far — beyond the near zone → suspend iframe to save resources */ renderPriority?: 'active' | 'near' | 'far'; } /** Builds the iframe src from a base URL + optional route path. * e.g. ("http://localhost:4170/", "/dashboard") → "http://localhost:4170/dashboard" */ function buildSrc(base: string, route?: string): string { if (!route || route === '/' || route === '') return base; const trimmed = base.replace(/\/$/, ''); const path = route.startsWith('/') ? route : '/' + route; return trimmed + path; } // Status color map matching the inspector const DIFF_STATUS_BADGE: Record = { DRAFT: { color: '#FFBA7B', bg: 'rgba(255,186,123,0.15)', label: 'draft' }, REVIEWED: { color: '#7EB8FF', bg: 'rgba(126,184,255,0.15)', label: 'reviewed' }, APPLIED: { color: '#10B981', bg: 'rgba(16,185,129,0.15)', label: 'applied' }, REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' }, }; export function Artboard({ id, label, x, y, width, height, renderUrl, route, artboard_type = 'route', isolation_component, isolation_file, isolation_props, onRoutesDiscovered, renderPriority = 'active', }: ArtboardProps) { const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags, selectedComponentId, setSelectedArtboardSize, artboardResizeEvent, clearArtboardResize, artboardThumbnails, setArtboardThumbnail, setElementSnapshot, designLanguageTokens, } = useCanvas(); const thumbnailDataUrl = artboardThumbnails[id] ?? null; // Phase 6: convert DesignToken[] → Record CSS var map so that // LiveArtboard can forward them to the iframe via SET_DESIGN_TOKENS on READY // and on every change (including Supabase Realtime updates). const designTokens = designLanguageTokens ? Object.fromEntries(designLanguageTokens.map((t) => [t.key, t.rawValue])) : undefined; const selected = selectedArtboardId === id; // Push dimensions into canvas store when this artboard is selected // so the Toolbar's device preset picker can read them without prop drilling. useEffect(() => { if (selected) setSelectedArtboardSize(width, height); }, [selected, width, height, setSelectedArtboardSize]); const queryClient = useQueryClient(); // Watch for device-preset resize events addressed to this artboard and // perform the PATCH, then clear the event. useEffect(() => { if (!artboardResizeEvent || artboardResizeEvent.artboardId !== id) return; const { width: newW, height: newH } = artboardResizeEvent; clearArtboardResize(); fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata_jsonb: { x, y, width: newW, height: newH, ...(renderUrl ? { renderUrl } : {}), ...(route ? { route } : {}), }, }), }).then(() => { queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }).catch(console.error); }, [artboardResizeEvent, id, x, y, renderUrl, route, workspaceId, projectId, queryClient, clearArtboardResize]); // Diff status badges — fetch is cached by TanStack Query across all artboards const { diffs } = useDiffs(id); // ── Fiber tree (from LiveArtboard) ───────────────────────────────────────── const [localFiberRoot, setLocalFiberRoot] = useState(undefined); // Set true when READY fires but no React commit follows (static HTML page). const [isStaticPage, setIsStaticPage] = useState(false); const handleFiberUpdate = useCallback((root: FiberNode) => { setIsStaticPage(false); // React confirmed — clear any static-page warning setFiberRoot(id, root); setArtboardLive(id, true); setLocalFiberRoot(root); }, [id, setFiberRoot, setArtboardLive]); const handleComponentSelected = useCallback((nodeId: string) => { if (!nodeId) { // Empty nodeId = COMPONENT_DESELECTED — clear selection + styles selectComponent(null, null); setComponentStyles(null); return; } // If the fiber tree hasn't arrived yet, still commit the selection so // REQUEST_ELEMENT_STYLES fires — just pass null for the fiber data. if (!localFiberRoot) { selectComponent(nodeId, null); return; } const node = findFiberNode(localFiberRoot, nodeId); selectComponent(nodeId, node ?? null); }, [localFiberRoot, selectComponent, setComponentStyles]); const handleComponentStylesUpdate = useCallback(( nodeId: string, styles: Record, hasDirectText: boolean, hasParagraphChildren: boolean, ) => { // Guard against stale responses arriving after the user has already clicked // a different component — only apply if artboard and node both still match. const { selectedComponentId: currentId, selectedArtboardId: currentArtboard } = useCanvas.getState(); if (currentArtboard === id && currentId === nodeId) { setComponentStyles(styles); setComponentTextFlags(hasDirectText, hasParagraphChildren); } }, [id, setComponentStyles, setComponentTextFlags]); // ── Drag to reposition ───────────────────────────────────────────────────── const isDragging = useRef(false); const dragStart = useRef({ mouseX: 0, mouseY: 0, artX: 0, artY: 0 }); // dragOffsetRef carries the live offset into the onUp closure, avoiding the // stale-state problem that occurs when useCallback captures dragOffset before // any mousemove events have fired. const dragOffsetRef = useRef({ dx: 0, dy: 0 }); const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 }); const onLabelMouseDown = useCallback((e: React.MouseEvent) => { // Only drag on left button; don't interfere with rename if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); isDragging.current = true; dragOffsetRef.current = { dx: 0, dy: 0 }; const { zoom, panX, panY } = useViewport.getState(); dragStart.current = { mouseX: (e.clientX - panX) / zoom, mouseY: (e.clientY - panY) / zoom, artX: x, artY: y, }; setDragOffset({ dx: 0, dy: 0 }); const onMove = (mv: MouseEvent) => { if (!isDragging.current) return; const { zoom: z, panX: px, panY: py } = useViewport.getState(); const curX = (mv.clientX - px) / z; const curY = (mv.clientY - py) / z; const offset = { dx: curX - dragStart.current.mouseX, dy: curY - dragStart.current.mouseY, }; // Update the ref first so onUp always reads the final position. dragOffsetRef.current = offset; setDragOffset(offset); }; const onUp = () => { if (!isDragging.current) return; isDragging.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); // Read the final offset from the ref — never stale regardless of when // useCallback last reconstructed onLabelMouseDown. const newX = Math.round(dragStart.current.artX + dragOffsetRef.current.dx); const newY = Math.round(dragStart.current.artY + dragOffsetRef.current.dy); // Phase 0 spec §4.3: mark as manually positioned when the drag exceeds // 10 world-space px so auto-arrange doesn't overwrite user layout. const dragDist = Math.sqrt( dragOffsetRef.current.dx ** 2 + dragOffsetRef.current.dy ** 2, ); const wasIntentionalDrag = dragDist >= 10; // Persist position fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, // H-6 fix: `route` was omitted from the PATCH body, so every drag // silently reset the artboard's SPA route back to undefined (root '/'). body: JSON.stringify({ metadata_jsonb: { x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}), ...(route ? { route } : {}), ...(wasIntentionalDrag ? { manuallyPositioned: true } : {}), }, }), }).then(() => { queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); setDragOffset({ dx: 0, dy: 0 }); dragOffsetRef.current = { dx: 0, dy: 0 }; }).catch(console.error); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }, [id, x, y, width, height, renderUrl, workspaceId, projectId, queryClient]); // ── Inline rename ────────────────────────────────────────────────────────── const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(label); const renameRef = useRef(null); const startRename = useCallback((e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); setRenameValue(label); setRenaming(true); setTimeout(() => renameRef.current?.select(), 0); }, [label]); const commitRename = useCallback(() => { setRenaming(false); const trimmed = renameValue.trim(); if (!trimmed || trimmed === label) return; fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: trimmed }), }).then(() => { queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }).catch(console.error); }, [id, renameValue, label, workspaceId, projectId, queryClient]); // ── Delete artboard ──────────────────────────────────────────────────────── const deleteArtboard = useCallback(() => { if (!window.confirm(`Delete "${label}"? This cannot be undone.`)) return; fetch(`/api/artboards/${id}`, { method: 'DELETE' }) .then(() => { selectArtboard(null); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); }) .catch(console.error); }, [id, label, selectArtboard, workspaceId, projectId, queryClient]); const effectiveX = x + (isDragging.current ? dragOffset.dx : 0); const effectiveY = y + (isDragging.current ? dragOffset.dy : 0); return (
e.stopPropagation()} onClick={(e) => { e.stopPropagation(); selectArtboard(id); }} > {/* Label / drag handle */}
{renaming ? ( setRenameValue(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setRenaming(false); e.stopPropagation(); }} onClick={(e) => e.stopPropagation()} style={{ fontSize: 11, fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace", fontWeight: 500, color: '#3385FF', background: 'rgba(51,133,255,0.12)', border: '1px solid rgba(51,133,255,0.4)', borderRadius: 3, padding: '1px 6px', outline: 'none', width: Math.max(80, label.length * 7), }} /> ) : ( <> {label} {/* Delete button — only visible when selected */} {selected && ( )} )}
{/* Frame */}
{/* Selection corner handles */} {selected && ( <> )} {/* Diff status badge strip — bottom-right overlay, only visible when diffs exist */} {diffs.length > 0 && (() => { // Group by status and show compact chips const counts: Record = {}; for (const d of diffs) counts[d.status] = (counts[d.status] ?? 0) + 1; const entries = Object.entries(counts).filter(([s]) => s in DIFF_STATUS_BADGE); if (entries.length === 0) return null; return (
{entries.map(([status, count]) => { const b = DIFF_STATUS_BADGE[status]!; return ( {count} ); })}
); })()} {/* Content */} {/* Isolation artboard — renders a single component via the CLI proxy */} {artboard_type === 'isolation' && renderUrl && isolation_component && isolation_file ? ( ) : renderUrl ? ( <> {renderPriority !== 'far' ? ( <> { setArtboardLive(id, true); setIsStaticPage(false); }} onFiberTreeUpdate={handleFiberUpdate} onComponentSelected={handleComponentSelected} onComponentStylesUpdate={handleComponentStylesUpdate} onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)} onStaticPageDetected={() => setIsStaticPage(true)} onThumbnailReady={(dataUrl) => { // 1. Store data URL in Zustand for immediate in-session display setArtboardThumbnail(id, dataUrl); // 2. Upload to Supabase Storage in the background (non-blocking). // Spec §3.6: only the public Storage URL is persisted in the DB; // data URIs are session-only. if (dataUrl && workspaceId) { void fetch('/api/artboards/thumbnail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ artboardId: id, workspaceId, dataUrl }), }).catch(() => { /* upload failure is non-fatal */ }); } }} onSnapshotReady={(dataUrl, nodeId) => setElementSnapshot(id, nodeId, dataUrl)} /> {/* Static-page banner — shown when the proxy serves a non-React page */} {isStaticPage && (
⚠️ Static HTML page — no React components detected. Navigate to a React route to enable inspection.
)} { if (sel) selectArtboard(id); handleComponentSelected(sel?.nodeId ?? ''); }} /> ) : ( /* Off-screen placeholder — iframe unmounted to save resources. * Displays the last JPEG thumbnail captured before suspension. */
{thumbnailDataUrl ? ( /* eslint-disable-next-line @next/next/no-img-element */ ) : (
off-screen
)}
)} ) : ( )}
); } // ── Empty artboard (no renderUrl) ───────────────────────────────────────────── function EmptyArtboardContent({ id, label, width, height, workspaceId, projectId, queryClient, }: { id: string; label: string; width: number; height: number; workspaceId: string | null; projectId: string | null; queryClient: ReturnType; }) { const [editing, setEditing] = useState(false); const [urlValue, setUrlValue] = useState(''); const [saving, setSaving] = useState(false); const save = async () => { const url = urlValue.trim(); if (!url) return; setSaving(true); await fetch(`/api/artboards/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ metadata_jsonb: { renderUrl: url, width, height, x: 0, y: 0 }, }), }).catch(console.error); queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); setSaving(false); setEditing(false); }; const hintStyle: React.CSSProperties = { margin: 0, fontSize: 10, color: '#A1A1AA', textAlign: 'center', lineHeight: 1.55, fontFamily: "'JetBrains Mono', ui-monospace, monospace", letterSpacing: '-0.01em', }; return (
{/* Artboard name */}
{label}
{editing ? (
setUrlValue(e.target.value)} placeholder="http://localhost:4170" onKeyDown={(e) => { if (e.key === 'Enter') void save(); if (e.key === 'Escape') setEditing(false); e.stopPropagation(); }} style={{ padding: '7px 10px', borderRadius: 6, border: '1.5px solid #0066FF', fontSize: 11, fontFamily: 'inherit', width: '100%', boxSizing: 'border-box' as const, color: '#0A0A0A', background: '#fff', }} />

Use the CLI proxy URL or a preview deployment URL with @originmain/live installed

) : ( <> {/* Icon */}

Connect your running app to enable live component inspection

npx @originmain/cli dev --target :3000

)}
); } // ── Helpers ─────────────────────────────────────────────────────────────────── function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null { if (root.id === nodeId) return root; if (!root.children) return null; for (const child of root.children) { const found = findFiberNode(child, nodeId); if (found) return found; } return null; } function Handle({ pos }: { pos: React.CSSProperties }) { return (
); }