mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
commit
81124dfc3e
23 changed files with 2056 additions and 22 deletions
BIN
apps/x/.pnpm-store/v11/index.db
Normal file
BIN
apps/x/.pnpm-store/v11/index.db
Normal file
Binary file not shown.
62
apps/x/apps/renderer/scripts/generate-tour-audio.mjs
Normal file
62
apps/x/apps/renderer/scripts/generate-tour-audio.mjs
Normal file
|
|
@ -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/<step-id>.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)}`)
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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 && (
|
||||
<TalkingHeadOverlay
|
||||
ttsState={tts.state}
|
||||
getLevel={tts.getLevel}
|
||||
onDismiss={handleToggleTtsAvatar}
|
||||
/>
|
||||
)}
|
||||
{/* Mascot-guided product tour */}
|
||||
{tourActive && (
|
||||
<ProductTour
|
||||
onClose={() => 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 */}
|
||||
<FixedSidebarToggle
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
|
|
|
|||
BIN
apps/x/apps/renderer/src/assets/tour/agents.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/agents.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/chats.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/chats.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/code.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/code.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/composer.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/composer.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/done.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/done.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/email.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/email.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/home.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/home.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/knowledge.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/knowledge.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/meetings.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/meetings.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/welcome.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/welcome.mp3
Normal file
Binary file not shown.
BIN
apps/x/apps/renderer/src/assets/tour/workspaces.mp3
Normal file
BIN
apps/x/apps/renderer/src/assets/tour/workspaces.mp3
Normal file
Binary file not shown.
|
|
@ -50,6 +50,7 @@ import {
|
|||
} from '@/lib/attachment-presentation'
|
||||
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MascotFaceIcon } from '@/components/talking-head'
|
||||
import {
|
||||
type FileMention,
|
||||
type PromptInputMessage,
|
||||
|
|
@ -235,6 +236,8 @@ interface ChatInputInnerProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => 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 (
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
<div data-tour-id="chat-composer" className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
|
|
@ -1318,6 +1323,33 @@ function ChatInputInner({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{onToggleTtsAvatar && ttsAvailable && (
|
||||
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleTtsAvatar}
|
||||
className={cn(
|
||||
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
|
||||
ttsAvatarEnabled
|
||||
? 'text-foreground hover:bg-muted'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
aria-label={ttsAvatarEnabled ? 'Disable talking head' : 'Enable talking head'}
|
||||
>
|
||||
<MascotFaceIcon />
|
||||
{!ttsAvatarEnabled && (
|
||||
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{ttsAvatarEnabled ? 'Talking head on' : 'Talking head off'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{voiceAvailable && onStartRecording && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1492,6 +1524,8 @@ export interface ChatInputWithMentionsProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
ttsAvatarEnabled?: boolean
|
||||
onToggleTtsAvatar?: () => void
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
workDir?: string | null
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
|
|
@ -1526,6 +1560,8 @@ export function ChatInputWithMentions({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
ttsAvatarEnabled,
|
||||
onToggleTtsAvatar,
|
||||
onSelectedModelChange,
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
|
|
@ -1557,6 +1593,8 @@ export function ChatInputWithMentions({
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
ttsAvatarEnabled={ttsAvatarEnabled}
|
||||
onToggleTtsAvatar={onToggleTtsAvatar}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
workDir={workDir}
|
||||
onWorkDirChange={onWorkDirChange}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,8 @@ interface ChatSidebarProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
ttsAvatarEnabled?: boolean
|
||||
onToggleTtsAvatar?: () => void
|
||||
onComposioConnected?: (toolkitSlug: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +258,8 @@ export function ChatSidebar({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
ttsAvatarEnabled,
|
||||
onToggleTtsAvatar,
|
||||
onComposioConnected,
|
||||
}: ChatSidebarProps) {
|
||||
const { state: sidebarState } = useSidebar()
|
||||
|
|
@ -830,6 +834,8 @@ export function ChatSidebar({
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={isActive ? onToggleTts : undefined}
|
||||
onTtsModeChange={isActive ? onTtsModeChange : undefined}
|
||||
ttsAvatarEnabled={ttsAvatarEnabled}
|
||||
onToggleTtsAvatar={isActive ? onToggleTtsAvatar : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
878
apps/x/apps/renderer/src/components/product-tour.tsx
Normal file
878
apps/x/apps/renderer/src/components/product-tour.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||
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<HTMLElement>(`[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 <body> 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<Spot | null>(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<HTMLDivElement>(null)
|
||||
const posRef = useRef<Pt>({
|
||||
x: window.innerWidth / 2 - MASCOT_SIZE / 2,
|
||||
y: window.innerHeight + 60,
|
||||
})
|
||||
const travelRafRef = useRef(0)
|
||||
const wakePathElRef = useRef<SVGPathElement>(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<HTMLElement | null>(null)
|
||||
const zoomRef = useRef<{ ox: number; oy: number; s: number } | null>(null)
|
||||
|
||||
const soundsRef = useRef<TourSounds | null>(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<HTMLElement>('.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(
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes tour-bubble-in {
|
||||
0% { opacity: 0; transform: translateY(6px) scale(0.97); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes tour-wave-drift {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes tour-stamp-in {
|
||||
0% { opacity: 0; transform: scale(2); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* night falls: dim everything except the spotlight cutout */}
|
||||
{spot && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[64]"
|
||||
style={{
|
||||
left: spot.left,
|
||||
top: spot.top,
|
||||
width: spot.width,
|
||||
height: spot.height,
|
||||
borderRadius: spot.round ? 9999 : 14,
|
||||
boxShadow: '0 0 0 200vmax rgba(7, 14, 26, 0.52)',
|
||||
transition: `all 0.9s ${GLIDE_EASING}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TourWater />
|
||||
|
||||
{/* wake trails: the committed dotted route + the wake being drawn now */}
|
||||
<svg className="pointer-events-none fixed inset-0 z-[66] h-full w-full">
|
||||
{wakes.map((w) => (
|
||||
<path
|
||||
key={w.id}
|
||||
d={w.d}
|
||||
fill="none"
|
||||
stroke="#9CCBEA"
|
||||
strokeWidth={4}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="1 11"
|
||||
opacity={0.75}
|
||||
/>
|
||||
))}
|
||||
{activeWake && (
|
||||
<path
|
||||
ref={wakePathElRef}
|
||||
d={activeWake.d}
|
||||
fill="none"
|
||||
stroke="#BFE0F5"
|
||||
strokeWidth={5}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={activeWake.len}
|
||||
strokeDashoffset={activeWake.len}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<TourMiniMap total={TOUR_STEPS.length} current={stepIndex} arrived={arrived} />
|
||||
|
||||
{/* the boat (position driven imperatively during travel) */}
|
||||
<div
|
||||
ref={mascotElRef}
|
||||
className="fixed left-0 top-0 z-[70]"
|
||||
style={{ width: MASCOT_SIZE, pointerEvents: 'none' }}
|
||||
>
|
||||
{arrived && !reducedMotion && step.vignette && step.vignette !== 'agents' && (
|
||||
<MascotVignette kind={step.vignette} playDing={playDing} />
|
||||
)}
|
||||
<div style={{ transform: flipped ? 'scaleX(-1)' : undefined, transition: 'transform 0.35s ease-in-out' }}>
|
||||
<TalkingHead ttsState={ttsState} getLevel={getLevel} size={MASCOT_SIZE} hat={step.hat} rowing={rowing} />
|
||||
</div>
|
||||
{arrived && (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute top-0 z-10 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-lg',
|
||||
bubbleSide === 'right' ? 'left-full ml-3' : 'right-full mr-3'
|
||||
)}
|
||||
style={{
|
||||
width: BUBBLE_WIDTH,
|
||||
animation: 'tour-bubble-in 0.25s ease-out',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="End tour"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<p className="pr-6 text-sm font-semibold">{step.title}</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{step.text}</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{stepIndex + 1} / {TOUR_STEPS.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isFirst && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2.5" onClick={() => goTo(stepIndex - 1, -1)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3"
|
||||
onClick={() => (isLast ? finish() : goTo(stepIndex + 1, 1))}
|
||||
>
|
||||
{isLast ? 'Done' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{arrived && !reducedMotion && step.vignette === 'agents' && <AgentsFleet />}
|
||||
|
||||
{confettiOn && !reducedMotion && <ConfettiBurst />}
|
||||
</>,
|
||||
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 (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[65] h-24 overflow-hidden" aria-hidden="true">
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 h-full"
|
||||
style={{ width: '200%', animation: 'tour-wave-drift 11s linear infinite' }}
|
||||
viewBox="0 0 2400 96"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={back} fill="#5F9BC9" opacity={0.3} />
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 h-full"
|
||||
style={{ width: '200%', animation: 'tour-wave-drift 7s linear infinite reverse' }}
|
||||
viewBox="0 0 2400 96"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={front} fill="#8FB6D9" opacity={0.35} />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="fixed bottom-5 left-5 z-[71] rounded-lg border-2 border-[#8A6B3D]/60 bg-[#F4E9CE] px-2 pb-1.5 pt-2 shadow-xl"
|
||||
style={{ transform: 'rotate(-1.2deg)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="mb-0.5 px-1 text-[9px] font-semibold uppercase tracking-[0.22em] text-[#6B5138]">
|
||||
Voyage chart
|
||||
</p>
|
||||
<svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`}>
|
||||
<path d={route} fill="none" stroke="#8A6B3D" strokeWidth={1.5} strokeDasharray="3 4" opacity={0.65} />
|
||||
{points.map((p, i) => {
|
||||
const visited = i < current || (i === current && arrived)
|
||||
return (
|
||||
<g key={i}>
|
||||
<ellipse cx={p.x} cy={p.y} rx={7} ry={5} fill="#DFC896" stroke="#8A6B3D" strokeWidth={1.5} />
|
||||
{visited && (
|
||||
<g style={{ animation: 'tour-stamp-in 0.3s ease-out backwards' }}>
|
||||
<line x1={p.x - 1} y1={p.y - 13} x2={p.x - 1} y2={p.y - 3} stroke="#7A4A21" strokeWidth={1.5} />
|
||||
<path d={`M ${p.x - 1} ${p.y - 13} L ${p.x + 6} ${p.y - 10.5} L ${p.x - 1} ${p.y - 8} Z`} fill="#D9534F" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* boat marker glides between islands in step with the real mascot */}
|
||||
<g style={{ transform: `translate(${boat.x}px, ${boat.y - 7}px)`, transition: `transform 0.9s ${GLIDE_EASING}` }}>
|
||||
<path d="M -7 0 Q 0 4 7 0 Q 4 6 0 6 Q -4 6 -7 0 Z" fill="#54402F" stroke="#3E2E24" strokeWidth={1} />
|
||||
<circle cx={0} cy={-3} r={3} fill="#E8E9F5" stroke="#17171B" strokeWidth={1} />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Two confetti cannons firing from the bottom corners, canvas-driven. */
|
||||
function ConfettiBurst() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[72]"
|
||||
style={{ width: '100vw', height: '100vh' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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({
|
|||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'home'} onClick={onOpenHome}>
|
||||
<SidebarMenuButton data-tour-id="nav-home" isActive={activeNav === 'home'} onClick={onOpenHome}>
|
||||
<Home className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Home</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-email"
|
||||
isActive={activeNav === 'email'}
|
||||
onClick={() => onOpenEmail?.()}
|
||||
className={previewEmail ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -724,6 +729,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-meetings"
|
||||
isActive={activeNav === 'meetings'}
|
||||
onClick={onOpenMeetings}
|
||||
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -802,7 +808,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<SidebarMenuButton data-tour-id="nav-code" isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
|
|
@ -810,6 +816,7 @@ export function SidebarContentPanel({
|
|||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-knowledge"
|
||||
isActive={activeNav === 'knowledge'}
|
||||
onClick={() => knowledgeActions.openKnowledgeView()}
|
||||
className={knowledgeUpdatedLabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -830,6 +837,7 @@ export function SidebarContentPanel({
|
|||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-agents"
|
||||
isActive={activeNav === 'agents'}
|
||||
onClick={onOpenBgTasks}
|
||||
className={bgAgentsLabel ? 'h-auto py-1.5' : undefined}
|
||||
|
|
@ -850,6 +858,7 @@ export function SidebarContentPanel({
|
|||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-tour-id="nav-workspaces"
|
||||
isActive={activeNav === 'workspaces'}
|
||||
onClick={() => knowledgeActions.openWorkspaceAt()}
|
||||
className="h-auto py-1.5"
|
||||
|
|
@ -874,6 +883,7 @@ export function SidebarContentPanel({
|
|||
<SidebarGroupContent>
|
||||
<button
|
||||
type="button"
|
||||
data-tour-id="nav-chats"
|
||||
onClick={() => setChatsExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
|
|
@ -1015,6 +1025,15 @@ export function SidebarContentPanel({
|
|||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
{onStartTour && (
|
||||
<button
|
||||
onClick={onStartTour}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<MascotFaceIcon className="size-4" />
|
||||
<span>Take a tour</span>
|
||||
</button>
|
||||
)}
|
||||
<SettingsDialog>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors">
|
||||
<Settings className="size-4" />
|
||||
|
|
|
|||
487
apps/x/apps/renderer/src/components/talking-head.tsx
Normal file
487
apps/x/apps/renderer/src/components/talking-head.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import type { TTSState } from '@/hooks/useVoiceTTS'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const POSITION_STORAGE_KEY = 'talking-head-position'
|
||||
|
||||
// Must match the overlay's `bottom-28 right-8` anchor classes.
|
||||
const ANCHOR_RIGHT_PX = 32
|
||||
const ANCHOR_BOTTOM_PX = 112
|
||||
const VIEWPORT_MARGIN_PX = 8
|
||||
|
||||
// Palette pulled from the mascot artwork: pale lavender body, dark walnut boat.
|
||||
const BODY_FILL = '#E8E9F5'
|
||||
const BODY_STROKE = '#17171B'
|
||||
const CHEEK_FILL = '#F2B8BE'
|
||||
const BOAT_DARK = '#3E2E24'
|
||||
const BOAT_MID = '#54402F'
|
||||
const BOAT_LIGHT = '#6B5138'
|
||||
const MOUTH_FILL = '#2A1E19'
|
||||
|
||||
export type MascotHat =
|
||||
| 'mailcap'
|
||||
| 'headphones'
|
||||
| 'hardhat'
|
||||
| 'gradcap'
|
||||
| 'captain'
|
||||
| 'explorer'
|
||||
| 'party'
|
||||
|
||||
type TalkingHeadProps = {
|
||||
ttsState: TTSState
|
||||
getLevel: () => number
|
||||
size?: number
|
||||
/** Costume piece drawn on the head (used by the product tour). */
|
||||
hat?: MascotHat
|
||||
/** Paddle hard: fast bobbing + oar strokes, e.g. while traveling in the tour. */
|
||||
rowing?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Rowboat mascot as an animated inline SVG: a round pale character sitting
|
||||
* in a wooden rowboat holding an oar. The mouth is driven every animation
|
||||
* frame from the live TTS audio level; eyes blink on a randomized timer.
|
||||
*/
|
||||
export function TalkingHead({ ttsState, getLevel, size = 160, hat, rowing = false }: TalkingHeadProps) {
|
||||
const mouthOpenRef = useRef<SVGEllipseElement>(null)
|
||||
const mouthSmileRef = useRef<SVGPathElement>(null)
|
||||
const oarRef = useRef<SVGGElement>(null)
|
||||
const smoothedRef = useRef(0)
|
||||
const [blinking, setBlinking] = useState(false)
|
||||
|
||||
const speaking = ttsState === 'speaking'
|
||||
const thinking = ttsState === 'synthesizing'
|
||||
|
||||
// Lip sync + oar paddle loop. Writes SVG attributes directly to avoid
|
||||
// re-rendering React at 60fps. Stops itself once speech has ended and the
|
||||
// mouth has settled closed; restarts when `speaking` flips this effect.
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let t = 0
|
||||
const tick = () => {
|
||||
const target = speaking ? getLevel() : 0
|
||||
const prev = smoothedRef.current
|
||||
// Fast attack, slower decay reads as natural mouth movement
|
||||
const smoothed = target > prev ? prev + (target - prev) * 0.5 : prev + (target - prev) * 0.2
|
||||
const settled = !speaking && !rowing && smoothed < 0.005
|
||||
smoothedRef.current = settled ? 0 : smoothed
|
||||
const open = settled ? 0 : Math.min(1, smoothed * 1.6)
|
||||
|
||||
const mouthOpen = mouthOpenRef.current
|
||||
const mouthSmile = mouthSmileRef.current
|
||||
if (mouthOpen && mouthSmile) {
|
||||
if (open > 0.06) {
|
||||
mouthOpen.setAttribute('rx', String(6.5 + open * 4))
|
||||
mouthOpen.setAttribute('ry', String(1.5 + open * 9))
|
||||
mouthOpen.style.opacity = '1'
|
||||
mouthSmile.style.opacity = '0'
|
||||
} else {
|
||||
mouthOpen.style.opacity = '0'
|
||||
mouthSmile.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
const oar = oarRef.current
|
||||
if (oar) {
|
||||
if (rowing) {
|
||||
t += 0.14
|
||||
const angle = Math.sin(t) * 13
|
||||
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
|
||||
} else if (speaking) {
|
||||
t += 0.045
|
||||
const angle = Math.sin(t) * 7
|
||||
oar.setAttribute('transform', `rotate(${angle.toFixed(2)} 128 118)`)
|
||||
} else {
|
||||
oar.setAttribute('transform', 'rotate(0 128 118)')
|
||||
}
|
||||
}
|
||||
|
||||
if (!settled) {
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [speaking, rowing, getLevel])
|
||||
|
||||
// Randomized blinking
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
let cancelled = false
|
||||
const scheduleBlink = () => {
|
||||
timeout = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setBlinking(true)
|
||||
setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setBlinking(false)
|
||||
scheduleBlink()
|
||||
}, 140)
|
||||
}, 2400 + Math.random() * 2600)
|
||||
}
|
||||
scheduleBlink()
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="talking-head-bob relative select-none"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
animationDuration: rowing ? '0.8s' : speaking ? '1.6s' : '3.2s',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes talking-head-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(-1.6deg); }
|
||||
50% { transform: translateY(-4px) rotate(1.6deg); }
|
||||
}
|
||||
.talking-head-bob {
|
||||
animation-name: talking-head-bob;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
@keyframes talking-head-ripple {
|
||||
0% { transform: scale(0.6); opacity: 0.5; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
.talking-head-ripple {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: talking-head-ripple 2.6s ease-out infinite;
|
||||
}
|
||||
@keyframes talking-head-bubble {
|
||||
0%, 100% { opacity: 0.25; transform: translateY(0); }
|
||||
50% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
.talking-head-bubble {
|
||||
animation: talking-head-bubble 1.2s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<svg viewBox="0 0 200 190" width={size} height={size} aria-hidden="true">
|
||||
{/* water ripples under the boat */}
|
||||
<g>
|
||||
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '0s' }} />
|
||||
<ellipse className="talking-head-ripple" cx="100" cy="168" rx="62" ry="9" fill="none" stroke="#8FB6D9" strokeWidth="2" style={{ animationDelay: '1.3s' }} />
|
||||
<ellipse cx="100" cy="168" rx="52" ry="7" fill="#8FB6D9" opacity="0.18" />
|
||||
</g>
|
||||
|
||||
{/* thinking bubbles while synthesizing */}
|
||||
{thinking && (
|
||||
<g fill={BODY_STROKE} opacity="0.75">
|
||||
<circle className="talking-head-bubble" cx="146" cy="34" r="3" style={{ animationDelay: '0s' }} />
|
||||
<circle className="talking-head-bubble" cx="157" cy="26" r="4.2" style={{ animationDelay: '0.2s' }} />
|
||||
<circle className="talking-head-bubble" cx="170" cy="16" r="5.4" style={{ animationDelay: '0.4s' }} />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* character: head + body blob */}
|
||||
<g>
|
||||
<path
|
||||
d="M 100 22
|
||||
C 129 22 148 43 148 68
|
||||
C 148 82 141 93 131 100
|
||||
C 141 107 147 117 148 128
|
||||
L 52 128
|
||||
C 53 115 60 105 69 99
|
||||
C 59 92 52 81 52 68
|
||||
C 52 43 71 22 100 22 Z"
|
||||
fill={BODY_FILL}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* eyes */}
|
||||
<g style={{ transform: thinking ? 'translateY(-2.5px)' : undefined, transition: 'transform 0.3s' }}>
|
||||
<ellipse
|
||||
cx="84" cy="64" rx="5" ry={blinking ? 0.8 : 7}
|
||||
fill={BODY_STROKE}
|
||||
style={{ transition: 'ry 0.06s' }}
|
||||
/>
|
||||
<ellipse
|
||||
cx="116" cy="64" rx="5" ry={blinking ? 0.8 : 7}
|
||||
fill={BODY_STROKE}
|
||||
style={{ transition: 'ry 0.06s' }}
|
||||
/>
|
||||
<circle cx="86" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
|
||||
<circle cx="118" cy="61" r="1.6" fill="#FFFFFF" opacity={blinking ? 0 : 0.9} />
|
||||
</g>
|
||||
{/* cheeks */}
|
||||
<ellipse cx="72" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
|
||||
<ellipse cx="128" cy="76" rx="6.5" ry="4" fill={CHEEK_FILL} opacity="0.85" />
|
||||
{/* mouth: smile when quiet, open ellipse driven by audio level */}
|
||||
<path
|
||||
ref={mouthSmileRef}
|
||||
d="M 91 80 Q 100 88 109 80"
|
||||
fill="none"
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<ellipse
|
||||
ref={mouthOpenRef}
|
||||
cx="100" cy="84" rx="7" ry="2"
|
||||
fill={MOUTH_FILL}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="3"
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
{hat && <MascotHatArt hat={hat} />}
|
||||
</g>
|
||||
|
||||
{/* oar (rotates while speaking) */}
|
||||
<g ref={oarRef}>
|
||||
<line x1="158" y1="88" x2="88" y2="152" stroke={BODY_STROKE} strokeWidth="12" strokeLinecap="round" />
|
||||
<line x1="158" y1="88" x2="88" y2="152" stroke={BOAT_MID} strokeWidth="7" strokeLinecap="round" />
|
||||
<path
|
||||
d="M 84 148 L 56 170 C 52 173 52 178 57 178 L 90 165 Z"
|
||||
fill={BOAT_DARK}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* hand resting over the oar */}
|
||||
<ellipse cx="121" cy="120" rx="10" ry="8" fill={BODY_FILL} stroke={BODY_STROKE} strokeWidth="4" />
|
||||
|
||||
{/* boat hull (drawn last so it overlaps the body) */}
|
||||
<g>
|
||||
<path
|
||||
d="M 30 120
|
||||
C 50 132 150 132 170 120
|
||||
C 168 142 152 160 100 160
|
||||
C 48 160 32 142 30 120 Z"
|
||||
fill={BOAT_MID}
|
||||
stroke={BODY_STROKE}
|
||||
strokeWidth="5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* plank lines */}
|
||||
<path d="M 36 133 C 60 143 140 143 164 133" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
<path d="M 44 145 C 66 153 134 153 156 145" fill="none" stroke={BOAT_DARK} strokeWidth="3" strokeLinecap="round" />
|
||||
{/* gunwale highlight */}
|
||||
<path d="M 33 121 C 52 131 148 131 167 121" fill="none" stroke={BOAT_LIGHT} strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TalkingHeadOverlayProps = {
|
||||
ttsState: TTSState
|
||||
getLevel: () => number
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
// Keep the widget fully on-screen relative to its bottom-right CSS anchor.
|
||||
// Falls back to the default render size when the element isn't mounted yet.
|
||||
function clampPositionToViewport(pos: { x: number; y: number }, el: HTMLDivElement | null): { x: number; y: number } {
|
||||
const width = el?.offsetWidth ?? 160
|
||||
const height = el?.offsetHeight ?? 160
|
||||
const baseLeft = window.innerWidth - ANCHOR_RIGHT_PX - width
|
||||
const baseTop = window.innerHeight - ANCHOR_BOTTOM_PX - height
|
||||
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v))
|
||||
return {
|
||||
x: clamp(pos.x, VIEWPORT_MARGIN_PX - baseLeft, window.innerWidth - VIEWPORT_MARGIN_PX - width - baseLeft),
|
||||
y: clamp(pos.y, VIEWPORT_MARGIN_PX - baseTop, window.innerHeight - VIEWPORT_MARGIN_PX - height - baseTop),
|
||||
}
|
||||
}
|
||||
|
||||
function loadStoredPosition(): { x: number; y: number } {
|
||||
try {
|
||||
const raw = localStorage.getItem(POSITION_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt stored position
|
||||
}
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating, draggable widget that hosts the talking head. Anchored to the
|
||||
* bottom-right of the window (above the composer) and offset by a persisted
|
||||
* drag position, so it hovers over whatever view is active.
|
||||
*/
|
||||
export function TalkingHeadOverlay({ ttsState, getLevel, onDismiss }: TalkingHeadOverlayProps) {
|
||||
// Clamp the stored offset at init so a stale position (e.g. saved on a
|
||||
// bigger window) can't leave the widget stranded off-screen.
|
||||
const [offset, setOffset] = useState(() => clampPositionToViewport(loadStoredPosition(), null))
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragStartRef = useRef<{ pointerX: number; pointerY: number; x: number; y: number } | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const clampToViewport = useCallback(
|
||||
(pos: { x: number; y: number }) => clampPositionToViewport(pos, containerRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
// Re-clamp when the window shrinks
|
||||
useEffect(() => {
|
||||
const handleResize = () => setOffset(prev => clampToViewport(prev))
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [clampToViewport])
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return
|
||||
dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, x: offset.x, y: offset.y }
|
||||
setDragging(true)
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [offset])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const start = dragStartRef.current
|
||||
if (!start) return
|
||||
setOffset({
|
||||
x: start.x + (e.clientX - start.pointerX),
|
||||
y: start.y + (e.clientY - start.pointerY),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragStartRef.current) return
|
||||
dragStartRef.current = null
|
||||
setDragging(false)
|
||||
setOffset(prev => clampToViewport(prev))
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}, [clampToViewport])
|
||||
|
||||
useEffect(() => {
|
||||
if (dragging) return
|
||||
try {
|
||||
localStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(offset))
|
||||
} catch {
|
||||
// best-effort persistence
|
||||
}
|
||||
}, [offset, dragging])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'group fixed bottom-28 right-8 z-50 touch-none',
|
||||
dragging ? 'cursor-grabbing' : 'cursor-grab'
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)`,
|
||||
// Constant value so the entrance animation runs once on mount and
|
||||
// never restarts (re-applying it after a drag would replay the pop).
|
||||
animation: 'talking-head-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
role="img"
|
||||
aria-label="Rowboat talking head"
|
||||
>
|
||||
<style>{`
|
||||
@keyframes talking-head-pop {
|
||||
0% { opacity: 0; scale: 0.4; }
|
||||
100% { opacity: 1; scale: 1; }
|
||||
}
|
||||
`}</style>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={onDismiss}
|
||||
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Hide talking head"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<TalkingHead ttsState={ttsState} getLevel={getLevel} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<g>
|
||||
<path d="M 68 36 Q 100 4 132 36 Q 100 26 68 36 Z" fill="#4A7DDB" {...outline} />
|
||||
<path d="M 126 33 Q 148 31 150 39 Q 132 42 124 39 Z" fill="#3D68B8" {...outline} />
|
||||
</g>
|
||||
)
|
||||
case 'headphones':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke={BODY_STROKE} strokeWidth="11" strokeLinecap="round" />
|
||||
<path d="M 64 58 Q 100 2 136 58" fill="none" stroke="#4B5563" strokeWidth="6" strokeLinecap="round" />
|
||||
<ellipse cx="64" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
|
||||
<ellipse cx="136" cy="61" rx="8" ry="11" fill="#4B5563" {...outline} />
|
||||
</g>
|
||||
)
|
||||
case 'hardhat':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 66 38 Q 100 0 134 38 Z" fill="#F6C445" {...outline} />
|
||||
<path d="M 96 10 Q 94 22 96 36 L 106 36 Q 107 22 105 9 Z" fill="#E0AC2C" stroke="none" />
|
||||
<path d="M 56 38 L 144 38" fill="none" stroke={BODY_STROKE} strokeWidth="9" strokeLinecap="round" />
|
||||
<path d="M 58 38 L 142 38" fill="none" stroke="#F6C445" strokeWidth="5" strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
case 'gradcap':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 80 28 Q 100 42 120 28 L 120 38 Q 100 50 80 38 Z" fill="#232838" {...outline} />
|
||||
<path d="M 100 2 L 144 20 L 100 38 L 56 20 Z" fill="#2E3450" {...outline} />
|
||||
<path d="M 100 20 Q 124 26 136 34" fill="none" stroke="#E8B94A" strokeWidth="3" strokeLinecap="round" />
|
||||
<circle cx="137" cy="38" r="4" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
case 'captain':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 68 36 Q 100 -2 132 36 Q 100 28 68 36 Z" fill="#F4F5F9" {...outline} />
|
||||
<path d="M 66 36 Q 100 46 134 36 L 134 42 Q 100 52 66 42 Z" fill="#22262E" {...outline} />
|
||||
<path d="M 122 42 Q 146 42 148 48 Q 130 52 120 48 Z" fill="#22262E" {...outline} />
|
||||
<circle cx="100" cy="38" r="4.5" fill="#E8B94A" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
case 'explorer':
|
||||
return (
|
||||
<g>
|
||||
<ellipse cx="100" cy="35" rx="46" ry="9" fill="#C9A46B" {...outline} />
|
||||
<path d="M 72 34 Q 100 4 128 34 Z" fill="#C9A46B" {...outline} />
|
||||
<path d="M 76 30 Q 100 38 124 30" fill="none" stroke="#8A6B3D" strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
case 'party':
|
||||
return (
|
||||
<g>
|
||||
<path d="M 100 -2 L 84 34 L 116 34 Z" fill="#F2699C" {...outline} />
|
||||
<path d="M 92 16 L 111 22 M 88 26 L 114 31" fill="none" stroke="#FFD166" strokeWidth="3.5" strokeLinecap="round" />
|
||||
<circle cx="100" cy="0" r="5" fill="#FFD166" stroke={BODY_STROKE} strokeWidth="3" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small static mascot face used as the toolbar toggle icon. */
|
||||
export function MascotFaceIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" className={className} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<ellipse cx="8.6" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
|
||||
<ellipse cx="15.4" cy="10.5" rx="1.3" ry="1.8" fill="currentColor" />
|
||||
<path d="M 9 14.5 Q 12 17 15 14.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
262
apps/x/apps/renderer/src/components/tour-vignettes.tsx
Normal file
262
apps/x/apps/renderer/src/components/tour-vignettes.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="pointer-events-none absolute left-1/2 top-0 z-0 -translate-x-1/2" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes tour-env-left {
|
||||
0% { opacity: 0; transform: translate(-150px, -130px) rotate(-26deg); }
|
||||
10% { opacity: 1; }
|
||||
52% { opacity: 1; transform: translate(0px, 0px) rotate(5deg); }
|
||||
64% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
|
||||
100% { opacity: 0; transform: translate(2px, 12px) rotate(5deg); }
|
||||
}
|
||||
@keyframes tour-env-right {
|
||||
0% { opacity: 0; transform: translate(150px, -140px) rotate(26deg); }
|
||||
10% { opacity: 1; }
|
||||
52% { opacity: 1; transform: translate(0px, 0px) rotate(-5deg); }
|
||||
64% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
|
||||
100% { opacity: 0; transform: translate(-2px, 12px) rotate(-5deg); }
|
||||
}
|
||||
@keyframes tour-badge-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
@keyframes tour-note-line {
|
||||
0% { width: 0; }
|
||||
18% { width: var(--w); }
|
||||
80% { width: var(--w); opacity: 1; }
|
||||
92%, 100% { width: var(--w); opacity: 0; }
|
||||
}
|
||||
@keyframes tour-quill-bob {
|
||||
0% { transform: translate(4px, 26px) rotate(-8deg); }
|
||||
25% { transform: translate(46px, 30px) rotate(4deg); }
|
||||
50% { transform: translate(6px, 40px) rotate(-8deg); }
|
||||
75% { transform: translate(48px, 44px) rotate(4deg); }
|
||||
100% { transform: translate(4px, 26px) rotate(-8deg); }
|
||||
}
|
||||
@keyframes tour-voice-bar {
|
||||
0%, 100% { transform: scaleY(0.35); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
@keyframes tour-orb {
|
||||
0% { opacity: 0; transform: translate(var(--from-x), var(--from-y)) scale(0.5); }
|
||||
15% { opacity: 0.9; }
|
||||
70% { opacity: 0.9; transform: translate(0, 0) scale(1); }
|
||||
85%, 100% { opacity: 0; transform: translate(0, 6px) scale(0.3); }
|
||||
}
|
||||
@keyframes tour-node-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@keyframes tour-edge-draw {
|
||||
from { stroke-dashoffset: 60; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{kind === 'email' && (
|
||||
<div className="relative" style={{ width: 240, height: 150 }}>
|
||||
{[
|
||||
{ anim: 'tour-env-left', delay: 0, x: 78, y: 96 },
|
||||
{ anim: 'tour-env-right', delay: 1.0, x: 128, y: 102 },
|
||||
{ anim: 'tour-env-left', delay: 2.0, x: 104, y: 92 },
|
||||
{ anim: 'tour-env-right', delay: 3.0, x: 90, y: 104 },
|
||||
].map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{ left: e.x, top: e.y, animation: `${e.anim} 4s ease-in-out ${e.delay}s infinite both` }}
|
||||
>
|
||||
<svg width="34" height="24" viewBox="0 0 34 24">
|
||||
<rect x="1.5" y="1.5" width="31" height="21" rx="3" fill="#FFF8E7" stroke="#17171B" strokeWidth="2.5" />
|
||||
<path d="M 2 4 L 17 14 L 32 4" fill="none" stroke="#17171B" strokeWidth="2.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ left: 148, top: 78, animation: 'tour-badge-pulse 2s ease-in-out infinite' }}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 22 22">
|
||||
<circle cx="11" cy="11" r="9.5" fill="#3FA95C" stroke="#17171B" strokeWidth="2.5" />
|
||||
<path d="M 6.5 11.5 L 9.5 14.5 L 15.5 8" fill="none" stroke="#FFFFFF" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'meetings' && (
|
||||
<div className="relative" style={{ width: 220, height: 160, top: -110 }}>
|
||||
{/* someone's talking */}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 items-end gap-1" style={{ top: 0, height: 26 }}>
|
||||
{[0.9, 1.1, 0.7, 1.3, 0.8].map((dur, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 origin-bottom rounded-full bg-[#8FB6D9]"
|
||||
style={{ height: 22, animation: `tour-voice-bar ${dur}s ease-in-out ${i * 0.12}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* ...and the notepad writes itself */}
|
||||
<div
|
||||
className="absolute left-1/2 rounded-md border-2 border-[#17171B] bg-[#FFFDF6] shadow-md"
|
||||
style={{ top: 36, width: 96, height: 104, transform: 'translateX(-50%) rotate(-3deg)' }}
|
||||
>
|
||||
<div className="mx-2 mt-2 h-1.5 rounded bg-[#D9534F]/70" />
|
||||
{[64, 52, 60, 44].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ml-2 mt-3 h-1.5 rounded bg-[#9AA1AE]"
|
||||
style={{ ['--w' as string]: `${w}px`, animation: `tour-note-line 5s ease-out ${i * 1.1}s infinite both` }}
|
||||
/>
|
||||
))}
|
||||
<svg
|
||||
className="absolute left-0 top-0"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 26 26"
|
||||
style={{ animation: 'tour-quill-bob 5s ease-in-out infinite' }}
|
||||
>
|
||||
<path d="M 4 22 L 10 12 Q 14 4 22 2 Q 18 10 12 14 Z" fill="#6B5138" stroke="#17171B" strokeWidth="2" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'brain' && (
|
||||
<div className="relative" style={{ width: 260, height: 180, top: -128 }}>
|
||||
{/* constellation assembling above the head */}
|
||||
<svg className="absolute left-1/2 -translate-x-1/2" width="140" height="80" viewBox="0 0 140 80" style={{ top: 0 }}>
|
||||
{[
|
||||
'M 22 58 L 52 24',
|
||||
'M 52 24 L 88 40',
|
||||
'M 88 40 L 120 18',
|
||||
'M 88 40 L 70 66',
|
||||
].map((d, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#9CCBEA"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="60"
|
||||
style={{ animation: `tour-edge-draw 1.2s ease-out ${0.4 + i * 0.35}s both` }}
|
||||
/>
|
||||
))}
|
||||
{[
|
||||
[22, 58], [52, 24], [88, 40], [120, 18], [70, 66],
|
||||
].map(([cx, cy], i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={5}
|
||||
fill="#BFE0F5"
|
||||
stroke="#17171B"
|
||||
strokeWidth="2"
|
||||
style={{ animation: `tour-node-pulse 2.4s ease-in-out ${i * 0.3}s infinite` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{/* thought-orbs drifting into the head */}
|
||||
{[
|
||||
{ fx: '-120px', fy: '-30px', delay: 0 },
|
||||
{ fx: '120px', fy: '-40px', delay: 1.1 },
|
||||
{ fx: '-90px', fy: '50px', delay: 2.2 },
|
||||
{ fx: '110px', fy: '46px', delay: 3.3 },
|
||||
].map((o, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: 122,
|
||||
top: 118,
|
||||
width: 16,
|
||||
height: 16,
|
||||
background: 'radial-gradient(circle, #FFF3B8 20%, #FFD166 60%, transparent 75%)',
|
||||
['--from-x' as string]: o.fx,
|
||||
['--from-y' as string]: o.fy,
|
||||
animation: `tour-orb 4.4s ease-in-out ${o.delay}s infinite both`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-agents vignette: a fleet of tiny mascots rowing across the water
|
||||
* at the bottom of the screen while the big one takes a break.
|
||||
*/
|
||||
export function AgentsFleet() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-[67] h-28 overflow-hidden" aria-hidden="true">
|
||||
<style>{`
|
||||
@keyframes tour-fleet-right {
|
||||
from { transform: translateX(-18vw); }
|
||||
to { transform: translateX(112vw); }
|
||||
}
|
||||
@keyframes tour-fleet-left {
|
||||
from { transform: translateX(112vw); }
|
||||
to { transform: translateX(-18vw); }
|
||||
}
|
||||
@keyframes tour-fleet-bob {
|
||||
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||
50% { transform: translateY(-3px) rotate(2deg); }
|
||||
}
|
||||
`}</style>
|
||||
{[
|
||||
{ size: 46, bottom: 4, dur: 16, delay: 0, dir: 'tour-fleet-right' },
|
||||
{ size: 34, bottom: 22, dur: 22, delay: -8, dir: 'tour-fleet-left' },
|
||||
{ size: 28, bottom: 14, dur: 27, delay: -3, dir: 'tour-fleet-right' },
|
||||
].map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute left-0"
|
||||
style={{ bottom: b.bottom, animation: `${b.dir} ${b.dur}s linear ${b.delay}s infinite` }}
|
||||
>
|
||||
<div style={{ animation: 'tour-fleet-bob 1.4s ease-in-out infinite', transform: b.dir === 'tour-fleet-left' ? 'scaleX(-1)' : undefined }}>
|
||||
<MiniRower size={b.size} flipped={b.dir === 'tour-fleet-left'} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniRower({ size, flipped }: { size: number; flipped: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size * 0.75}
|
||||
viewBox="0 0 60 45"
|
||||
style={{ transform: flipped ? 'scaleX(-1)' : undefined }}
|
||||
>
|
||||
<circle cx="27" cy="13" r="10" fill="#E8E9F5" stroke="#17171B" strokeWidth="3" />
|
||||
<circle cx="23.5" cy="11.5" r="1.4" fill="#17171B" />
|
||||
<circle cx="30.5" cy="11.5" r="1.4" fill="#17171B" />
|
||||
<path d="M 23 16 Q 27 19 31 16" fill="none" stroke="#17171B" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<line x1="40" y1="14" x2="20" y2="38" stroke="#17171B" strokeWidth="4.5" strokeLinecap="round" />
|
||||
<line x1="40" y1="14" x2="20" y2="38" stroke="#54402F" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path d="M 7 25 C 17 31 43 31 53 25 C 51 37 43 42 30 42 C 17 42 9 37 7 25 Z" fill="#54402F" stroke="#17171B" strokeWidth="3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type TTSState = 'idle' | 'synthesizing' | 'speaking';
|
||||
|
||||
|
|
@ -14,14 +14,23 @@ function synthesize(text: string): Promise<SynthesizedAudio> {
|
|||
);
|
||||
}
|
||||
|
||||
function playAudio(dataUrl: string, audioRef: React.MutableRefObject<HTMLAudioElement | null>): Promise<void> {
|
||||
function playAudio(
|
||||
dataUrl: string,
|
||||
audioRef: React.MutableRefObject<HTMLAudioElement | null>,
|
||||
onAudioElement?: (audio: HTMLAudioElement) => void
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const audio = new Audio(dataUrl);
|
||||
audioRef.current = audio;
|
||||
onAudioElement?.(audio);
|
||||
audio.onended = () => {
|
||||
console.log('[tts] audio ended');
|
||||
resolve();
|
||||
};
|
||||
// pause() (from cancel) must settle this promise too, or the queue
|
||||
// loop stays parked on it forever. Natural end also fires 'pause'
|
||||
// just before 'ended'; double-resolve is harmless.
|
||||
audio.onpause = () => resolve();
|
||||
audio.onerror = (e) => {
|
||||
console.error('[tts] audio error:', e);
|
||||
reject(new Error('Audio playback failed'));
|
||||
|
|
@ -35,47 +44,129 @@ function playAudio(dataUrl: string, audioRef: React.MutableRefObject<HTMLAudioEl
|
|||
});
|
||||
}
|
||||
|
||||
/** A queue entry: text to synthesize, or a ready-to-play audio URL (e.g. a bundled clip). */
|
||||
type QueueItem = { text: string } | { url: string };
|
||||
|
||||
export function useVoiceTTS() {
|
||||
const [state, setState] = useState<TTSState>('idle');
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const queueRef = useRef<string[]>([]);
|
||||
const queueRef = useRef<QueueItem[]>([]);
|
||||
const processingRef = useRef(false);
|
||||
// Pre-fetched audio ready to play immediately
|
||||
const prefetchedRef = useRef<Promise<SynthesizedAudio> | null>(null);
|
||||
// Bumped by cancel(). A queue loop that awaited across a cancel sees a
|
||||
// stale generation and exits instead of playing audio that was cancelled
|
||||
// while still synthesizing (which would overlap the next utterance).
|
||||
const generationRef = useRef(0);
|
||||
// Web Audio analyser tap for lip-sync (talking head)
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const levelBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
|
||||
|
||||
// Route playback through an AnalyserNode so consumers can read the live
|
||||
// output level. If Web Audio wiring fails, the element still plays directly.
|
||||
const connectAnalyser = useCallback((audio: HTMLAudioElement) => {
|
||||
try {
|
||||
let ctx = audioCtxRef.current;
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext();
|
||||
audioCtxRef.current = ctx;
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = 0.5;
|
||||
analyser.connect(ctx.destination);
|
||||
analyserRef.current = analyser;
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume();
|
||||
}
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
source.connect(analyserRef.current!);
|
||||
// Detach once this chunk is done (ended, cancelled via pause, or
|
||||
// failed) so source nodes don't accumulate over a long session.
|
||||
const disconnect = () => {
|
||||
try {
|
||||
source.disconnect();
|
||||
} catch {
|
||||
// already disconnected
|
||||
}
|
||||
};
|
||||
audio.addEventListener('ended', disconnect, { once: true });
|
||||
audio.addEventListener('pause', disconnect, { once: true });
|
||||
audio.addEventListener('error', disconnect, { once: true });
|
||||
} catch (err) {
|
||||
console.error('[tts] analyser hookup failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Current output level, 0..1. Safe to call every animation frame.
|
||||
// Release the audio graph when the owning component unmounts
|
||||
useEffect(() => () => {
|
||||
audioCtxRef.current?.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
const getLevel = useCallback((): number => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return 0;
|
||||
let buffer = levelBufferRef.current;
|
||||
if (!buffer || buffer.length !== analyser.fftSize) {
|
||||
buffer = new Uint8Array(analyser.fftSize);
|
||||
levelBufferRef.current = buffer;
|
||||
}
|
||||
analyser.getByteTimeDomainData(buffer);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const d = (buffer[i] - 128) / 128;
|
||||
sum += d * d;
|
||||
}
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
return Math.min(1, rms * 4);
|
||||
}, []);
|
||||
|
||||
const processQueue = useCallback(async () => {
|
||||
if (processingRef.current) return;
|
||||
processingRef.current = true;
|
||||
const gen = generationRef.current;
|
||||
|
||||
while (queueRef.current.length > 0) {
|
||||
const text = queueRef.current.shift()!;
|
||||
if (!text.trim()) continue;
|
||||
const item = queueRef.current.shift()!;
|
||||
if ('text' in item && !item.text.trim()) continue;
|
||||
|
||||
try {
|
||||
// Use pre-fetched result if available, otherwise synthesize now
|
||||
// Pre-recorded URL plays as-is; text uses the pre-fetched
|
||||
// result if available, otherwise synthesizes now.
|
||||
let audioPromise: Promise<SynthesizedAudio>;
|
||||
if (prefetchedRef.current) {
|
||||
if ('url' in item) {
|
||||
audioPromise = Promise.resolve({ dataUrl: item.url });
|
||||
} else if (prefetchedRef.current) {
|
||||
console.log('[tts] using pre-fetched audio');
|
||||
audioPromise = prefetchedRef.current;
|
||||
prefetchedRef.current = null;
|
||||
} else {
|
||||
setState('synthesizing');
|
||||
console.log('[tts] synthesizing:', text.substring(0, 80));
|
||||
audioPromise = synthesize(text);
|
||||
console.log('[tts] synthesizing:', item.text.substring(0, 80));
|
||||
audioPromise = synthesize(item.text);
|
||||
}
|
||||
|
||||
const audio = await audioPromise;
|
||||
// Cancelled while synthesizing — cancel() already reset all
|
||||
// state (and a new loop may be running), so just bail.
|
||||
if (generationRef.current !== gen) return;
|
||||
setState('speaking');
|
||||
|
||||
// Kick off pre-fetch for next chunk while this one plays
|
||||
const nextText = queueRef.current[0];
|
||||
if (nextText?.trim()) {
|
||||
console.log('[tts] pre-fetching next:', nextText.substring(0, 80));
|
||||
prefetchedRef.current = synthesize(nextText);
|
||||
const next = queueRef.current[0];
|
||||
if (next && 'text' in next && next.text.trim()) {
|
||||
console.log('[tts] pre-fetching next:', next.text.substring(0, 80));
|
||||
prefetchedRef.current = synthesize(next.text);
|
||||
}
|
||||
|
||||
await playAudio(audio.dataUrl, audioRef);
|
||||
await playAudio(audio.dataUrl, audioRef, connectAnalyser);
|
||||
if (generationRef.current !== gen) return;
|
||||
} catch (err) {
|
||||
if (generationRef.current !== gen) return;
|
||||
console.error('[tts] error:', err);
|
||||
prefetchedRef.current = null;
|
||||
}
|
||||
|
|
@ -85,15 +176,24 @@ export function useVoiceTTS() {
|
|||
prefetchedRef.current = null;
|
||||
processingRef.current = false;
|
||||
setState('idle');
|
||||
}, []);
|
||||
}, [connectAnalyser]);
|
||||
|
||||
const speak = useCallback((text: string) => {
|
||||
console.log('[tts] speak() called:', text.substring(0, 80));
|
||||
queueRef.current.push(text);
|
||||
queueRef.current.push({ text });
|
||||
processQueue();
|
||||
}, [processQueue]);
|
||||
|
||||
// Play a pre-recorded clip (e.g. bundled tour narration) through the same
|
||||
// queue, so lip-sync levels, state, and cancel() all work unchanged.
|
||||
const speakUrl = useCallback((url: string) => {
|
||||
console.log('[tts] speakUrl() called:', url.substring(0, 120));
|
||||
queueRef.current.push({ url });
|
||||
processQueue();
|
||||
}, [processQueue]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
generationRef.current++;
|
||||
queueRef.current = [];
|
||||
prefetchedRef.current = null;
|
||||
if (audioRef.current) {
|
||||
|
|
@ -104,5 +204,5 @@ export function useVoiceTTS() {
|
|||
setState('idle');
|
||||
}, []);
|
||||
|
||||
return { state, speak, cancel };
|
||||
return { state, speak, speakUrl, cancel, getLevel };
|
||||
}
|
||||
|
|
|
|||
109
apps/x/apps/renderer/src/lib/tour-sounds.ts
Normal file
109
apps/x/apps/renderer/src/lib/tour-sounds.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Tiny synthesized sound effects for the product tour — oar splashes, dock
|
||||
* bumps, and an arrival fanfare. Everything is generated with Web Audio
|
||||
* oscillators/noise so no audio assets are needed. All methods fail silently
|
||||
* if audio is unavailable.
|
||||
*/
|
||||
export class TourSounds {
|
||||
private ctx: AudioContext | null = null
|
||||
private master: GainNode | null = null
|
||||
|
||||
private ensure(): AudioContext | null {
|
||||
try {
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext()
|
||||
this.master = this.ctx.createGain()
|
||||
this.master.gain.value = 0.5
|
||||
this.master.connect(this.ctx.destination)
|
||||
}
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume()
|
||||
return this.ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Short filtered-noise burst with a falling pitch — an oar dipping. */
|
||||
splash() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const dur = 0.22
|
||||
const noise = ctx.createBufferSource()
|
||||
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1
|
||||
noise.buffer = buffer
|
||||
|
||||
const filter = ctx.createBiquadFilter()
|
||||
filter.type = 'bandpass'
|
||||
filter.Q.value = 1.2
|
||||
filter.frequency.setValueAtTime(1600, ctx.currentTime)
|
||||
filter.frequency.exponentialRampToValueAtTime(350, ctx.currentTime + dur)
|
||||
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.14, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur)
|
||||
|
||||
noise.connect(filter).connect(gain).connect(this.master)
|
||||
noise.start()
|
||||
noise.stop(ctx.currentTime + dur)
|
||||
}
|
||||
|
||||
/** Soft low thump — the boat nudging a dock. */
|
||||
bump() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(150, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + 0.18)
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.2, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25)
|
||||
osc.connect(gain).connect(this.master)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.3)
|
||||
}
|
||||
|
||||
/** Gentle high blip — an email landing in the boat. */
|
||||
ding() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(880, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.05)
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.08, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
|
||||
osc.connect(gain).connect(this.master)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.35)
|
||||
}
|
||||
|
||||
/** Little four-note arpeggio for the tour finale. */
|
||||
fanfare() {
|
||||
const ctx = this.ensure()
|
||||
if (!ctx || !this.master) return
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5] // C5 E5 G5 C6
|
||||
notes.forEach((freq, i) => {
|
||||
const start = ctx.currentTime + i * 0.13
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.value = freq
|
||||
const gain = ctx.createGain()
|
||||
gain.gain.setValueAtTime(0.0001, start)
|
||||
gain.gain.exponentialRampToValueAtTime(0.16, start + 0.02)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, start + (i === notes.length - 1 ? 0.7 : 0.3))
|
||||
osc.connect(gain).connect(this.master!)
|
||||
osc.start(start)
|
||||
osc.stop(start + 0.8)
|
||||
})
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.ctx?.close().catch(() => {})
|
||||
this.ctx = null
|
||||
this.master = null
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str
|
|||
let headers: Record<string, string>;
|
||||
|
||||
if (signedIn) {
|
||||
const voiceId = config.elevenlabs?.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
|
||||
const voiceId = config.elevenlabs?.voiceId || 's3TPKV1kjDlVtZbl4Ksh';
|
||||
const accessToken = await getAccessToken();
|
||||
url = `${API_URL}/v1/voice/text-to-speech/${voiceId}`;
|
||||
headers = {
|
||||
|
|
@ -52,7 +52,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str
|
|||
if (!config.elevenlabs) {
|
||||
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
|
||||
}
|
||||
const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
|
||||
const voiceId = config.elevenlabs.voiceId || 's3TPKV1kjDlVtZbl4Ksh';
|
||||
url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||
headers = {
|
||||
'xi-api-key': config.elevenlabs.apiKey,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue