feat: Figma-parity design editing — visual panel, live resize, route grid

Inspector:
- Design tab rebuilt with semantic Figma-style layout: W/H stepper inputs
  at top, Fill with colour-picker swatch, Typography row (size/weight/
  line-height), text-align toggle group, Layout section with flex controls
  + padding 4-corner grid, Appearance (radius/opacity/border/shadow)
- All fields fire patchStyleEdit on every keystroke / arrow-key step
- Sections conditionally render: Fill hidden when bg is transparent, etc.
- NumInput: strips/restores CSS unit, Shift+↑↓ for ×10 step
- ColorInput: native colour picker behind swatch + hex text input

SelectionOverlay:
- Resize handles call patchStyleEdit on every mousemove → live iframe preview
- 8 handles: 4 corners (nwse/nesw) + 4 edge midpoints (ns/ew)
- Delete button in label bar + Delete/Backspace keyboard shortcut
- Dimension tooltip: ComponentName W × H

Canvas / Artboard:
- ROUTES_DISCOVERED handler: auto-creates artboards in a row for every
  undiscovered route, pendingRouteCreation ref prevents duplicates
- buildSrc() helper resolves route-aware iframe src

live-sdk / hook:
- discoverRoutes(): scans <a href> + fiber tree for Link/NavLink components
- removeElement(), patchElementStyle(), respondWithStyles() handlers
- Route re-discovery on popstate

protocol: REQUEST_ELEMENT_STYLES, PATCH_ELEMENT_STYLE, REMOVE_ELEMENT host
messages; ELEMENT_STYLES, ROUTES_DISCOVERED renderer messages

canvas store: styleEditEvent + removeElementEvent mailboxes (Zustand v5
compatible — useEffect selectors, no 2-arg subscribe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SinachPat
2026-05-01 01:05:16 +01:00
co-authored by Claude Sonnet 4.6
parent edada3091e
commit 82dddb1801
8 changed files with 888 additions and 239 deletions
@@ -29,8 +29,8 @@ export interface LiveArtboardProps {
onComponentSelected?: (nodeId: string) => void;
/** Called when the iframe responds with computed CSS properties for a selected element. */
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
/** Forwards a CSS property patch from the Design tab into the iframe. */
patchElementStyle?: (nodeId: string, property: string, value: string) => void;
/** Called when the iframe discovers routes in the running app. */
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
style?: React.CSSProperties;
}
@@ -47,6 +47,7 @@ export function LiveArtboard({
onFiberTreeUpdate,
onComponentSelected,
onComponentStylesUpdate,
onRoutesDiscovered,
style,
}: LiveArtboardProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -107,12 +108,15 @@ export function LiveArtboard({
case 'ELEMENT_STYLES':
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
break;
case 'ROUTES_DISCOVERED':
onRoutesDiscovered?.(msg.routes);
break;
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate]);
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered]);
// ── Push updated design tokens whenever they change ───────────────────────
useEffect(() => {
@@ -125,6 +129,8 @@ export function LiveArtboard({
// this artboard and forwards them to the iframe immediately.
const styleEditEvent = useCanvas((s) => s.styleEditEvent);
const clearStyleEdit = useCanvas((s) => s.clearStyleEdit);
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
useEffect(() => {
if (styleEditEvent?.artboardId === id && isReadyRef.current) {
@@ -137,6 +143,13 @@ export function LiveArtboard({
}
}, [id, styleEditEvent, sendMessage, clearStyleEdit]);
useEffect(() => {
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId });
clearRemoveElement();
}
}, [id, removeElementEvent, sendMessage, clearRemoveElement]);
// ── 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.