diff --git a/apps/x/.pnpm-store/v11/index.db b/apps/x/.pnpm-store/v11/index.db new file mode 100644 index 00000000..7f9770bd Binary files /dev/null and b/apps/x/.pnpm-store/v11/index.db differ diff --git a/apps/x/apps/renderer/scripts/generate-tour-audio.mjs b/apps/x/apps/renderer/scripts/generate-tour-audio.mjs new file mode 100644 index 00000000..26d1535a --- /dev/null +++ b/apps/x/apps/renderer/scripts/generate-tour-audio.mjs @@ -0,0 +1,62 @@ +/** + * Regenerates the bundled product-tour narration clips. + * + * Parses the TOUR_STEPS texts straight out of product-tour.tsx (so the code + * stays the single source of truth), synthesizes each one via @x/core's + * synthesizeSpeech (Rowboat proxy when signed in, direct ElevenLabs otherwise, + * using the voice id configured there / in ~/.rowboat/config/elevenlabs.json), + * and writes MP3s to src/assets/tour/.mp3. + * + * Run whenever a step's narration text or the tour voice changes: + * cd apps/x && npm run deps # script imports core's built output + * node apps/renderer/scripts/generate-tour-audio.mjs + * + * Pass step ids to regenerate only those clips (e.g. to re-roll one whose + * synthesis came out glitchy): + * node apps/renderer/scripts/generate-tour-audio.mjs welcome done + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const tourSource = path.join(here, '../src/components/product-tour.tsx') +const outDir = path.join(here, '../src/assets/tour') +const corePath = path.join(here, '../../../packages/core/dist/voice/voice.js') + +const { synthesizeSpeech } = await import(corePath) + +const src = await readFile(tourSource, 'utf8') +const start = src.indexOf('const TOUR_STEPS') +const end = src.indexOf('\n]', start) +if (start === -1 || end === -1) throw new Error('Could not locate TOUR_STEPS in product-tour.tsx') +const block = src.slice(start, end) + +const steps = [] +// voiceText, when present, is the spoken variant of the bubble text. +const re = /id: '([^']+)'[\s\S]*?text:\s*("[^"]*"|'[^']*')(?:,\s*voiceText:\s*("[^"]*"|'[^']*'))?/g +for (let m; (m = re.exec(block)); ) { + // The captures are JS string literals from our own source; evaluate them + // to resolve the quoting. + steps.push({ id: m[1], text: new Function(`return ${m[3] ?? m[2]}`)() }) +} +if (steps.length === 0) throw new Error('Parsed zero tour steps — regex out of sync with product-tour.tsx?') +console.log(`Parsed ${steps.length} tour steps`) + +const only = process.argv.slice(2) +if (only.length > 0) { + const unknown = only.filter((id) => !steps.some((s) => s.id === id)) + if (unknown.length > 0) throw new Error(`Unknown step ids: ${unknown.join(', ')}`) + steps.splice(0, steps.length, ...steps.filter((s) => only.includes(s.id))) + console.log(`Regenerating only: ${only.join(', ')}`) +} + +await mkdir(outDir, { recursive: true }) +for (const step of steps) { + process.stdout.write(`synthesizing ${step.id}... `) + const { audioBase64 } = await synthesizeSpeech(step.text) + const file = path.join(outDir, `${step.id}.mp3`) + await writeFile(file, Buffer.from(audioBase64, 'base64')) + console.log(`${(audioBase64.length * 0.75 / 1024).toFixed(0)} KB`) +} +console.log(`Done — ${steps.length} clips in ${path.relative(process.cwd(), outDir)}`) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 5ef631e2..7398aa92 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -119,6 +119,8 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" import { useVoiceMode } from '@/hooks/useVoiceMode' import { useVoiceTTS } from '@/hooks/useVoiceTTS' +import { TalkingHeadOverlay } from '@/components/talking-head' +import { ProductTour, type TourNavTarget } from '@/components/product-tour' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' @@ -971,6 +973,8 @@ function App() { const ttsEnabledRef = useRef(false) const [ttsMode, setTtsMode] = useState<'summary' | 'full'>('summary') const ttsModeRef = useRef<'summary' | 'full'>('summary') + const [ttsAvatarEnabled, setTtsAvatarEnabled] = useState(false) + const [tourActive, setTourActive] = useState(false) const [isRecording, setIsRecording] = useState(false) const voiceTextBufferRef = useRef('') const spokenIndexRef = useRef(0) @@ -1092,6 +1096,21 @@ function App() { setTtsEnabled(prev => { const next = !prev ttsEnabledRef.current = next + if (!next) { + ttsRef.current.cancel() + setTtsAvatarEnabled(false) + } + return next + }) + }, []) + + // Talking-head mode implies voice output: enabling it turns TTS on, + // disabling it turns both off. + const handleToggleTtsAvatar = useCallback(() => { + setTtsAvatarEnabled(prev => { + const next = !prev + setTtsEnabled(next) + ttsEnabledRef.current = next if (!next) { ttsRef.current.cancel() } @@ -4924,6 +4943,33 @@ function App() { }, }), [tree, selectedPath, isGraphOpen, selectedBackgroundTask, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath]) + // Drives the mascot product tour through the app's main sections + const handleTourNavigate = useCallback((target: TourNavTarget) => { + switch (target) { + case 'home': + void navigateToView({ type: 'home' }) + break + case 'email': + openEmailView() + break + case 'meetings': + openMeetingsView() + break + case 'code': + openCodeView() + break + case 'knowledge': + knowledgeActions.openKnowledgeView() + break + case 'agents': + openBgTasksView() + break + case 'workspaces': + knowledgeActions.openWorkspaceAt() + break + } + }, [navigateToView, openEmailView, openMeetingsView, openCodeView, knowledgeActions, openBgTasksView]) + // Handler for when a voice note is created/updated const handleVoiceNoteCreated = useCallback(async (notePath: string) => { // Refresh the tree to show the new file/folder @@ -5583,6 +5629,7 @@ function App() { onNewChat={handleNewChatTab} onToggleBrowser={handleToggleBrowser} onVoiceNoteCreated={handleVoiceNoteCreated} + onStartTour={() => setTourActive(true)} meetingRecordingState={meetingTranscription.state} recordingMeetingSource={recordingMeetingSource} onToggleMeetingRecording={() => { void handleToggleMeeting() }} @@ -6288,6 +6335,8 @@ function App() { ttsMode={ttsMode} onToggleTts={isActive ? handleToggleTts : undefined} onTtsModeChange={isActive ? handleTtsModeChange : undefined} + ttsAvatarEnabled={ttsAvatarEnabled} + onToggleTtsAvatar={isActive ? handleToggleTtsAvatar : undefined} /> ) @@ -6399,9 +6448,33 @@ function App() { ttsMode={ttsMode} onToggleTts={handleToggleTts} onTtsModeChange={handleTtsModeChange} + ttsAvatarEnabled={ttsAvatarEnabled} + onToggleTtsAvatar={handleToggleTtsAvatar} onComposioConnected={handleComposioConnected} /> )} + {/* Talking head hovers over the active view while avatar voice mode is + on (hidden during the tour, which shows its own mascot) */} + {ttsAvatarEnabled && !tourActive && ( + + )} + {/* Mascot-guided product tour */} + {tourActive && ( + setTourActive(false)} + onNavigate={handleTourNavigate} + ttsAvailable={ttsAvailable} + ttsState={tts.state} + speak={tts.speak} + speakUrl={tts.speakUrl} + cancelSpeech={tts.cancel} + getLevel={tts.getLevel} + /> + )} {/* Rendered last so its no-drag region paints over the sidebar drag region */} void onTtsModeChange?: (mode: 'summary' | 'full') => void + ttsAvatarEnabled?: boolean + onToggleTtsAvatar?: () => void /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ onSelectedModelChange?: (model: SelectedModel | null) => void /** Work directory for this chat (per-chat). Null when none is set. */ @@ -273,6 +276,8 @@ function ChatInputInner({ ttsMode, onToggleTts, onTtsModeChange, + ttsAvatarEnabled, + onToggleTtsAvatar, onSelectedModelChange, workDir = null, onWorkDirChange, @@ -752,7 +757,7 @@ function ChatInputInner({ const currentWorkDirPath = effectiveWorkDir ? compactWorkDirPath(effectiveWorkDir) : '' return ( -
+
{attachments.length > 0 && (
{attachments.map((attachment) => { @@ -1318,6 +1323,33 @@ function ChatInputInner({ )}
)} + {onToggleTtsAvatar && ttsAvailable && ( + + + + + + {ttsAvatarEnabled ? 'Talking head on' : 'Talking head off'} + + + )} {voiceAvailable && onStartRecording && (
) diff --git a/apps/x/apps/renderer/src/components/product-tour.tsx b/apps/x/apps/renderer/src/components/product-tour.tsx new file mode 100644 index 00000000..b6e5fe8d --- /dev/null +++ b/apps/x/apps/renderer/src/components/product-tour.tsx @@ -0,0 +1,878 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { TalkingHead, type MascotHat } from '@/components/talking-head' +import { AgentsFleet, MascotVignette, type TourVignetteKind } from '@/components/tour-vignettes' +import { TourSounds } from '@/lib/tour-sounds' +import type { TTSState } from '@/hooks/useVoiceTTS' +import { cn } from '@/lib/utils' +import tourClipWelcome from '@/assets/tour/welcome.mp3' +import tourClipHome from '@/assets/tour/home.mp3' +import tourClipEmail from '@/assets/tour/email.mp3' +import tourClipMeetings from '@/assets/tour/meetings.mp3' +import tourClipCode from '@/assets/tour/code.mp3' +import tourClipKnowledge from '@/assets/tour/knowledge.mp3' +import tourClipAgents from '@/assets/tour/agents.mp3' +import tourClipWorkspaces from '@/assets/tour/workspaces.mp3' +import tourClipChats from '@/assets/tour/chats.mp3' +import tourClipComposer from '@/assets/tour/composer.mp3' +import tourClipDone from '@/assets/tour/done.mp3' + +export type TourNavTarget = + | 'home' + | 'email' + | 'meetings' + | 'code' + | 'knowledge' + | 'agents' + | 'workspaces' + +type TourStep = { + id: string + /** Matches a [data-tour-id] element. Steps whose target is absent are skipped. */ + targetId?: string + /** View to open when the step starts, via App's navigation handlers. */ + navigate?: TourNavTarget + /** Costume the mascot wears at this stop. */ + hat?: MascotHat + /** Looping animation staged around the mascot while it presents this stop. */ + vignette?: TourVignetteKind + title: string + text: string + /** Spoken narration, when it should differ from the bubble text. */ + voiceText?: string +} + +const TOUR_STEPS: TourStep[] = [ + { + id: 'welcome', + title: 'All aboard! ⚓', + text: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time. Use Next or your arrow keys.", + voiceText: "I'm your captain for the next minute. The lights are down and the water's in — let me row you across Rowboat, one stop at a time.", + }, + { + id: 'home', + targetId: 'nav-home', + navigate: 'home', + title: 'First stop: Home', + text: 'Home is your landing spot — a quick overview of what needs your attention to get you back into the flow.', + }, + { + id: 'email', + targetId: 'nav-email', + navigate: 'email', + hat: 'mailcap', + vignette: 'email', + title: 'Email', + text: 'Read and triage your inbox right here. Rowboat can summarize threads, label messages, and help you draft replies.', + }, + { + id: 'meetings', + targetId: 'nav-meetings', + navigate: 'meetings', + hat: 'headphones', + vignette: 'meetings', + title: 'Meetings', + text: 'Record or join meetings, and get transcripts and notes automatically — prep briefs show up before your calls, too.', + }, + { + id: 'code', + targetId: 'nav-code', + navigate: 'code', + hat: 'hardhat', + title: 'Code', + text: 'The Code section runs coding agents on your projects — point one at a folder and drive it from a chat.', + }, + { + id: 'knowledge', + targetId: 'nav-knowledge', + navigate: 'knowledge', + hat: 'gradcap', + vignette: 'brain', + title: 'Brain', + text: "Brain is your knowledge base — notes, files, and everything Rowboat learns for you, all connected and searchable.", + }, + { + id: 'agents', + targetId: 'nav-agents', + navigate: 'agents', + hat: 'captain', + vignette: 'agents', + title: 'Background agents', + text: 'Background agents work on schedules — they keep your Brain fresh and take care of recurring tasks while you row elsewhere.', + }, + { + id: 'workspaces', + targetId: 'nav-workspaces', + navigate: 'workspaces', + hat: 'explorer', + title: 'Workspaces', + text: 'Workspaces hold your project folders and files, so related work stays docked together.', + }, + { + id: 'chats', + targetId: 'nav-chats', + title: 'Chats', + text: 'Your recent conversations live here — pick any of them back up right where you left off.', + }, + { + id: 'composer', + targetId: 'chat-composer', + title: 'Talk to Rowboat', + text: 'And this is where we talk! Type, dictate with the mic, or turn on voice output — tap my face button and I’ll read replies out loud myself.', + }, + { + id: 'done', + hat: 'party', + title: "Land ho! 🎉", + text: "That's the whole bay — and there's my wake to prove it. Take this voyage again anytime from the bottom of the sidebar. Happy rowing!", + }, +] + +// Pre-recorded narration bundled with the app (no TTS API call, works +// offline/signed-out). Regenerate with scripts/generate-tour-audio.mjs after +// editing any step's text. Steps without a clip fall back to live TTS. +const TOUR_CLIPS: Record = { + welcome: tourClipWelcome, + home: tourClipHome, + email: tourClipEmail, + meetings: tourClipMeetings, + code: tourClipCode, + knowledge: tourClipKnowledge, + agents: tourClipAgents, + workspaces: tourClipWorkspaces, + chats: tourClipChats, + composer: tourClipComposer, + done: tourClipDone, +} + +const MASCOT_SIZE = 120 +const VIEWPORT_MARGIN = 16 +const BUBBLE_WIDTH = 288 +const TARGET_RESOLVE_TIMEOUT_MS = 1500 +const ZOOM_SCALE = 1.05 +const GLIDE_EASING = 'cubic-bezier(0.45, 0, 0.2, 1)' + +type Pt = { x: number; y: number } +type Rect = { left: number; top: number; width: number; height: number } +type Spot = Rect & { round: boolean } + +function clamp(v: number, min: number, max: number): number { + return Math.max(min, Math.min(max, v)) +} + +function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2 +} + +function quadPoint(p0: Pt, c: Pt, p1: Pt, t: number): Pt { + const u = 1 - t + return { + x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x, + y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y, + } +} + +function quadPathLength(d: string): number { + const p = document.createElementNS('http://www.w3.org/2000/svg', 'path') + p.setAttribute('d', d) + return p.getTotalLength() +} + +// A data-tour-id can legitimately appear on several elements (e.g. the chat +// composer renders in both the full-screen chat and the side pane) — pick the +// one that is actually laid out. +function findTourTarget(targetId: string): HTMLElement | null { + const nodes = document.querySelectorAll(`[data-tour-id="${targetId}"]`) + for (const el of nodes) { + const rect = el.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) return el + } + return null +} + +function mascotDestForCenter(): Pt { + return { + x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2, + y: window.innerHeight / 2 - MASCOT_SIZE / 2, + } +} + +function mascotDestForRect(rect: Rect): Pt { + let x: number + let y: number + const fitsRight = rect.left + rect.width + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth + if (fitsRight) { + x = rect.left + rect.width + 20 + y = rect.top + rect.height / 2 - MASCOT_SIZE / 2 + } else if (rect.top > MASCOT_SIZE + VIEWPORT_MARGIN * 2) { + x = rect.left + rect.width / 2 - MASCOT_SIZE / 2 + y = rect.top - MASCOT_SIZE - 20 + } else { + x = rect.left - MASCOT_SIZE - 20 + y = rect.top + rect.height / 2 - MASCOT_SIZE / 2 + } + return { + x: clamp(x, VIEWPORT_MARGIN, window.innerWidth - MASCOT_SIZE - VIEWPORT_MARGIN), + y: clamp(y, VIEWPORT_MARGIN, window.innerHeight - MASCOT_SIZE - VIEWPORT_MARGIN), + } +} + +function spotForRect(rect: Rect): Spot { + return { + left: rect.left - 6, + top: rect.top - 6, + width: rect.width + 12, + height: rect.height + 12, + round: false, + } +} + +function spotForMascot(dest: Pt): Spot { + return { + left: dest.x - 26, + top: dest.y - 18, + width: MASCOT_SIZE + 52, + height: MASCOT_SIZE + 44, + round: true, + } +} + +type ProductTourProps = { + onClose: () => void + onNavigate: (target: TourNavTarget) => void + ttsAvailable: boolean + ttsState: TTSState + speak: (text: string) => void + speakUrl: (url: string) => void + cancelSpeech: () => void + getLevel: () => number +} + +/** + * The Grand Voyage: a mascot-guided walkthrough where the app dims to a + * night-time bay, the boat rows curved routes between [data-tour-id] anchors + * (leaving a dotted wake behind it), a spotlight and gentle camera zoom reveal + * each section, and a parchment mini-map charts progress. Narrated with TTS + * lip sync when available; ends in confetti. + * + * Rendered through a portal to so the camera zoom applied to the app + * shell never transforms the tour's own fixed-position layers. + */ +export function ProductTour({ + onClose, + onNavigate, + ttsAvailable, + ttsState, + speak, + speakUrl, + cancelSpeech, + getLevel, +}: ProductTourProps) { + const [stepIndex, setStepIndex] = useState(0) + const [arrived, setArrived] = useState(false) + const [rowing, setRowing] = useState(false) + const [flipped, setFlipped] = useState(false) + const [bubbleSide, setBubbleSide] = useState<'left' | 'right'>('right') + const [spot, setSpot] = useState(null) + const [wakes, setWakes] = useState<{ id: number; d: string }[]>([]) + const [activeWake, setActiveWake] = useState<{ d: string; len: number } | null>(null) + const [confettiOn, setConfettiOn] = useState(false) + const [resizeNonce, setResizeNonce] = useState(0) + + const reducedMotion = useMemo( + () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, + [] + ) + + // Mascot position is animated by mutating the container's transform directly + // (60fps travel without re-rendering React); posRef is the source of truth. + const mascotElRef = useRef(null) + const posRef = useRef({ + x: window.innerWidth / 2 - MASCOT_SIZE / 2, + y: window.innerHeight + 60, + }) + const travelRafRef = useRef(0) + const wakePathElRef = useRef(null) + const wakeIdRef = useRef(0) + const curveSideRef = useRef(1) + const lastSplashRef = useRef(0) + + const directionRef = useRef(1) + const enteredStepRef = useRef(-1) + const stepIndexRef = useRef(stepIndex) + + // Camera zoom state applied to the app shell (outside the portal) + const shellRef = useRef(null) + const zoomRef = useRef<{ ox: number; oy: number; s: number } | null>(null) + + const soundsRef = useRef(null) + + const onCloseRef = useRef(onClose) + const onNavigateRef = useRef(onNavigate) + const speakRef = useRef(speak) + const speakUrlRef = useRef(speakUrl) + const cancelSpeechRef = useRef(cancelSpeech) + const ttsAvailableRef = useRef(ttsAvailable) + + // Keep latest callbacks/state in refs so the step effect and key handlers + // stay stable. Runs before the step effect below (effect order = call order). + useEffect(() => { + stepIndexRef.current = stepIndex + onCloseRef.current = onClose + onNavigateRef.current = onNavigate + speakRef.current = speak + speakUrlRef.current = speakUrl + cancelSpeechRef.current = cancelSpeech + ttsAvailableRef.current = ttsAvailable + }) + + useLayoutEffect(() => { + if (mascotElRef.current) { + mascotElRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)` + } + soundsRef.current = new TourSounds() + return () => { + soundsRef.current?.dispose() + soundsRef.current = null + } + }, []) + + // Grab the app shell for the camera zoom; restore it when the tour ends + useEffect(() => { + const shell = document.querySelector('.rowboat-shell') + shellRef.current = shell + return () => { + if (shell) { + shell.style.transform = '' + shell.style.transformOrigin = '' + shell.style.transition = '' + } + shellRef.current = null + zoomRef.current = null + } + }, []) + + const applyZoom = useCallback((origin: { ox: number; oy: number } | null) => { + const shell = shellRef.current + if (!shell || reducedMotion) return + if (origin) { + // transform-origin transitions too, so moving between targets pans + // smoothly instead of jumping when the origin changes + shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}` + shell.style.transformOrigin = `${origin.ox}px ${origin.oy}px` + shell.style.transform = `scale(${ZOOM_SCALE})` + zoomRef.current = { ox: origin.ox, oy: origin.oy, s: ZOOM_SCALE } + } else { + shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}` + shell.style.transform = 'scale(1)' + zoomRef.current = null + } + }, [reducedMotion]) + + // Where the element will sit on screen once this step's zoom settles: + // undo the current zoom mathematically, then apply the upcoming one (whose + // origin is the target's own center, so the center never moves). + const displayedRect = useCallback((el: HTMLElement, willZoom: boolean): { rect: Rect; origin: { ox: number; oy: number } } => { + const m = el.getBoundingClientRect() + const z = zoomRef.current + let cx = m.left + m.width / 2 + let cy = m.top + m.height / 2 + let w = m.width + let h = m.height + if (z) { + cx = z.ox + (cx - z.ox) / z.s + cy = z.oy + (cy - z.oy) / z.s + w /= z.s + h /= z.s + } + const s = willZoom && !reducedMotion ? ZOOM_SCALE : 1 + return { + rect: { left: cx - (w * s) / 2, top: cy - (h * s) / 2, width: w * s, height: h * s }, + origin: { ox: cx, oy: cy }, + } + }, [reducedMotion]) + + const cancelTravel = useCallback(() => { + cancelAnimationFrame(travelRafRef.current) + }, []) + + const moveMascot = useCallback((p: Pt) => { + posRef.current = p + if (mascotElRef.current) { + mascotElRef.current.style.transform = `translate(${p.x}px, ${p.y}px)` + } + }, []) + + // Row along a curved path from the current position, drawing the wake as we + // go and splashing the oar; commits the wake as a dotted trail on arrival. + const startTravel = useCallback((dest: Pt, onArrive: () => void) => { + cancelTravel() + const from = { ...posRef.current } + const dist = Math.hypot(dest.x - from.x, dest.y - from.y) + if (dist < 6 || reducedMotion) { + moveMascot(dest) + setRowing(false) + onArrive() + return + } + const dur = clamp(dist * 1.1, 550, 1500) + curveSideRef.current = -curveSideRef.current + const mx = (from.x + dest.x) / 2 + const my = (from.y + dest.y) / 2 + const nx = -(dest.y - from.y) / dist + const ny = (dest.x - from.x) / dist + const mag = Math.min(140, dist * 0.3) * curveSideRef.current + const c = { x: mx + nx * mag, y: my + ny * mag } + + // Wake follows the stern (bottom-center of the mascot box) + const sternX = MASCOT_SIZE / 2 + const sternY = MASCOT_SIZE * 0.82 + const d = `M ${from.x + sternX} ${from.y + sternY} Q ${c.x + sternX} ${c.y + sternY} ${dest.x + sternX} ${dest.y + sternY}` + const len = quadPathLength(d) + setActiveWake({ d, len }) + setFlipped(dest.x < from.x - 4) + setRowing(true) + lastSplashRef.current = 0 + + const t0 = performance.now() + const frame = (now: number) => { + const raw = Math.min(1, (now - t0) / dur) + const t = easeInOutCubic(raw) + moveMascot(quadPoint(from, c, dest, t)) + if (wakePathElRef.current) { + wakePathElRef.current.style.strokeDashoffset = String(len * (1 - t)) + } + if (now - lastSplashRef.current > 420) { + lastSplashRef.current = now + soundsRef.current?.splash() + } + if (raw < 1) { + travelRafRef.current = requestAnimationFrame(frame) + } else { + setRowing(false) + setActiveWake(null) + setWakes((ws) => [...ws, { id: wakeIdRef.current++, d }]) + onArrive() + } + } + travelRafRef.current = requestAnimationFrame(frame) + }, [cancelTravel, moveMascot, reducedMotion]) + + const playDing = useCallback(() => soundsRef.current?.ding(), []) + + const finish = useCallback(() => { + cancelSpeechRef.current() + onCloseRef.current() + }, []) + + const goTo = useCallback((index: number, direction: 1 | -1) => { + directionRef.current = direction + if (index < 0) return + if (index >= TOUR_STEPS.length) { + finish() + return + } + // Silence the current step's narration right away — not on arrival — + // so it can't talk over (or into) the next step's speech. + cancelSpeechRef.current() + setStepIndex(index) + }, [finish]) + + // Stop any in-flight narration when the tour unmounts + useEffect(() => () => cancelSpeechRef.current(), []) + + useEffect(() => { + const handleResize = () => setResizeNonce((n) => n + 1) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + // Enter the current step: navigate, wait for its anchor, aim the spotlight + // and camera, row over, then narrate. Re-runs on resize to re-anchor. + useEffect(() => { + const step = TOUR_STEPS[stepIndex] + const entering = enteredStepRef.current !== stepIndex + let cancelled = false + + if (entering && step.navigate) { + onNavigateRef.current(step.navigate) + } + + const settle = (dest: Pt, side: 'left' | 'right', spotlight: Spot, origin: { ox: number; oy: number } | null) => { + applyZoom(origin) + setSpot(spotlight) + setBubbleSide(side) + if (!entering) { + // Resize while already at this step: jump, keep the bubble up + cancelTravel() + moveMascot(dest) + return + } + enteredStepRef.current = stepIndex + setArrived(false) + startTravel(dest, () => { + if (cancelled) return + setArrived(true) + soundsRef.current?.bump() + cancelSpeechRef.current() + const clip = TOUR_CLIPS[step.id] + if (clip) { + speakUrlRef.current(clip) + } else if (ttsAvailableRef.current) { + speakRef.current(step.voiceText ?? step.text) + } + if (stepIndex === TOUR_STEPS.length - 1) { + setConfettiOn(true) + soundsRef.current?.fanfare() + } + }) + } + + if (!step.targetId) { + const dest = mascotDestForCenter() + applyZoom(null) + settle(dest, 'right', spotForMascot(dest), null) + return () => { + cancelled = true + cancelTravel() + } + } + + const startedAt = performance.now() + let pollRaf = 0 + const attempt = () => { + if (cancelled) return + const el = findTourTarget(step.targetId!) + if (el) { + const { rect, origin } = displayedRect(el, true) + const dest = mascotDestForRect(rect) + const side: 'left' | 'right' = dest.x + MASCOT_SIZE / 2 < window.innerWidth / 2 ? 'right' : 'left' + settle(dest, side, spotForRect(rect), origin) + return + } + if (performance.now() - startedAt < TARGET_RESOLVE_TIMEOUT_MS) { + pollRaf = requestAnimationFrame(attempt) + } else if (entering) { + // Anchor never appeared (feature disabled / pane closed) — skip past it + goTo(stepIndex + directionRef.current, directionRef.current as 1 | -1) + } + } + attempt() + return () => { + cancelled = true + cancelAnimationFrame(pollRaf) + cancelTravel() + } + }, [stepIndex, resizeNonce, goTo, applyZoom, displayedRect, startTravel, cancelTravel, moveMascot]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + finish() + } else if (e.key === 'ArrowRight' || e.key === 'Enter') { + e.preventDefault() + goTo(stepIndexRef.current + 1, 1) + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + goTo(stepIndexRef.current - 1, -1) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [finish, goTo]) + + const step = TOUR_STEPS[stepIndex] + const isFirst = stepIndex === 0 + const isLast = stepIndex === TOUR_STEPS.length - 1 + + return createPortal( + <> + + + {/* night falls: dim everything except the spotlight cutout */} + {spot && ( +
+ )} + + + + {/* wake trails: the committed dotted route + the wake being drawn now */} + + {wakes.map((w) => ( + + ))} + {activeWake && ( + + )} + + + + + {/* the boat (position driven imperatively during travel) */} +
+ {arrived && !reducedMotion && step.vignette && step.vignette !== 'agents' && ( + + )} +
+ +
+ {arrived && ( +
+ +

{step.title}

+

{step.text}

+
+ + {stepIndex + 1} / {TOUR_STEPS.length} + +
+ {!isFirst && ( + + )} + +
+
+
+ )} +
+ + {arrived && !reducedMotion && step.vignette === 'agents' && } + + {confettiOn && !reducedMotion && } + , + document.body + ) +} + +/** Animated translucent waves lapping at the bottom of the screen. */ +function TourWater() { + const back = useMemo(() => wavePath(2400, 96, 8, 22), []) + const front = useMemo(() => wavePath(2400, 96, 12, 30), []) + return ( + + ) +} + +// Periodic wave: alternating up/down humps so a -50% translate loops seamlessly +// (both hump counts are even, so half the width is a whole number of periods). +function wavePath(width: number, height: number, humps: number, amp: number): string { + const yTop = 34 + const seg = width / humps + let d = `M 0 ${yTop}` + for (let i = 0; i < humps; i++) { + d += ` q ${seg / 2} ${i % 2 === 0 ? -amp : amp} ${seg} 0` + } + d += ` L ${width} ${height} L 0 ${height} Z` + return d +} + +/** Parchment chart in the corner: islands per stop, dotted route, boat marker. */ +function TourMiniMap({ total, current, arrived }: { total: number; current: number; arrived: boolean }) { + const MW = 184 + const MH = 96 + const points = useMemo( + () => + Array.from({ length: total }, (_, i) => { + const t = total === 1 ? 0 : i / (total - 1) + return { + x: 14 + (MW - 28) * t, + y: MH / 2 + 4 + Math.sin(t * Math.PI * 1.5 + 0.6) * (MH * 0.26), + } + }), + [total] + ) + const route = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + const boat = points[clamp(current, 0, total - 1)] + + return ( + + ) +} + +/** Two confetti cannons firing from the bottom corners, canvas-driven. */ +function ConfettiBurst() { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + const w = window.innerWidth + const h = window.innerHeight + const dpr = window.devicePixelRatio || 1 + canvas.width = w * dpr + canvas.height = h * dpr + ctx.scale(dpr, dpr) + + const colors = ['#5B8DEF', '#F2B8BE', '#FFD166', '#7AC74F', '#8FB6D9', '#F2699C'] + const parts = Array.from({ length: 150 }, (_, i) => { + const fromLeft = i % 2 === 0 + return { + x: fromLeft ? 24 : w - 24, + y: h - 40, + vx: (fromLeft ? 1 : -1) * (2.5 + Math.random() * 6.5), + vy: -(9 + Math.random() * 8), + size: 5 + Math.random() * 5, + color: colors[i % colors.length], + rot: Math.random() * Math.PI, + vr: (Math.random() - 0.5) * 0.3, + } + }) + + let raf = 0 + const t0 = performance.now() + const frame = (now: number) => { + ctx.clearRect(0, 0, w, h) + for (const p of parts) { + p.vy += 0.18 + p.vx *= 0.99 + p.x += p.vx + p.y += p.vy + p.rot += p.vr + ctx.save() + ctx.translate(p.x, p.y) + ctx.rotate(p.rot) + ctx.fillStyle = p.color + ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.66) + ctx.restore() + } + if (now - t0 < 3200) { + raf = requestAnimationFrame(frame) + } else { + ctx.clearRect(0, 0, w, h) + } + } + raf = requestAnimationFrame(frame) + return () => cancelAnimationFrame(raf) + }, []) + + return ( + + ) +} diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index bd3d7888..f2c26145 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -59,6 +59,7 @@ import { } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { SettingsDialog } from "@/components/settings-dialog" +import { MascotFaceIcon } from "@/components/talking-head" import { extractConferenceLink } from "@/lib/calendar-event" import { useBilling } from "@/hooks/useBilling" import { toast } from "@/lib/toast" @@ -170,6 +171,8 @@ type SidebarContentPanelProps = { onNewChat?: () => void onToggleBrowser?: () => void onVoiceNoteCreated?: (path: string) => void + /** Starts the mascot-guided product tour. */ + onStartTour?: () => void /** Which primary destination is currently active, for nav highlighting. */ activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'workspaces' | null /** Live meeting recording state, so the recording row can show its indicator/stop. */ @@ -418,6 +421,7 @@ export function SidebarContentPanel({ onNewChat, onToggleBrowser, onVoiceNoteCreated, + onStartTour, activeNav, meetingRecordingState = 'idle', recordingMeetingSource = null, @@ -695,13 +699,14 @@ export function SidebarContentPanel({ - + Home onOpenEmail?.()} className={previewEmail ? 'h-auto py-1.5' : undefined} @@ -724,6 +729,7 @@ export function SidebarContentPanel({ {codeModeEnabled && ( - + Code @@ -810,6 +816,7 @@ export function SidebarContentPanel({ )} knowledgeActions.openKnowledgeView()} className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined} @@ -830,6 +837,7 @@ export function SidebarContentPanel({ knowledgeActions.openWorkspaceAt()} className="h-auto py-1.5" @@ -874,6 +883,7 @@ export function SidebarContentPanel({
+ {onStartTour && ( + + )} + )} + +
+ ) +} + +/** Costume pieces for the product tour, drawn on the mascot's head. */ +function MascotHatArt({ hat }: { hat: MascotHat }) { + const outline = { stroke: BODY_STROKE, strokeWidth: 4, strokeLinejoin: 'round' as const } + switch (hat) { + case 'mailcap': + return ( + + + + + ) + case 'headphones': + return ( + + + + + + + ) + case 'hardhat': + return ( + + + + + + + ) + case 'gradcap': + return ( + + + + + + + ) + case 'captain': + return ( + + + + + + + ) + case 'explorer': + return ( + + + + + + ) + case 'party': + return ( + + + + + + ) + } +} + +/** Small static mascot face used as the toolbar toggle icon. */ +export function MascotFaceIcon({ className }: { className?: string }) { + return ( + + ) +} diff --git a/apps/x/apps/renderer/src/components/tour-vignettes.tsx b/apps/x/apps/renderer/src/components/tour-vignettes.tsx new file mode 100644 index 00000000..909409e7 --- /dev/null +++ b/apps/x/apps/renderer/src/components/tour-vignettes.tsx @@ -0,0 +1,262 @@ +import { useEffect } from 'react' + +export type MascotVignetteKind = 'email' | 'meetings' | 'brain' +export type TourVignetteKind = MascotVignetteKind | 'agents' + +/** + * Little looping "shows" staged around the mascot while it presents a section + * during the product tour. Purely decorative: everything is pointer-events-none + * and rendered on the tour's own layers, never inside the section's real UI. + */ +export function MascotVignette({ kind, playDing }: { kind: MascotVignetteKind; playDing?: () => void }) { + // One round of dings as the first envelopes land, then let the loop run silent + useEffect(() => { + if (kind !== 'email' || !playDing) return + const timers = [900, 1900, 2900].map((ms) => setTimeout(playDing, ms)) + return () => timers.forEach(clearTimeout) + }, [kind, playDing]) + + return ( +