improved a lot of things

This commit is contained in:
SinachPat
2026-04-30 01:21:48 +01:00
parent 1edf7b8a94
commit 0c3ac48a3b
15 changed files with 1322 additions and 176 deletions
+2
View File
@@ -5,6 +5,7 @@ import { FluentProvider } from '@fluentui/react-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
import { useTheme } from '@/store/theme';
import { TourOverlay } from '@/components/walkthrough/TourOverlay';
export function Providers({ children }: { children: ReactNode }) {
// TanStack Query v5: create QueryClient inside useState so it's stable across
@@ -35,6 +36,7 @@ export function Providers({ children }: { children: ReactNode }) {
<QueryClientProvider client={queryClient}>
<FluentProvider theme={theme} style={{ height: '100%' }}>
{children}
<TourOverlay />
</FluentProvider>
</QueryClientProvider>
);
@@ -5,6 +5,7 @@ import { serverClient } from '@/lib/supabase';
import { AppHeader } from '@/components/shell/AppHeader';
import { ProjectCard } from '@/components/shell/ProjectCard';
import { DesignLanguageUpload } from '@/components/shell/DesignLanguageUpload';
import { TourAutoStart } from '@/components/walkthrough/TourAutoStart';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
import type { Workspace, Project } from '@originmain/origin-graph';
@@ -47,6 +48,8 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
return (
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
{/* Auto-start tour for first-time visitors (client component, renders null) */}
<TourAutoStart />
<AppHeader breadcrumbs={[
{ label: 'Workspaces', href: '/workspaces' },
{ label: workspace.name },
@@ -54,8 +57,8 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
{/* Title row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
{/* Title row — data-tour="projects-section" anchors the tour spotlight */}
<div data-tour="projects-section" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.03em', color: 'var(--page-text)', margin: 0 }}>
Projects
@@ -92,7 +95,10 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
</svg>
Settings
</Link>
<Link href={`/workspace/${wid}/project/new`} style={{
<Link
href={`/workspace/${wid}/project/new`}
data-tour="new-project-btn"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'var(--btn-bg)', color: 'var(--btn-fg)',
fontSize: '0.875rem', fontWeight: 600,
@@ -28,7 +28,11 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
};
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
const {
selectedArtboardId, selectArtboard, workspaceId, projectId,
setArtboardLive, setFiberRoot, selectComponent,
selectedComponentId,
} = useCanvas();
const selected = selectedArtboardId === id;
const queryClient = useQueryClient();
@@ -305,6 +309,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
src={renderUrl}
width={width}
height={height}
selectedComponentId={selectedComponentId}
onFiberTreeUpdate={handleFiberUpdate}
onComponentSelected={handleComponentSelected}
/>
@@ -360,6 +365,7 @@ function EmptyArtboardContent({
return (
<div
data-tour="artboard-empty"
style={{
width, height, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
@@ -432,6 +438,7 @@ function EmptyArtboardContent({
npx @originmain/cli dev --target :3000
</p>
<button
data-tour="artboard-url-btn"
onClick={() => setEditing(true)}
style={{
padding: '7px 14px', borderRadius: 6, border: '1px solid rgba(0,0,0,0.12)',
@@ -17,7 +17,12 @@ export interface LiveArtboardProps {
src: string;
width?: number;
height?: number;
/** DLF design tokens to inject into the iframe as CSS custom properties. */
designTokens?: Record<string, string>;
/** The currently selected component node ID (from the canvas store).
* When set, sends SELECT_COMPONENT to the iframe so the fiber hook renders
* a blue highlight ring over that component's DOM element. */
selectedComponentId?: string | null;
onReady?: () => void;
onFiberTreeUpdate?: (root: FiberNode) => void;
onComponentSelected?: (nodeId: string) => void;
@@ -32,25 +37,31 @@ export function LiveArtboard({
width = 1280,
height = 720,
designTokens,
selectedComponentId,
onReady,
onFiberTreeUpdate,
onComponentSelected,
style,
}: LiveArtboardProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// Track whether the iframe has sent READY so we don't send messages too early.
const isReadyRef = useRef(false);
// Send a message to the iframe via the typed protocol
// ── Send a typed message to the iframe ───────────────────────────────────
const sendMessage = useCallback(
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
const iframe = iframeRef.current;
if (!iframe?.contentWindow) return;
const envelope = createHostEnvelope(id, { type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[1]);
const envelope = createHostEnvelope(
id,
{ type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[1],
);
iframe.contentWindow.postMessage(envelope, '*');
},
[id]
[id],
);
// Handle messages from the renderer iframe
// ── Handle messages from the renderer iframe ──────────────────────────────
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (!isRendererEnvelope(event.data)) return;
@@ -59,10 +70,13 @@ export function LiveArtboard({
const msg: RendererMessage = event.data.message;
switch (msg.type) {
case 'READY':
// The fiber hook is already installed — either by the CLI proxy
// (injected into the HTML response) or by @originmain/live SDK
// (imported before React in the user's app). No injection needed.
isReadyRef.current = true;
// Push current design tokens into the iframe immediately.
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
// Restore any active selection that existed before the iframe loaded.
if (selectedComponentId) {
sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId });
}
onReady?.();
break;
case 'FIBER_TREE_UPDATE':
@@ -76,26 +90,39 @@ export function LiveArtboard({
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [id, designTokens, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]);
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]);
// Push updated design tokens whenever they change
// ── Push updated design tokens whenever they change ───────────────────────
useEffect(() => {
if (!designTokens) return;
if (!isReadyRef.current || !designTokens) return;
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
}, [designTokens, sendMessage]);
// ── 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.
useEffect(() => {
if (!isReadyRef.current) return;
if (selectedComponentId) {
sendMessage('SELECT_COMPONENT', { nodeId: selectedComponentId });
} else {
sendMessage('DESELECT');
}
}, [selectedComponentId, sendMessage]);
return (
<iframe
ref={iframeRef}
// The name attribute carries the artboard ID to the fiber hook.
// The hook reads window.name to tag postMessage envelopes.
// The hook reads window.name to tag postMessage envelopes and to
// guard against activating outside Originmain iframes.
// Format: "om:<artboardId>"
name={`om:${id}`}
src={src}
title={`artboard-${id}`}
// Security: allow-scripts required to run React; allow-same-origin required
// for postMessage with targeted origin validation. Do NOT combine these with
// untrusted third-party content.
// Security: allow-scripts required for React; allow-same-origin required
// for postMessage origin validation. Do NOT add allow-top-navigation or
// allow-popups unless explicitly needed — principle of least privilege.
sandbox="allow-scripts allow-same-origin allow-forms"
style={{
width,
@@ -12,6 +12,7 @@ import { useCanvas, type Tool } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { useTheme } from '@/store/theme';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useWalkthrough } from '@/store/walkthrough';
interface AppChromeProps {
workspaceId?: string;
@@ -26,6 +27,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
const setActiveTool = useCanvas((s) => s.setActiveTool);
const { mode: themeMode, toggle: toggleTheme } = useTheme();
const CT = useCanvasTheme();
const startTour = useWalkthrough((s) => s.start);
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
useEffect(() => {
@@ -168,6 +170,36 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
<div style={{ flex: 1 }} />
{/* Tour trigger */}
<button
onClick={startTour}
title="Start product tour"
aria-label="Start product tour"
style={{
background: 'none',
border: `1px solid ${CT.border}`,
cursor: 'pointer',
color: CT.fgMuted,
width: 22, height: 22, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6875rem', fontWeight: 600, marginRight: 6,
transition: 'color 0.12s, background 0.12s, border-color 0.12s',
flexShrink: 0,
}}
onMouseEnter={e => {
e.currentTarget.style.color = CT.accent;
e.currentTarget.style.borderColor = `${CT.accent}66`;
e.currentTarget.style.background = `${CT.accent}14`;
}}
onMouseLeave={e => {
e.currentTarget.style.color = CT.fgMuted;
e.currentTarget.style.borderColor = CT.border;
e.currentTarget.style.background = 'none';
}}
>
?
</button>
{/* Theme toggle */}
<button
onClick={toggleTheme}
@@ -29,6 +29,7 @@ export function Inspector() {
return (
<div
data-tour="inspector-panel"
className="dark-panel"
style={{
gridColumn: 3,
@@ -109,6 +109,7 @@ export function ArtboardNavigator() {
return (
<div
data-tour="navigator-panel"
className="dark-panel"
style={{
gridColumn: 1,
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { UserButton } from '@clerk/nextjs';
import { useTheme } from '@/store/theme';
import { useWalkthrough } from '@/store/walkthrough';
interface Crumb { label: string; href?: string }
@@ -16,6 +17,7 @@ interface AppHeaderProps {
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
const { mode, toggle } = useTheme();
const startTour = useWalkthrough((s) => s.start);
// Back-compat: if old props are passed without breadcrumbs, synthesise them
const crumbs: Crumb[] = breadcrumbs.length > 0
@@ -73,6 +75,34 @@ export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppH
<div style={{ flex: 1 }} />
{/* Tour trigger */}
<button
onClick={startTour}
title="Start product tour"
aria-label="Start product tour"
style={{
background: 'none', border: `1px solid ${mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)'}`,
cursor: 'pointer',
color: mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA',
width: 26, height: 26, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.75rem', fontWeight: 600, marginRight: 8,
transition: 'color 0.12s, background 0.12s, border-color 0.12s',
}}
onMouseEnter={e => {
e.currentTarget.style.color = '#3385FF';
e.currentTarget.style.borderColor = 'rgba(51,133,255,0.45)';
e.currentTarget.style.background = 'rgba(51,133,255,0.08)';
}}
onMouseLeave={e => {
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA';
e.currentTarget.style.borderColor = mode === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.10)';
e.currentTarget.style.background = 'none';
}}
>
?
</button>
{/* Theme toggle */}
<button
onClick={toggle}
@@ -26,6 +26,7 @@ export function ProjectCard({ workspaceId, projectId, name, framework, appUrl, d
return (
<Link href={`/workspace/${workspaceId}/project/${projectId}`} style={{ textDecoration: 'none' }}>
<div
data-tour="project-card"
style={{
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
@@ -0,0 +1,39 @@
'use client';
import { useEffect } from 'react';
import { useWalkthrough } from '@/store/walkthrough';
/**
* Invisible client component that auto-starts the product tour for
* first-time visitors. Renders null mount it anywhere on the page.
*
* Logic:
* Skip if the user has already completed the tour (persisted in localStorage).
* Skip if the tour is already active.
* Use a sessionStorage flag so the auto-start fires at most once per
* browser session (prevents re-firing on every client navigation).
* A 900 ms delay lets the page fully paint before the overlay appears.
*/
export function TourAutoStart() {
const completed = useWalkthrough((s) => s.completed);
const active = useWalkthrough((s) => s.active);
const start = useWalkthrough((s) => s.start);
useEffect(() => {
// Already done or currently running — nothing to do.
if (completed || active) return;
const FLAG = 'originmain:tour-auto-started';
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(FLAG)) return;
// Mark as fired for this session before the timeout so a fast re-render
// doesn't double-fire.
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(FLAG, '1');
const timer = setTimeout(start, 900);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // intentionally empty — run only on first mount
return null;
}
@@ -0,0 +1,441 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useWalkthrough } from '@/store/walkthrough';
import { TOUR_STEPS } from './tourSteps';
// ── Types ─────────────────────────────────────────────────────────────────────
interface Rect { x: number; y: number; width: number; height: number }
// ── Constants ─────────────────────────────────────────────────────────────────
const TOOLTIP_WIDTH = 340;
const SPOTLIGHT_PAD = 8; // px of padding around the spotlight target
const GAP = 14; // px gap between spotlight and tooltip card
// ── Component ─────────────────────────────────────────────────────────────────
export function TourOverlay() {
const { active, stepIndex, next, prev, dismiss } = useWalkthrough();
const [mounted, setMounted] = useState(false);
const [targetRect, setTargetRect] = useState<Rect | null>(null);
const [pathMismatch, setPathMismatch] = useState(false);
// Only render portals after client hydration.
useEffect(() => { setMounted(true); }, []);
const step = TOUR_STEPS[stepIndex];
// ── Measure the spotlight target element ─────────────────────────────────
const measureTarget = useCallback(() => {
if (!step) { setTargetRect(null); return; }
const { targetSelector, requiredPathPart } = step;
// Check we're on the right page for this step.
const onCorrectPage = !requiredPathPart ||
window.location.pathname.includes(requiredPathPart);
setPathMismatch(!onCorrectPage);
if (!targetSelector || !onCorrectPage) {
setTargetRect(null);
return;
}
const el = document.querySelector(targetSelector);
if (!el) {
setTargetRect(null);
return;
}
const r = el.getBoundingClientRect();
setTargetRect({ x: r.x, y: r.y, width: r.width, height: r.height });
}, [step]);
useEffect(() => {
if (!active) { setTargetRect(null); return; }
measureTarget();
// Re-measure on resize and scroll so the spotlight stays aligned.
window.addEventListener('resize', measureTarget);
window.addEventListener('scroll', measureTarget, true);
return () => {
window.removeEventListener('resize', measureTarget);
window.removeEventListener('scroll', measureTarget, true);
};
}, [active, measureTarget]);
// ── Keyboard navigation ───────────────────────────────────────────────────
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); dismiss(); }
if (e.key === 'ArrowRight') { e.preventDefault(); next(TOUR_STEPS.length); }
if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, next, prev, dismiss]);
// ── Compute tooltip position ──────────────────────────────────────────────
const tooltipStyle = computeTooltipPosition(targetRect, step?.placement ?? 'auto');
if (!mounted || !active || !step) return null;
const isFirstStep = stepIndex === 0;
const isLastStep = stepIndex === TOUR_STEPS.length - 1;
const progress = ((stepIndex + 1) / TOUR_STEPS.length) * 100;
// Spotlight rect with padding applied.
const spotlight = targetRect ? {
x: targetRect.x - SPOTLIGHT_PAD,
y: targetRect.y - SPOTLIGHT_PAD,
width: targetRect.width + SPOTLIGHT_PAD * 2,
height: targetRect.height + SPOTLIGHT_PAD * 2,
} : null;
return createPortal(
<>
{/* ── Dim backdrop (click outside = dismiss) ─────────────────────── */}
<div
aria-hidden="true"
onClick={dismiss}
style={{
position: 'fixed', inset: 0,
zIndex: 9990,
// When there's a spotlight, the backdrop darkness is created by the
// spotlight element's box-shadow, so we only need a backdrop when
// the tour card is centered (no spotlight).
background: spotlight ? 'transparent' : 'rgba(0,0,0,0.55)',
}}
/>
{/* ── Spotlight ring (box-shadow creates dark outside the rect) ────── */}
{spotlight && (
<div
aria-hidden="true"
style={{
position: 'fixed',
left: spotlight.x,
top: spotlight.y,
width: spotlight.width,
height: spotlight.height,
borderRadius: 8,
// 9999px box-shadow fills the entire viewport outside this rect.
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)',
border: '2px solid rgba(51,133,255,0.7)',
zIndex: 9991,
pointerEvents: 'none',
transition: 'all 0.12s ease',
}}
/>
)}
{/* ── Tooltip card ──────────────────────────────────────────────────── */}
<div
role="dialog"
aria-modal="true"
aria-label={step.id}
style={{
position: 'fixed',
zIndex: 9999,
width: TOOLTIP_WIDTH,
...tooltipStyle,
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 16,
padding: '22px 24px 20px',
boxShadow: '0 12px 40px rgba(0,0,0,0.22), 0 2px 8px rgba(0,0,0,0.12)',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}
// Prevent click from propagating to the backdrop (which would dismiss)
onClick={(e) => e.stopPropagation()}
>
{/* ── Progress bar ─────────────────────────────────────────────── */}
<div style={{
height: 2, borderRadius: 2,
background: 'var(--card-border)',
marginBottom: 18,
overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 2,
background: '#3385FF',
width: `${progress}%`,
transition: 'width 0.25s ease',
}} />
</div>
{/* ── Step counter ─────────────────────────────────────────────── */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 10,
}}>
<span style={{
fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: '#3385FF',
}}>
Step {stepIndex + 1} of {TOUR_STEPS.length}
</span>
<button
onClick={dismiss}
aria-label="Close tour"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--card-muted)', padding: '2px 4px',
display: 'flex', alignItems: 'center', borderRadius: 4,
lineHeight: 1,
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
{/* ── Title ────────────────────────────────────────────────────── */}
<h3 style={{
fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.025em',
color: 'var(--card-text)', margin: '0 0 10px',
}}>
{step.title}
</h3>
{/* ── Body ─────────────────────────────────────────────────────── */}
<div style={{
fontSize: '0.8125rem', color: 'var(--card-muted)',
lineHeight: 1.65, margin: '0 0 14px',
}}>
{step.body}
</div>
{/* ── Path mismatch hint ───────────────────────────────────────── */}
{pathMismatch && step.requiredPathPart && (
<div style={{
padding: '8px 10px', borderRadius: 7, marginBottom: 14,
background: 'rgba(51,133,255,0.08)',
border: '1px solid rgba(51,133,255,0.2)',
fontSize: '0.75rem', color: 'var(--card-muted)', lineHeight: 1.5,
}}>
💡 Navigate to the{' '}
<strong style={{ color: 'var(--card-text)' }}>
{step.requiredPathPart.includes('/project/') ? 'canvas' : 'workspace dashboard'}
</strong>{' '}
to follow this step interactively.
</div>
)}
{/* ── CLI code block ───────────────────────────────────────────── */}
{step.codeBlock && (
<pre style={{
background: 'var(--card-subtle)',
border: '1px solid var(--card-border)',
borderRadius: 8, padding: '10px 12px',
fontSize: '0.6875rem', lineHeight: 1.7,
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
color: 'var(--card-text)',
margin: '0 0 14px',
overflow: 'auto', whiteSpace: 'pre',
}}>
{step.codeBlock}
</pre>
)}
{/* ── Navigation ───────────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
{/* Back button */}
{!isFirstStep && (
<button
onClick={prev}
style={{
padding: '8px 14px', borderRadius: 8,
border: '1px solid var(--card-border)',
background: 'var(--btn-idle-bg)',
color: 'var(--btn-idle-fg)',
fontSize: '0.8125rem', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Back
</button>
)}
{/* Next / Finish button */}
<button
onClick={() => next(TOUR_STEPS.length)}
style={{
flex: 1,
padding: '9px 16px', borderRadius: 8,
border: 'none',
background: isLastStep ? '#10B981' : '#3385FF',
color: '#FFFFFF',
fontSize: '0.8125rem', fontWeight: 600,
cursor: 'pointer', fontFamily: 'inherit',
letterSpacing: '-0.01em',
transition: 'background 0.12s',
}}
>
{isFirstStep
? "Let's go →"
: isLastStep
? 'Start building ✓'
: 'Next →'}
</button>
{/* Skip tour */}
{!isLastStep && (
<button
onClick={dismiss}
style={{
padding: '8px 10px', background: 'none', border: 'none',
color: 'var(--card-muted)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Skip
</button>
)}
</div>
</div>
{/* ── Spotlight arrow (pointer from tooltip toward the target) ────── */}
{spotlight && <SpotlightArrow spotlight={spotlight} tooltipStyle={tooltipStyle} />}
</>,
document.body,
);
}
// ── Spotlight arrow ───────────────────────────────────────────────────────────
// A small triangle that connects the tooltip card to the spotlit element.
function SpotlightArrow({
spotlight,
tooltipStyle,
}: {
spotlight: Rect;
tooltipStyle: React.CSSProperties;
}) {
// Determine rough position of tooltip relative to spotlight centre to pick
// the correct arrow direction.
const slCX = spotlight.x + spotlight.width / 2;
const slCY = spotlight.y + spotlight.height / 2;
// Resolve tooltip top-left from the style object (may use numbers or strings)
const ttTop = typeof tooltipStyle.top === 'number' ? tooltipStyle.top : 0;
const ttLeft = typeof tooltipStyle.left === 'number' ? tooltipStyle.left : 0;
const ttCX = ttLeft + TOOLTIP_WIDTH / 2;
// Arrow placed on the edge of the spotlight nearest the tooltip.
const above = ttTop < slCY; // tooltip is above spotlight
const arrowX = Math.min(
Math.max(ttCX, spotlight.x + 12),
spotlight.x + spotlight.width - 12,
);
if (above) {
// Arrow at bottom of tooltip (pointing down toward spotlight)
return (
<div aria-hidden="true" style={{
position: 'fixed',
left: arrowX - 7,
top: Number(ttTop) + /* tooltip est. height */ 260,
width: 14, height: 8,
zIndex: 9999,
pointerEvents: 'none',
overflow: 'hidden',
}}>
<div style={{
width: 14, height: 14,
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
transform: 'rotate(45deg) translateY(-7px)',
}} />
</div>
);
}
// Arrow at top of tooltip (pointing up toward spotlight — tooltip is below)
const arrowTop = typeof tooltipStyle.top === 'number' ? tooltipStyle.top - 8 : 0;
return (
<div aria-hidden="true" style={{
position: 'fixed',
left: arrowX - 7,
top: arrowTop,
width: 14, height: 8,
zIndex: 9999,
pointerEvents: 'none',
overflow: 'hidden',
}}>
<div style={{
width: 14, height: 14,
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
transform: 'rotate(45deg) translateY(1px)',
}} />
</div>
);
}
// ── Tooltip positioning ───────────────────────────────────────────────────────
function computeTooltipPosition(
rect: Rect | null,
placement: TourStep['placement'],
): React.CSSProperties {
const vw = typeof window !== 'undefined' ? window.innerWidth : 1280;
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
const pad = 16;
if (!rect) {
// Centered card
return {
left: Math.max(pad, (vw - TOOLTIP_WIDTH) / 2),
top: Math.max(pad, vh / 2 - 160),
};
}
const sl = {
x: rect.x - SPOTLIGHT_PAD,
y: rect.y - SPOTLIGHT_PAD,
width: rect.width + SPOTLIGHT_PAD * 2,
height: rect.height + SPOTLIGHT_PAD * 2,
};
// Clamp a horizontal position to stay within viewport.
const clampLeft = (l: number) =>
Math.max(pad, Math.min(vw - TOOLTIP_WIDTH - pad, l));
const centredLeft = clampLeft(sl.x + sl.width / 2 - TOOLTIP_WIDTH / 2);
const spaceBelow = vh - (sl.y + sl.height);
const spaceAbove = sl.y;
const spaceRight = vw - (sl.x + sl.width);
const spaceLeft = sl.x;
const effectivePlacement = placement === 'auto' || !placement
? spaceBelow >= 240 ? 'bottom'
: spaceAbove >= 240 ? 'top'
: spaceRight >= TOOLTIP_WIDTH + GAP ? 'right'
: spaceLeft >= TOOLTIP_WIDTH + GAP ? 'left'
: 'bottom'
: placement;
switch (effectivePlacement) {
case 'bottom':
return { left: centredLeft, top: sl.y + sl.height + GAP };
case 'top':
return { left: centredLeft, top: Math.max(pad, sl.y - GAP - 280) };
case 'right':
return { left: sl.x + sl.width + GAP, top: Math.max(pad, sl.y) };
case 'left':
return { left: Math.max(pad, sl.x - TOOLTIP_WIDTH - GAP), top: Math.max(pad, sl.y) };
default:
return { left: centredLeft, top: sl.y + sl.height + GAP };
}
}
// Imported only for the type — avoids a circular import.
import type { TourStep } from './tourSteps';
@@ -0,0 +1,189 @@
import type { ReactNode } from 'react';
export interface TourStep {
/** Unique key — also used as aria-label. */
id: string;
title: string;
body: ReactNode;
/** Optional shell command shown in a monospace code block. */
codeBlock?: string;
/** CSS selector for the element to spotlight. Falls back to centered modal. */
targetSelector?: string;
/** Which side of the spotlight to place the tooltip.
* 'auto' (default): below if space available, otherwise above. */
placement?: 'top' | 'bottom' | 'left' | 'right' | 'auto';
/** URL substring if present and current URL does NOT include it,
* the spotlight is skipped and the card shows a navigation hint. */
requiredPathPart?: string;
}
// ── Tour steps ─────────────────────────────────────────────────────────────────
// Steps walk the user from the workspace dashboard all the way through live
// component inspection on the canvas. Descriptions are concise (< 3 sentences).
export const TOUR_STEPS: TourStep[] = [
{
id: 'welcome',
title: 'Welcome to Originmain',
body: (
<>
Originmain gives you a live visual canvas for your running React apps
inspect components, generate intent diffs, and ship changes with AI. Let&apos;s
get you set up in under 2 minutes.
</>
),
// No target — renders as a centered welcome card.
},
{
id: 'workspace-projects',
title: 'Your workspace',
body: (
<>
This is your workspace. It holds all your projects, team members, and
design language files. Each project connects to a single running
application local, staging, or preview.
</>
),
targetSelector: '[data-tour="projects-section"]',
placement: 'bottom',
requiredPathPart: '/workspace/',
},
{
id: 'new-project',
title: 'Create a project',
body: (
<>
Click <strong>New project</strong> to create your first project.
You&apos;ll give it a name, an optional URL, and a framework you can
always change these later in project settings.
</>
),
targetSelector: '[data-tour="new-project-btn"]',
placement: 'bottom',
requiredPathPart: '/workspace/',
},
{
id: 'open-canvas',
title: 'Open the canvas',
body: (
<>
Click any project card to open its visual canvas editor. The canvas is
where live rendering, component inspection, diff generation, and AI
queries all live.
</>
),
targetSelector: '[data-tour="project-card"]',
placement: 'bottom',
requiredPathPart: '/workspace/',
},
{
id: 'canvas-overview',
title: 'The canvas editor',
body: (
<>
Left panel: artboard navigator and file tree. Center: infinite canvas
with your artboards. Right: the inspector props, diffs, and the live
component graph. Press <kbd style={{ fontFamily: 'inherit' }}>V</kbd>,{' '}
<kbd style={{ fontFamily: 'inherit' }}>H</kbd>,{' '}
<kbd style={{ fontFamily: 'inherit' }}>A</kbd> to switch tools.
</>
),
// No target — full-canvas overview shown as a centered card.
requiredPathPart: '/project/',
},
{
id: 'connect-app',
title: 'Connect your app',
body: (
<>
Run the CLI proxy in a second terminal. It strips iframing headers and
injects the Originmain fiber hook into your dev server&apos;s HTML zero
changes to your app code needed.
</>
),
codeBlock: `# Terminal 2 — keep your dev server running in Terminal 1
npx @originmain/cli dev --target http://localhost:3000
# Proxy listening on http://localhost:4170
# Paste http://localhost:4170 into the artboard URL input`,
targetSelector: '[data-tour="artboard-empty"]',
placement: 'right',
requiredPathPart: '/project/',
},
{
id: 'enter-url',
title: 'Enter the proxy URL',
body: (
<>
Click <strong>Enter proxy URL</strong>, paste{' '}
<code
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.8em',
background: 'var(--card-subtle)',
padding: '1px 4px',
borderRadius: 3,
}}
>
http://localhost:4170
</code>
, and press Connect. The artboard will render your live app and begin
streaming the React fiber tree.
</>
),
targetSelector: '[data-tour="artboard-url-btn"]',
placement: 'top',
requiredPathPart: '/project/',
},
{
id: 'navigator-live',
title: 'Live connection established',
body: (
<>
A pulsing green dot in the navigator means Originmain has an active
fiber connection. Every React commit including HMR hot updates
streams the full component tree to the canvas in real time.
</>
),
targetSelector: '[data-tour="navigator-panel"]',
placement: 'right',
requiredPathPart: '/project/',
},
{
id: 'inspector',
title: 'Inspect any component',
body: (
<>
Click any element in the canvas to select it. The inspector shows its
live props, lets you export an intent diff, and gives you a full
component graph. The AI query bar runs cross-artboard analysis.
</>
),
targetSelector: '[data-tour="inspector-panel"]',
placement: 'left',
requiredPathPart: '/project/',
},
{
id: 'done',
title: "You're all set 🎉",
body: (
<>
You know how to connect an app, inspect components, and navigate the
canvas. Explore the diff tab to generate intent diffs, or add more
artboards with{' '}
<kbd style={{ fontFamily: 'inherit' }}>A</kbd> and connect different
routes.
</>
),
// Centered finish card — no spotlight.
},
];
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface WalkthroughStore {
/** Whether the tour overlay is currently visible. */
active: boolean;
/** Zero-based index of the current step. */
stepIndex: number;
/** True once the user reaches the final step and closes the tour. */
completed: boolean;
start: () => void;
next: (totalSteps: number) => void;
prev: () => void;
dismiss: () => void;
restart: () => void;
}
export const useWalkthrough = create<WalkthroughStore>()(
persist(
(set) => ({
active: false,
stepIndex: 0,
completed: false,
start: () => set({ active: true, stepIndex: 0 }),
next: (totalSteps: number) =>
set((s) => {
const next = s.stepIndex + 1;
if (next >= totalSteps) {
// Reached the end — mark complete and close.
return { active: false, completed: true, stepIndex: 0 };
}
return { stepIndex: next };
}),
prev: () =>
set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
dismiss: () => set({ active: false }),
restart: () => set({ active: true, stepIndex: 0, completed: false }),
}),
{
name: 'originmain:tour',
},
),
);
+225 -56
View File
@@ -1,28 +1,37 @@
// ── Originmain Fiber Hook ────────────────────────────────────────────────────
// This module 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.
// ── 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.
//
// The hook only activates when the app is iframed by Originmain (detected by
// the `om:` prefix in `window.name`, which LiveArtboard.tsx sets on the
// <iframe> element). Outside an Originmain iframe the module is a no-op.
// Full bidirectional protocol:
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, ERROR
// Host → Renderer : SET_DESIGN_TOKENS, NAVIGATE, SELECT_COMPONENT, DESELECT
//
// This file is intentionally self-contained — no imports from other
// @originmain/* packages — because it ships as a public npm package.
// 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: only run inside an Originmain iframe ──────────────────────────────
// ── Guard ─────────────────────────────────────────────────────────────────────
function isOriginmainIframe(): boolean {
try {
return window.parent !== window
&& typeof window.name === 'string'
&& window.name.startsWith(NAME_PREFIX);
return (
window.parent !== window &&
typeof window.name === 'string' &&
window.name.startsWith(NAME_PREFIX)
);
} catch {
// Accessing window.parent can throw in certain sandboxed contexts.
return false;
return false; // Accessing window.parent can throw in certain sandboxed contexts.
}
}
@@ -30,11 +39,12 @@ if (isOriginmainIframe()) {
installFiberHook();
}
// ── Core ─────────────────────────────────────────────────────────────────────
// ── 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(
@@ -46,8 +56,13 @@ function installFiberHook(): void {
}
}
// Install or wrap the global hook. If React DevTools is already present,
// we wrap its onCommitFiberRoot so both receive commits.
// ── Runtime state ─────────────────────────────────────────────────────────
let currentTree: SerializedNode | null = null;
const nodeMap = new Map<string, { domRect: DomRect | null }>();
let selectedNodeId: string | null = null;
let highlightEl: HTMLElement | null = null;
// ── React DevTools global hook ─────────────────────────────────────────────
type Hook = {
renderers: Map<unknown, unknown>;
supportsFiber: boolean;
@@ -64,69 +79,64 @@ function installFiberHook(): void {
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
const originalOnCommit = hook.onCommitFiberRoot;
const _prevCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function onCommitFiberRoot(...args: unknown[]) {
// Delegate to the previous handler (React DevTools) first.
if (typeof originalOnCommit === 'function') {
try { originalOnCommit.apply(this, args); }
catch { /* don't break DevTools */ }
// 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 — { current: Fiber }
// args[1] is the FiberRoot object — { current: Fiber }
const root = args[1] as { current: FiberLike } | undefined;
if (!root?.current) return;
const tree = serializeFiber(root.current);
const tree = serializeFiber(root.current, '');
currentTree = tree;
rebuildNodeMap(tree);
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) });
}
};
// Signal readiness.
post({ type: 'READY' });
}
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ── Fiber Serialization ──────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
index: number;
child: FiberLike | null;
sibling: FiberLike | null;
stateNode: unknown;
memoizedProps: Record<string, unknown> | null;
}
interface SerializedNode {
id: string;
name: string;
props: Record<string, string | number | boolean | null>;
children: SerializedNode[];
domRect?: { x: number; y: number; width: number; height: number };
}
function serializeFiber(fiber: FiberLike | null): SerializedNode | null {
function serializeFiber(
fiber: FiberLike | null,
parentId: string,
): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child);
if (!name) {
// Unnamed fiber — skip level but keep walking children.
let child = fiber.child;
while (child) {
const s = serializeFiber(child, parentId);
if (s) return s;
child = child.sibling;
}
return null;
}
const nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
const rect = getDomRect(fiber);
const node: SerializedNode = {
id: String(fiber.index || Math.random()),
id: nodeId,
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) {
node.domRect = rect;
}
if (rect) node.domRect = rect;
let child = fiber.child;
while (child) {
const serialized = serializeFiber(child);
const serialized = serializeFiber(child, nodeId);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
@@ -150,14 +160,14 @@ function getDisplayName(fiber: FiberLike): string | null {
return null;
}
function getDomRect(fiber: FiberLike): { x: number; y: number; width: number; height: number } | 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 { /* no DOM node */ }
} catch { /* stateNode has no layout */ }
return null;
}
@@ -176,3 +186,162 @@ function serializeProps(
}
return out;
}
// ── Node map: flat O(1) lookup by stable ID ───────────────────────────────
function rebuildNodeMap(node: SerializedNode | null): void {
nodeMap.clear();
fillMap(node);
}
function fillMap(node: SerializedNode | null): void {
if (!node) return;
nodeMap.set(node.id, { domRect: node.domRect ?? null });
for (const child of node.children) fillMap(child);
}
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
function updateHighlight(): void {
const info = selectedNodeId ? nodeMap.get(selectedNodeId) : undefined;
if (info?.domRect) renderHighlight(info.domRect);
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;
}
}
// ── Click-to-select (capturing phase) ────────────────────────────────────
document.addEventListener('click', (event: MouseEvent) => {
const node = findDeepestAt(currentTree, event.clientX, event.clientY);
if (node?.domRect) {
post({ type: 'COMPONENT_SELECTED', nodeId: node.id, rect: node.domRect });
}
}, true);
function findDeepestAt(
node: SerializedNode | null,
x: number,
y: number,
): SerializedNode | null {
if (!node) return null;
const r = node.domRect;
const hit = r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height;
if (hit) {
for (const child of node.children) {
const deeper = findDeepestAt(child, x, y);
if (deeper) return deeper;
}
return node;
}
// No hit — still check children for overflow:visible scenarios.
for (const child of node.children) {
const found = findDeepestAt(child, x, y);
if (found) return found;
}
return null;
}
// ── 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 };
};
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;
}
});
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;
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;
}
+201 -48
View File
@@ -1,23 +1,15 @@
import { RENDERER_SOURCE } from './protocol.js';
import { RENDERER_SOURCE, HOST_SOURCE } from './protocol.js';
import type { FiberNode, DOMRectLike } from './protocol.js';
// ── Fiber hook script ─────────────────────────────────────────────────────────
// This script is injected into the sandboxed iframe before the remote app
// initialises. It installs a React DevTools global hook so React reports every
// commit. On each commit, we walk the Fiber tree, serialize it to FiberNode[],
// and postMessage the result to the host.
//
// The script must be self-contained (no imports) because it runs in the iframe.
// We generate it as a string via buildFiberHookScript() so it can be injected
// via a <script> tag or a blob URL.
// ── Legacy fiber hook script ──────────────────────────────────────────────────
// @deprecated Use buildProxyFiberHookScript() instead. This version bakes
// the artboard ID into the script at generation time; the proxy-compatible
// version reads it from window.name at runtime.
export function buildFiberHookScript(artboardId: string): string {
// Inline the constants and logic — the iframe has no access to this module.
return `(function(artboardId) {
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
// Install the React DevTools global hook BEFORE React loads.
// React checks for this object at module evaluation time and registers itself.
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), _isDisabled: false };
@@ -102,7 +94,6 @@ export function buildFiberHookScript(artboardId: string): string {
return out;
}
// Signal that the renderer iframe is ready.
window.parent.postMessage(
{ source: SOURCE, artboardId: artboardId, message: { type: 'READY' } },
'*'
@@ -111,17 +102,22 @@ export function buildFiberHookScript(artboardId: string): string {
}
// ── Proxy-compatible fiber hook script ────────────────────────────────────────
// Unlike buildFiberHookScript (which bakes in an artboard ID), this version
// reads the artboard ID from `window.name` at runtime. The iframe element sets
// `name="om:<artboardId>"` and this script extracts the ID.
// Reads the artboard ID from window.name at runtime (set by LiveArtboard:
// <iframe name="om:{id}">). Supports full bidirectional protocol:
//
// This script is injected by the CLI proxy (`@originmain/cli`) into every HTML
// response, and is also used by the `@originmain/live` SDK. It is fully
// self-contained — no imports, no dependencies.
// Renderer → Host : READY, FIBER_TREE_UPDATE, COMPONENT_SELECTED, 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 tree structure is unchanged.
//
// Used by @originmain/cli (proxy injection) and @originmain/live (SDK).
export function buildProxyFiberHookScript(): string {
return `(function() {
// Only activate inside an Originmain iframe
'use strict';
// ── Guard: only activate inside an Originmain iframe ─────────────────────
if (window.parent === window) return;
var NAME_PREFIX = 'om:';
@@ -130,57 +126,84 @@ export function buildProxyFiberHookScript(): string {
if (typeof window.name === 'string' && window.name.indexOf(NAME_PREFIX) === 0) {
artboardId = window.name.slice(NAME_PREFIX.length);
}
} catch (e) { /* window.name access denied — not our iframe */ }
} catch (e) { /* sandboxed context — not our iframe */ }
if (!artboardId) return;
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
var RENDERER_SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
var HOST_SOURCE = ${JSON.stringify(HOST_SOURCE)};
// ── Runtime state ─────────────────────────────────────────────────────────
var currentTree = null; // latest serialized FiberNode tree
var nodeMap = {}; // nodeId → { domRect } (flat for O(1) lookup)
var selectedNodeId = null; // currently highlighted component
var highlightEl = null; // the blue-ring DOM overlay element
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg) {
try { window.parent.postMessage({ source: SOURCE, artboardId: artboardId, message: msg }, '*'); }
catch (e) { /* parent unreachable */ }
try {
window.parent.postMessage(
{ source: RENDERER_SOURCE, artboardId: artboardId, message: msg }, '*'
);
} catch (e) { /* parent unreachable — swallow silently */ }
}
// Install the React DevTools global hook BEFORE React loads.
// If React DevTools extension is already present, wrap its handler.
// ── React DevTools global hook ────────────────────────────────────────────
// Must be installed before React evaluates its module body. React checks for
// __REACT_DEVTOOLS_GLOBAL_HOOK__ exactly once at import time.
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), supportsFiber: true, _isDisabled: false };
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
var originalOnCommit = hook.onCommitFiberRoot;
var _prevCommit = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function(rendererId, root, priorityLevel, didError) {
if (typeof originalOnCommit === 'function') {
try { originalOnCommit.call(this, rendererId, root, priorityLevel, didError); }
catch (e) { /* don't break DevTools */ }
// Delegate to any pre-existing handler (e.g. React DevTools extension).
if (typeof _prevCommit === 'function') {
try { _prevCommit.call(this, rendererId, root, priorityLevel, didError); }
catch (e) { /* don't break existing DevTools */ }
}
try {
var fiberRoot = root.current;
var tree = serializeFiber(fiberRoot);
var tree = serializeFiber(root.current, '');
currentTree = tree;
rebuildNodeMap(tree);
post({ type: 'FIBER_TREE_UPDATE', root: tree });
// Re-sync the highlight ring after each React commit (position may shift).
if (selectedNodeId) updateHighlight();
} catch (err) {
post({ type: 'ERROR', message: String(err) });
}
};
function serializeFiber(fiber) {
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
// ID format: "ComponentName:siblingIndex/ChildName:siblingIndex/..."
// Stable across re-renders provided the tree structure doesn't change.
function serializeFiber(fiber, parentId) {
if (!fiber) return null;
var name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child) || null;
if (!name) {
// Unnamed fiber (Fragment, Context, Provider) — skip this level,
// but keep walking children so named descendants are not lost.
var c = fiber.child;
while (c) {
var s = serializeFiber(c, parentId);
if (s) return s;
c = c.sibling;
}
return null;
}
var nodeId = (parentId ? parentId + '/' : '') + name + ':' + String(fiber.index);
var rect = getDomRect(fiber);
var node = {
id: String(fiber.index || Math.random()),
name: name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
var node = { id: nodeId, name: name, props: serializeProps(fiber.memoizedProps), children: [] };
if (rect) node.domRect = rect;
var child = fiber.child;
while (child) {
var serialized = serializeFiber(child);
var serialized = serializeFiber(child, nodeId);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
@@ -192,7 +215,7 @@ export function buildProxyFiberHookScript(): string {
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') return type.displayName || type.name || null;
if (type.$$typeof) return type.displayName || type.name || null;
if (type && type.$$typeof) return type.displayName || type.name || null;
return null;
}
@@ -203,7 +226,7 @@ export function buildProxyFiberHookScript(): string {
var r = dom.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch (e) { /* no DOM node */ }
} catch (e) { /* stateNode has no layout (Context, Memo, etc.) */ }
return null;
}
@@ -214,14 +237,144 @@ export function buildProxyFiberHookScript(): string {
if (key === 'children') continue;
var val = props[key];
var t = typeof val;
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) {
out[key] = val;
}
if (t === 'string' || t === 'number' || t === 'boolean' || val === null) out[key] = val;
}
return out;
}
// Signal that the fiber hook is installed and the iframe is ready.
// ── Node map: flat O(1) lookup by stable ID ───────────────────────────────
function rebuildNodeMap(node) {
nodeMap = {};
fillMap(node);
}
function fillMap(node) {
if (!node) return;
nodeMap[node.id] = { domRect: node.domRect || null };
var children = node.children;
for (var i = 0; i < children.length; i++) fillMap(children[i]);
}
// ── Highlight overlay (blue ring inside the iframe) ───────────────────────
function updateHighlight() {
var info = nodeMap[selectedNodeId];
if (info && info.domRect) renderHighlight(info.domRect);
else removeHighlight();
}
function renderHighlight(rect) {
if (!highlightEl) {
highlightEl = document.createElement('div');
highlightEl.id = '__om_sel__';
highlightEl.setAttribute('aria-hidden', 'true');
// CSS kept inline so no stylesheet dependency. Transition animates when
// the selected component moves (e.g. during a re-render or scroll).
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() {
if (highlightEl && highlightEl.parentNode) {
highlightEl.parentNode.removeChild(highlightEl);
highlightEl = null;
}
}
// ── Click-to-select (capturing phase) ────────────────────────────────────
// Finds the deepest named fiber node at the click point and reports it back.
// In normal canvas usage the SelectionOverlay sits on top of the iframe and
// this listener fires when the overlay is bypassed (e.g. direct preview mode).
document.addEventListener('click', function(event) {
var node = findDeepestAt(currentTree, event.clientX, event.clientY);
if (node && node.domRect) {
post({ type: 'COMPONENT_SELECTED', nodeId: node.id, rect: node.domRect });
}
}, true);
function findDeepestAt(node, x, y) {
if (!node) return null;
var r = node.domRect;
var hit = r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height;
if (hit) {
// Matched — recurse children for a deeper (more specific) match.
var children = node.children;
for (var i = 0; i < children.length; i++) {
var deeper = findDeepestAt(children[i], x, y);
if (deeper) return deeper;
}
return node;
}
// No hit on this node — still check children for overflow:visible cases.
var children = node.children;
for (var i = 0; i < children.length; i++) {
var found = findDeepestAt(children[i], x, y);
if (found) return found;
}
return null;
}
// ── Host → Renderer message handler ──────────────────────────────────────
window.addEventListener('message', function(event) {
var data = event.data;
if (!data || typeof data !== 'object') return;
if (data.source !== HOST_SOURCE) return;
if (data.artboardId !== artboardId) return;
var msg = data.message;
if (!msg) return;
switch (msg.type) {
case 'SET_DESIGN_TOKENS':
if (msg.tokens && typeof msg.tokens === 'object') applyTokens(msg.tokens);
break;
case 'NAVIGATE':
if (typeof msg.path === 'string') doNavigate(msg.path);
break;
case 'SELECT_COMPONENT':
if (typeof msg.nodeId === 'string') {
selectedNodeId = msg.nodeId;
updateHighlight();
}
break;
case 'DESELECT':
selectedNodeId = null;
removeHighlight();
break;
}
});
// Apply DLF design tokens as CSS custom properties on :root.
function applyTokens(tokens) {
var root = document.documentElement;
for (var k in tokens) {
if (Object.prototype.hasOwnProperty.call(tokens, k)) {
root.style.setProperty(k, String(tokens[k]));
}
}
}
// SPA-safe navigation: push to history and fire popstate so framework
// routers (React Router, Next.js) pick up the route change.
function doNavigate(path) {
try {
history.pushState(null, '', path);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch (e) { /* navigation not available in this context */ }
}
// ── Ready signal ──────────────────────────────────────────────────────────
post({ type: 'READY' });
})();`;
}