diff --git a/packages/landing/src/components/demo.test.tsx b/packages/landing/src/components/demo.test.tsx new file mode 100644 index 0000000..afceb17 --- /dev/null +++ b/packages/landing/src/components/demo.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Demo } from './demo'; + +// All assertions use the `initialPhase` test seam so the auto-cycle timers +// never run — each phase renders deterministically. +describe('Demo', () => { + it('exposes an accessible recording-demo region', () => { + render(); + expect(screen.getByRole('region', { name: /recording demo/i })).toBeInTheDocument(); + }); + + it('idle shows the dictate prompt + shortcut keys (Space is OS-agnostic)', () => { + render(); + expect(screen.getByText(/press to dictate/i)).toBeInTheDocument(); + // Both macOS (⌘⇧Space) and Windows/Linux (Ctrl+⇧Space) end in Space. + expect(screen.getByText('Space')).toBeInTheDocument(); + expect(screen.getByTestId('demo-pill')).toHaveAttribute('data-state', 'idle'); + }); + + it('recording shows the Recording pill + waveform', () => { + render(); + expect(screen.getByText('Recording')).toBeInTheDocument(); + expect(screen.getByTestId('demo-waveform')).toBeInTheDocument(); + expect(screen.getByTestId('demo-pill')).toHaveAttribute('data-state', 'recording'); + }); + + it('transcribing shows the Transcribing pill', () => { + render(); + expect(screen.getByText(/transcribing/i)).toBeInTheDocument(); + expect(screen.getByTestId('demo-pill')).toHaveAttribute('data-state', 'transcribing'); + }); + + it('done shows the full transcript + Pasted pill', () => { + render(); + expect(screen.getByText('Pasted')).toBeInTheDocument(); + expect(screen.getByTestId('demo-pill')).toHaveAttribute('data-state', 'pasted'); + expect(screen.getByText(/schedule a follow-up with the design team/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/landing/src/components/demo.tsx b/packages/landing/src/components/demo.tsx index 7b2921c..bb79aae 100644 --- a/packages/landing/src/components/demo.tsx +++ b/packages/landing/src/components/demo.tsx @@ -1,48 +1,248 @@ -// Self-contained mockup of the dictation flow. Replaces the earlier while the real screen recording is being produced — the -// previous placeholder gif was a 43-byte stub that rendered as a broken-image -// section on the landing page. -export function Demo() { +'use client'; + +import { osMeta, useClientOS } from '@/lib/use-client-os'; +import { cn } from '@/lib/utils'; +import { useEffect, useState } from 'react'; + +// An interactive, looping mock of a real bluemacaw session — replaces the +// old broken demo.gif. Instead of a video we drive a tiny state machine +// through the actual UX beats (press shortcut → record → transcribe → paste) +// so the "demo" is a live component that adapts to the visitor's OS and +// theme, and never ships a heavy asset. Honors prefers-reduced-motion by +// rendering the finished state statically. + +type Phase = 'idle' | 'recording' | 'transcribing' | 'typing' | 'done'; + +const TRANSCRIPT = + 'Schedule a follow-up with the design team next Tuesday at three, and remind me to share the dashboard mockups.'; +const WORDS = TRANSCRIPT.split(' '); + +const NEXT: Record = { + idle: 'recording', + recording: 'transcribing', + transcribing: 'typing', + typing: 'done', + done: 'idle', +}; + +// Per-phase dwell time (ms). `typing` isn't here — its duration is driven by +// the word-reveal cadence below. +const PHASE_MS: Record, number> = { + idle: 1400, + recording: 2400, + transcribing: 1100, + done: 1900, +}; +const WORD_MS = 90; + +export interface DemoProps { + /** + * Test seam: when set, the auto-cycle is disabled and the component + * renders this phase deterministically. Production never passes it. + */ + initialPhase?: Phase; +} + +export function Demo({ initialPhase }: DemoProps = {}) { + const os = useClientOS(); + const [phase, setPhase] = useState(initialPhase ?? 'idle'); + const [wordCount, setWordCount] = useState(0); + const [reduced, setReduced] = useState(false); + + // Detect reduced-motion once on mount. For a marketing loop we don't + // bother subscribing to changes — a visitor toggling the OS setting + // mid-view is not worth the listener. + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + setReduced(window.matchMedia('(prefers-reduced-motion: reduce)').matches); + }, []); + + const running = initialPhase === undefined && !reduced; + + // Phase clock for every phase except `typing`. + useEffect(() => { + if (!running || phase === 'typing') return; + const id = window.setTimeout(() => { + // Reset the transcript right before we loop back to idle so the + // next cycle types from an empty document. + if (phase === 'done') setWordCount(0); + setPhase(NEXT[phase]); + }, PHASE_MS[phase]); + return () => window.clearTimeout(id); + }, [running, phase]); + + // Word-by-word reveal during `typing`, then advance to `done`. + useEffect(() => { + if (!running || phase !== 'typing') return; + if (wordCount >= WORDS.length) { + const id = window.setTimeout(() => setPhase('done'), 450); + return () => window.clearTimeout(id); + } + const id = window.setTimeout(() => setWordCount((c) => c + 1), WORD_MS); + return () => window.clearTimeout(id); + }, [running, phase, wordCount]); + + const displayPhase: Phase = reduced ? 'done' : (initialPhase ?? phase); + const revealed = + displayPhase === 'done' ? WORDS.length : displayPhase === 'typing' ? wordCount : 0; + const shownText = WORDS.slice(0, revealed).join(' '); + const shortcut = osMeta(os).shortcut; + return (
-
-
-
- - + - - + - Space - hold to dictate +
+ {/* Faux app window */} +
+ {/* Title bar with traffic-light dots */} +
+
-
-
- - - - - Recording — OpenAI · gpt-4o-mini-transcribe -
-

- Schedule a follow-up with the design team next Tuesday at three, and - remind me to share the new dashboard mockups before the meeting. -

+ {/* Document body */} +
+ {shownText ? ( +

+ {shownText} + {displayPhase === 'typing' && ( +

+ ) : ( +
+

+ {displayPhase === 'recording' + ? 'Listening… speak naturally.' + : displayPhase === 'transcribing' + ? 'Turning speech into text…' + : 'Press to dictate into any app.'} +

+ + {shortcut.map((key, i) => ( + + {i > 0 && ( + + + + + )} + + {key} + + + ))} + +
+ )}
-

- Release the key → text pastes into the focused app instantly. -

+ {/* Floating status pill — mirrors the real desktop overlay */} +
+ +
+ +

+ A live preview — no video, just the real UI. Hold{' '} + {osMeta(os).label === 'macOS' ? '⌘⇧Space' : 'Ctrl+⇧Space'}, talk, release. +

); } -function Kbd({ children }: { children: React.ReactNode }) { +const PILL = cn( + 'inline-flex items-center gap-2.5 rounded-full', + 'bg-brand-navy/90 backdrop-blur-md', + 'pl-3 pr-4 py-2 select-none text-white shadow-pop', +); + +function StatusPill({ phase }: { phase: Phase }) { + if (phase === 'recording') { + return ( +
+ + + Recording +
+ ); + } + if (phase === 'transcribing') { + return ( +
+
+ ); + } + if (phase === 'typing' || phase === 'done') { + return ( +
+ + Pasted +
+ ); + } + // idle + return ( +
+ + Ready +
+ ); +} + +function Waveform() { return ( - - {children} - +