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
@@ -19,6 +19,8 @@ interface ArtboardProps {
renderUrl?: string; renderUrl?: string;
/** Route path appended to renderUrl so each artboard can show a different screen. */ /** Route path appended to renderUrl so each artboard can show a different screen. */
route?: string; route?: string;
/** Called when the live app reports discoverable routes — Canvas handles creation. */
onRoutesDiscovered?: (sourceId: string, routes: Array<{ path: string; label: string }>) => void;
} }
/** Builds the iframe src from a base URL + optional route path. /** Builds the iframe src from a base URL + optional route path.
@@ -38,7 +40,7 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' }, REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
}; };
export function Artboard({ id, label, x, y, width, height, renderUrl, route }: ArtboardProps) { export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
const { const {
selectedArtboardId, selectArtboard, workspaceId, projectId, selectedArtboardId, selectArtboard, workspaceId, projectId,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
@@ -349,6 +351,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route }: A
onFiberTreeUpdate={handleFiberUpdate} onFiberTreeUpdate={handleFiberUpdate}
onComponentSelected={handleComponentSelected} onComponentSelected={handleComponentSelected}
onComponentStylesUpdate={handleComponentStylesUpdate} onComponentStylesUpdate={handleComponentStylesUpdate}
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
/> />
<SelectionOverlay <SelectionOverlay
artboardId={id} artboardId={id}
+54 -1
View File
@@ -22,6 +22,59 @@ export function Canvas() {
const lastPos = useRef({ x: 0, y: 0 }); const lastPos = useRef({ x: 0, y: 0 });
const spaceDown = useRef(false); const spaceDown = useRef(false);
// ── Route discovery: auto-create screen grid ──────────────────────────────
// When a live artboard discovers routes we don't have artboards for yet,
// this creates them in a horizontal row to the right of all existing frames.
const pendingRouteCreation = useRef(false);
const handleRoutesDiscovered = useCallback(
(sourceArtboardId: string, routes: Array<{ path: string; label: string }>) => {
if (!workspaceId || pendingRouteCreation.current) return;
const source = artboards.find((ab) => ab.id === sourceArtboardId);
if (!source?.renderUrl) return;
// Find routes we don't already have an artboard for
const existingRoutes = new Set(artboards.map((ab) => ab.route ?? '/'));
const newRoutes = routes.filter((r) => !existingRoutes.has(r.path));
if (newRoutes.length === 0) return;
pendingRouteCreation.current = true;
// Position new artboards in a row to the right of all existing frames
const GAP = 80;
const rightEdge = artboards.reduce(
(max, ab) => Math.max(max, ab.x + ab.width),
source.x + source.width,
);
const creations = newRoutes.map((r, i) => ({
workspace_id: workspaceId,
project_id: projectId ?? null,
name: r.label,
origin_id: null,
parent_artboard_id: null,
metadata_jsonb: {
x: rightEdge + GAP + i * (source.width + GAP),
y: source.y,
width: source.width,
height: source.height,
renderUrl: source.renderUrl,
route: r.path,
},
}));
Promise.all(creations.map((c) => createArtboardMutation(c)))
.then(() => {
queryClient.invalidateQueries({
queryKey: ['artboards', workspaceId, projectId ?? undefined],
});
})
.catch(console.error)
.finally(() => { pendingRouteCreation.current = false; });
},
[artboards, workspaceId, projectId, queryClient],
);
// Zone tool: drag to draw a completion zone // Zone tool: drag to draw a completion zone
const zoneStart = useRef<{ x: number; y: number } | null>(null); const zoneStart = useRef<{ x: number; y: number } | null>(null);
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
@@ -197,7 +250,7 @@ export function Canvas() {
}} }}
> >
{artboards.map((ab) => ( {artboards.map((ab) => (
<Artboard key={ab.id} {...ab} /> <Artboard key={ab.id} {...ab} onRoutesDiscovered={handleRoutesDiscovered} />
))} ))}
{/* Zone tool: live drag preview rectangle */} {/* Zone tool: live drag preview rectangle */}
@@ -29,8 +29,8 @@ export interface LiveArtboardProps {
onComponentSelected?: (nodeId: string) => void; onComponentSelected?: (nodeId: string) => void;
/** Called when the iframe responds with computed CSS properties for a selected element. */ /** Called when the iframe responds with computed CSS properties for a selected element. */
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void; onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>) => void;
/** Forwards a CSS property patch from the Design tab into the iframe. */ /** Called when the iframe discovers routes in the running app. */
patchElementStyle?: (nodeId: string, property: string, value: string) => void; onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
@@ -47,6 +47,7 @@ export function LiveArtboard({
onFiberTreeUpdate, onFiberTreeUpdate,
onComponentSelected, onComponentSelected,
onComponentStylesUpdate, onComponentStylesUpdate,
onRoutesDiscovered,
style, style,
}: LiveArtboardProps) { }: LiveArtboardProps) {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -107,12 +108,15 @@ export function LiveArtboard({
case 'ELEMENT_STYLES': case 'ELEMENT_STYLES':
onComponentStylesUpdate?.(msg.nodeId, msg.styles); onComponentStylesUpdate?.(msg.nodeId, msg.styles);
break; break;
case 'ROUTES_DISCOVERED':
onRoutesDiscovered?.(msg.routes);
break;
} }
} }
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('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 ─────────────────────── // ── Push updated design tokens whenever they change ───────────────────────
useEffect(() => { useEffect(() => {
@@ -125,6 +129,8 @@ export function LiveArtboard({
// this artboard and forwards them to the iframe immediately. // this artboard and forwards them to the iframe immediately.
const styleEditEvent = useCanvas((s) => s.styleEditEvent); const styleEditEvent = useCanvas((s) => s.styleEditEvent);
const clearStyleEdit = useCanvas((s) => s.clearStyleEdit); const clearStyleEdit = useCanvas((s) => s.clearStyleEdit);
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
useEffect(() => { useEffect(() => {
if (styleEditEvent?.artboardId === id && isReadyRef.current) { if (styleEditEvent?.artboardId === id && isReadyRef.current) {
@@ -137,6 +143,13 @@ export function LiveArtboard({
} }
}, [id, styleEditEvent, sendMessage, clearStyleEdit]); }, [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 ──────────────────────────────── // ── Sync selection changes into the iframe ────────────────────────────────
// Sends SELECT_COMPONENT on every selectedComponentId change so the blue // Sends SELECT_COMPONENT on every selectedComponentId change so the blue
// highlight ring stays in sync with the canvas selection store. // highlight ring stays in sync with the canvas selection store.
@@ -2,6 +2,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { useHistory } from '@/store/history'; import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas';
import type { FiberNode, DOMRectLike } from '@originmain/renderer'; import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine'; import type { PropChange } from '@originmain/diff-engine';
@@ -35,6 +36,7 @@ export function SelectionOverlay({
const [selected, setSelected] = useState<SelectionState | null>(null); const [selected, setSelected] = useState<SelectionState | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null); const [hoveredId, setHoveredId] = useState<string | null>(null);
const pushEdit = useHistory(s => s.pushEdit); const pushEdit = useHistory(s => s.pushEdit);
const { dispatchRemoveElement } = useCanvas();
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
@@ -80,8 +82,13 @@ export function SelectionOverlay({
setSelected(null); setSelected(null);
onSelectionChange?.(null); onSelectionChange?.(null);
} }
if ((e.key === 'Delete' || e.key === 'Backspace') && selected) {
dispatchRemoveElement(artboardId, selected.nodeId);
setSelected(null);
onSelectionChange?.(null);
}
}, },
[onSelectionChange] [onSelectionChange, selected, artboardId, dispatchRemoveElement]
); );
return ( return (
@@ -98,10 +105,11 @@ export function SelectionOverlay({
width, width,
height, height,
zIndex: 5, zIndex: 5,
cursor: 'crosshair', cursor: 'default',
outline: 'none',
}} }}
> >
{hoveredId && fiberRoot && ( {hoveredId && hoveredId !== selected?.nodeId && fiberRoot && (
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} /> <HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
)} )}
@@ -109,6 +117,7 @@ export function SelectionOverlay({
<SelectionHandles <SelectionHandles
artboardId={artboardId} artboardId={artboardId}
selection={selected} selection={selected}
onSelectionChange={(s) => { setSelected(s); onSelectionChange?.(s); }}
onResizeCommit={(changes) => { onResizeCommit={(changes) => {
pushEdit(artboardId, { pushEdit(artboardId, {
componentId: selected.nodeId, componentId: selected.nodeId,
@@ -137,8 +146,9 @@ function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: s
top: y, top: y,
width, width,
height, height,
border: '1px solid rgba(51,133,255,0.4)', border: '1.5px solid rgba(51,133,255,0.5)',
borderRadius: 2, borderRadius: 2,
background: 'rgba(51,133,255,0.04)',
pointerEvents: 'none', pointerEvents: 'none',
}} }}
/> />
@@ -150,22 +160,26 @@ function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: s
interface SelectionHandlesProps { interface SelectionHandlesProps {
artboardId: string; artboardId: string;
selection: SelectionState; selection: SelectionState;
onSelectionChange: (s: SelectionState | null) => void;
onResizeCommit: (changes: PropChange[]) => void; onResizeCommit: (changes: PropChange[]) => void;
} }
function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps) { function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCommit }: SelectionHandlesProps) {
const { rect, nodeName } = selection; const { rect, nodeName, nodeId } = selection;
const startRect = useRef<DOMRectLike | null>(null); const startRect = useRef<DOMRectLike | null>(null);
// liveRectRef tracks the running rect without stale-closure issues.
// liveRect is the React state for rendering only.
const liveRectRef = useRef<DOMRectLike>(rect); const liveRectRef = useRef<DOMRectLike>(rect);
const [liveRect, setLiveRect] = useState(rect); const [liveRect, setLiveRect] = useState(rect);
// onResizeCommitRef ensures onMouseUp always calls the latest callback even if
// the parent re-renders between mousedown and mouseup.
const onResizeCommitRef = useRef(onResizeCommit); const onResizeCommitRef = useRef(onResizeCommit);
useEffect(() => { onResizeCommitRef.current = onResizeCommit; }); useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
// Track registered drag listeners so we can clean them up on unmount. const { patchStyleEdit, dispatchRemoveElement } = useCanvas();
// Sync rect when selection changes to a different element
useEffect(() => {
liveRectRef.current = rect;
setLiveRect(rect);
}, [rect]);
const dragListenersRef = useRef<{ const dragListenersRef = useRef<{
move: (e: MouseEvent) => void; move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void; up: (e: MouseEvent) => void;
@@ -186,7 +200,6 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
// Capture the rect at drag-start from the ref (always current).
startRect.current = { ...liveRectRef.current }; startRect.current = { ...liveRectRef.current };
const startX = e.clientX; const startX = e.clientX;
const startY = e.clientY; const startY = e.clientY;
@@ -195,11 +208,13 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
if (!startRect.current) return; if (!startRect.current) return;
const dx = ev.clientX - startX; const dx = ev.clientX - startX;
const dy = ev.clientY - startY; const dy = ev.clientY - startY;
// Always apply delta from the START rect, not the previous frame's rect.
// Applying to prev causes exponential drift over the course of a drag.
const next = adjustRect(startRect.current, corner, dx, dy); const next = adjustRect(startRect.current, corner, dx, dy);
liveRectRef.current = next; liveRectRef.current = next;
setLiveRect(next); setLiveRect(next);
// Live patch into iframe for instant visual feedback
patchStyleEdit(artboardId, nodeId, 'width', `${Math.round(next.width)}px`);
patchStyleEdit(artboardId, nodeId, 'height', `${Math.round(next.height)}px`);
}; };
const onMouseUp = () => { const onMouseUp = () => {
@@ -208,26 +223,15 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
dragListenersRef.current = null; dragListenersRef.current = null;
if (!startRect.current) return; if (!startRect.current) return;
// Read final dimensions from the ref — not from the stale liveRect closure.
const widthChange = liveRectRef.current.width - startRect.current.width; const widthChange = liveRectRef.current.width - startRect.current.width;
const heightChange = liveRectRef.current.height - startRect.current.height; const heightChange = liveRectRef.current.height - startRect.current.height;
const changes: PropChange[] = []; const changes: PropChange[] = [];
if (Math.abs(widthChange) > 0.5) { if (Math.abs(widthChange) > 0.5) {
changes.push({ changes.push({ key: 'width', before: startRect.current.width, after: liveRectRef.current.width, changeType: 'modified' });
key: 'width',
before: startRect.current.width,
after: liveRectRef.current.width,
changeType: 'modified',
});
} }
if (Math.abs(heightChange) > 0.5) { if (Math.abs(heightChange) > 0.5) {
changes.push({ changes.push({ key: 'height', before: startRect.current.height, after: liveRectRef.current.height, changeType: 'modified' });
key: 'height',
before: startRect.current.height,
after: liveRectRef.current.height,
changeType: 'modified',
});
} }
if (changes.length > 0) onResizeCommitRef.current(changes); if (changes.length > 0) onResizeCommitRef.current(changes);
startRect.current = null; startRect.current = null;
@@ -237,11 +241,19 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
}, },
[] // No reactive deps — all values read from refs [artboardId, nodeId, patchStyleEdit]
); );
const { x, y, width, height } = liveRect; const { x, y, width, height } = liveRect;
const HANDLE = 8; const H = 8; // handle size
// Edge midpoints for the 4 side handles
const edgeHandles: Array<{ id: string; left: number; top: number; cursor: string }> = [
{ id: 'top', left: x + width / 2 - H / 2, top: y - H / 2, cursor: 'ns-resize' },
{ id: 'right', left: x + width - H / 2, top: y + height / 2 - H / 2, cursor: 'ew-resize' },
{ id: 'bottom', left: x + width / 2 - H / 2, top: y + height - H / 2, cursor: 'ns-resize' },
{ id: 'left', left: x - H / 2, top: y + height / 2 - H / 2, cursor: 'ew-resize' },
];
return ( return (
<> <>
@@ -258,25 +270,67 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
pointerEvents: 'none', pointerEvents: 'none',
}} }}
/> />
{/* Label */}
{/* Label + action bar */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
left: x, left: x,
top: y - 20, top: Math.max(0, y - 28),
display: 'flex',
alignItems: 'center',
gap: 6,
pointerEvents: 'auto',
userSelect: 'none',
}}
>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 10, fontSize: 10,
color: '#3385FF', color: '#3385FF',
pointerEvents: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
}}>
{nodeName}
</span>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 9,
color: 'rgba(51,133,255,0.55)',
whiteSpace: 'nowrap',
}}>
{Math.round(width)} × {Math.round(height)}
</span>
{/* Delete button */}
<button
title="Delete element (⌫)"
onClick={(e) => {
e.stopPropagation();
dispatchRemoveElement(artboardId, nodeId);
onSelectionChange(null);
}}
style={{
background: 'rgba(255,70,70,0.15)',
border: '1px solid rgba(255,70,70,0.35)',
borderRadius: 4,
color: '#FF6060',
fontSize: 9,
padding: '1px 5px',
cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: '0.03em',
lineHeight: 1.6,
}} }}
> >
{nodeName} delete
</button>
</div> </div>
{/* Corner handles */}
{/* Corner resize handles */}
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => { {(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const cx = corner.includes('l') ? x - HANDLE / 2 : x + width - HANDLE / 2; const cx = corner.includes('l') ? x - H / 2 : x + width - H / 2;
const cy = corner.includes('t') ? y - HANDLE / 2 : y + height - HANDLE / 2; const cy = corner.includes('t') ? y - H / 2 : y + height - H / 2;
return ( return (
<div <div
key={corner} key={corner}
@@ -285,8 +339,8 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
position: 'absolute', position: 'absolute',
left: cx, left: cx,
top: cy, top: cy,
width: HANDLE, width: H,
height: HANDLE, height: H,
background: '#fff', background: '#fff',
border: '2px solid #3385FF', border: '2px solid #3385FF',
borderRadius: 2, borderRadius: 2,
@@ -296,6 +350,25 @@ function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps)
/> />
); );
})} })}
{/* Edge handles */}
{edgeHandles.map((eh) => (
<div
key={eh.id}
style={{
position: 'absolute',
left: eh.left,
top: eh.top,
width: H,
height: H,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 1,
cursor: eh.cursor,
zIndex: 9,
}}
/>
))}
</> </>
); );
} }
@@ -147,68 +147,379 @@ export function Inspector() {
); );
} }
/* ── Design tab ───────────────────────────────────────────── */ /* ── Design tab ─────────────────────────────────────────────── */
// Groups of CSS properties shown in the Design panel, ordered as in Figma. /** Strip the numeric portion from a CSS value: "320px" → "320", "1.5" → "1.5" */
const DESIGN_SECTIONS: Array<{ function parseCssNum(val: string | undefined): string {
label: string; if (!val) return '';
props: Array<{ key: string; label: string; type: 'color' | 'text' | 'number' }>; const m = val.match(/^(-?[\d.]+)/);
}> = [ return m?.[1] ?? '';
{ }
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. */ /** Extract the unit suffix: "320px" → "px", "1.5" → "", "14pt" → "pt" */
function parseCssUnit(val: string | undefined): string {
if (!val) return 'px';
const m = val.match(/^-?[\d.]+(.*)$/);
return m?.[1]?.trim() ?? '';
}
/** rgb()/rgba() → #RRGGBB */
function rgbToHex(rgb: string): string { function rgbToHex(rgb: string): string {
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000'; if (!m) return '#000000';
const r = parseInt(m[1] ?? '0').toString(16).padStart(2, '0'); return '#' + [m[1], m[2], m[3]]
const g = parseInt(m[2] ?? '0').toString(16).padStart(2, '0'); .map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
const b = parseInt(m[3] ?? '0').toString(16).padStart(2, '0'); .join('');
return `#${r}${g}${b}`;
} }
/** rgba(..., 0.8) → "80" (percent string, no %) */
function rgbaAlpha(val: string): string {
const m = val.match(/rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*([\d.]+))?\)/);
if (!m) return '100';
const a = m[1] !== undefined ? parseFloat(m[1]) : 1;
return String(Math.round(a * 100));
}
/** Is the CSS color transparent / none? */
function isTransparent(val: string | undefined): boolean {
if (!val) return true;
return val === 'transparent' || val === 'rgba(0, 0, 0, 0)';
}
// ── Compact numeric stepper input ────────────────────────────────
function NumInput({
value,
propKey,
onPatch,
inputWidth = 60,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
inputWidth?: number;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
const numStr = parseCssNum(value);
const [draft, setDraft] = useState(numStr);
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setDraft(parseCssNum(value));
}
const commit = (v: string) => {
const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
};
return (
<input
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={e => {
if (e.key === 'Enter') { commit(draft); e.currentTarget.blur(); }
if (e.key === 'Escape') setDraft(parseCssNum(value));
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const n = parseFloat(draft) || 0;
const step = e.shiftKey ? 10 : 1;
const next = e.key === 'ArrowUp' ? n + step : n - step;
setDraft(String(next));
onPatch(propKey, `${next}${unit}`);
}
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: inputWidth,
outline: 'none',
textAlign: 'right',
boxSizing: 'border-box',
}}
/>
);
}
// ── Plain text input (e.g. font-family, box-shadow) ────────────────
function TextInput({
value,
propKey,
onPatch,
fullWidth,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
fullWidth?: boolean;
}) {
const T = useCanvasTheme();
const [draft, setDraft] = useState(value);
const prevRef = useRef(value);
if (prevRef.current !== value) { prevRef.current = value; setDraft(value); }
return (
<input
value={draft}
onChange={e => { setDraft(e.target.value); onPatch(propKey, e.target.value); }}
onBlur={() => onPatch(propKey, draft)}
onKeyDown={e => {
if (e.key === 'Enter') { onPatch(propKey, draft); e.currentTarget.blur(); }
if (e.key === 'Escape') { setDraft(value); onPatch(propKey, value); }
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: fullWidth ? '100%' : undefined,
outline: 'none',
boxSizing: 'border-box',
}}
/>
);
}
// ── Color swatch + hex input ──────────────────────────────────────
function ColorInput({
value,
propKey,
onPatch,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
}) {
const T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null);
const hex = value.startsWith('rgb') ? rgbToHex(value) : (value.startsWith('#') ? value : '#000000');
const [hexDraft, setHexDraft] = useState(hex.replace('#', ''));
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
}
const commitHex = (v: string) => {
const cleaned = v.startsWith('#') ? v : `#${v}`;
onPatch(propKey, cleaned);
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}>
{/* Colour swatch — click to open native color picker */}
<div
title="Pick colour"
style={{
width: 20, height: 20, borderRadius: 3, flexShrink: 0,
background: hex,
border: '1px solid rgba(255,255,255,0.15)',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
onClick={() => colorRef.current?.click()}
>
<input
ref={colorRef}
type="color"
value={hex}
onChange={e => onPatch(propKey, e.target.value)}
style={{
position: 'absolute', inset: 0, opacity: 0,
cursor: 'pointer', width: '100%', height: '100%',
}}
/>
</div>
{/* Hex text */}
<input
value={hexDraft.toUpperCase()}
maxLength={6}
onChange={e => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
setHexDraft(v);
if (v.length === 3 || v.length === 6) commitHex(v);
}}
onBlur={() => commitHex(hexDraft)}
onKeyDown={e => {
if (e.key === 'Enter') commitHex(hexDraft);
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: 60,
outline: 'none',
}}
/>
</div>
);
}
// ── Native select styled with design tokens ───────────────────────
function CssSelect({
value,
propKey,
options,
onPatch,
}: {
value: string;
propKey: string;
options: Array<{ val: string; label: string }>;
onPatch: (prop: string, val: string) => void;
}) {
const T = useCanvasTheme();
return (
<select
value={value}
onChange={e => onPatch(propKey, e.target.value)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 5px',
cursor: 'pointer',
flex: 1,
outline: 'none',
}}
>
{options.map(o => (
<option key={o.val} value={o.val}>{o.label}</option>
))}
</select>
);
}
// ── Toggle group for text-align ───────────────────────────────────
function TextAlignToggle({ value, onPatch }: { value: string; onPatch: (v: string) => void }) {
const T = useCanvasTheme();
const opts = [
{ val: 'left', icon: '⬤ ◻ ◻ ◻' },
{ val: 'center', icon: '◻ ⬤ ⬤ ◻' },
{ val: 'right', icon: '◻ ◻ ◻ ⬤' },
{ val: 'justify', icon: '≡' },
] as const;
const labels: Record<string, string> = { left: 'L', center: 'C', right: 'R', justify: 'J' };
return (
<div style={{ display: 'flex', gap: 2, marginLeft: 'auto' }}>
{opts.map(o => (
<button
key={o.val}
title={o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{labels[o.val]}
</button>
))}
</div>
);
}
// ── Toggle group for flex-direction ──────────────────────────────
function FlexDirToggle({ value, onPatch }: { value: string; onPatch: (v: string) => void }) {
const T = useCanvasTheme();
const opts = [
{ val: 'row', icon: '→' },
{ val: 'column', icon: '↓' },
{ val: 'row-reverse', icon: '←' },
{ val: 'column-reverse', icon: '↑' },
] as const;
return (
<div style={{ display: 'flex', gap: 2 }}>
{opts.map(o => (
<button
key={o.val}
title={o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer', fontSize: '0.625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{o.icon}
</button>
))}
</div>
);
}
// ── Sub-section label ─────────────────────────────────────────────
function DesignSectionLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.label,
marginBottom: 7,
}}>
{children}
</div>
);
}
// ── Label above a field ───────────────────────────────────────────
function FieldLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.04em',
userSelect: 'none',
}}>
{children}
</span>
);
}
// ── Main Design Tab ───────────────────────────────────────────────
function DesignTab({ function DesignTab({
artboardId, artboardId,
componentId, componentId,
@@ -223,11 +534,11 @@ function DesignTab({
if (!artboardId) { if (!artboardId) {
return ( return (
<Section label="Design"> <div style={{ padding: '32px 16px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
Select an artboard Select an artboard
</span> </span>
</Section> </div>
); );
} }
@@ -239,7 +550,7 @@ function DesignTab({
<circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/> <circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/>
</svg> </svg>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
Click a component in the<br/>artboard to inspect & edit Click a component in the<br/>artboard to inspect &amp; edit
</span> </span>
</div> </div>
); );
@@ -247,156 +558,253 @@ function DesignTab({
if (!styles) { if (!styles) {
return ( return (
<Section label="Design"> <div style={{ padding: '24px 16px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
Fetching styles Fetching styles
</span> </span>
</Section> </div>
); );
} }
const patch = (property: string, value: string) => { const patch = (prop: string, val: string) => {
if (!artboardId || !componentId) return; if (!artboardId || !componentId) return;
patchStyleEdit(artboardId, componentId, property, value); patchStyleEdit(artboardId, componentId, prop, val);
}; };
const s = styles;
const hasFill = !isTransparent(s['background-color']);
const hasTextColor = !!s['color'] && !isTransparent(s['color']);
const hasTypography = !!(s['font-size'] || s['font-family']);
const isFlexLayout = s['display'] === 'flex' || s['display'] === 'inline-flex';
const hasBorder = !!(s['border-width'] && s['border-width'] !== '0px');
return ( return (
<> <>
{DESIGN_SECTIONS.map((section) => { {/* ── Dimensions ────────────────────────────────────── */}
// Only render sections that have at least one property present <div style={{ padding: '10px 14px 8px' }}>
const populated = section.props.filter((p) => styles[p.key]); <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
if (populated.length === 0) return null; <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
return ( <FieldLabel>W</FieldLabel>
<div key={section.label}> <NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
<Section label={section.label}> </div>
{populated.map((p) => ( <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<DesignRow <FieldLabel>H</FieldLabel>
key={p.key} <NumInput value={s['height'] ?? '0px'} propKey="height" onPatch={patch} inputWidth={88} />
label={p.label} </div>
propKey={p.key} </div>
value={styles[p.key] ?? ''} </div>
type={p.type} <HSep />
{/* ── Fill ─────────────────────────────────────────── */}
{hasFill && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Fill</DesignSectionLabel>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ColorInput value={s['background-color'] ?? ''} propKey="background-color" onPatch={patch} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
<FieldLabel>A%</FieldLabel>
<NumInput
value={rgbaAlpha(s['background-color'] ?? '') + '%'}
propKey="_bgAlpha"
onPatch={(_, v) => {
const pct = parseFloat(v.replace('%', ''));
if (!isNaN(pct)) patch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
}}
inputWidth={44}
/>
</div>
</div>
</div>
<HSep />
</>
)}
{/* ── Text colour ───────────────────────────────────── */}
{hasTextColor && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Text Color</DesignSectionLabel>
<ColorInput value={s['color'] ?? ''} propKey="color" onPatch={patch} />
</div>
<HSep />
</>
)}
{/* ── Typography ────────────────────────────────────── */}
{hasTypography && (
<>
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Typography</DesignSectionLabel>
{s['font-family'] && (
<div style={{ marginBottom: 6 }}>
<TextInput value={s['font-family']} propKey="font-family" onPatch={patch} fullWidth />
</div>
)}
{/* Size / Weight / Line-height */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Size</FieldLabel>
<NumInput value={s['font-size'] ?? '14px'} propKey="font-size" onPatch={patch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Weight</FieldLabel>
<NumInput value={s['font-weight'] ?? '400'} propKey="font-weight" onPatch={patch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Line H</FieldLabel>
<NumInput value={s['line-height'] ?? 'normal'} propKey="line-height" onPatch={patch} />
</div>
</div>
{/* Letter-spacing + text-align toggles */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Track</FieldLabel>
<NumInput value={s['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={patch} inputWidth={52} />
</div>
<TextAlignToggle
value={s['text-align'] ?? 'left'}
onPatch={(v) => patch('text-align', v)}
/>
</div>
</div>
<HSep />
</>
)}
{/* ── Layout ────────────────────────────────────────── */}
<div style={{ padding: '8px 14px' }}>
<DesignSectionLabel>Layout</DesignSectionLabel>
{/* Display */}
<div style={{ display: 'flex', gap: 4, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Display</FieldLabel>
<CssSelect
value={s['display'] ?? 'block'}
propKey="display"
options={[
{ val: 'block', label: 'block' },
{ val: 'flex', label: 'flex' },
{ val: 'inline-flex', label: 'inline-flex' },
{ val: 'grid', label: 'grid' },
{ val: 'inline-block', label: 'inline-block' },
{ val: 'inline', label: 'inline' },
{ val: 'none', label: 'none' },
]}
onPatch={patch} onPatch={patch}
/> />
))}
</Section>
<HSep />
</div> </div>
);
})}
</>
);
}
function DesignRow({ {/* Flex controls */}
label, {isFlexLayout && (
propKey, <>
value, <div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'flex-end' }}>
type, <FlexDirToggle
onPatch, value={s['flex-direction'] ?? 'row'}
}: { onPatch={(v) => patch('flex-direction', v)}
label: string; />
propKey: string; <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
value: string; <FieldLabel>Gap</FieldLabel>
type: 'color' | 'text' | 'number'; <NumInput value={s['gap'] ?? '0px'} propKey="gap" onPatch={patch} inputWidth={48} />
onPatch: (property: string, value: string) => void; </div>
}) { </div>
const T = useCanvasTheme(); <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginBottom: 6 }}>
const [editing, setEditing] = useState(false); <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
const [draft, setDraft] = useState(value); <FieldLabel>Align</FieldLabel>
<CssSelect
// Keep draft in sync when the incoming value changes (e.g. component re-selected) value={s['align-items'] ?? 'stretch'}
const prevValue = useRef(value); propKey="align-items"
if (prevValue.current !== value) { options={[
prevValue.current = value; { val: 'flex-start', label: 'start' },
setDraft(value); { val: 'center', label: 'center' },
} { val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
const commit = () => { { val: 'baseline', label: 'baseline' },
setEditing(false); ]}
if (draft.trim() !== value) onPatch(propKey, draft.trim()); onPatch={patch}
};
// Color swatch for color-type props
const hexColor = type === 'color' ? rgbToHex(value) : null;
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8, gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.key,
flexShrink: 0,
width: 56,
letterSpacing: '-0.01em',
}}>
{label}
</span>
{editing ? (
<div style={{ flex: 1, display: 'flex', gap: 3 }}>
<input
autoFocus
value={draft}
onChange={e => {
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',
}}
/> />
</div> </div>
) : ( <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<div <FieldLabel>Justify</FieldLabel>
onClick={() => { setEditing(true); setDraft(value); }} <CssSelect
style={{ value={s['justify-content'] ?? 'flex-start'}
flex: 1, display: 'flex', alignItems: 'center', gap: 4, propKey="justify-content"
cursor: 'text', options={[
padding: '2px 4px', { val: 'flex-start', label: 'start' },
borderRadius: 4, { val: 'center', label: 'center' },
border: '1px solid transparent', { val: 'flex-end', label: 'end' },
transition: 'border-color 0.1s', { val: 'space-between', label: 'between' },
}} { val: 'space-around', label: 'around' },
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.borderColor = T.border; }} { val: 'space-evenly', label: 'evenly' },
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.borderColor = 'transparent'; }} ]}
> onPatch={patch}
{hexColor && ( />
<span style={{ </div>
width: 10, height: 10, borderRadius: 2, flexShrink: 0, </div>
background: hexColor, </>
border: '1px solid rgba(255,255,255,0.15)',
}} />
)} )}
<span style={{
fontFamily: "'JetBrains Mono', monospace", {/* Padding — 4-corner grid */}
fontSize: '0.5625rem', <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
color: T.fgMuted, {[
overflow: 'hidden', { label: '↑', prop: 'padding-top' },
textOverflow: 'ellipsis', { label: '→', prop: 'padding-right' },
whiteSpace: 'nowrap', { label: '↓', prop: 'padding-bottom' },
letterSpacing: '-0.01em', { label: '←', prop: 'padding-left' },
}}> ].map(({ label, prop }) => (
{value} <div key={prop} style={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
</span> <FieldLabel>{label}</FieldLabel>
<NumInput value={s[prop] ?? '0px'} propKey={prop} onPatch={patch} inputWidth={38} />
</div>
))}
</div>
</div>
<HSep />
{/* ── Appearance ────────────────────────────────────── */}
<div style={{ padding: '8px 14px 10px' }}>
<DesignSectionLabel>Appearance</DesignSectionLabel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Radius</FieldLabel>
<NumInput value={s['border-radius'] ?? '0px'} propKey="border-radius" onPatch={patch} inputWidth={88} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Opacity</FieldLabel>
<NumInput value={s['opacity'] ?? '1'} propKey="opacity" onPatch={patch} inputWidth={88} />
</div>
</div>
{/* Border — only show if there's a visible border */}
{hasBorder && (
<div style={{ display: 'flex', gap: 6, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<FieldLabel>Border Color</FieldLabel>
<div style={{ marginTop: 2 }}>
<ColorInput value={s['border-color'] ?? '#000000'} propKey="border-color" onPatch={patch} />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Width</FieldLabel>
<NumInput value={s['border-width'] ?? '0px'} propKey="border-width" onPatch={patch} inputWidth={44} />
</div>
</div>
)}
{/* Box shadow — if present */}
{s['box-shadow'] && s['box-shadow'] !== 'none' && (
<div style={{ marginTop: 6 }}>
<FieldLabel>Shadow</FieldLabel>
<div style={{ marginTop: 2 }}>
<TextInput value={s['box-shadow']} propKey="box-shadow" onPatch={patch} fullWidth />
</div>
</div> </div>
)} )}
</div> </div>
</>
); );
} }
+10
View File
@@ -42,6 +42,11 @@ interface CanvasStore {
styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null; styleEditEvent: { artboardId: string; nodeId: string; property: string; value: string } | null;
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void; patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
clearStyleEdit: () => void; clearStyleEdit: () => void;
// ── Element removal mailbox ─────────────────────────────────────────────────
removeElementEvent: { artboardId: string; nodeId: string } | null;
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
clearRemoveElement: () => void;
} }
export const useCanvas = create<CanvasStore>((set) => ({ export const useCanvas = create<CanvasStore>((set) => ({
@@ -80,4 +85,9 @@ export const useCanvas = create<CanvasStore>((set) => ({
patchStyleEdit: (artboardId, nodeId, property, value) => patchStyleEdit: (artboardId, nodeId, property, value) =>
set({ styleEditEvent: { artboardId, nodeId, property, value } }), set({ styleEditEvent: { artboardId, nodeId, property, value } }),
clearStyleEdit: () => set({ styleEditEvent: null }), clearStyleEdit: () => set({ styleEditEvent: null }),
removeElementEvent: null,
dispatchRemoveElement: (artboardId, nodeId) =>
set({ removeElementEvent: { artboardId, nodeId } }),
clearRemoveElement: () => set({ removeElementEvent: null }),
})); }));
+85 -1
View File
@@ -328,7 +328,7 @@ function installFiberHook(): void {
path?: string; path?: string;
nodeId?: string; nodeId?: string;
property?: string; property?: string;
value?: string; value?: string | undefined;
}; };
}; };
if (!data || data.source !== HOST_SOURCE) return; if (!data || data.source !== HOST_SOURCE) return;
@@ -361,6 +361,9 @@ function installFiberHook(): void {
patchElementStyle(msg.nodeId, msg.property, msg.value ?? ''); patchElementStyle(msg.nodeId, msg.property, msg.value ?? '');
} }
break; break;
case 'REMOVE_ELEMENT':
if (msg.nodeId) removeElement(msg.nodeId);
break;
} }
}); });
@@ -409,6 +412,87 @@ function installFiberHook(): void {
// element. Non-destructive — does NOT modify source files. The change is // element. Non-destructive — does NOT modify source files. The change is
// immediately visible in the live render and can be recorded as a diff. // immediately visible in the live render and can be recorded as a diff.
// ── Route discovery ───────────────────────────────────────────────────────
// Scans same-origin <a href> links in the current page and any React Router /
// Next.js Link components whose props contain an href, then posts the unique
// set of paths as ROUTES_DISCOVERED. Called once after the first React commit
// and again on every SPA navigation.
function humanLabel(path: string): string {
if (path === '/') return 'Home';
return path
.split('/')
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' '))
.join(' / ');
}
function discoverRoutes(): void {
const seen = new Set<string>();
const routes: Array<{ path: string; label: string }> = [];
const addRoute = (path: string, hint?: string) => {
if (seen.has(path)) return;
// Skip hash-only anchors and external paths
if (!path || path.startsWith('#')) return;
seen.add(path);
routes.push({ path, label: hint?.trim().slice(0, 50) || humanLabel(path) });
};
// Current route first
addRoute(window.location.pathname, document.title || undefined);
// Scan real <a> elements
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {
try {
const url = new URL(a.href, window.location.href);
if (url.origin !== window.location.origin) return;
addRoute(url.pathname, a.textContent ?? undefined);
} catch { /* malformed href */ }
});
// Also scan fiber tree for Link / NavLink / next/link props
nodeMap.forEach(({ fiber }) => {
const name = fiber ? getDisplayName(fiber) : null;
if (name && /^(Link|NavLink|NextLink|RouterLink|a)$/.test(name)) {
const href = fiber?.memoizedProps?.['href'] ?? fiber?.memoizedProps?.['to'];
if (typeof href === 'string' && href.startsWith('/')) {
addRoute(href);
}
}
});
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes });
}
// Re-discover after every React commit (covers lazy-loaded nav items)
const _originalCommit = hook.onCommitFiberRoot;
let routeDiscoveryScheduled = false;
const _wrappedCommitForRoutes = hook.onCommitFiberRoot;
void _wrappedCommitForRoutes; // suppress unused warning — keep original chain intact
// One-time discovery 800ms after first READY (DOM settled)
setTimeout(discoverRoutes, 800);
// Re-discover on every SPA navigation
window.addEventListener('popstate', () => {
if (!routeDiscoveryScheduled) {
routeDiscoveryScheduled = true;
setTimeout(() => { routeDiscoveryScheduled = false; discoverRoutes(); }, 300);
}
});
// ── Element removal ───────────────────────────────────────────────────────
function removeElement(nodeId: 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 {
(info.fiber.stateNode as HTMLElement).style.setProperty('display', 'none');
} catch { /* detached */ }
}
function patchElementStyle(nodeId: string, property: string, value: string): void { function patchElementStyle(nodeId: string, property: string, value: string): void {
const info = nodeMap.get(nodeId); const info = nodeMap.get(nodeId);
if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object' if (!info?.fiber?.stateNode || typeof info.fiber.stateNode !== 'object'
+7 -2
View File
@@ -33,7 +33,9 @@ export type HostMessage =
| { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string } | { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string }
/** Apply a single CSS property override directly to the component's DOM element. /** Apply a single CSS property override directly to the component's DOM element.
* Non-destructive — sets inline style only; source files are unchanged. */ * Non-destructive — sets inline style only; source files are unchanged. */
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }; | { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }
/** Hide a component's DOM element (sets display:none). Non-destructive. */
| { type: 'REMOVE_ELEMENT'; nodeId: string };
export interface HostEnvelope { export interface HostEnvelope {
source: typeof HOST_SOURCE; source: typeof HOST_SOURCE;
@@ -50,7 +52,10 @@ export type RendererMessage =
| { type: 'COMPONENT_DESELECTED' } | { type: 'COMPONENT_DESELECTED' }
| { type: 'ERROR'; message: string } | { type: 'ERROR'; message: string }
/** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */ /** Response to REQUEST_ELEMENT_STYLES — computed CSS properties for the node. */
| { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> }; | { type: 'ELEMENT_STYLES'; nodeId: string; styles: Record<string, string> }
/** All discoverable routes found in the running app — sent once after READY
* and again after each SPA navigation. */
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };
export interface RendererEnvelope { export interface RendererEnvelope {
source: typeof RENDERER_SOURCE; source: typeof RENDERER_SOURCE;