Files
wursor/packages/live-sdk/src/hook.ts
T
SinachPatandClaude Sonnet 4.6 edada3091e 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>
2026-04-30 22:04:37 +01:00

471 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Originmain Fiber Hook (Live SDK) ─────────────────────────────────────────
// Installs a React DevToolscompatible global hook BEFORE React evaluates its
// module body. React checks for __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at
// import time; any later installation is too late.
//
// Full bidirectional protocol:
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, COMPONENT_DESELECTED, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
//
// Node IDs are stable path strings: "Component:idx/Child:idx/…"
// This survives re-renders as long as the component tree structure is unchanged.
//
// Guard: only activates when window.name starts with "om:" — the prefix set by
// LiveArtboard.tsx on the <iframe name="om:{id}"> element. Outside Originmain
// iframes this module is a complete no-op.
//
// This file is intentionally self-contained (no @originmain/* imports) because
// it ships as a public npm package and must work standalone.
const RENDERER_SOURCE = 'originmain-renderer';
const HOST_SOURCE = 'originmain-host';
const NAME_PREFIX = 'om:';
// ── Guard ─────────────────────────────────────────────────────────────────────
function isOriginmainIframe(): boolean {
try {
return (
window.parent !== window &&
typeof window.name === 'string' &&
window.name.startsWith(NAME_PREFIX)
);
} catch {
return false; // Accessing window.parent can throw in certain sandboxed contexts.
}
}
if (isOriginmainIframe()) {
installFiberHook();
}
// ── Core ──────────────────────────────────────────────────────────────────────
function installFiberHook(): void {
const artboardId = window.name.slice(NAME_PREFIX.length);
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg: Record<string, unknown>): void {
try {
window.parent.postMessage(
{ source: RENDERER_SOURCE, artboardId, message: msg },
'*',
);
} catch {
// Parent frame unreachable — silently ignore.
}
}
// ── Runtime state ─────────────────────────────────────────────────────────
// nodeMap: nodeId → { domRect (snapshot), fiber (live reference for re-measurement) }
let nodeMap = new Map<string, { domRect: DomRect | null; fiber: FiberLike }>();
// fiberMap: fiber object → nodeId for O(1) hit-test lookup via __reactFiber$ DOM keys.
// A null value means the fiber is unnamed/transparent and not directly selectable.
let fiberMap = new WeakMap<object, string | null>();
let selectedNodeId: string | null = null;
let highlightEl: HTMLElement | null = null;
// ── React DevTools global hook ─────────────────────────────────────────────
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
_isDisabled: boolean;
onCommitFiberRoot?: (...args: unknown[]) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = globalThis as any;
let hook: Hook = g.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
const _prevCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
// Delegate to any pre-existing handler (React DevTools extension) first.
if (typeof _prevCommit === 'function') {
try { _prevCommit.apply(this, args); }
catch { /* don't break existing DevTools */ }
}
try {
// args[1] is the FiberRoot object — { current: Fiber }
const root = args[1] as { current: FiberLike } | undefined;
if (!root?.current) return;
// Reset both maps before each walk so stale entries from the previous tree
// don't accumulate. fiberMap is a WeakMap so it self-cleans, but nodeMap
// must be rebuilt from scratch on every commit.
nodeMap = new Map();
fiberMap = new WeakMap();
const tree = serializeFiber(root.current, '');
post({ type: 'FIBER_TREE_UPDATE', root: tree });
// Re-sync the highlight ring after each React commit (component may move).
if (selectedNodeId) updateHighlight();
} catch (err) {
post({ type: 'ERROR', message: String(err) });
}
};
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
//
// IMPORTANT: unnamed fibers (Fragment, Context.Provider, React.memo wrappers)
// are transparent — their children are collected directly into their parent's
// children array. Returning only the first named child (old approach) caused
// entire subtrees to vanish from the tree.
function serializeFiber(
fiber: FiberLike | null,
parentId: string,
): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) {
// Unnamed root fiber (HostRoot) — collectChildren handles Fragment recursively.
const children: SerializedNode[] = [];
collectChildren(fiber, parentId, children);
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 };
}
const nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
const rect = getDomRect(fiber);
const node: SerializedNode = {
id: nodeId,
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) node.domRect = rect;
// Register in both maps for O(1) lookup.
nodeMap.set(nodeId, { domRect: rect, fiber });
fiberMap.set(fiber, nodeId);
collectChildren(fiber, nodeId, node.children);
return node;
}
// Collect all named descendants of fiber.child into out[], transparently
// flattening unnamed intermediates (Fragments, Providers, wrappers).
function collectChildren(fiber: FiberLike, parentId: string, out: SerializedNode[]): void {
let child = fiber.child;
while (child) {
const name = getDisplayName(child);
if (name) {
const serialized = serializeFiber(child, parentId);
if (serialized) out.push(serialized);
} else {
// Unnamed (Fragment / Context / Provider / forwardRef wrapper etc.):
// mark as non-selectable and flatten children directly into our level.
fiberMap.set(child, null);
collectChildren(child, parentId, out);
}
child = child.sibling;
}
}
function getDisplayName(fiber: FiberLike): string | null {
const type = fiber.type;
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
}
if (typeof type === 'object' && type !== null && '$$typeof' in type) {
return (type as { displayName?: string; name?: string }).displayName
?? (type as { name?: string }).name
?? null;
}
return null;
}
function getDomRect(fiber: FiberLike): DomRect | null {
try {
const dom = fiber.stateNode;
if (dom && typeof dom === 'object' && 'getBoundingClientRect' in dom) {
const r = (dom as Element).getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch { /* stateNode has no layout */ }
return null;
}
function serializeProps(
props: Record<string, unknown> | null,
): Record<string, string | number | boolean | null> {
if (!props || typeof props !== 'object') return {};
const out: Record<string, string | number | boolean | null> = {};
for (const key in props) {
if (key === 'children') continue;
const val = props[key];
const t = typeof val;
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
out[key] = val as string | number | boolean | null;
}
}
return out;
}
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
// updateHighlight re-measures from the live fiber stateNode so the ring stays
// accurate even after scroll (between React commits).
function updateHighlight(): void {
const info = selectedNodeId ? nodeMap.get(selectedNodeId) : undefined;
if (!info) { removeHighlight(); return; }
// Re-measure from the live DOM element for scroll accuracy.
const rect = getDomRect(info.fiber) ?? info.domRect;
if (rect && rect.width > 0 && rect.height > 0) {
renderHighlight(rect);
} else {
removeHighlight();
}
}
function renderHighlight(rect: DomRect): void {
if (!highlightEl) {
highlightEl = document.createElement('div');
highlightEl.id = '__om_sel__';
highlightEl.setAttribute('aria-hidden', 'true');
highlightEl.style.cssText = [
'position:fixed', 'pointer-events:none', 'z-index:2147483647',
'box-shadow:0 0 0 2px #3385FF',
'outline:3px solid rgba(51,133,255,0.18)',
'border-radius:3px',
'transition:left .07s ease,top .07s ease,width .07s ease,height .07s ease',
].join(';');
document.body.appendChild(highlightEl);
}
highlightEl.style.left = `${rect.x}px`;
highlightEl.style.top = `${rect.y}px`;
highlightEl.style.width = `${rect.width}px`;
highlightEl.style.height = `${rect.height}px`;
}
function removeHighlight(): void {
if (highlightEl?.parentNode) {
highlightEl.parentNode.removeChild(highlightEl);
highlightEl = null;
}
}
// Re-measure on scroll so the ring follows the element without needing
// a React commit (which only fires on state/prop changes).
window.addEventListener('scroll', () => {
if (selectedNodeId) updateHighlight();
}, true);
// ── Click-to-select (capturing phase) ────────────────────────────────────
// Uses document.elementFromPoint to get the live DOM element at the click
// position (accurate even after scroll), then walks the React fiber tree
// upward via __reactFiber$ keys to find the nearest tracked component.
function getFiberKey(el: Element): string | null {
const keys = Object.keys(el);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (k !== undefined && k.startsWith('__reactFiber$')) return k;
}
return null;
}
document.addEventListener('click', (event: MouseEvent) => {
// Ignore clicks on our own highlight overlay.
if (event.target === highlightEl) return;
const el = document.elementFromPoint(event.clientX, event.clientY);
// Walk the DOM upward, trying to find a tracked React fiber at each level.
let current: Element | null = el;
while (current && current !== document.documentElement) {
const fiberKey = getFiberKey(current);
if (fiberKey) {
// Walk the fiber's return (parent) chain to find the nearest tracked node.
let fiber: FiberLike | null =
(current as unknown as Record<string, unknown>)[fiberKey] as FiberLike | null;
while (fiber) {
const nodeId = fiberMap.get(fiber);
if (nodeId) {
// Re-measure from the live element for accurate post-scroll rect.
const liveEl = fiber.stateNode;
if (liveEl && typeof liveEl === 'object' && 'getBoundingClientRect' in liveEl) {
const r = (liveEl as Element).getBoundingClientRect();
post({ type: 'COMPONENT_SELECTED', nodeId, rect: { x: r.x, y: r.y, width: r.width, height: r.height } });
return;
}
}
fiber = fiber.return;
}
}
current = current.parentElement;
}
// Nothing found — clear the selection.
post({ type: 'COMPONENT_DESELECTED' });
}, true);
// ── Host → Renderer message handler ──────────────────────────────────────
window.addEventListener('message', (event: MessageEvent) => {
const data = event.data as {
source?: string;
artboardId?: 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;
const msg = data.message;
if (!msg) return;
switch (msg.type) {
case 'SET_DESIGN_TOKENS':
if (msg.tokens) applyTokens(msg.tokens);
break;
case 'NAVIGATE':
if (msg.path) doNavigate(msg.path);
break;
case 'SELECT_COMPONENT':
if (msg.nodeId) {
selectedNodeId = msg.nodeId;
updateHighlight();
}
break;
case 'DESELECT':
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)) {
root.style.setProperty(k, v);
}
}
function doNavigate(path: string): void {
try {
history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch { /* navigation not available */ }
}
// ── Ready signal ──────────────────────────────────────────────────────────
post({ type: 'READY' });
}
// ── Internal types ────────────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
/** Parent fiber — needed for click-to-select chain walk via __reactFiber$ keys. */
return: FiberLike | null;
stateNode: unknown;
memoizedProps: Record<string, unknown> | null;
}
interface DomRect {
x: number;
y: number;
width: number;
height: number;
}
interface SerializedNode {
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: DomRect;
}