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