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>
);
+15 -9
View File
@@ -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,13 +95,16 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
</svg>
Settings
</Link>
<Link href={`/workspace/${wid}/project/new`} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'var(--btn-bg)', color: 'var(--btn-fg)',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 18px', borderRadius: 9,
textDecoration: 'none', letterSpacing: '-0.01em',
}}>
<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,
padding: '9px 18px', borderRadius: 9,
textDecoration: 'none', letterSpacing: '-0.01em',
}}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
</svg>
@@ -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',
},
),
);
+271 -102
View File
@@ -1,28 +1,37 @@
// ── Originmain Fiber Hook ────────────────────────────────────────────────────
// This module installs a React DevTools–compatible 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 DevTools–compatible 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 NAME_PREFIX = 'om:';
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,31 +79,248 @@ 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.
// ── Fiber serialization (stable path-based IDs) ───────────────────────────
function serializeFiber(
fiber: FiberLike | null,
parentId: string,
): SerializedNode | null {
if (!fiber) return null;
const name = getDisplayName(fiber);
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: nodeId,
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) node.domRect = rect;
let child = fiber.child;
while (child) {
const serialized = serializeFiber(child, nodeId);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
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;
}
// ── 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' });
}
// ── Fiber Serialization ──────────────────────────────────────────────────────
// ── Internal types ────────────────────────────────────────────────────────────
interface FiberLike {
type: unknown;
@@ -99,80 +331,17 @@ interface FiberLike {
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>;
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 {
if (!fiber) return null;
const name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child);
const rect = getDomRect(fiber);
const node: SerializedNode = {
id: String(fiber.index || Math.random()),
name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
if (rect) {
node.domRect = rect;
}
let child = fiber.child;
while (child) {
const serialized = serializeFiber(child);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
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): { x: number; y: number; width: number; height: number } | 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 */ }
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;
domRect?: DomRect;
}
+203 -50
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,76 +102,108 @@ 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:';
var artboardId = '';
var artboardId = '';
try {
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;
var rect = getDomRect(fiber);
var node = {
id: String(fiber.index || Math.random()),
name: name,
props: serializeProps(fiber.memoizedProps),
children: [],
};
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: 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' });
})();`;
}