From 27c313673915001d28c79db429d300eaccc5ca6a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:39:21 +0530 Subject: [PATCH] dramatic tour --- .../renderer/src/components/product-tour.tsx | 668 ++++++++++++++---- .../renderer/src/components/talking-head.tsx | 94 ++- apps/x/apps/renderer/src/hooks/useVoiceTTS.ts | 2 +- apps/x/apps/renderer/src/lib/tour-sounds.ts | 93 +++ 4 files changed, 728 insertions(+), 129 deletions(-) create mode 100644 apps/x/apps/renderer/src/lib/tour-sounds.ts diff --git a/apps/x/apps/renderer/src/components/product-tour.tsx b/apps/x/apps/renderer/src/components/product-tour.tsx index b2c8dea2..bb4896a4 100644 --- a/apps/x/apps/renderer/src/components/product-tour.tsx +++ b/apps/x/apps/renderer/src/components/product-tour.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +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 } from '@/components/talking-head' +import { TalkingHead, type MascotHat } from '@/components/talking-head' +import { TourSounds } from '@/lib/tour-sounds' import type { TTSState } from '@/hooks/useVoiceTTS' import { cn } from '@/lib/utils' @@ -20,6 +22,8 @@ type TourStep = { 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 title: string text: string } @@ -27,20 +31,21 @@ type TourStep = { const TOUR_STEPS: TourStep[] = [ { id: 'welcome', - title: 'Ahoy! 👋', - text: "I'm the Rowboat mascot. Let me row you around the app — it only takes a minute. Use the Next button or your arrow keys.", + 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.", }, { id: 'home', targetId: 'nav-home', navigate: 'home', - title: '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', title: 'Email', text: 'Read and triage your inbox right here. Rowboat can summarize threads, label messages, and help you draft replies.', }, @@ -48,6 +53,7 @@ const TOUR_STEPS: TourStep[] = [ id: 'meetings', targetId: 'nav-meetings', navigate: 'meetings', + hat: 'headphones', title: 'Meetings', text: 'Record or join meetings, and get transcripts and notes automatically — prep briefs show up before your calls, too.', }, @@ -55,6 +61,7 @@ const TOUR_STEPS: TourStep[] = [ 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.', }, @@ -62,6 +69,7 @@ const TOUR_STEPS: TourStep[] = [ id: 'knowledge', targetId: 'nav-knowledge', navigate: 'knowledge', + hat: 'gradcap', title: 'Brain', text: "Brain is your knowledge base — notes, files, and everything Rowboat learns for you, all connected and searchable.", }, @@ -69,6 +77,7 @@ const TOUR_STEPS: TourStep[] = [ id: 'agents', targetId: 'nav-agents', navigate: 'agents', + hat: 'captain', title: 'Background agents', text: 'Background agents work on schedules — they keep your Brain fresh and take care of recurring tasks while you row elsewhere.', }, @@ -76,6 +85,7 @@ const TOUR_STEPS: TourStep[] = [ 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.', }, @@ -93,8 +103,9 @@ const TOUR_STEPS: TourStep[] = [ }, { id: 'done', - title: "That's the lay of the water! 🚣", - text: 'You can take this tour again anytime from the bottom of the sidebar. Happy rowing!', + 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!", }, ] @@ -102,46 +113,60 @@ 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 TourLayout = { - mascot: { x: number; y: number } - ring: { left: number; top: number; width: number; height: number } | null - bubbleSide: 'left' | 'right' -} +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): DOMRect | null { +function findTourTarget(targetId: string): HTMLElement | null { const nodes = document.querySelectorAll(`[data-tour-id="${targetId}"]`) for (const el of nodes) { const rect = el.getBoundingClientRect() - if (rect.width > 0 && rect.height > 0) return rect + if (rect.width > 0 && rect.height > 0) return el } return null } -function layoutForCenter(): TourLayout { +function mascotDestForCenter(): Pt { return { - mascot: { - x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2, - y: window.innerHeight / 2 - MASCOT_SIZE / 2, - }, - ring: null, - bubbleSide: 'right', + x: window.innerWidth / 2 - MASCOT_SIZE / 2 - BUBBLE_WIDTH / 2, + y: window.innerHeight / 2 - MASCOT_SIZE / 2, } } -function layoutForTarget(rect: DOMRect): TourLayout { +function mascotDestForRect(rect: Rect): Pt { let x: number let y: number - const fitsRight = rect.right + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth + const fitsRight = rect.left + rect.width + MASCOT_SIZE + BUBBLE_WIDTH + VIEWPORT_MARGIN * 3 < window.innerWidth if (fitsRight) { - x = rect.right + 20 + 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 @@ -150,17 +175,29 @@ function layoutForTarget(rect: DOMRect): TourLayout { x = rect.left - MASCOT_SIZE - 20 y = rect.top + rect.height / 2 - MASCOT_SIZE / 2 } - x = clamp(x, VIEWPORT_MARGIN, window.innerWidth - MASCOT_SIZE - VIEWPORT_MARGIN) - y = clamp(y, VIEWPORT_MARGIN, window.innerHeight - MASCOT_SIZE - VIEWPORT_MARGIN) return { - mascot: { x, y }, - ring: { - left: rect.left - 6, - top: rect.top - 6, - width: rect.width + 12, - height: rect.height + 12, - }, - bubbleSide: x + MASCOT_SIZE / 2 < window.innerWidth / 2 ? 'right' : 'left', + 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, } } @@ -175,9 +212,14 @@ type ProductTourProps = { } /** - * Mascot-guided walkthrough. The talking head glides between the app's - * [data-tour-id] anchors, opening each section as it goes, with a speech - * bubble (and spoken narration when TTS is configured) describing each stop. + * The Grand Voyage: a mascot-guided walkthrough where the app dims to a + * night-time bay, the boat rows curved routes between [data-tour-id] anchors + * (leaving a dotted wake behind it), a spotlight and gentle camera zoom reveal + * each section, and a parchment mini-map charts progress. Narrated with TTS + * lip sync when available; ends in confetti. + * + * Rendered through a portal to so the camera zoom applied to the app + * shell never transforms the tour's own fixed-position layers. */ export function ProductTour({ onClose, @@ -189,22 +231,44 @@ export function ProductTour({ getLevel, }: ProductTourProps) { const [stepIndex, setStepIndex] = useState(0) - // Start below the bottom-right corner so the first layout glides the mascot in - const [layout, setLayout] = useState(() => ({ - mascot: { x: window.innerWidth - MASCOT_SIZE - 32, y: window.innerHeight + 40 }, - ring: null, - bubbleSide: 'left', - })) - const [resizeNonce, setResizeNonce] = useState(0) + const [arrived, setArrived] = useState(false) + const [rowing, setRowing] = useState(false) const [flipped, setFlipped] = useState(false) + const [bubbleSide, setBubbleSide] = useState<'left' | 'right'>('right') + const [spot, setSpot] = useState(null) + const [wakes, setWakes] = useState<{ id: number; d: string }[]>([]) + const [activeWake, setActiveWake] = useState<{ d: string; len: number } | null>(null) + const [confettiOn, setConfettiOn] = useState(false) + const [resizeNonce, setResizeNonce] = useState(0) + + const reducedMotion = useMemo( + () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, + [] + ) + + // Mascot position is animated by mutating the container's transform directly + // (60fps travel without re-rendering React); posRef is the source of truth. + const mascotElRef = useRef(null) + const posRef = useRef({ + x: window.innerWidth / 2 - MASCOT_SIZE / 2, + y: window.innerHeight + 60, + }) + const travelRafRef = useRef(0) + const wakePathElRef = useRef(null) + const wakeIdRef = useRef(0) + const curveSideRef = useRef(1) + const lastSplashRef = useRef(0) - // Direction of travel through the steps, used when a step's target is - // missing (e.g. Code mode disabled) and the tour has to skip over it. const directionRef = useRef(1) const enteredStepRef = useRef(-1) - const lastXRef = useRef(null) const stepIndexRef = useRef(stepIndex) + // Camera zoom state applied to the app shell (outside the portal) + const shellRef = useRef(null) + const zoomRef = useRef<{ ox: number; oy: number; s: number } | null>(null) + + const soundsRef = useRef(null) + const onCloseRef = useRef(onClose) const onNavigateRef = useRef(onNavigate) const speakRef = useRef(speak) @@ -222,6 +286,138 @@ export function ProductTour({ ttsAvailableRef.current = ttsAvailable }) + useLayoutEffect(() => { + if (mascotElRef.current) { + mascotElRef.current.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)` + } + soundsRef.current = new TourSounds() + return () => { + soundsRef.current?.dispose() + soundsRef.current = null + } + }, []) + + // Grab the app shell for the camera zoom; restore it when the tour ends + useEffect(() => { + const shell = document.querySelector('.rowboat-shell') + shellRef.current = shell + return () => { + if (shell) { + shell.style.transform = '' + shell.style.transformOrigin = '' + shell.style.transition = '' + } + shellRef.current = null + zoomRef.current = null + } + }, []) + + const applyZoom = useCallback((origin: { ox: number; oy: number } | null) => { + const shell = shellRef.current + if (!shell || reducedMotion) return + if (origin) { + // transform-origin transitions too, so moving between targets pans + // smoothly instead of jumping when the origin changes + shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}` + shell.style.transformOrigin = `${origin.ox}px ${origin.oy}px` + shell.style.transform = `scale(${ZOOM_SCALE})` + zoomRef.current = { ox: origin.ox, oy: origin.oy, s: ZOOM_SCALE } + } else { + shell.style.transition = `transform 0.9s ${GLIDE_EASING}, transform-origin 0.9s ${GLIDE_EASING}` + shell.style.transform = 'scale(1)' + zoomRef.current = null + } + }, [reducedMotion]) + + // Where the element will sit on screen once this step's zoom settles: + // undo the current zoom mathematically, then apply the upcoming one (whose + // origin is the target's own center, so the center never moves). + const displayedRect = useCallback((el: HTMLElement, willZoom: boolean): { rect: Rect; origin: { ox: number; oy: number } } => { + const m = el.getBoundingClientRect() + const z = zoomRef.current + let cx = m.left + m.width / 2 + let cy = m.top + m.height / 2 + let w = m.width + let h = m.height + if (z) { + cx = z.ox + (cx - z.ox) / z.s + cy = z.oy + (cy - z.oy) / z.s + w /= z.s + h /= z.s + } + const s = willZoom && !reducedMotion ? ZOOM_SCALE : 1 + return { + rect: { left: cx - (w * s) / 2, top: cy - (h * s) / 2, width: w * s, height: h * s }, + origin: { ox: cx, oy: cy }, + } + }, [reducedMotion]) + + const cancelTravel = useCallback(() => { + cancelAnimationFrame(travelRafRef.current) + }, []) + + const moveMascot = useCallback((p: Pt) => { + posRef.current = p + if (mascotElRef.current) { + mascotElRef.current.style.transform = `translate(${p.x}px, ${p.y}px)` + } + }, []) + + // Row along a curved path from the current position, drawing the wake as we + // go and splashing the oar; commits the wake as a dotted trail on arrival. + const startTravel = useCallback((dest: Pt, onArrive: () => void) => { + cancelTravel() + const from = { ...posRef.current } + const dist = Math.hypot(dest.x - from.x, dest.y - from.y) + if (dist < 6 || reducedMotion) { + moveMascot(dest) + setRowing(false) + onArrive() + return + } + const dur = clamp(dist * 1.1, 550, 1500) + curveSideRef.current = -curveSideRef.current + const mx = (from.x + dest.x) / 2 + const my = (from.y + dest.y) / 2 + const nx = -(dest.y - from.y) / dist + const ny = (dest.x - from.x) / dist + const mag = Math.min(140, dist * 0.3) * curveSideRef.current + const c = { x: mx + nx * mag, y: my + ny * mag } + + // Wake follows the stern (bottom-center of the mascot box) + const sternX = MASCOT_SIZE / 2 + const sternY = MASCOT_SIZE * 0.82 + const d = `M ${from.x + sternX} ${from.y + sternY} Q ${c.x + sternX} ${c.y + sternY} ${dest.x + sternX} ${dest.y + sternY}` + const len = quadPathLength(d) + setActiveWake({ d, len }) + setFlipped(dest.x < from.x - 4) + setRowing(true) + lastSplashRef.current = 0 + + const t0 = performance.now() + const frame = (now: number) => { + const raw = Math.min(1, (now - t0) / dur) + const t = easeInOutCubic(raw) + moveMascot(quadPoint(from, c, dest, t)) + if (wakePathElRef.current) { + wakePathElRef.current.style.strokeDashoffset = String(len * (1 - t)) + } + if (now - lastSplashRef.current > 420) { + lastSplashRef.current = now + soundsRef.current?.splash() + } + if (raw < 1) { + travelRafRef.current = requestAnimationFrame(frame) + } else { + setRowing(false) + setActiveWake(null) + setWakes((ws) => [...ws, { id: wakeIdRef.current++, d }]) + onArrive() + } + } + travelRafRef.current = requestAnimationFrame(frame) + }, [cancelTravel, moveMascot, reducedMotion]) + const finish = useCallback(() => { cancelSpeechRef.current() onCloseRef.current() @@ -246,8 +442,8 @@ export function ProductTour({ return () => window.removeEventListener('resize', handleResize) }, []) - // Enter the current step: navigate, wait for its anchor to exist, position - // the mascot, and narrate. Re-runs on resize purely to recompute positions. + // 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 @@ -257,34 +453,57 @@ export function ProductTour({ onNavigateRef.current(step.navigate) } - const applyLayout = (next: TourLayout) => { - setLayout(next) - setFlipped(lastXRef.current != null && next.mascot.x < lastXRef.current) - lastXRef.current = next.mascot.x - if (entering) { - enteredStepRef.current = stepIndex + 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() if (ttsAvailableRef.current) { speakRef.current(step.text) } - } + if (stepIndex === TOUR_STEPS.length - 1) { + setConfettiOn(true) + soundsRef.current?.fanfare() + } + }) } if (!step.targetId) { - applyLayout(layoutForCenter()) - return + 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 rect = findTourTarget(step.targetId!) - if (rect) { - applyLayout(layoutForTarget(rect)) + 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) { - requestAnimationFrame(attempt) + 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) @@ -293,8 +512,10 @@ export function ProductTour({ attempt() return () => { cancelled = true + cancelAnimationFrame(pollRaf) + cancelTravel() } - }, [stepIndex, resizeNonce, goTo]) + }, [stepIndex, resizeNonce, goTo, applyZoom, displayedRect, startTravel, cancelTravel, moveMascot]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -317,85 +538,286 @@ export function ProductTour({ const isFirst = stepIndex === 0 const isLast = stepIndex === TOUR_STEPS.length - 1 - return ( + return createPortal( <> - {/* highlight ring around the current step's anchor */} - {layout.ring && ( + + {/* night falls: dim everything except the spotlight cutout */} + {spot && (
)} - {/* mascot + speech bubble */} + + + + {/* wake trails: the committed dotted route + the wake being drawn now */} + + {wakes.map((w) => ( + + ))} + {activeWake && ( + + )} + + + + + {/* the boat (position driven imperatively during travel) */}
-
- +
+
-
- -

{step.title}

-

{step.text}

-
- - {stepIndex + 1} / {TOUR_STEPS.length} - -
- {!isFirst && ( - +

{step.title}

+

{step.text}

+
+ + {stepIndex + 1} / {TOUR_STEPS.length} + +
+ {!isFirst && ( + + )} + - )} - +
-
+ )}
- + + {confettiOn && !reducedMotion && } + , + document.body + ) +} + +/** Animated translucent waves lapping at the bottom of the screen. */ +function TourWater() { + const back = useMemo(() => wavePath(2400, 96, 8, 22), []) + const front = useMemo(() => wavePath(2400, 96, 12, 30), []) + return ( + + ) +} + +// Periodic wave: alternating up/down humps so a -50% translate loops seamlessly +// (both hump counts are even, so half the width is a whole number of periods). +function wavePath(width: number, height: number, humps: number, amp: number): string { + const yTop = 34 + const seg = width / humps + let d = `M 0 ${yTop}` + for (let i = 0; i < humps; i++) { + d += ` q ${seg / 2} ${i % 2 === 0 ? -amp : amp} ${seg} 0` + } + d += ` L ${width} ${height} L 0 ${height} Z` + return d +} + +/** Parchment chart in the corner: islands per stop, dotted route, boat marker. */ +function TourMiniMap({ total, current, arrived }: { total: number; current: number; arrived: boolean }) { + const MW = 184 + const MH = 96 + const points = useMemo( + () => + Array.from({ length: total }, (_, i) => { + const t = total === 1 ? 0 : i / (total - 1) + return { + x: 14 + (MW - 28) * t, + y: MH / 2 + 4 + Math.sin(t * Math.PI * 1.5 + 0.6) * (MH * 0.26), + } + }), + [total] + ) + const route = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + const boat = points[clamp(current, 0, total - 1)] + + return ( + + ) +} + +/** Two confetti cannons firing from the bottom corners, canvas-driven. */ +function ConfettiBurst() { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + const w = window.innerWidth + const h = window.innerHeight + const dpr = window.devicePixelRatio || 1 + canvas.width = w * dpr + canvas.height = h * dpr + ctx.scale(dpr, dpr) + + const colors = ['#5B8DEF', '#F2B8BE', '#FFD166', '#7AC74F', '#8FB6D9', '#F2699C'] + const parts = Array.from({ length: 150 }, (_, i) => { + const fromLeft = i % 2 === 0 + return { + x: fromLeft ? 24 : w - 24, + y: h - 40, + vx: (fromLeft ? 1 : -1) * (2.5 + Math.random() * 6.5), + vy: -(9 + Math.random() * 8), + size: 5 + Math.random() * 5, + color: colors[i % colors.length], + rot: Math.random() * Math.PI, + vr: (Math.random() - 0.5) * 0.3, + } + }) + + let raf = 0 + const t0 = performance.now() + const frame = (now: number) => { + ctx.clearRect(0, 0, w, h) + for (const p of parts) { + p.vy += 0.18 + p.vx *= 0.99 + p.x += p.vx + p.y += p.vy + p.rot += p.vr + ctx.save() + ctx.translate(p.x, p.y) + ctx.rotate(p.rot) + ctx.fillStyle = p.color + ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size * 0.66) + ctx.restore() + } + if (now - t0 < 3200) { + raf = requestAnimationFrame(frame) + } else { + ctx.clearRect(0, 0, w, h) + } + } + raf = requestAnimationFrame(frame) + return () => cancelAnimationFrame(raf) + }, []) + + return ( + ) } diff --git a/apps/x/apps/renderer/src/components/talking-head.tsx b/apps/x/apps/renderer/src/components/talking-head.tsx index bdbdb543..fee8f7db 100644 --- a/apps/x/apps/renderer/src/components/talking-head.tsx +++ b/apps/x/apps/renderer/src/components/talking-head.tsx @@ -19,10 +19,23 @@ 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 } /** @@ -30,7 +43,7 @@ type TalkingHeadProps = { * 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 }: TalkingHeadProps) { +export function TalkingHead({ ttsState, getLevel, size = 160, hat, rowing = false }: TalkingHeadProps) { const mouthOpenRef = useRef(null) const mouthSmileRef = useRef(null) const oarRef = useRef(null) @@ -51,7 +64,7 @@ export function TalkingHead({ ttsState, getLevel, size = 160 }: TalkingHeadProps 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 && smoothed < 0.005 + const settled = !speaking && !rowing && smoothed < 0.005 smoothedRef.current = settled ? 0 : smoothed const open = settled ? 0 : Math.min(1, smoothed * 1.6) @@ -71,7 +84,11 @@ export function TalkingHead({ ttsState, getLevel, size = 160 }: TalkingHeadProps const oar = oarRef.current if (oar) { - if (speaking) { + 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)`) @@ -86,7 +103,7 @@ export function TalkingHead({ ttsState, getLevel, size = 160 }: TalkingHeadProps } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) - }, [speaking, getLevel]) + }, [speaking, rowing, getLevel]) // Randomized blinking useEffect(() => { @@ -116,7 +133,7 @@ export function TalkingHead({ ttsState, getLevel, size = 160 }: TalkingHeadProps style={{ width: size, height: size, - animationDuration: speaking ? '1.6s' : '3.2s', + animationDuration: rowing ? '0.8s' : speaking ? '1.6s' : '3.2s', }} >