From edada3091e8e799eee53c41dac14f60efb521fe2 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Thu, 30 Apr 2026 22:04:37 +0100 Subject: [PATCH] feat: Design tab, live style editing, route-aware artboards, canvas onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design tab (4-tab inspector: Design / Props / Diff / Graph) - Click any component in a live artboard to inspect computed CSS - Typography, Layout, Visual sections auto-populated from getComputedStyle - Every property is inline-editable; changes apply live via PATCH_ELEMENT_STYLE - Zero source-code modifications — purely in-browser style overrides Protocol additions (packages/renderer) - REQUEST_ELEMENT_STYLES / ELEMENT_STYLES round-trip for CSS inspection - PATCH_ELEMENT_STYLE for live style patching Live SDK (packages/live-sdk) - respondWithStyles(): reads 30 curated computed CSS properties - patchElementStyle(): applies inline style override to fiber's DOM element Canvas store - selectedComponentStyles: current element's CSS map - styleEditEvent mailbox: Design tab → LiveArtboard style dispatch (Zustand v5 compatible) Route-aware artboards - Each artboard now has an optional route (e.g. /dashboard) - buildSrc(base, route) helper in Artboard.tsx - Route field editable in Inspector Props tab, persisted to metadata_jsonb On-canvas onboarding - Empty canvas now shows 3-step guide: press A → CLI command → paste URL - CLI command shown inline in a highlighted code block Changelog and Handoff updated Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 49 +++ HANDOFF.md | 43 ++- .../app/src/components/canvas/Artboard.tsx | 33 +- packages/app/src/components/canvas/Canvas.tsx | 82 ++++- .../src/components/canvas/LiveArtboard.tsx | 30 +- .../src/components/inspector/Inspector.tsx | 347 +++++++++++++++++- packages/app/src/hooks/useArtboards.ts | 3 + packages/app/src/store/canvas.ts | 25 +- packages/live-sdk/src/hook.ts | 81 +++- packages/renderer/src/protocol.ts | 11 +- 10 files changed, 669 insertions(+), 35 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6285e42 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to Originmain are documented here. +Format: [version] — date — summary + +--- + +## [Unreleased] + +### Added +- **Design tab** — New first-position inspector tab (Design / Props / Diff / Graph). When a component is selected in a live artboard, computed CSS properties appear grouped into Typography, Layout, and Visual sections. Every property is inline-editable: changes apply immediately to the live iframe with zero source-code modifications. +- **Live style patching** — `PATCH_ELEMENT_STYLE` protocol message applies an inline-style override to a fiber node's DOM element in real time. Non-destructive — source files are unchanged; changes are exportable as diffs. +- **Element style inspection** — `REQUEST_ELEMENT_STYLES` / `ELEMENT_STYLES` protocol round-trip: the CLI proxy hook reads `window.getComputedStyle()` for ~30 curated CSS properties (typography, layout, visual) and posts them back to the host. +- **Route-aware artboards** — Each artboard now has an optional `route` field (e.g. `/dashboard`). The iframe src becomes `baseUrl + route`, letting designers have one artboard per screen with a single shared proxy URL. Route is editable inline in the Props tab. +- **Multi-screen workflow** — Create one artboard per page, assign routes, get a full-app design view on the canvas. The `CanvasArtboard` type and `useArtboards` hook now surface the `route` field. +- **On-canvas onboarding** — The empty canvas now shows a 3-step guide (Press A → run CLI → paste proxy URL) including the exact CLI command, replacing the single-line hint. +- **@originmain/cli v0.0.3** — Published to npm. README covers quick start, CLI flags, programmatic API (`startProxy`, `injectFiberHook`), and architecture notes. +- **Artboard creation fixed** — Removed the `e.target === e.currentTarget` guard that permanently broke artboard creation because the canvas transform layer always intercepts pointer events. +- **Artboard drag fixed** — Stale closure bug in `onUp`: final drag position now reads from a mutable ref (`dragOffsetRef`) rather than captured state, so artboards land where the user dropped them. + +### Changed +- Inspector default tab changed from **Props** to **Design** — mirrors Figma's inspect-first workflow. +- `selectComponent` store action now clears `selectedComponentStyles` on every selection change so stale data never bleeds into a new selection. +- `selectArtboard` store action clears both component selection and styles. +- Tab order: **Design → Props → Diff → Graph** (was Props → Diff → Graph). + +### Fixed +- HTTP 204 with body in `/api/design-language` route (RFC 7230 §3.3 violation) — now returns 200 with `null` body. +- `tools/list` MCP endpoint now includes `inputSchema` per spec so IDE clients can construct valid calls without out-of-band documentation. +- `ProjectSettingsForm` role check used the wrong constant (`'DEVELOPER'` → `'ENGINEER'`); delete-confirm compared against mutable `name` state instead of the original `initialName`. +- Removed dead `INJECT_FIBER_HOOK` message type from the renderer protocol. + +--- + +## [0.0.3] — 2026-04-28 +- CLI proxy published to npm as `@originmain/cli` +- README added covering quick start, CLI flags, programmatic API + +## [0.0.2] — 2026-04-27 +- Live rendering foundation: fiber hook injection, X-Frame-Options stripping, WebSocket passthrough +- Canvas artboard system: create, drag, rename, delete +- Inspector: Props / Diff / Graph tabs +- Live artboard iframe with bidirectional postMessage protocol +- Click-to-select components in live render with blue highlight ring +- Design token injection via CSS custom properties +- Drift report generation via AI layer +- Diff export with AI-generated summaries +- MCP agent bridge with JSON-RPC 2.0 workspace token auth +- Origin Graph query tab in inspector diff --git a/HANDOFF.md b/HANDOFF.md index 41a81b7..784788d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -427,4 +427,45 @@ The original Layer 2 rendering system used direct cross-origin iframe script inj - [ ] **GitHub App + deployment_status webhook**: Full OAuth flow to connect GitHub repos + auto-populate `deploymentUrl` from Vercel/Netlify deployment webhooks - [ ] **Marketing page — remaining `section-dark` / `--bg-inv` surfaces**: other sections using `background: var(--bg-inv)` (e.g. `#features-alt`, footer) should be audited for the same light-mode-in-dark-mode inversion issue if the marketing page is expected to fully support theme toggling -*Last updated: 2026-04-29 — Session 7 complete* +### ✅ Completed — Session 8 (2026-04-30) + +#### Canvas artboard creation fixed +- **Bug**: `e.target === e.currentTarget` guard in `Canvas.tsx` was always false because the canvas has a full-size `position:absolute; inset:0; z-index:2` transform layer that intercepts all pointer events. Pressing A + clicking the canvas never created an artboard. +- **Fix**: Removed the guard. Added `onMouseDown={(e) => e.stopPropagation()}` to the artboard root div so clicks on existing artboards don't bubble up and trigger creation. + +#### Artboard drag stale-closure fix +- **Bug**: `onUp` closed over `dragOffset` state from `useCallback` creation time (always `{dx:0,dy:0}`). Artboards snapped back to original position after drag. +- **Fix**: Added `dragOffsetRef` mutable ref. `onMove` updates both state and ref; `onUp` reads only from ref. + +#### Design tab (live CSS inspector + editor) +- **`packages/renderer/src/protocol.ts`** — Two new message types: + - `REQUEST_ELEMENT_STYLES { nodeId }` (Host → Renderer) + - `ELEMENT_STYLES { nodeId, styles }` (Renderer → Host) + - `PATCH_ELEMENT_STYLE { nodeId, property, value }` (Host → Renderer) +- **`packages/live-sdk/src/hook.ts`** — Three new handlers: + - `REQUEST_ELEMENT_STYLES` → reads `window.getComputedStyle(el)` for ~30 curated properties → posts `ELEMENT_STYLES` + - `PATCH_ELEMENT_STYLE` → calls `el.style.setProperty(property, value)` on the fiber's DOM element +- **`packages/app/src/store/canvas.ts`** — Added `selectedComponentStyles`, `setComponentStyles`, `styleEditEvent` mailbox, `patchStyleEdit`, `clearStyleEdit`. +- **`packages/app/src/components/canvas/LiveArtboard.tsx`** — Sends `REQUEST_ELEMENT_STYLES` immediately after `COMPONENT_SELECTED`. Handles `ELEMENT_STYLES` response. Watches Zustand `styleEditEvent` and forwards `PATCH_ELEMENT_STYLE` to the iframe. +- **`packages/app/src/components/canvas/Artboard.tsx`** — Wires `onComponentStylesUpdate` → `setComponentStyles`; handles deselect with styles clear. +- **`packages/app/src/components/inspector/Inspector.tsx`** — **DESIGN** tab added as first tab. `DesignTab` component shows Typography, Layout, Visual sections. Every property is inline-editable — changes send `PATCH_ELEMENT_STYLE` via the Zustand mailbox for immediate live preview. +- **Inspector tab order**: Design → Props → Diff → Graph (was Props → Diff → Graph). +- **Default tab**: Design (was Props). + +#### Route-aware artboards (multi-screen workflow) +- **`packages/app/src/hooks/useArtboards.ts`** — `CanvasArtboard` gets `route?: string`; `toCanvasArtboard` extracts `metadata_jsonb.route`. +- **`packages/app/src/components/canvas/Artboard.tsx`** — `route` prop; `buildSrc(base, route)` helper combines base URL + path. Artboards on the same proxy now show different pages. +- **`packages/app/src/components/inspector/Inspector.tsx`** — `route` field in PropsTab Render Target section; editable inline; saved to `metadata_jsonb.route`. `reservedKeys` set expanded to exclude `route` from "extra props" section. + +#### On-canvas onboarding +- **`packages/app/src/components/canvas/Canvas.tsx`** — Empty canvas now shows a 3-step guide: ① Press A + click, ② CLI command (with copy-ready code block), ③ Paste proxy URL. Replaces the single-line hint. + +#### CLI published +- `@originmain/cli@0.0.3` on npm. README covers quick start, flags, programmatic API, how it works. + +#### Bug fixes (prior sessions) +- HTTP 204 with body in `GET /api/design-language` (RFC 7230 §3.3 violation) +- `tools/list` MCP endpoint missing `inputSchema` (breaks IDE clients) +- `ProjectSettingsForm` wrong role constant + wrong delete-confirm comparison target + +*Last updated: 2026-04-30 — Session 8 complete* diff --git a/packages/app/src/components/canvas/Artboard.tsx b/packages/app/src/components/canvas/Artboard.tsx index 2e5f3d7..1689093 100644 --- a/packages/app/src/components/canvas/Artboard.tsx +++ b/packages/app/src/components/canvas/Artboard.tsx @@ -17,6 +17,17 @@ interface ArtboardProps { width: number; height: number; renderUrl?: string; + /** Route path appended to renderUrl so each artboard can show a different screen. */ + route?: string; +} + +/** 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 @@ -27,10 +38,10 @@ const DIFF_STATUS_BADGE: Record { + if (!nodeId) { + // Empty nodeId = COMPONENT_DESELECTED — clear selection + styles + selectComponent(null, null); + setComponentStyles(null); + return; + } if (!localFiberRoot) return; const node = findFiberNode(localFiberRoot, nodeId); selectComponent(nodeId, node ?? null); - }, [localFiberRoot, selectComponent]); + }, [localFiberRoot, selectComponent, setComponentStyles]); + + const handleComponentStylesUpdate = useCallback((_nodeId: string, styles: Record) => { + // Only update styles if this artboard is the one currently selected + if (selectedArtboardId === id) { + setComponentStyles(styles); + } + }, [id, selectedArtboardId, setComponentStyles]); // ── Drag to reposition ───────────────────────────────────────────────────── const isDragging = useRef(false); @@ -318,12 +342,13 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard <> )} - {/* Empty canvas hint — shown only when workspace has no artboards yet */} + {/* Empty canvas onboarding — shown only when the project has no artboards yet */} {artboards.length === 0 && (
- - - - - + {/* Icon */} + + + + + - - Press A to create an artboard - + +
+
+ Get started +
+
+ Live render your running app into design frames +
+
+ + {/* Steps */} +
+ {[ + { n: '1', text: 'Press A and click the canvas to place a screen' }, + { n: '2', text: 'Start the CLI proxy pointing at your dev server', code: 'npx @originmain/cli dev --target http://localhost:3000' }, + { n: '3', text: "Paste the proxy URL into the screen's Connect field" }, + ].map(({ n, text, code }) => ( +
+
+ {n} +
+
+
+ {text} +
+ {code && ( +
+ {code} +
+ )} +
+
+ ))} +
)} diff --git a/packages/app/src/components/canvas/LiveArtboard.tsx b/packages/app/src/components/canvas/LiveArtboard.tsx index fb56438..f943e31 100644 --- a/packages/app/src/components/canvas/LiveArtboard.tsx +++ b/packages/app/src/components/canvas/LiveArtboard.tsx @@ -6,6 +6,7 @@ import { isRendererEnvelope, } from '@originmain/renderer'; import type { FiberNode, RendererMessage } from '@originmain/renderer'; +import { useCanvas } from '@/store/canvas'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -26,6 +27,10 @@ export interface LiveArtboardProps { onReady?: () => void; onFiberTreeUpdate?: (root: FiberNode) => void; onComponentSelected?: (nodeId: string) => void; + /** Called when the iframe responds with computed CSS properties for a selected element. */ + onComponentStylesUpdate?: (nodeId: string, styles: Record) => void; + /** Forwards a CSS property patch from the Design tab into the iframe. */ + patchElementStyle?: (nodeId: string, property: string, value: string) => void; style?: React.CSSProperties; } @@ -41,6 +46,7 @@ export function LiveArtboard({ onReady, onFiberTreeUpdate, onComponentSelected, + onComponentStylesUpdate, style, }: LiveArtboardProps) { const iframeRef = useRef(null); @@ -91,17 +97,22 @@ export function LiveArtboard({ break; case 'COMPONENT_SELECTED': onComponentSelected?.(msg.nodeId); + // Request computed styles so the Design tab can populate immediately. + sendMessage('REQUEST_ELEMENT_STYLES', { nodeId: msg.nodeId }); break; case 'COMPONENT_DESELECTED': // Renderer clicked empty space — clear the host-side selection. onComponentSelected?.(''); break; + case 'ELEMENT_STYLES': + onComponentStylesUpdate?.(msg.nodeId, msg.styles); + break; } } window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]); + }, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate]); // ── Push updated design tokens whenever they change ─────────────────────── useEffect(() => { @@ -109,6 +120,23 @@ export function LiveArtboard({ sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); }, [designTokens, sendMessage]); + // ── Style edit mailbox ───────────────────────────────────────────────────── + // Watches the Zustand mailbox for PATCH_ELEMENT_STYLE events addressed to + // this artboard and forwards them to the iframe immediately. + const styleEditEvent = useCanvas((s) => s.styleEditEvent); + const clearStyleEdit = useCanvas((s) => s.clearStyleEdit); + + useEffect(() => { + if (styleEditEvent?.artboardId === id && isReadyRef.current) { + sendMessage('PATCH_ELEMENT_STYLE', { + nodeId: styleEditEvent.nodeId, + property: styleEditEvent.property, + value: styleEditEvent.value, + }); + clearStyleEdit(); + } + }, [id, styleEditEvent, sendMessage, clearStyleEdit]); + // ── Sync selection changes into the iframe ──────────────────────────────── // Sends SELECT_COMPONENT on every selectedComponentId change so the blue // highlight ring stays in sync with the canvas selection store. diff --git a/packages/app/src/components/inspector/Inspector.tsx b/packages/app/src/components/inspector/Inspector.tsx index 4f64246..4f4c5aa 100644 --- a/packages/app/src/components/inspector/Inspector.tsx +++ b/packages/app/src/components/inspector/Inspector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { useCanvas } from '@/store/canvas'; import { useHistory } from '@/store/history'; import { useArtboards, patchArtboard } from '@/hooks/useArtboards'; @@ -17,12 +17,12 @@ const TYPE_COLORS: Record = { b: '#FFBA7B', }; -type TabId = 'props' | 'diff' | 'graph'; +type TabId = 'design' | 'props' | 'diff' | 'graph'; export function Inspector() { const T = useCanvasTheme(); - const { selectedArtboardId, liveArtboardIds, artboardFiberRoots, selectedComponentId, selectedComponentData, workspaceId, projectId } = useCanvas(); - const [tab, setTab] = useState('props'); + const { selectedArtboardId, liveArtboardIds, artboardFiberRoots, selectedComponentId, selectedComponentData, selectedComponentStyles, workspaceId, projectId } = useCanvas(); + const [tab, setTab] = useState('design'); const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null; const isLive = selectedArtboardId ? liveArtboardIds.has(selectedArtboardId) : false; @@ -44,7 +44,7 @@ export function Inspector() { > {/* Tab bar */}
- {(['props', 'diff', 'graph'] as TabId[]).map((t) => ( + {(['design', 'props', 'diff', 'graph'] as TabId[]).map((t) => (
+ ) : tab === 'design' ? ( + ) : tab === 'props' ? ( ; +}> = [ + { + label: 'Typography', + props: [ + { key: 'font-family', label: 'Family', type: 'text' }, + { key: 'font-size', label: 'Size', type: 'text' }, + { key: 'font-weight', label: 'Weight', type: 'text' }, + { key: 'line-height', label: 'Line H.', type: 'text' }, + { key: 'letter-spacing', label: 'Tracking', type: 'text' }, + { key: 'color', label: 'Color', type: 'color' }, + { key: 'text-align', label: 'Align', type: 'text' }, + { key: 'text-transform', label: 'Transform',type: 'text' }, + ], + }, + { + label: 'Layout', + props: [ + { key: 'display', label: 'Display', type: 'text' }, + { key: 'width', label: 'Width', type: 'text' }, + { key: 'height', label: 'Height', type: 'text' }, + { key: 'padding-top', label: 'Pad↑', type: 'text' }, + { key: 'padding-bottom', label: 'Pad↓', type: 'text' }, + { key: 'padding-left', label: 'Pad←', type: 'text' }, + { key: 'padding-right', label: 'Pad→', type: 'text' }, + { key: 'margin-top', label: 'Mar↑', type: 'text' }, + { key: 'margin-bottom', label: 'Mar↓', type: 'text' }, + { key: 'gap', label: 'Gap', type: 'text' }, + { key: 'flex-direction', label: 'Direction',type: 'text' }, + { key: 'align-items', label: 'Align', type: 'text' }, + { key: 'justify-content', label: 'Justify', type: 'text' }, + ], + }, + { + label: 'Visual', + props: [ + { key: 'background-color', label: 'Fill', type: 'color' }, + { key: 'border-radius', label: 'Radius', type: 'text' }, + { key: 'opacity', label: 'Opacity', type: 'number' }, + { key: 'border-width', label: 'Border W',type: 'text' }, + { key: 'border-color', label: 'Border C',type: 'color' }, + { key: 'border-style', label: 'Border S',type: 'text' }, + { key: 'box-shadow', label: 'Shadow', type: 'text' }, + ], + }, +]; + +/** Converts a computed rgb()/rgba() string into a hex-like color for the picker. */ +function rgbToHex(rgb: string): string { + const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!m) return '#000000'; + const r = parseInt(m[1] ?? '0').toString(16).padStart(2, '0'); + const g = parseInt(m[2] ?? '0').toString(16).padStart(2, '0'); + const b = parseInt(m[3] ?? '0').toString(16).padStart(2, '0'); + return `#${r}${g}${b}`; +} + +function DesignTab({ + artboardId, + componentId, + styles, +}: { + artboardId: string | null; + componentId: string | null; + styles: Record | null; +}) { + const T = useCanvasTheme(); + const { patchStyleEdit } = useCanvas(); + + if (!artboardId) { + return ( +
+ + Select an artboard + +
+ ); + } + + if (!componentId) { + return ( +
+ + + + + + Click a component in the
artboard to inspect & edit +
+
+ ); + } + + if (!styles) { + return ( +
+ + Fetching styles… + +
+ ); + } + + const patch = (property: string, value: string) => { + if (!artboardId || !componentId) return; + patchStyleEdit(artboardId, componentId, property, value); + }; + + return ( + <> + {DESIGN_SECTIONS.map((section) => { + // Only render sections that have at least one property present + const populated = section.props.filter((p) => styles[p.key]); + if (populated.length === 0) return null; + return ( +
+
+ {populated.map((p) => ( + + ))} +
+ +
+ ); + })} + + ); +} + +function DesignRow({ + label, + propKey, + value, + type, + onPatch, +}: { + label: string; + propKey: string; + value: string; + type: 'color' | 'text' | 'number'; + onPatch: (property: string, value: string) => void; +}) { + const T = useCanvasTheme(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + + // Keep draft in sync when the incoming value changes (e.g. component re-selected) + const prevValue = useRef(value); + if (prevValue.current !== value) { + prevValue.current = value; + setDraft(value); + } + + const commit = () => { + setEditing(false); + if (draft.trim() !== value) onPatch(propKey, draft.trim()); + }; + + // Color swatch for color-type props + const hexColor = type === 'color' ? rgbToHex(value) : null; + + return ( +
+ + {label} + + + {editing ? ( +
+ { + setDraft(e.target.value); + // Live preview on every keystroke + onPatch(propKey, e.target.value); + }} + onBlur={commit} + onKeyDown={e => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') { setEditing(false); setDraft(value); onPatch(propKey, value); } + e.stopPropagation(); + }} + style={{ + flex: 1, minWidth: 0, + fontFamily: "'JetBrains Mono', monospace", + fontSize: '0.5625rem', + background: T.bgDeep, + border: `1px solid ${T.accent}`, + borderRadius: 4, + padding: '2px 6px', + color: T.fg, + outline: 'none', + }} + /> +
+ ) : ( +
{ setEditing(true); setDraft(value); }} + style={{ + flex: 1, display: 'flex', alignItems: 'center', gap: 4, + cursor: 'text', + padding: '2px 4px', + borderRadius: 4, + border: '1px solid transparent', + transition: 'border-color 0.1s', + }} + onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.borderColor = T.border; }} + onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.borderColor = 'transparent'; }} + > + {hexColor && ( + + )} + + {value} + +
+ )} +
+ ); +} + /* ── Props tab ────────────────────────────────────────────── */ function PropsTab({ artboard, @@ -157,6 +416,8 @@ function PropsTab({ const queryClient = useQueryClient(); const [editingUrl, setEditingUrl] = useState(false); const [urlDraft, setUrlDraft] = useState(''); + const [editingRoute, setEditingRoute] = useState(false); + const [routeDraft, setRouteDraft] = useState(''); // Drift report state const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); @@ -210,7 +471,7 @@ function PropsTab({ { key: 'height', val: String(meta['height'] ?? 0), color: N }, ]; - const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl']); + const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl', 'route']); const extraProps = Object.entries(meta) .filter(([k]) => !reservedKeys.has(k)) .map(([k, v]) => { @@ -221,6 +482,22 @@ function PropsTab({ }); const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : ''; + const currentRoute = typeof meta['route'] === 'string' ? meta['route'] as string : '/'; + + const saveRoute = useCallback(async () => { + if (!artboard) return; + const cleaned = routeDraft.trim() || '/'; + const { route: _r, ...rest } = artboard.metadata_jsonb; + const meta2: Record = + cleaned === '/' ? { ...rest } : { ...rest, route: cleaned }; + try { + await patchArtboard(artboard.id, { metadata_jsonb: meta2 }); + queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); + } catch (e) { + console.error('[Inspector] patch route failed', e); + } + setEditingRoute(false); + }, [artboard, routeDraft, workspaceId, projectId, queryClient]); return ( <> @@ -329,6 +606,64 @@ function PropsTab({ )} + + {/* route — which screen/path this artboard renders */} +
+
+ + route + + +
+ + {editingRoute ? ( +
+ setRouteDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void saveRoute(); + if (e.key === 'Escape') setEditingRoute(false); + }} + placeholder="/dashboard" + style={{ + flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace", + background: T.bgDeep, border: `1px solid ${T.border}`, + borderRadius: 5, padding: '4px 8px', color: T.fg, + outline: 'none', + }} + /> + +
+ ) : ( + + {currentRoute} + + )} +
diff --git a/packages/app/src/hooks/useArtboards.ts b/packages/app/src/hooks/useArtboards.ts index 09791ea..2179925 100644 --- a/packages/app/src/hooks/useArtboards.ts +++ b/packages/app/src/hooks/useArtboards.ts @@ -9,6 +9,8 @@ export interface CanvasArtboard { width: number; height: number; renderUrl?: string; + /** Route path appended to renderUrl when rendering a specific screen, e.g. "/dashboard". */ + route?: string; } @@ -21,6 +23,7 @@ function toCanvasArtboard(ab: Artboard): CanvasArtboard | null { if (x === null || y === null || width === null || height === null) return null; const base: CanvasArtboard = { id: ab.id, label: ab.name, x, y, width, height }; if (typeof meta['renderUrl'] === 'string') base.renderUrl = meta['renderUrl']; + if (typeof meta['route'] === 'string' && meta['route']) base.route = meta['route']; return base; } diff --git a/packages/app/src/store/canvas.ts b/packages/app/src/store/canvas.ts index d32cdf0..8710509 100644 --- a/packages/app/src/store/canvas.ts +++ b/packages/app/src/store/canvas.ts @@ -30,6 +30,18 @@ interface CanvasStore { selectedComponentId: string | null; selectedComponentData: FiberNode | null; selectComponent: (id: string | null, data: FiberNode | null) => void; + + // ── Component computed styles (populated by ELEMENT_STYLES response) ──────── + /** Computed CSS property map for the currently-selected component DOM element. */ + selectedComponentStyles: Record | null; + setComponentStyles: (styles: Record | null) => void; + + // ── Style edit mailbox ────────────────────────────────────────────────────── + // The Design tab drops a patch here; the owning LiveArtboard picks it up, + // forwards it to the iframe, then clears it. + styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null; + patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void; + clearStyleEdit: () => void; } export const useCanvas = create((set) => ({ @@ -38,7 +50,7 @@ export const useCanvas = create((set) => ({ selectedArtboardId: null, selectArtboard: (id) => - set({ selectedArtboardId: id, selectedComponentId: null, selectedComponentData: null }), + set({ selectedArtboardId: id, selectedComponentId: null, selectedComponentData: null, selectedComponentStyles: null }), workspaceId: null, projectId: null, @@ -58,5 +70,14 @@ export const useCanvas = create((set) => ({ selectedComponentId: null, selectedComponentData: null, - selectComponent: (id, data) => set({ selectedComponentId: id, selectedComponentData: data }), + selectComponent: (id, data) => + set({ selectedComponentId: id, selectedComponentData: data, selectedComponentStyles: null }), + + selectedComponentStyles: null, + setComponentStyles: (styles) => set({ selectedComponentStyles: styles }), + + styleEditEvent: null, + patchStyleEdit: (artboardId, nodeId, property, value) => + set({ styleEditEvent: { artboardId, nodeId, property, value } }), + clearStyleEdit: () => set({ styleEditEvent: null }), })); diff --git a/packages/live-sdk/src/hook.ts b/packages/live-sdk/src/hook.ts index ae6a066..f483807 100644 --- a/packages/live-sdk/src/hook.ts +++ b/packages/live-sdk/src/hook.ts @@ -130,7 +130,7 @@ function installFiberHook(): void { // Unnamed root fiber (HostRoot) — collectChildren handles Fragment recursively. const children: SerializedNode[] = []; collectChildren(fiber, parentId, children); - if (children.length === 1) return children[0]; + if (children.length === 1) return children[0] ?? null; if (children.length === 0) return null; // Multiple named children at root — wrap in a synthetic root node. return { id: '__root__', name: '__root__', props: {}, children }; @@ -275,7 +275,8 @@ function installFiberHook(): void { function getFiberKey(el: Element): string | null { const keys = Object.keys(el); for (let i = 0; i < keys.length; i++) { - if (keys[i].startsWith('__reactFiber$')) return keys[i]; + const k = keys[i]; + if (k !== undefined && k.startsWith('__reactFiber$')) return k; } return null; } @@ -321,7 +322,14 @@ function installFiberHook(): void { const data = event.data as { source?: string; artboardId?: string; - message?: { type: string; tokens?: Record; path?: string; nodeId?: string }; + message?: { + type: string; + tokens?: Record; + path?: string; + nodeId?: string; + property?: string; + value?: string; + }; }; if (!data || data.source !== HOST_SOURCE) return; if (data.artboardId !== artboardId) return; @@ -345,9 +353,76 @@ function installFiberHook(): void { selectedNodeId = null; removeHighlight(); break; + case 'REQUEST_ELEMENT_STYLES': + if (msg.nodeId) respondWithStyles(msg.nodeId); + break; + case 'PATCH_ELEMENT_STYLE': + if (msg.nodeId && msg.property && msg.value !== undefined) { + patchElementStyle(msg.nodeId, msg.property, msg.value ?? ''); + } + break; } }); + // ── Element style inspection ────────────────────────────────────────────── + // Reads computed CSS properties from the fiber node's DOM element and posts + // them back as ELEMENT_STYLES. We extract a curated subset covering the + // properties designers care about (typography, layout, visual) rather than the + // full ~300-property computed style object. + + const INSPECTED_PROPS = [ + // Typography + 'color', 'font-family', 'font-size', 'font-weight', 'line-height', + 'letter-spacing', 'text-align', 'text-transform', 'text-decoration', + // Layout + 'display', 'width', 'height', + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'flex-direction', 'align-items', 'justify-content', 'gap', + 'position', 'top', 'right', 'bottom', 'left', + // Visual + 'background-color', 'border-radius', 'opacity', + 'box-shadow', 'border-width', 'border-color', 'border-style', + 'overflow', 'cursor', 'transition', + ] as const; + + function respondWithStyles(nodeId: string): void { + const info = nodeMap.get(nodeId); + const styles: Record = {}; + + if (info?.fiber?.stateNode && typeof info.fiber.stateNode === 'object' + && 'nodeType' in (info.fiber.stateNode as object)) { + try { + const computed = window.getComputedStyle(info.fiber.stateNode as Element); + for (const prop of INSPECTED_PROPS) { + const val = computed.getPropertyValue(prop); + if (val) styles[prop] = val; + } + } catch { /* element may be detached */ } + } + + post({ type: 'ELEMENT_STYLES', nodeId, styles }); + } + + // ── Inline style patching ───────────────────────────────────────────────── + // Applies a single CSS property as an inline style on the component's DOM + // element. Non-destructive — does NOT modify source files. The change is + // immediately visible in the live render and can be recorded as a diff. + + function patchElementStyle(nodeId: string, property: string, value: string): void { + const info = nodeMap.get(nodeId); + if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object' + || !('nodeType' in (info.fiber.stateNode as object))) return; + try { + const el = info.fiber.stateNode as HTMLElement; + if (value === '') { + el.style.removeProperty(property); + } else { + el.style.setProperty(property, value); + } + } catch { /* element may be detached */ } + } + function applyTokens(tokens: Record): void { const root = document.documentElement; for (const [k, v] of Object.entries(tokens)) { diff --git a/packages/renderer/src/protocol.ts b/packages/renderer/src/protocol.ts index 60e1b47..543bab4 100644 --- a/packages/renderer/src/protocol.ts +++ b/packages/renderer/src/protocol.ts @@ -28,7 +28,12 @@ export type HostMessage = | { type: 'SET_DESIGN_TOKENS'; tokens: Record } | { type: 'NAVIGATE'; path: string } | { type: 'SELECT_COMPONENT'; nodeId: string } - | { type: 'DESELECT' }; + | { type: 'DESELECT' } + /** Ask the renderer to respond with computed CSS properties for a fiber node. */ + | { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string } + /** Apply a single CSS property override directly to the component's DOM element. + * Non-destructive — sets inline style only; source files are unchanged. */ + | { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }; export interface HostEnvelope { source: typeof HOST_SOURCE; @@ -43,7 +48,9 @@ export type RendererMessage = | { type: 'FIBER_TREE_UPDATE'; root: FiberNode } | { type: 'COMPONENT_SELECTED'; nodeId: string; rect: DOMRectLike } | { type: 'COMPONENT_DESELECTED' } - | { type: 'ERROR'; message: string }; + | { type: 'ERROR'; message: string } + /** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */ + | { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record }; export interface RendererEnvelope { source: typeof RENDERER_SOURCE;