improved a lot of things
This commit is contained in:
@@ -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'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'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'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.
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user