feat: add onboarding tooltip tour for new users

- Create 6-step spotlight tour covering key features:
  - Local-first storage
  - Encrypted identity (CryptID)
  - Share & collaborate
  - Creative toolkit
  - Mycelial Intelligence
  - Keyboard shortcuts

- Features:
  - Auto-starts for first-time users (1.5s delay)
  - Spotlight effect with darkened backdrop
  - Keyboard navigation (Escape, arrows, Enter)
  - "Show Tutorial" button in settings (desktop + mobile)
  - Dark mode support
  - Progress dots indicator

- New files: src/ui/OnboardingTour/{index,OnboardingTour,TourTooltip,tourSteps}.ts(x)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-19 18:10:23 -05:00
parent 09eb17605e
commit 6a85381a6c
5 changed files with 739 additions and 3 deletions

View File

@ -0,0 +1,232 @@
import React, { useEffect, useReducer, useCallback, useState } from 'react'
import { createPortal } from 'react-dom'
import { TOUR_STEPS, TourStep } from './tourSteps'
import { TourTooltip } from './TourTooltip'
// Storage key for persistence
const STORAGE_KEY = 'canvas_onboarding_completed'
// State types
interface TourState {
isActive: boolean
currentStepIndex: number
hasCompletedTour: boolean
}
type TourAction =
| { type: 'START_TOUR' }
| { type: 'NEXT_STEP' }
| { type: 'PREV_STEP' }
| { type: 'SKIP_TOUR' }
| { type: 'COMPLETE_TOUR' }
| { type: 'GO_TO_STEP'; payload: number }
// Reducer for tour state
function tourReducer(state: TourState, action: TourAction): TourState {
switch (action.type) {
case 'START_TOUR':
return { ...state, isActive: true, currentStepIndex: 0 }
case 'NEXT_STEP':
return { ...state, currentStepIndex: state.currentStepIndex + 1 }
case 'PREV_STEP':
return { ...state, currentStepIndex: Math.max(0, state.currentStepIndex - 1) }
case 'SKIP_TOUR':
case 'COMPLETE_TOUR':
localStorage.setItem(STORAGE_KEY, 'true')
return { ...state, isActive: false, hasCompletedTour: true }
case 'GO_TO_STEP':
return { ...state, currentStepIndex: action.payload }
default:
return state
}
}
// Hook to detect dark mode
function useDarkMode() {
const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
useEffect(() => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDark(document.documentElement.classList.contains('dark'))
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => observer.disconnect()
}, [])
return isDark
}
// Global function to trigger tour from anywhere (e.g., Help menu)
export function startOnboardingTour() {
window.dispatchEvent(new CustomEvent('start-onboarding-tour'))
}
// Main tour component
export function OnboardingTour() {
const isDark = useDarkMode()
const totalSteps = TOUR_STEPS.length
const [state, dispatch] = useReducer(tourReducer, {
isActive: false,
currentStepIndex: 0,
hasCompletedTour: localStorage.getItem(STORAGE_KEY) === 'true'
})
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
const { isActive, currentStepIndex, hasCompletedTour } = state
const currentStep = TOUR_STEPS[currentStepIndex]
const isFirstStep = currentStepIndex === 0
const isLastStep = currentStepIndex === totalSteps - 1
// Actions
const startTour = useCallback(() => dispatch({ type: 'START_TOUR' }), [])
const nextStep = useCallback(() => {
if (currentStepIndex >= totalSteps - 1) {
dispatch({ type: 'COMPLETE_TOUR' })
} else {
dispatch({ type: 'NEXT_STEP' })
}
}, [currentStepIndex, totalSteps])
const prevStep = useCallback(() => dispatch({ type: 'PREV_STEP' }), [])
const skipTour = useCallback(() => dispatch({ type: 'SKIP_TOUR' }), [])
const resetTour = useCallback(() => {
localStorage.removeItem(STORAGE_KEY)
dispatch({ type: 'START_TOUR' })
}, [])
// Listen for external trigger (from Help menu)
useEffect(() => {
const handleStartTour = () => resetTour()
window.addEventListener('start-onboarding-tour', handleStartTour)
return () => window.removeEventListener('start-onboarding-tour', handleStartTour)
}, [resetTour])
// Auto-start tour for first-time users (with delay to let UI render)
useEffect(() => {
if (!hasCompletedTour) {
const timer = setTimeout(() => startTour(), 1500)
return () => clearTimeout(timer)
}
}, [hasCompletedTour, startTour])
// Update target element rect when step changes
useEffect(() => {
if (!isActive || !currentStep) return
const updateTargetRect = () => {
// Try each selector (some steps have multiple selectors separated by comma)
const selectors = currentStep.targetSelector.split(',').map(s => s.trim())
let target: Element | null = null
for (const selector of selectors) {
target = document.querySelector(selector)
if (target) break
}
if (target) {
setTargetRect(target.getBoundingClientRect())
} else if (currentStep.fallbackPosition) {
// Create a synthetic rect for fallback position
setTargetRect(new DOMRect(
currentStep.fallbackPosition.left,
currentStep.fallbackPosition.top,
40,
40
))
} else {
// Center of screen fallback
setTargetRect(new DOMRect(
window.innerWidth / 2 - 20,
window.innerHeight / 2 - 20,
40,
40
))
}
}
// Initial update
updateTargetRect()
// Update on resize and scroll
window.addEventListener('resize', updateTargetRect)
window.addEventListener('scroll', updateTargetRect, true)
// Also update periodically in case elements are dynamically rendered
const interval = setInterval(updateTargetRect, 500)
return () => {
window.removeEventListener('resize', updateTargetRect)
window.removeEventListener('scroll', updateTargetRect, true)
clearInterval(interval)
}
}, [isActive, currentStep, currentStepIndex])
// Keyboard navigation
useEffect(() => {
if (!isActive) return
const handleKeyDown = (e: KeyboardEvent) => {
// Don't capture if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.key) {
case 'Escape':
e.preventDefault()
e.stopPropagation()
skipTour()
break
case 'ArrowRight':
case 'Enter':
e.preventDefault()
e.stopPropagation()
nextStep()
break
case 'ArrowLeft':
e.preventDefault()
e.stopPropagation()
if (!isFirstStep) prevStep()
break
}
}
// Use capture phase to intercept before tldraw
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isActive, nextStep, prevStep, skipTour, isFirstStep])
// Don't render if not active
if (!isActive || !currentStep) return null
return createPortal(
<TourTooltip
step={currentStep}
stepNumber={currentStepIndex + 1}
totalSteps={totalSteps}
targetRect={targetRect}
isDark={isDark}
onNext={nextStep}
onPrev={prevStep}
onSkip={skipTour}
isFirstStep={isFirstStep}
isLastStep={isLastStep}
/>,
document.body
)
}

