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:
SinachPat
2026-04-30 22:04:37 +01:00
co-authored by Claude Sonnet 4.6
parent 8514ffdc1a
commit edada3091e
10 changed files with 669 additions and 35 deletions
+78 -3
View File
@@ -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)) {