made tiny updates

This commit is contained in:
SinachPat
2026-05-03 20:34:18 +01:00
parent 4dca0f1bac
commit 3f029e15c2
30 changed files with 3463 additions and 357 deletions
@@ -43,7 +43,7 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
const {
selectedArtboardId, selectArtboard, workspaceId, projectId,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
selectedComponentId,
} = useCanvas();
const selected = selectedArtboardId === id;
@@ -81,14 +81,20 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
selectComponent(nodeId, node ?? null);
}, [localFiberRoot, selectComponent, setComponentStyles]);
const handleComponentStylesUpdate = useCallback((nodeId: string, styles: Record<string, string>) => {
const handleComponentStylesUpdate = useCallback((
nodeId: string,
styles: Record<string, string>,
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]);
}, [id, setComponentStyles, setComponentTextFlags]);
// ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false);
@@ -144,8 +150,14 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
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 } : {}) },
metadata_jsonb: {
x: newX, y: newY, width, height,
...(renderUrl ? { renderUrl } : {}),
...(route ? { route } : {}),
},
}),
}).then(() => {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
@@ -27,8 +27,9 @@ 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<string, string>) => void;
/** Called when the iframe responds with computed CSS properties for a selected element.
* Also carries structural flags from the ELEMENT_STYLES message. */
onComponentStylesUpdate?: (nodeId: string, styles: Record<string, string>, hasDirectText: boolean, hasParagraphChildren: boolean) => void;
/** Called when the iframe discovers routes in the running app. */
onRoutesDiscovered?: (routes: Array<{ path: string; label: string }>) => void;
/** Called when READY fires but no React commits arrive within 4 s — signals a
@@ -140,7 +141,7 @@ export function LiveArtboard({
onComponentSelected?.('');
break;
case 'ELEMENT_STYLES':
onComponentStylesUpdate?.(msg.nodeId, msg.styles);
onComponentStylesUpdate?.(msg.nodeId, msg.styles, msg.hasDirectText, msg.hasParagraphChildren);
break;
case 'ROUTES_DISCOVERED':
onRoutesDiscovered?.(msg.routes);
@@ -164,6 +165,8 @@ export function LiveArtboard({
// simultaneous width + height patches from resize are both delivered.
const styleEditQueue = useCanvas((s) => s.styleEditQueue);
const clearStyleEdits = useCanvas((s) => s.clearStyleEdits);
const childrenStyleEditQueue = useCanvas((s) => s.childrenStyleEditQueue);
const clearChildrenStyleEdits = useCanvas((s) => s.clearChildrenStyleEdits);
const removeElementEvent = useCanvas((s) => s.removeElementEvent);
const clearRemoveElement = useCanvas((s) => s.clearRemoveElement);
@@ -176,6 +179,16 @@ export function LiveArtboard({
clearStyleEdits(id);
}, [id, styleEditQueue, sendMessage, clearStyleEdits]);
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
useEffect(() => {
const mine = childrenStyleEditQueue.filter((e) => e.artboardId === id);
if (mine.length === 0 || !isReadyRef.current) return;
for (const e of mine) {
sendMessage('PATCH_CHILDREN_STYLE', { parentNodeId: e.parentNodeId, selector: e.selector, property: e.property, value: e.value });
}
clearChildrenStyleEdits(id);
}, [id, childrenStyleEditQueue, sendMessage, clearChildrenStyleEdits]);
useEffect(() => {
if (removeElementEvent?.artboardId === id && isReadyRef.current) {
sendMessage('REMOVE_ELEMENT', { nodeId: removeElementEvent.nodeId });
@@ -3,6 +3,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine';
@@ -37,6 +38,11 @@ export function SelectionOverlay({
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pushEdit = useHistory(s => s.pushEdit);
const { dispatchRemoveElement } = useCanvas();
// C-3 fix: domRect values come from getBoundingClientRect() inside the iframe —
// they are in the iframe's own unscaled coordinate space (0..frameWidth/Height).
// Mouse events on the overlay are in screen space, which is scaled by canvas zoom.
// We must divide by zoom to convert screen-space click coords to iframe-space.
const zoom = useViewport(s => s.zoom);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -48,8 +54,9 @@ export function SelectionOverlay({
}
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const clickX = e.clientX - overlayRect.left;
const clickY = e.clientY - overlayRect.top;
// Convert screen-space coords to iframe-space by dividing by zoom.
const clickX = (e.clientX - overlayRect.left) / zoom;
const clickY = (e.clientY - overlayRect.top) / zoom;
const hit = hitTestFiber(fiberRoot, clickX, clickY);
if (hit) {
@@ -61,19 +68,19 @@ export function SelectionOverlay({
onSelectionChange?.(null);
}
},
[fiberRoot, onSelectionChange]
[fiberRoot, onSelectionChange, zoom]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!fiberRoot) return;
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - overlayRect.left;
const y = e.clientY - overlayRect.top;
const x = (e.clientX - overlayRect.left) / zoom;
const y = (e.clientY - overlayRect.top) / zoom;
const hit = hitTestFiber(fiberRoot, x, y);
setHoveredId(hit?.id ?? null);
},
[fiberRoot]
[fiberRoot, zoom]
);
const handleKeyDown = useCallback(
@@ -13,6 +13,7 @@ import { useViewport } from '@/store/viewport';
import { useTheme } from '@/store/theme';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useWalkthrough } from '@/store/walkthrough';
import { useIndexer } from '@/hooks/useIndexer';
interface AppChromeProps {
workspaceId?: string;
@@ -29,6 +30,9 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
const CT = useCanvasTheme();
const startTour = useWalkthrough((s) => s.start);
// Connect to the CLI AST indexer (reads window.__OM_INDEX_URL__, no-op if absent)
useIndexer();
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
useEffect(() => {
if (workspaceId && projectId) setContext(workspaceId, projectId);
@@ -0,0 +1,382 @@
'use client';
// ── Shared Design Panel Primitives ────────────────────────────────────────────
// Small, reusable input components for the Design Panel sections.
// All accept an `onPatch(property, value)` callback that flows up to
// useCanvas().patchStyleEdit → PATCH_ELEMENT_STYLE → fiber hook.
import { useState, useRef } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
// ── Number / CSS value helpers ────────────────────────────────────────────────
export function parseCssNum(val: string | undefined): string {
if (!val) return '';
const m = val.match(/^(-?[\d.]+)/);
return m?.[1] ?? '';
}
export function parseCssUnit(val: string | undefined): string {
if (!val) return 'px';
const m = val.match(/^-?[\d.]+(.*)$/);
return m?.[1]?.trim() ?? '';
}
export function rgbToHex(rgb: string): string {
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000';
return '#' + [m[1], m[2], m[3]]
.map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
.join('');
}
export 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));
}
export function isTransparent(val: string | undefined): boolean {
if (!val) return true;
// M-1 fix: browsers differ in their getComputedStyle representation of
// transparent. Chrome: "rgba(0, 0, 0, 0)", Firefox: "rgba(0,0,0,0)",
// Safari: "transparent". Match all three with a regex.
return val === 'transparent' || /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(val);
}
// ── Section separator ─────────────────────────────────────────────────────────
export function HSep() {
const T = useCanvasTheme();
return <div style={{ height: 1, background: T.sep, flexShrink: 0 }} />;
}
// ── Section header with collapse toggle ──────────────────────────────────────
export function SectionHeader({
label,
expanded,
onToggle,
action,
}: {
label: string;
expanded: boolean;
onToggle: () => void;
action?: React.ReactNode;
}) {
const T = useCanvasTheme();
return (
<button
onClick={onToggle}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '7px 14px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
userSelect: 'none',
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.label,
flex: 1,
textAlign: 'left',
}}>
{label}
</span>
{action}
<span style={{ color: T.dim, fontSize: '0.55rem', marginLeft: 6 }}>
{expanded ? '▾' : '▸'}
</span>
</button>
);
}
// ── Field label ───────────────────────────────────────────────────────────────
export 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>
);
}
// ── Numeric stepper input ─────────────────────────────────────────────────────
export function NumInput({
value,
propKey,
onPatch,
inputWidth = 60,
readOnly,
title,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
inputWidth?: number;
readOnly?: boolean;
title?: string;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
const [draft, setDraft] = useState(parseCssNum(value));
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
readOnly={readOnly}
title={title}
value={draft}
onChange={e => !readOnly && setDraft(e.target.value)}
onBlur={() => !readOnly && commit(draft)}
onKeyDown={e => {
if (readOnly) return;
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: readOnly ? T.bgDeep : T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: readOnly ? T.dim : T.fg,
padding: '3px 6px',
width: inputWidth,
outline: 'none',
textAlign: 'right',
boxSizing: 'border-box',
cursor: readOnly ? 'default' : 'text',
}}
/>
);
}
// ── Plain text input ──────────────────────────────────────────────────────────
export function TextInput({
value,
propKey,
onPatch,
fullWidth,
placeholder,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
fullWidth?: boolean;
placeholder?: string;
}) {
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}
placeholder={placeholder}
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 ──────────────────────────────────────────────────
export 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 }}>
<div
title="Pick color"
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>
<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 styled select ──────────────────────────────────────────────────────
export function CssSelect({
value,
propKey,
options,
onPatch,
width,
}: {
value: string;
propKey: string;
options: Array<{ val: string; label: string }>;
onPatch: (prop: string, val: string) => void;
width?: number | string;
}) {
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: width ? undefined : 1,
width: width,
outline: 'none',
}}
>
{options.map(o => <option key={o.val} value={o.val}>{o.label}</option>)}
</select>
);
}
// ── Icon toggle group ─────────────────────────────────────────────────────────
export function IconToggleGroup<T extends string>({
value,
options,
onPatch,
}: {
value: string;
options: Array<{ val: T; icon: string; title?: string }>;
onPatch: (val: T) => void;
}) {
const T = useCanvasTheme();
return (
<div style={{ display: 'flex', gap: 2 }}>
{options.map(o => (
<button
key={o.val}
title={o.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,
}}
>
{o.icon}
</button>
))}
</div>
);
}
@@ -10,6 +10,14 @@ import { useCanvasTheme } from '@/store/canvasTheme';
import type { PropChange } from '@originmain/diff-engine';
import type { FiberNode } from '@originmain/renderer';
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
import { FrameSection } from './sections/FrameSection';
import { LayoutSection } from './sections/LayoutSection';
import { FillSection } from './sections/FillSection';
import { StrokeSection } from './sections/StrokeSection';
import { EffectsSection } from './sections/EffectsSection';
import { TypographySection } from './sections/TypographySection';
import { BoxModelSection } from './sections/BoxModelSection';
import { ConstraintsSection } from './sections/ConstraintsSection';
const TYPE_COLORS: Record<string, string> = {
s: '#7DD3A8',
@@ -99,6 +107,7 @@ export function Inspector() {
<DesignTab
artboardId={selectedArtboardId}
componentId={selectedComponentId}
componentData={selectedComponentData}
styles={selectedComponentStyles}
/>
) : tab === 'props' ? (
@@ -523,14 +532,16 @@ function FieldLabel({ children }: { children: React.ReactNode }) {
function DesignTab({
artboardId,
componentId,
componentData,
styles,
}: {
artboardId: string | null;
componentId: string | null;
componentData: FiberNode | null;
styles: Record<string, string> | null;
}) {
const T = useCanvasTheme();
const { patchStyleEdit } = useCanvas();
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren } = useCanvas();
if (!artboardId) {
return (
@@ -571,239 +582,98 @@ function DesignTab({
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');
const patchChildren = (selector: string, prop: string, val: string) => {
if (!artboardId || !componentId) return;
patchChildrenStyleEdit(artboardId, componentId, selector, prop, val);
};
// ── Derive call-site display ──────────────────────────────────────
const callSite = componentData?.callSite;
const callSiteLabel = callSite
? (() => {
// Show the last two path segments for readability: "app/page.tsx:34"
const parts = callSite.fileName.replace(/\\/g, '/').split('/');
const short = parts.slice(-2).join('/');
return `${short}:${callSite.lineNumber}`;
})()
: null;
// ── Indexer status dot ────────────────────────────────────────────
const indexerDot = {
offline: { color: T.dim, title: 'CLI indexer offline' },
indexing: { color: '#FFBA7B', title: 'Indexing…' },
ready: { color: '#7DD3A8', title: 'Indexer ready' },
}[indexerStatus];
return (
<>
{/* ── Dimensions ────────────────────────────────────── */}
<div style={{ padding: '10px 14px 8px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 10px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>W</FieldLabel>
<NumInput value={s['width'] ?? '0px'} propKey="width" onPatch={patch} inputWidth={88} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>H</FieldLabel>
<NumInput value={s['height'] ?? '0px'} propKey="height" onPatch={patch} inputWidth={88} />
</div>
</div>
</div>
<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}
{/* ── Component identity header ────────────────────────────── */}
<div style={{
padding: '10px 14px 8px',
borderBottom: `1px solid ${T.sep}`,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Component name */}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.6875rem',
fontWeight: 600,
color: T.fg,
letterSpacing: '-0.01em',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{componentData?.name ?? componentId}
</span>
{/* Indexer dot */}
<div
title={indexerDot.title}
style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: indexerDot.color,
boxShadow: indexerStatus === 'ready' ? `0 0 5px ${indexerDot.color}` : 'none',
transition: 'background 0.3s',
}}
/>
</div>
{/* Flex controls */}
{isFlexLayout && (
<>
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'flex-end' }}>
<FlexDirToggle
value={s['flex-direction'] ?? 'row'}
onPatch={(v) => patch('flex-direction', v)}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Gap</FieldLabel>
<NumInput value={s['gap'] ?? '0px'} propKey="gap" onPatch={patch} inputWidth={48} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={s['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
{ val: 'baseline', label: 'baseline' },
]}
onPatch={patch}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Justify</FieldLabel>
<CssSelect
value={s['justify-content'] ?? 'flex-start'}
propKey="justify-content"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'space-between', label: 'between' },
{ val: 'space-around', label: 'around' },
{ val: 'space-evenly', label: 'evenly' },
]}
onPatch={patch}
/>
</div>
</div>
</>
)}
{/* Padding — 4-corner grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 4 }}>
{[
{ label: '↑', prop: 'padding-top' },
{ label: '→', prop: 'padding-right' },
{ label: '↓', prop: 'padding-bottom' },
{ label: '←', prop: 'padding-left' },
].map(({ label, prop }) => (
<div key={prop} style={{ display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'center' }}>
<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>
{/* Call-site breadcrumb — "used in app/page.tsx:34" */}
{callSiteLabel && (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.02em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={`${callSite?.fileName}:${callSite?.lineNumber}`}
>
{callSiteLabel}
</span>
)}
</div>
{/* ── Section components ───────────────────────────────────── */}
<FrameSection styles={styles} onPatch={patch} />
<ConstraintsSection styles={styles} onPatch={patch} />
<LayoutSection styles={styles} onPatch={patch} />
<FillSection styles={styles} onPatch={patch} />
<StrokeSection styles={styles} onPatch={patch} />
<TypographySection
styles={styles}
hasDirectText={selectedComponentHasDirectText}
hasParagraphChildren={selectedComponentHasParagraphChildren}
onPatch={patch}
onPatchChildren={patchChildren}
/>
<EffectsSection styles={styles} onPatch={patch} />
<BoxModelSection styles={styles} onPatch={patch} />
</>
);
}
@@ -0,0 +1,237 @@
'use client';
// ── Box Model Section — Margin / Padding visual diagram ───────────────────────
// Renders a concentric-rectangle diagram (like browser DevTools' box model view).
// Outer ring = margin, middle ring = border, inner ring = padding, center = W×H.
// Each editable quad shows the current value; clicking focuses an inline input.
import { useState } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { SectionHeader, HSep, parseCssNum, parseCssUnit } from '../DesignInputs';
interface BoxModelSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
// Parse a shorthand-or-individual CSS value for one side.
function getSide(styles: Record<string, string>, base: string, side: string): string {
const individual = styles[`${base}-${side}`];
if (individual) return individual;
return styles[base] ?? '0px';
}
// Tiny inline editable cell for a box model value.
function BoxCell({
prop,
value,
onPatch,
mini = false,
style: styleProp,
}: {
prop: string;
value: string;
onPatch: (prop: string, val: string) => void;
mini?: boolean;
style?: React.CSSProperties;
}) {
const T = useCanvasTheme();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const num = parseCssNum(value);
const unit = parseCssUnit(value);
const display = num || '0';
function commit(raw: string) {
const n = parseFloat(raw);
if (!isNaN(n)) onPatch(prop, `${n}${unit || 'px'}`);
setEditing(false);
}
if (editing) {
return (
<input
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={e => {
if (e.key === 'Enter') commit(draft);
if (e.key === 'Escape') setEditing(false);
}}
style={{
width: mini ? 28 : 36,
textAlign: 'center',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
background: T.bgDeep,
border: `1px solid ${T.accent}`,
borderRadius: 3,
color: T.fg,
outline: 'none',
padding: '1px 3px',
...styleProp,
}}
/>
);
}
return (
<span
onClick={() => { setEditing(true); setDraft(num || '0'); }}
title={`${prop}: ${value}`}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.fg,
cursor: 'text',
minWidth: mini ? 20 : 28,
textAlign: 'center',
display: 'inline-block',
padding: '1px 2px',
borderRadius: 2,
transition: 'background 0.1s',
...styleProp,
}}
onMouseEnter={e => (e.currentTarget.style.background = T.hoverBg ?? 'rgba(255,255,255,0.06)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{display}
</span>
);
}
export function BoxModelSection({ styles, onPatch }: BoxModelSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(false);
// Margin values
const mt = getSide(styles, 'margin', 'top');
const mr = getSide(styles, 'margin', 'right');
const mb = getSide(styles, 'margin', 'bottom');
const ml = getSide(styles, 'margin', 'left');
// Padding values
const pt = getSide(styles, 'padding', 'top');
const pr = getSide(styles, 'padding', 'right');
const pb = getSide(styles, 'padding', 'bottom');
const pl = getSide(styles, 'padding', 'left');
// Border values (display only — editable via StrokeSection)
const bw = styles['border-width'] ?? '0px';
// Dimensions
const w = parseCssNum(styles['width'] ?? '0px') || '—';
const h = parseCssNum(styles['height'] ?? '0px') || '—';
// Shared ring styles
const ringBase: React.CSSProperties = {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};
const labelStyle: React.CSSProperties = {
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.4375rem',
color: T.dim,
letterSpacing: '0.06em',
textTransform: 'uppercase',
position: 'absolute',
top: 4,
left: 6,
userSelect: 'none',
pointerEvents: 'none',
};
return (
<>
<SectionHeader label="Box Model" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '8px 14px 12px' }}>
{/* Outermost = margin */}
<div style={{
...ringBase,
background: 'rgba(255,200,100,0.07)',
border: `1px solid rgba(255,200,100,0.18)`,
borderRadius: 5,
padding: '18px 20px',
minHeight: 130,
}}>
<span style={labelStyle}>margin</span>
{/* Top margin */}
<BoxCell prop="margin-top" value={mt} onPatch={onPatch}
mini style={{ position: 'absolute', top: 6, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
{/* Bottom margin */}
<BoxCell prop="margin-bottom" value={mb} onPatch={onPatch}
mini style={{ position: 'absolute', bottom: 6, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
{/* Left margin */}
<BoxCell prop="margin-left" value={ml} onPatch={onPatch}
mini style={{ position: 'absolute', left: 4, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
{/* Right margin */}
<BoxCell prop="margin-right" value={mr} onPatch={onPatch}
mini style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
{/* Border ring */}
<div style={{
...ringBase,
background: 'rgba(130,170,255,0.07)',
border: `1px solid rgba(130,170,255,0.22)`,
borderRadius: 4,
width: '100%',
minHeight: 96,
padding: '14px 16px',
}}>
<span style={{ ...labelStyle, color: 'rgba(130,170,255,0.55)' }}>border</span>
{/* Border width — read-only hint */}
<span style={{
position: 'absolute', top: 6, left: '50%', transform: 'translateX(-50%)',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: 'rgba(130,170,255,0.55)',
}}>
{parseCssNum(bw) || '0'}
</span>
{/* Padding ring */}
<div style={{
...ringBase,
background: 'rgba(100,200,130,0.07)',
border: `1px solid rgba(100,200,130,0.22)`,
borderRadius: 3,
width: '100%',
minHeight: 60,
padding: '10px 12px',
}}>
<span style={{ ...labelStyle, color: 'rgba(100,200,130,0.55)' }}>padding</span>
<BoxCell prop="padding-top" value={pt} onPatch={onPatch}
mini style={{ position: 'absolute', top: 4, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
<BoxCell prop="padding-bottom" value={pb} onPatch={onPatch}
mini style={{ position: 'absolute', bottom: 4, left: '50%', transform: 'translateX(-50%)' } as React.CSSProperties} />
<BoxCell prop="padding-left" value={pl} onPatch={onPatch}
mini style={{ position: 'absolute', left: 2, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
<BoxCell prop="padding-right" value={pr} onPatch={onPatch}
mini style={{ position: 'absolute', right: 2, top: '50%', transform: 'translateY(-50%)' } as React.CSSProperties} />
{/* Content dimensions */}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.fg,
opacity: 0.7,
whiteSpace: 'nowrap',
}}>
{w} × {h}
</span>
</div>
</div>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,361 @@
'use client';
// ── Constraints Section ───────────────────────────────────────────────────────
// Figma-style constraint anchors: 3×3 grid for horizontal and vertical pinning.
// Only shown when the element's position is 'absolute' or 'fixed'.
//
// Horizontal options: Left | Center | Right | Left+Right | Scale
// Vertical options: Top | Center | Bottom | Top+Bottom | Scale
//
// Selecting a constraint writes the appropriate CSS properties:
// Left → left: Xpx, removes right
// Right → right: Xpx, removes left
// Center → left: 50%, transform: translateX(-50%)
// Left+Right → both left and right set
// Scale → width: X% (relative to parent)
import { useState } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { SectionHeader, HSep } from '../DesignInputs';
type HConstraint = 'left' | 'center' | 'right' | 'left+right' | 'scale';
type VConstraint = 'top' | 'center' | 'bottom' | 'top+bottom' | 'scale';
interface ConstraintsSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
// ── Transform composition helpers ────────────────────────────────────────────
// These keep centering (translateX/Y) from clobbering other transforms such
// as CSS `rotate` or `scale` that may be set on the same element. (BUG-4)
/**
* Replace or insert a single CSS translate function inside an existing
* transform string without disturbing other functions (rotate, scale, etc.).
*/
function withTranslateFn(
existing: string,
fn: 'translateX' | 'translateY',
value: string,
): string {
const newFn = `${fn}(${value})`;
if (!existing || existing === 'none') return newFn;
const re = new RegExp(`${fn}\\([^)]*\\)`, 'g');
if (re.test(existing)) return existing.replace(re, newFn);
return `${existing} ${newFn}`;
}
/**
* Remove all translateX/Y functions from a transform string.
* Used when switching to a pin or scale constraint that shouldn't centre.
*/
function withoutTranslateFns(existing: string): string {
if (!existing || existing === 'none') return 'none';
const cleaned = existing.replace(/translate[XY]\([^)]*\)\s*/g, '').trim();
return cleaned || 'none';
}
// ── Constraint inference ──────────────────────────────────────────────────────
// NOTE: `styles` comes from getComputedStyle, so percentage values are
// resolved to pixels. We can't detect 'center' from left==='50%' (BUG-5).
// Instead we use the presence of a translateX/Y in the transform string as a
// proxy — applyH/applyV always add them when centering. This is still
// best-effort: after a page reload the inline style is gone and the element
// will appear as 'left'/'top' until the user re-applies a constraint.
/** Infer current H constraint from computed styles */
function inferHConstraint(styles: Record<string, string>): HConstraint {
const left = styles['left'] ?? 'auto';
const right = styles['right'] ?? 'auto';
const transform = styles['transform'] ?? '';
// left+right must be checked before center to handle the edge case where
// both sides are pinned and a translateX somehow also exists.
if (left !== 'auto' && right !== 'auto') return 'left+right';
// Detect center via translateX in the transform (set by applyH).
// The '50%' check is retained as a secondary signal for inline styles.
if (transform.includes('translateX') || left.includes('50%')) return 'center';
if (right !== 'auto' && left === 'auto') return 'right';
return 'left';
}
/** Infer current V constraint from computed styles */
function inferVConstraint(styles: Record<string, string>): VConstraint {
const top = styles['top'] ?? 'auto';
const bottom = styles['bottom'] ?? 'auto';
const transform = styles['transform'] ?? '';
if (top !== 'auto' && bottom !== 'auto') return 'top+bottom';
if (transform.includes('translateY') || top.includes('50%')) return 'center';
if (bottom !== 'auto' && top === 'auto') return 'bottom';
return 'top';
}
// A single dot in the 3×3 constraint grid.
function ConstraintDot({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
const T = useCanvasTheme();
return (
<button
onClick={onClick}
title={label}
aria-label={label}
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: active ? T.accent : 'transparent',
border: `1.5px solid ${active ? T.accent : 'rgba(255,255,255,0.22)'}`,
cursor: 'pointer',
padding: 0,
transition: 'background 0.1s, border-color 0.1s',
flexShrink: 0,
}}
/>
);
}
export function ConstraintsSection({ styles, onPatch }: ConstraintsSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(true);
const isPositioned = styles['position'] === 'absolute' || styles['position'] === 'fixed';
if (!isPositioned) return null;
const hConstraint = inferHConstraint(styles);
const vConstraint = inferVConstraint(styles);
function applyH(c: HConstraint) {
const curLeft = styles['left'] ?? '0px';
const curRight = styles['right'] ?? '0px';
const curTransform = styles['transform'] ?? 'none';
switch (c) {
case 'left':
onPatch('left', curLeft === 'auto' ? '0px' : curLeft);
onPatch('right', 'auto');
// BUG-15 fix: clear any translateX that was set by a previous center constraint.
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'right':
onPatch('right', curRight === 'auto' ? '0px' : curRight);
onPatch('left', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'center':
onPatch('left', '50%');
onPatch('right', 'auto');
// BUG-4 fix: compose translateX into the existing transform rather than
// replacing it, so rotate/scale on the element are preserved.
onPatch('transform', withTranslateFn(curTransform, 'translateX', '-50%'));
break;
case 'left+right':
onPatch('left', curLeft === 'auto' ? '0px' : curLeft);
onPatch('right', curRight === 'auto' ? '0px' : curRight);
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'scale':
onPatch('width', '100%');
onPatch('left', '0px');
onPatch('right', 'auto');
// BUG-15 fix: clear translateX so a previously-centred element doesn't
// remain shifted after switching to scale mode.
onPatch('transform', withoutTranslateFns(curTransform));
break;
}
}
function applyV(c: VConstraint) {
const curTop = styles['top'] ?? '0px';
const curBottom = styles['bottom'] ?? '0px';
const curTransform = styles['transform'] ?? 'none';
switch (c) {
case 'top':
onPatch('top', curTop === 'auto' ? '0px' : curTop);
onPatch('bottom', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'bottom':
onPatch('bottom', curBottom === 'auto' ? '0px' : curBottom);
onPatch('top', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'center':
onPatch('top', '50%');
onPatch('bottom', 'auto');
// BUG-4 fix: compose translateY, preserving other transform functions.
onPatch('transform', withTranslateFn(curTransform, 'translateY', '-50%'));
break;
case 'top+bottom':
onPatch('top', curTop === 'auto' ? '0px' : curTop);
onPatch('bottom', curBottom === 'auto' ? '0px' : curBottom);
onPatch('transform', withoutTranslateFns(curTransform));
break;
case 'scale':
onPatch('height', '100%');
onPatch('top', '0px');
onPatch('bottom', 'auto');
onPatch('transform', withoutTranslateFns(curTransform));
break;
}
}
// The 3×3 grid encodes:
// Row/col 0 = start (left/top)
// Row/col 1 = center
// Row/col 2 = end (right/bottom)
// Both H and V dots share the same grid; the horizontal line represents H constraints
// and the vertical line represents V constraints.
//
// TL T TR ← these dots are the 9 "anchor" positions
// L C R
// BL B BR
type GridPos = [number, number]; // [col, row]
const hPositions: Array<{ pos: GridPos; h: HConstraint; label: string }> = [
{ pos: [0, 1], h: 'left', label: 'Pin left' },
{ pos: [1, 1], h: 'center', label: 'Center horizontally' },
{ pos: [2, 1], h: 'right', label: 'Pin right' },
];
const vPositions: Array<{ pos: GridPos; v: VConstraint; label: string }> = [
{ pos: [1, 0], v: 'top', label: 'Pin top' },
{ pos: [1, 1], v: 'center', label: 'Center vertically' },
{ pos: [1, 2], v: 'bottom', label: 'Pin bottom' },
];
const cornerLabels: Record<string, string> = {
'0,0': 'Pin top-left', '1,0': '', '2,0': 'Pin top-right',
'0,1': '', '1,1': '', '2,1': '',
'0,2': 'Pin bottom-left', '1,2': '', '2,2': 'Pin bottom-right',
};
return (
<>
<SectionHeader label="Constraints" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '8px 14px 12px' }}>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
{/* 3×3 dot grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 10px)',
gridTemplateRows: 'repeat(3, 10px)',
gap: 7,
position: 'relative',
}}
>
{/* Guide lines inside the grid */}
<div style={{
position: 'absolute',
left: '50%', top: 4, bottom: 4,
width: 1, background: 'rgba(255,255,255,0.08)',
transform: 'translateX(-50%)',
pointerEvents: 'none',
}} />
<div style={{
position: 'absolute',
top: '50%', left: 4, right: 4,
height: 1, background: 'rgba(255,255,255,0.08)',
transform: 'translateY(-50%)',
pointerEvents: 'none',
}} />
{/* Render 9 dots */}
{[0, 1, 2].flatMap(row =>
[0, 1, 2].map(col => {
const key = `${col},${row}`;
const hMatch = hPositions.find(p => p.pos[0] === col && p.pos[1] === row);
const vMatch = vPositions.find(p => p.pos[0] === col && p.pos[1] === row);
const isActiveH = hMatch ? hConstraint === hMatch.h : false;
const isActiveV = vMatch ? vConstraint === vMatch.v : false;
const isActive = isActiveH || isActiveV;
const label = hMatch?.label ?? vMatch?.label ?? cornerLabels[key] ?? '';
return (
<ConstraintDot
key={key}
active={isActive}
label={label}
onClick={() => {
if (hMatch) applyH(hMatch.h);
if (vMatch) applyV(vMatch.v);
}}
/>
);
})
)}
</div>
{/* Text labels for current constraints */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem', color: T.dim, width: 18, flexShrink: 0,
}}>H</span>
<div style={{ display: 'flex', gap: 3 }}>
{(['left', 'center', 'right', 'left+right', 'scale'] as HConstraint[]).map(c => (
<button
key={c}
onClick={() => applyH(c)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '2px 5px',
borderRadius: 3,
border: `1px solid ${hConstraint === c ? T.accent : 'rgba(255,255,255,0.12)'}`,
background: hConstraint === c ? `${T.accent}22` : 'transparent',
color: hConstraint === c ? T.accent : T.dim,
cursor: 'pointer',
transition: 'all 0.1s',
whiteSpace: 'nowrap',
}}
>
{c === 'left+right' ? '↔' : c === 'scale' ? '%' : c}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem', color: T.dim, width: 18, flexShrink: 0,
}}>V</span>
<div style={{ display: 'flex', gap: 3 }}>
{(['top', 'center', 'bottom', 'top+bottom', 'scale'] as VConstraint[]).map(c => (
<button
key={c}
onClick={() => applyV(c)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '2px 5px',
borderRadius: 3,
border: `1px solid ${vConstraint === c ? T.accent : 'rgba(255,255,255,0.12)'}`,
background: vConstraint === c ? `${T.accent}22` : 'transparent',
color: vConstraint === c ? T.accent : T.dim,
cursor: 'pointer',
transition: 'all 0.1s',
whiteSpace: 'nowrap',
}}
>
{c === 'top+bottom' ? '↕' : c === 'scale' ? '%' : c}
</button>
))}
</div>
</div>
</div>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,132 @@
'use client';
// ── Effects Section — Box Shadow & Filters ────────────────────────────────────
// Surfaces box-shadow (drop shadow / inner shadow), filter: blur, backdrop-filter.
import { useState } from 'react';
import { NumInput, ColorInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
import { useCanvasTheme } from '@/store/canvasTheme';
// ── Filter helpers ────────────────────────────────────────────────────────────
// BUG-9 fix: naive .replace('blur(','') breaks for multi-function filter
// strings like "brightness(1.1) blur(4px)". Use regex-based extraction and
// surgical replacement instead.
/** Extract the numeric argument of blur() from a CSS filter string. */
function extractBlurValue(filter: string): string {
const m = filter.match(/\bblur\(([\d.]+[a-z%]*)\)/);
return m?.[1] ?? '0px';
}
/** Replace (or insert) the blur() function in a filter string, preserving others. */
function patchBlurInFilter(filter: string, newVal: string): string {
const fn = `blur(${newVal})`;
if (!filter || filter === 'none') return fn;
if (/\bblur\(/.test(filter)) return filter.replace(/\bblur\([^)]*\)/, fn);
return `${filter} ${fn}`;
}
interface EffectsSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function EffectsSection({ styles, onPatch }: EffectsSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(false);
const boxShadow = styles['box-shadow'] ?? '';
const filterBlur = styles['filter'] ?? '';
const backdropBlur = styles['backdrop-filter'] ?? '';
const hasEffect = boxShadow && boxShadow !== 'none'
|| filterBlur && filterBlur !== 'none'
|| backdropBlur && backdropBlur !== 'none';
return (
<>
<SectionHeader label="Effects" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* Box shadow display */}
{boxShadow && boxShadow !== 'none' ? (
<div style={{ marginBottom: 8 }}>
<FieldLabel>Shadow</FieldLabel>
<div
style={{
marginTop: 4,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.fgMuted,
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
padding: '4px 6px',
wordBreak: 'break-all',
}}
>
{boxShadow}
</div>
<button
onClick={() => onPatch('box-shadow', 'none')}
style={{
marginTop: 4,
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
background: 'transparent',
border: 'none',
color: 'rgba(255,80,80,0.7)',
cursor: 'pointer',
padding: 0,
}}
>
Remove shadow
</button>
</div>
) : (
<button
onClick={() => onPatch('box-shadow', '0 2px 8px rgba(0,0,0,0.3)')}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
marginBottom: 8,
}}
>
+ Add shadow
</button>
)}
{/* Layer blur */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<FieldLabel>Layer blur</FieldLabel>
<NumInput
value={extractBlurValue(filterBlur)}
propKey="filter"
onPatch={(_, v) => onPatch('filter', patchBlurInFilter(filterBlur, v))}
inputWidth={52}
/>
</div>
{/* Background blur */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FieldLabel>BG blur</FieldLabel>
<NumInput
value={extractBlurValue(backdropBlur)}
propKey="backdrop-filter"
onPatch={(_, v) => onPatch('backdrop-filter', patchBlurInFilter(backdropBlur, v))}
inputWidth={52}
/>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,64 @@
'use client';
// ── Fill Section — Background Color & Opacity ─────────────────────────────────
// Shows fill color, opacity, and a "no fill" empty state.
import { useState } from 'react';
import { isTransparent, ColorInput, NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
interface FillSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function FillSection({ styles, onPatch }: FillSectionProps) {
const [open, setOpen] = useState(true);
const bg = styles['background-color'] ?? '';
return (
<>
<SectionHeader label="Fill" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{isTransparent(bg) ? (
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={() => onPatch('background-color', '#ffffff')}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
}}
>
+ Add fill
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ColorInput value={bg} propKey="background-color" onPatch={onPatch} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
<FieldLabel>Opacity</FieldLabel>
<NumInput
value={styles['opacity'] !== undefined ? `${Math.round(parseFloat(styles['opacity'] ?? '1') * 100)}%` : '100%'}
propKey="_opacity"
onPatch={(_, v) => {
const pct = parseFloat(v.replace('%', ''));
if (!isNaN(pct)) onPatch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
}}
inputWidth={44}
/>
</div>
</div>
)}
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,141 @@
'use client';
// ── Frame Section — Position & Size ──────────────────────────────────────────
// Shows X, Y (read-only for static; editable for absolute/fixed), W, H,
// rotation, and corner radius.
import { useState } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
// ── Rotation helpers ──────────────────────────────────────────────────────────
// We use the CSS Transforms Level 2 `rotate` property rather than the
// `transform` shorthand. This keeps rotation orthogonal to any
// translateX(-50%) centering applied by the Constraints section.
//
// For display purposes we also try to parse an existing `transform: matrix(…)`
// so pre-existing rotated elements show their angle on first selection.
/** Extract rotation degrees from the CSS `rotate` property or a `matrix()` transform. */
function readRotationDeg(styles: Record<string, string>): string {
// CSS Transforms Level 2: `rotate: 45deg` — use this if present.
const rotateProp = styles['rotate'];
if (rotateProp && rotateProp !== 'none') {
const m = rotateProp.match(/^(-?[\d.]+)deg$/);
if (m) return m[1] ?? '0';
}
// Fallback: extract angle from a computed matrix(a,b,c,d,tx,ty).
const transform = styles['transform'];
if (transform && transform.startsWith('matrix(')) {
const parts = transform.slice(7, -1).split(',');
const a = parseFloat(parts[0] ?? '1');
const b = parseFloat(parts[1] ?? '0');
const deg = Math.round(Math.atan2(b, a) * (180 / Math.PI));
return String(deg);
}
return '0';
}
interface FrameSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function FrameSection({ styles, onPatch }: FrameSectionProps) {
const T = useCanvasTheme();
const [open, setOpen] = useState(true);
const isPositioned = styles['position'] === 'absolute' || styles['position'] === 'fixed';
// Corner radius: parse uniform value
const borderRadius = styles['border-radius'] ?? '0px';
return (
<>
<SectionHeader label="Frame" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* X / Y */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>X</FieldLabel>
<NumInput
value={styles['left'] ?? '0px'}
propKey="left"
onPatch={onPatch}
inputWidth={80}
readOnly={!isPositioned}
{...(!isPositioned && { title: 'Set position: absolute to edit' })}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Y</FieldLabel>
<NumInput
value={styles['top'] ?? '0px'}
propKey="top"
onPatch={onPatch}
inputWidth={80}
readOnly={!isPositioned}
{...(!isPositioned && { title: 'Set position: absolute to edit' })}
/>
</div>
</div>
{/* W / H */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>W</FieldLabel>
<NumInput value={styles['width'] ?? '0px'} propKey="width" onPatch={onPatch} inputWidth={80} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>H</FieldLabel>
<NumInput value={styles['height'] ?? '0px'} propKey="height" onPatch={onPatch} inputWidth={80} />
</div>
</div>
{/* Rotation — writes to the `rotate` CSS property (Transforms Level 2)
so it doesn't clobber translateX(-50%) from Constraints centering. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Rotation °</FieldLabel>
<NumInput
value={readRotationDeg(styles) + 'deg'}
propKey="rotate"
onPatch={onPatch}
inputWidth={80}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Radius</FieldLabel>
<NumInput value={borderRadius} propKey="border-radius" onPatch={onPatch} inputWidth={80} />
</div>
</div>
{/* Overflow / clip */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FieldLabel>Overflow</FieldLabel>
<select
value={styles['overflow'] ?? 'visible'}
onChange={e => onPatch('overflow', 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',
outline: 'none',
}}
>
<option value="visible">visible</option>
<option value="hidden">hidden</option>
<option value="auto">auto</option>
<option value="scroll">scroll</option>
</select>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,192 @@
'use client';
// ── Layout Section — Display / Flex / Grid / Padding ─────────────────────────
// Adapts to the element's display mode: block (collapsed), flex, grid.
import { useState, useEffect, useRef } from 'react';
import { NumInput, TextInput, FieldLabel, SectionHeader, CssSelect, IconToggleGroup, HSep } from '../DesignInputs';
interface LayoutSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function LayoutSection({ styles, onPatch }: LayoutSectionProps) {
const display = styles['display'] ?? 'block';
// BUG-20 fix: useState only runs its initialiser once. When the user selects
// a different component the `display` prop changes but `open` would stay
// stale. We sync it on display changes while preserving explicit user
// toggles: if display changes (new component selected), auto-open for
// flex/grid, auto-close for block.
const [open, setOpen] = useState(display === 'flex' || display === 'grid' || display === 'inline-flex');
const prevDisplayRef = useRef(display);
useEffect(() => {
if (prevDisplayRef.current !== display) {
prevDisplayRef.current = display;
setOpen(display === 'flex' || display === 'grid' || display === 'inline-flex' || display === 'inline-grid');
}
}, [display]);
const isFlex = display === 'flex' || display === 'inline-flex';
const isGrid = display === 'grid' || display === 'inline-grid';
return (
<>
<SectionHeader label="Layout" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* Display mode */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<FieldLabel>Display</FieldLabel>
<CssSelect
value={display}
propKey="display"
options={[
{ val: 'block', label: 'block' },
{ val: 'flex', label: 'flex' },
{ val: 'inline-flex', label: 'inline-flex' },
{ val: 'grid', label: 'grid' },
{ val: 'inline-grid', label: 'inline-grid' },
{ val: 'inline-block', label: 'inline-block' },
{ val: 'inline', label: 'inline' },
{ val: 'none', label: 'none' },
]}
onPatch={onPatch}
/>
</div>
{/* Flex-specific controls */}
{isFlex && (
<>
{/* Direction + Wrap */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Direction</FieldLabel>
<IconToggleGroup
value={styles['flex-direction'] ?? 'row'}
options={[
{ val: 'row', icon: '→', title: 'Row' },
{ val: 'column', icon: '↓', title: 'Column' },
{ val: 'row-reverse', icon: '←', title: 'Row reverse' },
{ val: 'column-reverse', icon: '↑', title: 'Column reverse' },
]}
onPatch={v => onPatch('flex-direction', v)}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Wrap</FieldLabel>
<IconToggleGroup
value={styles['flex-wrap'] ?? 'nowrap'}
options={[
{ val: 'nowrap', icon: '⟷', title: 'No wrap' },
{ val: 'wrap', icon: '↩', title: 'Wrap' },
]}
onPatch={v => onPatch('flex-wrap', v)}
/>
</div>
{/* Align items */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={styles['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
{ val: 'baseline', label: 'baseline' },
]}
onPatch={onPatch}
/>
</div>
{/* Justify content */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Justify</FieldLabel>
<CssSelect
value={styles['justify-content'] ?? 'flex-start'}
propKey="justify-content"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'space-between', label: 'between' },
{ val: 'space-around', label: 'around' },
{ val: 'space-evenly', label: 'evenly' },
]}
onPatch={onPatch}
/>
</div>
{/* Gap */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Gap</FieldLabel>
<NumInput value={styles['gap'] ?? '0px'} propKey="gap" onPatch={onPatch} inputWidth={60} />
</div>
</>
)}
{/* Grid-specific controls */}
{isGrid && (
<>
<div style={{ marginBottom: 6 }}>
<FieldLabel>Columns</FieldLabel>
<TextInput value={styles['grid-template-columns'] ?? ''} propKey="grid-template-columns" onPatch={onPatch} fullWidth placeholder="1fr 1fr" />
</div>
<div style={{ marginBottom: 6 }}>
<FieldLabel>Rows</FieldLabel>
<TextInput value={styles['grid-template-rows'] ?? ''} propKey="grid-template-rows" onPatch={onPatch} fullWidth placeholder="auto" />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Col gap</FieldLabel>
<NumInput value={styles['column-gap'] ?? '0px'} propKey="column-gap" onPatch={onPatch} inputWidth={60} />
<FieldLabel>Row gap</FieldLabel>
<NumInput value={styles['row-gap'] ?? '0px'} propKey="row-gap" onPatch={onPatch} inputWidth={60} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Align</FieldLabel>
<CssSelect
value={styles['align-items'] ?? 'stretch'}
propKey="align-items"
options={[
{ val: 'flex-start', label: 'start' },
{ val: 'center', label: 'center' },
{ val: 'flex-end', label: 'end' },
{ val: 'stretch', label: 'stretch' },
]}
onPatch={onPatch}
/>
</div>
</>
)}
{/* Padding (always shown) */}
<div style={{ marginTop: 8 }}>
<FieldLabel>Padding</FieldLabel>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 6px', marginTop: 4 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Top</FieldLabel>
<NumInput value={styles['padding-top'] ?? '0px'} propKey="padding-top" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Right</FieldLabel>
<NumInput value={styles['padding-right'] ?? '0px'} propKey="padding-right" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Bottom</FieldLabel>
<NumInput value={styles['padding-bottom'] ?? '0px'} propKey="padding-bottom" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Left</FieldLabel>
<NumInput value={styles['padding-left'] ?? '0px'} propKey="padding-left" onPatch={onPatch} inputWidth={72} />
</div>
</div>
</div>
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,77 @@
'use client';
// ── Stroke Section — Border ────────────────────────────────────────────────────
import { useState } from 'react';
import { ColorInput, NumInput, FieldLabel, SectionHeader, CssSelect, HSep } from '../DesignInputs';
interface StrokeSectionProps {
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function StrokeSection({ styles, onPatch }: StrokeSectionProps) {
const [open, setOpen] = useState(false);
// M-3 fix: getComputedStyle returns `border-width` as a 4-value shorthand
// (e.g., "0px 0px 0px 0px" or "1px 1px 1px 1px"), not a single value.
// Read the more reliable `border-top-width` longhand for the presence check,
// and display the top width (uniform borders are by far the common case).
const bw = styles['border-top-width'] ?? styles['border-width'] ?? '0px';
// A border exists when any individual side width is non-zero.
const hasBorder = bw !== '' && bw !== '0px' && !bw.split(' ').every(v => v === '0px' || v === '0');
return (
<>
<SectionHeader label="Stroke" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{!hasBorder ? (
<button
onClick={() => { onPatch('border-width', '1px'); onPatch('border-style', 'solid'); onPatch('border-color', 'rgba(0,0,0,1)'); }}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
}}
>
+ Add stroke
</button>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<ColorInput value={styles['border-color'] ?? ''} propKey="border-color" onPatch={onPatch} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Width</FieldLabel>
<NumInput value={bw} propKey="border-width" onPatch={onPatch} inputWidth={72} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Style</FieldLabel>
<CssSelect
value={styles['border-style'] ?? 'solid'}
propKey="border-style"
options={[
{ val: 'solid', label: 'Solid' },
{ val: 'dashed', label: 'Dashed' },
{ val: 'dotted', label: 'Dotted' },
{ val: 'none', label: 'None' },
]}
onPatch={onPatch}
/>
</div>
</div>
</>
)}
</div>
)}
<HSep />
</>
);
}
@@ -0,0 +1,138 @@
'use client';
// ── Typography Section ────────────────────────────────────────────────────────
// Shown only when the element has direct text content (hasDirectText === true).
// Includes font family, size, weight, line height, letter spacing, paragraph
// spacing (when hasParagraphChildren), text-align, decoration, transform, color.
import { useState } from 'react';
import { NumInput, TextInput, FieldLabel, SectionHeader, CssSelect, ColorInput, IconToggleGroup, HSep } from '../DesignInputs';
interface TypographySectionProps {
styles: Record<string, string>;
hasDirectText: boolean;
hasParagraphChildren: boolean;
/** Fires PATCH_ELEMENT_STYLE for normal properties */
onPatch: (prop: string, val: string) => void;
/** Fires PATCH_CHILDREN_STYLE for paragraph-spacing */
onPatchChildren: (selector: string, prop: string, val: string) => void;
}
export function TypographySection({
styles,
hasDirectText,
hasParagraphChildren,
onPatch,
onPatchChildren,
}: TypographySectionProps) {
const [open, setOpen] = useState(true);
// Only show this section when the element has direct text content
if (!hasDirectText) return null;
return (
<>
<SectionHeader label="Typography" expanded={open} onToggle={() => setOpen(!open)} />
{open && (
<div style={{ padding: '0 14px 10px' }}>
{/* Font family */}
<div style={{ marginBottom: 6 }}>
<FieldLabel>Family</FieldLabel>
<TextInput
value={styles['font-family'] ?? ''}
propKey="font-family"
onPatch={onPatch}
fullWidth
/>
</div>
{/* Size / Weight / Line height */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px 6px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Size</FieldLabel>
<NumInput value={styles['font-size'] ?? '14px'} propKey="font-size" onPatch={onPatch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Weight</FieldLabel>
<NumInput value={styles['font-weight'] ?? '400'} propKey="font-weight" onPatch={onPatch} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Line H</FieldLabel>
<NumInput value={styles['line-height'] ?? 'normal'} propKey="line-height" onPatch={onPatch} />
</div>
</div>
{/* Letter spacing + text align */}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Tracking</FieldLabel>
<NumInput value={styles['letter-spacing'] ?? '0px'} propKey="letter-spacing" onPatch={onPatch} inputWidth={52} />
</div>
<IconToggleGroup
value={styles['text-align'] ?? 'left'}
options={[
{ val: 'left', icon: 'L', title: 'Left' },
{ val: 'center', icon: 'C', title: 'Center' },
{ val: 'right', icon: 'R', title: 'Right' },
{ val: 'justify', icon: 'J', title: 'Justify' },
]}
onPatch={v => onPatch('text-align', v)}
/>
</div>
{/* Color */}
<div style={{ marginBottom: 6 }}>
<FieldLabel>Color</FieldLabel>
<ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} />
</div>
{/* Decoration + Transform */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<div style={{ flex: 1 }}>
<FieldLabel>Decoration</FieldLabel>
<CssSelect
value={styles['text-decoration'] ?? 'none'}
propKey="text-decoration"
options={[
{ val: 'none', label: 'None' },
{ val: 'underline', label: 'Underline' },
{ val: 'line-through', label: 'Strikethrough' },
{ val: 'overline', label: 'Overline' },
]}
onPatch={onPatch}
/>
</div>
<div style={{ flex: 1 }}>
<FieldLabel>Transform</FieldLabel>
<CssSelect
value={styles['text-transform'] ?? 'none'}
propKey="text-transform"
options={[
{ val: 'none', label: 'None' },
{ val: 'uppercase', label: 'Uppercase' },
{ val: 'lowercase', label: 'Lowercase' },
{ val: 'capitalize', label: 'Capitalize' },
]}
onPatch={onPatch}
/>
</div>
</div>
{/* Paragraph spacing — only when <p> children exist */}
{hasParagraphChildren && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Paragraph spacing (applies to &lt;p&gt; children)</FieldLabel>
<NumInput
value={styles['margin-bottom'] ?? '0px'}
propKey="p-margin-bottom"
onPatch={(_, val) => onPatchChildren('p', 'margin-bottom', val)}
inputWidth={72}
/>
</div>
)}
</div>
)}
<HSep />
</>
);
}
+165
View File
@@ -0,0 +1,165 @@
'use client';
// ── useIndexer ────────────────────────────────────────────────────────────────
// Connects the canvas app to the CLI AST indexer server.
//
// On mount it:
// 1. Reads window.__OM_INDEX_URL__ injected by the CLI proxy. If absent,
// the indexer stays 'offline' and the hook is a no-op.
// 2. Fetches GET /health to hydrate projectMeta (framework, tailwind, etc.)
// and sets indexerStatus → 'ready'.
// 3. Opens a GET /events SSE subscription that updates indexerStatus to
// 'indexing' during re-scans and back to 'ready' when done.
//
// Exports fetchComponents(name) and fetchFile(path) for use by the Props tab
// and future Code tab.
import { useEffect, useRef, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import type { ProjectMeta } from '@/store/canvas';
interface ComponentEntry {
name: string;
filePath: string;
line: number;
cssImports: string[];
}
interface IndexerHealthResponse {
status: string;
projectMeta: ProjectMeta;
}
declare global {
interface Window {
__OM_INDEX_URL__?: string;
}
}
export function useIndexer() {
const { setIndexerStatus, setProjectMeta } = useCanvas();
const baseUrlRef = useRef<string | null>(null);
// ── 1. Detect indexer URL and fetch /health ───────────────────────────────
useEffect(() => {
const base = typeof window !== 'undefined' ? (window.__OM_INDEX_URL__ ?? null) : null;
if (!base) {
setIndexerStatus('offline');
return;
}
// Store for use by SSE effect and fetch helpers. Both effects run in the
// same render cycle so the ref is populated before the SSE effect opens.
baseUrlRef.current = base;
let cancelled = false;
async function fetchHealth() {
try {
const res = await fetch(`${base}/health`);
if (!res.ok) throw new Error(`/health ${res.status}`);
const data = await res.json() as IndexerHealthResponse;
if (!cancelled) {
setProjectMeta(data.projectMeta);
setIndexerStatus('ready');
}
} catch (err) {
if (!cancelled) {
console.warn('[useIndexer] /health failed — indexer offline', err);
setIndexerStatus('offline');
}
}
}
void fetchHealth();
return () => { cancelled = true; };
}, [setIndexerStatus, setProjectMeta]);
// ── 2. SSE subscription for live re-index events ───────────────────────────
useEffect(() => {
// BUG-11 fix: read from ref, not window, for consistency and to avoid
// a second window access after the first effect already resolved the URL.
const base = baseUrlRef.current;
if (!base) return;
const es = new EventSource(`${base}/events`);
// BUG-1 fix: `onopen` fires on EVERY connection, including the very first
// one. We must distinguish initial open (health already fetched by the
// sibling useEffect above) from a *reconnection* after an error. Calling
// /health again on the initial open produces two concurrent fetches racing
// to set projectMeta/indexerStatus, and the second fetch has no cleanup.
let isFirstOpen = true;
// Track whether an onopen-triggered health fetch is in-flight so that the
// effect cleanup can cancel it if the component unmounts before it resolves.
let reopenCancelled = false;
es.addEventListener('INDEX_START', () => {
setIndexerStatus('indexing');
});
es.addEventListener('INDEX_UPDATED', () => {
setIndexerStatus('ready');
});
es.onerror = () => {
// Connection dropped — mark offline. The browser will auto-reconnect;
// onopen will fire again and we will re-sync health at that point.
setIndexerStatus('offline');
};
es.onopen = () => {
if (isFirstOpen) {
// Initial connection: health was already fetched by the sibling effect.
isFirstOpen = false;
return;
}
// Reconnection after an error — re-fetch /health to sync projectMeta
// (the CLI may have been restarted with a different project).
reopenCancelled = false;
void fetch(`${base}/health`)
.then(r => r.json() as Promise<IndexerHealthResponse>)
.then(data => {
if (!reopenCancelled) {
setProjectMeta(data.projectMeta);
setIndexerStatus('ready');
}
})
.catch(() => { if (!reopenCancelled) setIndexerStatus('offline'); });
};
return () => {
es.close();
reopenCancelled = true;
};
}, [setIndexerStatus, setProjectMeta]);
// ── 3. Data-fetching helpers exposed to consuming components ───────────────
/** Fetch all component entries matching a display name from the index. */
const fetchComponents = useCallback(async (name: string): Promise<ComponentEntry[]> => {
const base = baseUrlRef.current;
if (!base) return [];
try {
const res = await fetch(`${base}/components?name=${encodeURIComponent(name)}`);
if (!res.ok) return [];
return await res.json() as ComponentEntry[];
} catch {
return [];
}
}, []);
/** Fetch the raw source text of a file by absolute path. */
const fetchFile = useCallback(async (filePath: string): Promise<string | null> => {
const base = baseUrlRef.current;
if (!base) return null;
try {
const res = await fetch(`${base}/file?path=${encodeURIComponent(filePath)}`);
if (!res.ok) return null;
return await res.text();
} catch {
return null;
}
}, []);
return { fetchComponents, fetchFile };
}
+63 -1
View File
@@ -1,6 +1,14 @@
import { create } from 'zustand';
import type { FiberNode } from '@originmain/renderer';
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
export interface ProjectMeta {
framework: 'next' | 'vite' | 'remix' | 'generic';
tailwind: boolean;
cssModules: boolean;
styledComponents: boolean;
}
export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
interface CanvasStore {
@@ -36,6 +44,13 @@ interface CanvasStore {
selectedComponentStyles: Record<string, string> | null;
setComponentStyles: (styles: Record<string, string> | null) => void;
/** True if the selected element has a direct TEXT_NODE child (gates Typography section). */
selectedComponentHasDirectText: boolean;
/** True if the selected element has at least one direct <p> child (gates paragraph spacing). */
selectedComponentHasParagraphChildren: boolean;
/** Set both structural text flags together — always called alongside setComponentStyles. */
setComponentTextFlags: (hasDirectText: boolean, hasParagraphChildren: boolean) => void;
// ── Style edit queue ────────────────────────────────────────────────────────
// The Design tab and resize handles push patches here; each owning
// LiveArtboard drains entries addressed to it, then removes them.
@@ -45,10 +60,29 @@ interface CanvasStore {
patchStyleEdit: (artboardId: string, nodeId: string, property: string, value: string) => void;
clearStyleEdits: (artboardId: string) => void;
// ── Children style edit queue (PATCH_CHILDREN_STYLE — paragraph spacing etc.) ──
childrenStyleEditQueue: Array<{ artboardId: string; parentNodeId: string; selector: string; property: string; value: string }>;
patchChildrenStyleEdit: (artboardId: string, parentNodeId: string, selector: string, property: string, value: string) => void;
clearChildrenStyleEdits: (artboardId: string) => void;
// ── Element removal mailbox ─────────────────────────────────────────────────
removeElementEvent: { artboardId: string; nodeId: string } | null;
dispatchRemoveElement: (artboardId: string, nodeId: string) => void;
clearRemoveElement: () => void;
// ── Selected element mode (Phase 2) ─────────────────────────────────────────
/** 'component' = capitalized React component; 'element' = lowercase DOM tag; null = nothing selected */
selectedElementMode: 'component' | 'element' | null;
setSelectedElementMode: (mode: 'component' | 'element' | null) => void;
// ── CLI AST indexer integration (Phase 3) ────────────────────────────────────
/** Status of the CLI AST indexer; drives Props tab and Code tab behavior */
indexerStatus: 'offline' | 'indexing' | 'ready';
setIndexerStatus: (status: 'offline' | 'indexing' | 'ready') => void;
/** Project metadata fetched from GET /health on CLI connection */
projectMeta: ProjectMeta | null;
setProjectMeta: (meta: ProjectMeta | null) => void;
}
export const useCanvas = create<CanvasStore>((set) => ({
@@ -78,19 +112,47 @@ export const useCanvas = create<CanvasStore>((set) => ({
selectedComponentId: null,
selectedComponentData: null,
selectComponent: (id, data) =>
set({ selectedComponentId: id, selectedComponentData: data, selectedComponentStyles: null }),
// BUG-2 fix: reset text flags alongside styles so a component without
// direct text doesn't inherit the previous selection's Typography section.
set({
selectedComponentId: id,
selectedComponentData: data,
selectedComponentStyles: null,
selectedComponentHasDirectText: false,
selectedComponentHasParagraphChildren: false,
}),
selectedComponentStyles: null,
setComponentStyles: (styles) => set({ selectedComponentStyles: styles }),
selectedComponentHasDirectText: false,
selectedComponentHasParagraphChildren: false,
setComponentTextFlags: (hasDirectText, hasParagraphChildren) =>
set({ selectedComponentHasDirectText: hasDirectText, selectedComponentHasParagraphChildren: hasParagraphChildren }),
styleEditQueue: [],
patchStyleEdit: (artboardId, nodeId, property, value) =>
set((s) => ({ styleEditQueue: [...s.styleEditQueue, { artboardId, nodeId, property, value }] })),
clearStyleEdits: (artboardId) =>
set((s) => ({ styleEditQueue: s.styleEditQueue.filter((e) => e.artboardId !== artboardId) })),
childrenStyleEditQueue: [],
patchChildrenStyleEdit: (artboardId, parentNodeId, selector, property, value) =>
set((s) => ({ childrenStyleEditQueue: [...s.childrenStyleEditQueue, { artboardId, parentNodeId, selector, property, value }] })),
clearChildrenStyleEdits: (artboardId) =>
set((s) => ({ childrenStyleEditQueue: s.childrenStyleEditQueue.filter((e) => e.artboardId !== artboardId) })),
removeElementEvent: null,
dispatchRemoveElement: (artboardId, nodeId) =>
set({ removeElementEvent: { artboardId, nodeId } }),
clearRemoveElement: () => set({ removeElementEvent: null }),
selectedElementMode: null,
setSelectedElementMode: (mode) => set({ selectedElementMode: mode }),
indexerStatus: 'offline',
setIndexerStatus: (status) => set({ indexerStatus: status }),
projectMeta: null,
setProjectMeta: (meta) => set({ projectMeta: meta }),
}));