View File

@ -0,0 +1,349 @@
import React from 'react'
import { TourStep } from './tourSteps'
interface TourTooltipProps {
step: TourStep
stepNumber: number
totalSteps: number
targetRect: DOMRect | null
isDark: boolean
onNext: () => void
onPrev: () => void
onSkip: () => void
isFirstStep: boolean
isLastStep: boolean
}
// Calculate tooltip position relative to target
function calculateTooltipPosition(
targetRect: DOMRect,
placement: TourStep['placement'],
tooltipWidth: number,
tooltipHeight: number,
gap: number
): { top: number; left: number } {
const { top, left, right, bottom, width, height } = targetRect
const centerX = left + width / 2
const centerY = top + height / 2
switch (placement) {
case 'top':
return { top: top - tooltipHeight - gap, left: centerX - tooltipWidth / 2 }
case 'bottom':
return { top: bottom + gap, left: centerX - tooltipWidth / 2 }
case 'left':
return { top: centerY - tooltipHeight / 2, left: left - tooltipWidth - gap }
case 'right':
return { top: centerY - tooltipHeight / 2, left: right + gap }
case 'top-left':
return { top: top - tooltipHeight - gap, left: left }
case 'top-right':
return { top: top - tooltipHeight - gap, left: right - tooltipWidth }
case 'bottom-left':
return { top: bottom + gap, left: left }
case 'bottom-right':
return { top: bottom + gap, left: right - tooltipWidth }
default:
return { top: bottom + gap, left: centerX - tooltipWidth / 2 }
}
}
// Clamp position to viewport bounds
function clampToViewport(
position: { top: number; left: number },
tooltipWidth: number,
tooltipHeight: number,
padding: number = 16
): { top: number; left: number } {
return {
top: Math.max(padding, Math.min(window.innerHeight - tooltipHeight - padding, position.top)),
left: Math.max(padding, Math.min(window.innerWidth - tooltipWidth - padding, position.left))
}
}
export function TourTooltip({
step,
stepNumber,
totalSteps,
targetRect,
isDark,
onNext,
onPrev,
onSkip,
isFirstStep,
isLastStep
}: TourTooltipProps) {
const tooltipWidth = 320
const tooltipHeight = 200 // Approximate, will auto-size
const highlightPadding = step.highlightPadding || 8
const gap = 12
// Theme colors
const colors = isDark ? {
background: 'rgba(30, 30, 30, 0.98)',
border: 'rgba(70, 70, 70, 0.8)',
text: '#e4e4e4',
textMuted: '#a1a1aa',
accent: '#10b981',
accentHover: '#059669',
buttonBg: 'rgba(50, 50, 50, 0.8)',
buttonBorder: 'rgba(70, 70, 70, 0.6)',
buttonHover: 'rgba(70, 70, 70, 1)',
overlay: 'rgba(0, 0, 0, 0.7)'
} : {
background: 'rgba(255, 255, 255, 0.98)',
border: 'rgba(229, 231, 235, 0.8)',
text: '#18181b',
textMuted: '#71717a',
accent: '#10b981',
accentHover: '#059669',
buttonBg: 'rgba(244, 244, 245, 0.8)',
buttonBorder: 'rgba(212, 212, 216, 0.8)',
buttonHover: 'rgba(228, 228, 231, 1)',
overlay: 'rgba(0, 0, 0, 0.5)'
}
// Calculate tooltip position
let tooltipPosition = { top: 100, left: 100 }
if (targetRect) {
const calculated = calculateTooltipPosition(
targetRect,
step.placement,
tooltipWidth,
tooltipHeight,
gap
)
tooltipPosition = clampToViewport(calculated, tooltipWidth, tooltipHeight)
}
return (
<>
{/* Spotlight overlay with cutout */}
{targetRect && (
<div
style={{
position: 'fixed',
top: targetRect.y - highlightPadding,
left: targetRect.x - highlightPadding,
width: targetRect.width + highlightPadding * 2,
height: targetRect.height + highlightPadding * 2,
borderRadius: '12px',
boxShadow: `0 0 0 9999px ${colors.overlay}`,
zIndex: 99998,
pointerEvents: 'none',
transition: 'all 0.3s ease-out'
}}
/>
)}
{/* Tooltip */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="tour-step-title"
aria-describedby="tour-step-content"
style={{
position: 'fixed',
top: tooltipPosition.top,
left: tooltipPosition.left,
width: tooltipWidth,
zIndex: 99999,
background: colors.background,
borderRadius: '16px',
border: `1px solid ${colors.border}`,
boxShadow: isDark
? '0 20px 50px rgba(0, 0, 0, 0.5), 0 10px 20px rgba(0, 0, 0, 0.3)'
: '0 20px 50px rgba(0, 0, 0, 0.15), 0 10px 20px rgba(0, 0, 0, 0.1)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
animation: 'tourTooltipFadeIn 0.25s ease-out',
pointerEvents: 'auto'
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Header with icon and step counter */}
<div style={{
padding: '16px 18px 0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{step.icon && (
<span style={{
fontSize: '24px',
filter: isDark ? 'none' : 'saturate(0.9)'
}}>
{step.icon}
</span>
)}
<span style={{
fontSize: '11px',
fontWeight: 600,
color: colors.accent,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Step {stepNumber} of {totalSteps}
</span>
</div>
<button
onClick={onSkip}
style={{
background: 'none',
border: 'none',
color: colors.textMuted,
cursor: 'pointer',
padding: '4px 8px',
fontSize: '16px',
opacity: 0.6,
borderRadius: '6px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
e.currentTarget.style.background = colors.buttonBg
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.6'
e.currentTarget.style.background = 'none'
}}
title="Skip tour (Esc)"
aria-label="Skip tour"
>
×
</button>
</div>
{/* Content */}
<div style={{ padding: '14px 18px 16px' }}>
<h3
id="tour-step-title"
style={{
margin: '0 0 10px',
fontSize: '17px',
fontWeight: 600,
color: colors.text,
lineHeight: 1.3
}}
>
{step.title}
</h3>
<p
id="tour-step-content"
style={{
margin: 0,
fontSize: '13px',
lineHeight: 1.6,
color: colors.textMuted
}}
>
{step.content}
</p>
{step.actionHint && (
<p style={{
margin: '12px 0 0',
fontSize: '11px',
color: colors.accent,
fontStyle: 'italic',
opacity: 0.9
}}>
{step.actionHint}
</p>
)}
</div>
{/* Navigation */}
<div style={{
padding: '14px 18px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderTop: `1px solid ${colors.border}`
}}>
{/* Progress dots */}
<div style={{ display: 'flex', gap: '6px' }}>
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
style={{
width: i === stepNumber - 1 ? '16px' : '6px',
height: '6px',
borderRadius: '3px',
background: i === stepNumber - 1 ? colors.accent : colors.buttonBg,
transition: 'all 0.2s ease'
}}
/>
))}
</div>
{/* Buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
{!isFirstStep && (
<button
onClick={onPrev}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: `1px solid ${colors.buttonBorder}`,
background: colors.buttonBg,
color: colors.text,
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = colors.buttonHover
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = colors.buttonBg
}}
>
Back
</button>
)}
<button
onClick={onNext}
style={{
padding: '8px 18px',
borderRadius: '8px',
border: 'none',
background: colors.accent,
color: 'white',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = colors.accentHover
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = colors.accent
}}
>
{isLastStep ? 'Get Started' : 'Next'}
</button>
</div>
</div>
</div>
{/* CSS Animation */}
<style>{`
@keyframes tourTooltipFadeIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`}</style>
</>
)
}

View File

@ -0,0 +1,2 @@
export { OnboardingTour, startOnboardingTour } from './OnboardingTour'
export type { TourStep } from './tourSteps'

View File

@ -0,0 +1,86 @@
// Tour step definitions for the onboarding walkthrough
export interface TourStep {
id: string
title: string
content: string
// Target element selector - CSS selector string
targetSelector: string
// Fallback positioning if element not found
fallbackPosition?: { top: number; left: number }
// Tooltip placement relative to target
placement: 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
// Optional highlight padding around target element
highlightPadding?: number
// Optional action hint (e.g., "Click to continue" vs "Press Enter")
actionHint?: string
// Optional icon for visual interest
icon?: string
}
export const TOUR_STEPS: TourStep[] = [
{
id: 'local-first',
title: 'Your Data, Your Device',
content: 'This canvas stores everything locally in your browser first. Your work is saved automatically and works offline - no account required to start creating.',
targetSelector: '.tlui-menu__button',
placement: 'bottom-right',
highlightPadding: 8,
icon: '🏠',
actionHint: 'Your data never leaves your device unless you choose to sync'
},
{
id: 'cryptid-login',
title: 'Encrypted Identity',
content: 'Sign in with CryptID for end-to-end encrypted sync across devices. Your password never leaves your browser - we use cryptographic keys instead.',
targetSelector: '.cryptid-dropdown-trigger',
fallbackPosition: { top: 60, left: window.innerWidth - 200 },
placement: 'bottom-left',
highlightPadding: 8,
icon: '🔐',
actionHint: 'Create an account or sign in to sync'
},
{
id: 'share-collaborate',
title: 'Share & Collaborate',
content: 'Invite others to view or edit this board in real-time. See live cursors and collaborate together on the same canvas.',
targetSelector: '.share-board-button',
fallbackPosition: { top: 60, left: window.innerWidth - 150 },
placement: 'bottom-left',
highlightPadding: 8,
icon: '👥',
actionHint: 'Share via link or QR code'
},
{
id: 'toolbar-tools',
title: 'Your Creative Toolkit',
content: 'Draw, write, add shapes, embed media, generate AI images, and more. Everything you need to think visually is here.',
targetSelector: '.tlui-toolbar',
placement: 'right',
highlightPadding: 12,
icon: '🎨',
actionHint: 'Select a tool to get started'
},
{
id: 'mycelial-ai',
title: 'Mycelial Intelligence',
content: 'Ask questions about your canvas. The AI understands all your shapes, notes, and connections - like a second brain for your visual thinking.',
targetSelector: '.mycelial-intelligence-bar',
fallbackPosition: { top: window.innerHeight - 100, left: window.innerWidth / 2 - 150 },
placement: 'top',
highlightPadding: 8,
icon: '🍄',
actionHint: 'Type a question to get started'
},
{
id: 'keyboard-shortcuts',
title: 'Keyboard Shortcuts',
content: 'Press ? anytime to see all keyboard shortcuts. Power users can navigate, zoom, and create without touching the mouse.',
targetSelector: '.help-button, [title*="Keyboard shortcuts"]',
fallbackPosition: { top: 60, left: window.innerWidth - 50 },
placement: 'bottom-left',
highlightPadding: 8,
icon: '⌨️',
actionHint: 'Press ? to open shortcuts anytime'
}
]

View File

@ -27,6 +27,7 @@ import {
useDialogs,
} from "tldraw"
import { SlidesPanel } from "@/slides/SlidesPanel"
import { OnboardingTour, startOnboardingTour } from "./OnboardingTour"
// AI tool model configurations
const AI_TOOLS = [
@ -636,6 +637,31 @@ function CustomSharePanel() {
<span>API Keys</span>
</span>
</button>
{/* Show Tutorial */}
<button
onClick={() => {
setShowMobileMenu(false)
startOnboardingTour()
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '14px',
fontFamily: 'inherit',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px' }}>🎓</span>
<span>Show Tutorial</span>
</span>
</button>
</>
)}
@ -836,14 +862,14 @@ function CustomSharePanel() {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}>
{/* CryptID dropdown - leftmost */}
<div style={{ padding: '0 4px' }}>
<div className="cryptid-dropdown-trigger" style={{ padding: '0 4px' }}>
<CryptIDDropdown isDarkMode={isDarkMode} />
</div>
<Separator />
{/* Share board button */}
<div style={{ padding: '0 2px' }}>
<div className="share-board-button" style={{ padding: '0 2px' }}>
<ShareBoardButton className="share-panel-btn" />
</div>
@ -1332,6 +1358,46 @@ function CustomSharePanel() {
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* Show Tutorial Button */}
<div style={{ padding: '12px 16px' }}>
<button
onClick={() => {
setShowSettingsDropdown(false)
startOnboardingTour()
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '10px 16px',
background: 'none',
border: `1px solid ${isDarkMode ? '#404040' : '#e5e7eb'}`,
borderRadius: '8px',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '13px',
fontWeight: 500,
fontFamily: 'inherit',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'
e.currentTarget.style.borderColor = '#10b981'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none'
e.currentTarget.style.borderColor = isDarkMode ? '#404040' : '#e5e7eb'
}}
>
<span style={{ fontSize: '16px' }}>🎓</span>
<span>Show Tutorial</span>
</button>
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* AI Models Accordion */}
<div>
<button
@ -1530,7 +1596,7 @@ function CustomSharePanel() {
<Separator />
{/* Help/Keyboard shortcuts button - rightmost - opens Command Palette */}
<div style={{ padding: '0 4px' }}>
<div className="help-button" style={{ padding: '0 4px' }}>
<button
onClick={() => openCommandPalette()}
className="share-panel-btn"
@ -1595,6 +1661,7 @@ function CustomInFrontOfCanvas() {
<FocusLockIndicator />
<CommandPalette />
<NetworkGraphPanel />
<OnboardingTour />
</>
)
}