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',
},
),
);