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
@@ -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.
},
];