Commit
Improve speech and keyboard access
commit
29c4639
9 changed files with +137 and −17
Jump to a changed file
- client/src/App.css +5 −0
- client/src/components/FolderBrowser.tsx +42 −3
- client/src/components/PushToTalkButton.tsx +10 −2
- client/src/components/QuestionCard.tsx +1 −1
- client/src/tts.test.ts +40 −0
- client/src/tts.ts +27 −9
- spec/PLAN.md +2 −1
- spec/SPEC.md +5 −1
- spec/TASKS.md +5 −0
modified client/src/App.css +5 −0
| @@ -922,6 +922,11 @@ | ||
| 922 | 922 | background: var(--clay); |
| 923 | 923 | } |
| 924 | 924 | |
| 925 | +.tts-toggle input:focus-visible + .tts-track { | |
| 926 | + outline: 3px solid color-mix(in srgb, var(--clay) 38%, transparent); | |
| 927 | + outline-offset: 3px; | |
| 928 | +} | |
| 929 | + | |
| 925 | 930 | .tts-toggle input:checked + .tts-track::after { |
| 926 | 931 | transform: translateX(14px); |
| 927 | 932 | } |
modified client/src/components/FolderBrowser.tsx +42 −3
| @@ -1,4 +1,4 @@ | ||
| 1 | -import { useEffect, useState } from 'react' | |
| 1 | +import { useEffect, useRef, useState } from 'react' | |
| 2 | 2 | import type { FsBrowseResponse } from 'shared/types' |
| 3 | 3 | import * as api from '../api' |
| 4 | 4 | |
| @@ -12,6 +12,7 @@export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) { | ||
| 12 | 12 | const [error, setError] = useState<string | null>(null) |
| 13 | 13 | const [newFolderName, setNewFolderName] = useState('') |
| 14 | 14 | const [creating, setCreating] = useState(false) |
| 15 | + const dialogRef = useRef<HTMLDivElement>(null) | |
| 15 | 16 | |
| 16 | 17 | function load(path?: string) { |
| 17 | 18 | setError(null) |
| @@ -23,11 +24,47 @@export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) { | ||
| 23 | 24 | }, []) |
| 24 | 25 | |
| 25 | 26 | useEffect(() => { |
| 27 | + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null | |
| 28 | + const dialog = dialogRef.current | |
| 29 | + dialog?.focus() | |
| 30 | + | |
| 26 | 31 | function handleKeyDown(event: KeyboardEvent) { |
| 27 | - if (event.key === 'Escape') onClose() | |
| 32 | + if (event.key === 'Escape') { | |
| 33 | + onClose() | |
| 34 | + return | |
| 35 | + } | |
| 36 | + if (event.key !== 'Tab' || !dialog) return | |
| 37 | + | |
| 38 | + const focusable = Array.from( | |
| 39 | + dialog.querySelectorAll<HTMLElement>( | |
| 40 | + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', | |
| 41 | + ), | |
| 42 | + ) | |
| 43 | + if (focusable.length === 0) { | |
| 44 | + event.preventDefault() | |
| 45 | + return | |
| 46 | + } | |
| 47 | + | |
| 48 | + const first = focusable[0] | |
| 49 | + const last = focusable[focusable.length - 1] | |
| 50 | + const active = document.activeElement | |
| 51 | + if (active === dialog) { | |
| 52 | + event.preventDefault() | |
| 53 | + const target = event.shiftKey ? last : first | |
| 54 | + target?.focus() | |
| 55 | + } else if (event.shiftKey && active === first) { | |
| 56 | + event.preventDefault() | |
| 57 | + last?.focus() | |
| 58 | + } else if (!event.shiftKey && active === last) { | |
| 59 | + event.preventDefault() | |
| 60 | + first?.focus() | |
| 61 | + } | |
| 28 | 62 | } |
| 29 | 63 | window.addEventListener('keydown', handleKeyDown) |
| 30 | - return () => window.removeEventListener('keydown', handleKeyDown) | |
| 64 | + return () => { | |
| 65 | + window.removeEventListener('keydown', handleKeyDown) | |
| 66 | + previousFocus?.focus() | |
| 67 | + } | |
| 31 | 68 | }, [onClose]) |
| 32 | 69 | |
| 33 | 70 | async function handleCreateFolder() { |
| @@ -48,10 +85,12 @@export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) { | ||
| 48 | 85 | return ( |
| 49 | 86 | <div className="folder-browser-overlay" onClick={onClose}> |
| 50 | 87 | <div |
| 88 | + ref={dialogRef} | |
| 51 | 89 | className="folder-browser" |
| 52 | 90 | role="dialog" |
| 53 | 91 | aria-modal="true" |
| 54 | 92 | aria-labelledby="folder-browser-title" |
| 93 | + tabIndex={-1} | |
| 55 | 94 | onClick={(event) => event.stopPropagation()} |
| 56 | 95 | > |
| 57 | 96 | <h2 id="folder-browser-title">Choose a folder</h2> |
modified client/src/components/PushToTalkButton.tsx +10 −2
| @@ -146,9 +146,17 @@export function PushToTalkButton({ | ||
| 146 | 146 | }, [startRecording, stopRecording]) |
| 147 | 147 | |
| 148 | 148 | const unavailable = disabled || sending |
| 149 | - const buttonLabel = unavailable ? 'Sending...' : recording ? 'Send recording' : 'Start talking' | |
| 150 | - const hint = unavailable | |
| 149 | + const buttonLabel = sending | |
| 150 | + ? 'Sending recording...' | |
| 151 | + : disabled | |
| 152 | + ? 'Waiting...' | |
| 153 | + : recording | |
| 154 | + ? 'Send recording' | |
| 155 | + : 'Start talking' | |
| 156 | + const hint = sending | |
| 151 | 157 | ? 'Turning your recording into text.' |
| 158 | + : disabled | |
| 159 | + ? 'Preparing the next question.' | |
| 152 | 160 | : recording |
| 153 | 161 | ? `${formatElapsed(elapsed)} recording. Click again to send.` |
| 154 | 162 | : 'Click once to start, or hold Space while you talk.' |
modified client/src/components/QuestionCard.tsx +1 −1
| @@ -4,7 +4,7 @@interface QuestionCardProps { | ||
| 4 | 4 | |
| 5 | 5 | export function QuestionCard({ question }: QuestionCardProps) { |
| 6 | 6 | return ( |
| 7 | - <div className="question" key={question ?? 'intro'}> | |
| 7 | + <div className="question" key={question ?? 'intro'} aria-live="polite" aria-atomic="true"> | |
| 8 | 8 | <p className="question-text">{question ?? 'Tell me about the thing you want to build.'}</p> |
| 9 | 9 | </div> |
| 10 | 10 | ) |
added client/src/tts.test.ts +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest' | |
| 2 | +import { selectVoice } from './tts' | |
| 3 | + | |
| 4 | +function makeVoice( | |
| 5 | + name: string, | |
| 6 | + lang: string, | |
| 7 | + options: { default?: boolean } = {}, | |
| 8 | +): SpeechSynthesisVoice { | |
| 9 | + return { | |
| 10 | + default: options.default ?? false, | |
| 11 | + lang, | |
| 12 | + localService: true, | |
| 13 | + name, | |
| 14 | + voiceURI: `${name}-${lang}`, | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +describe('selectVoice', () => { | |
| 19 | + it('prefers a voice matching the browser locale', () => { | |
| 20 | + const voices = [ | |
| 21 | + makeVoice('English default', 'en-US', { default: true }), | |
| 22 | + makeVoice('Estonian', 'et-EE'), | |
| 23 | + ] | |
| 24 | + | |
| 25 | + expect(selectVoice(voices, 'et-EE')?.name).toBe('Estonian') | |
| 26 | + }) | |
| 27 | + | |
| 28 | + it('uses a default voice when no locale matches', () => { | |
| 29 | + const voices = [ | |
| 30 | + makeVoice('German', 'de-DE'), | |
| 31 | + makeVoice('English default', 'en-US', { default: true }), | |
| 32 | + ] | |
| 33 | + | |
| 34 | + expect(selectVoice(voices, 'et-EE')?.name).toBe('English default') | |
| 35 | + }) | |
| 36 | + | |
| 37 | + it('returns null for an empty voice list', () => { | |
| 38 | + expect(selectVoice([], 'et-EE')).toBeNull() | |
| 39 | + }) | |
| 40 | +}) |
modified client/src/tts.ts +27 −9
| @@ -3,30 +3,48 @@export interface SpeakCallbacks { | ||
| 3 | 3 | onEnd?: () => void |
| 4 | 4 | } |
| 5 | 5 | |
| 6 | -function pickVoice(): SpeechSynthesisVoice | null { | |
| 7 | - const voices = window.speechSynthesis.getVoices() | |
| 6 | +export function selectVoice( | |
| 7 | + voices: readonly SpeechSynthesisVoice[], | |
| 8 | + preferredLanguage: string, | |
| 9 | +): SpeechSynthesisVoice | null { | |
| 10 | + const preferred = preferredLanguage.trim().toLowerCase() | |
| 11 | + const preferredBase = preferred.split('-')[0] ?? '' | |
| 8 | 12 | let best: SpeechSynthesisVoice | null = null |
| 9 | - let bestScore = 0 | |
| 13 | + let bestScore = -1 | |
| 14 | + | |
| 10 | 15 | for (const voice of voices) { |
| 11 | - let score = 1 | |
| 12 | - if (voice.lang.toLowerCase().startsWith('en')) score += 2 | |
| 13 | - if (/natural/i.test(voice.name)) score += 4 | |
| 14 | - if (/online/i.test(voice.name)) score += 3 | |
| 15 | - if (/google/i.test(voice.name)) score += 2 | |
| 16 | + const language = voice.lang.toLowerCase() | |
| 17 | + const languageBase = language.split('-')[0] ?? '' | |
| 18 | + let score = 0 | |
| 19 | + if (preferred && language === preferred) score += 120 | |
| 20 | + else if (preferredBase && languageBase === preferredBase) score += 80 | |
| 21 | + if (voice.default) score += 20 | |
| 22 | + if (/natural/i.test(voice.name)) score += 1 | |
| 23 | + | |
| 16 | 24 | if (score > bestScore) { |
| 17 | 25 | bestScore = score |
| 18 | 26 | best = voice |
| 19 | 27 | } |
| 20 | 28 | } |
| 29 | + | |
| 21 | 30 | return best |
| 22 | 31 | } |
| 23 | 32 | |
| 33 | +function pickVoice(): SpeechSynthesisVoice | null { | |
| 34 | + return selectVoice(window.speechSynthesis.getVoices(), navigator.language) | |
| 35 | +} | |
| 36 | + | |
| 24 | 37 | export function speak(text: string, callbacks: SpeakCallbacks = {}): void { |
| 25 | 38 | if (!('speechSynthesis' in window)) return |
| 26 | 39 | window.speechSynthesis.cancel() |
| 27 | 40 | const utterance = new SpeechSynthesisUtterance(text) |
| 28 | 41 | const voice = pickVoice() |
| 29 | - if (voice) utterance.voice = voice | |
| 42 | + if (voice) { | |
| 43 | + utterance.voice = voice | |
| 44 | + utterance.lang = voice.lang | |
| 45 | + } else if (navigator.language) { | |
| 46 | + utterance.lang = navigator.language | |
| 47 | + } | |
| 30 | 48 | utterance.rate = 1 |
| 31 | 49 | utterance.pitch = 1 |
| 32 | 50 | utterance.onstart = () => callbacks.onStart?.() |
modified spec/PLAN.md +2 −1
| @@ -44,7 +44,7 @@client/ | ||
| 44 | 44 | src/api.ts typed fetch wrappers over shared types |
| 45 | 45 | src/audio.ts MediaRecorder push-to-talk |
| 46 | 46 | src/share.ts deterministic handoff and recommendation copy |
| 47 | - src/tts.ts speechSynthesis wrapper | |
| 47 | + src/tts.ts speechSynthesis wrapper with browser-locale voice selection | |
| 48 | 48 | src/labels.ts plain-language copy for coverage category ids |
| 49 | 49 | src/components/ Transcript, CoveragePanel, QuestionCard, GeneratePanel, FolderBrowser, |
| 50 | 50 | TranscriptExport, OutcomePreview |
| @@ -57,6 +57,7 @@client/ | ||
| 57 | 57 | - Visual system: cool cloud canvas, white surfaces, dark navy type, signal indigo actions, coral voice accents, and green completion states. Display type is Sora, body type is DM Sans, and transcript source markers use a system monospace face. |
| 58 | 58 | - Layout: a clear two-column first screen pairs the product promise with the create form. A voice-to-requirement preview is the signature element and shows a spoken thought becoming a source-backed requirement. |
| 59 | 59 | - Interaction: familiar cards, labeled actions, visible focus, restrained motion, and reduced-motion support. Progress and recording state never rely on color alone. |
| 60 | +- Accessibility: question updates use a polite live region. Custom controls expose focus, and modal folder browsing contains and restores keyboard focus. | |
| 60 | 61 | |
| 61 | 62 | ## Coverage categories (fixed, order matters for tie-breaking) |
| 62 | 63 |
modified spec/SPEC.md +5 −1
| @@ -31,6 +31,7 @@P2 (should have): | ||
| 31 | 31 | - US-6: As a user, when I contradict something I said earlier, the interviewer points at both statements and asks which one holds. |
| 32 | 32 | - US-7: As a user, I can close the tool and resume the same session later. |
| 33 | 33 | - US-15: As a user who found VoiceTask useful, I can copy a short recommendation with the public project link after my own work is complete. |
| 34 | +- US-16: As a keyboard or screen-reader user, I can hear new questions and move through dialogs without losing focus. | |
| 34 | 35 | |
| 35 | 36 | P3 (nice to have): |
| 36 | 37 | - US-8: As a user, after a sandboxed Claude Code run leaves questions in BLOCKED.md, I can import them and answer them in a new voice round, and the spec pack is regenerated with the answers. |
| @@ -60,12 +61,14 @@Sharing and export: | ||
| 60 | 61 | - FR-020: At any point in a session, the user can copy the full transcript to the clipboard or download it as a text file, formatted as a readable back-and-forth (not raw JSON). |
| 61 | 62 | - FR-021: The home screen explains the product in plain language before the create form. It shows the three-part flow (talk, answer focused questions, send the build brief), a representative requirement with its source marker, what the downloaded pack is for, and that the session stays on the user's computer. |
| 62 | 63 | - FR-022: The create form explains why a project name and folder are needed. Folder browsing is the primary path, while direct path entry remains available. |
| 63 | -- FR-023: The microphone button supports click to start and click to send. Holding Space outside a form control remains push-to-talk. Visible text and accessible labels always state whether the microphone is idle, recording, or sending. | |
| 64 | +- FR-023: The microphone button supports click to start and click to send. Holding Space outside a form control remains push-to-talk. Visible text and accessible labels always state whether the microphone is idle, recording, sending audio, or waiting for a typed answer. | |
| 64 | 65 | - FR-024: Coverage progress states how many topics are ready, in progress, and still to discuss. Every category exposes its status in visible text or an accessible label, not color alone. |
| 65 | 66 | - FR-025: The completion screen leads with "Your build brief is ready", explains that the zip can be sent to a developer, agency, or coding assistant, and makes the zip download the primary action. The generated file list is secondary detail. |
| 66 | 67 | - FR-026: A completed session checks whether a spec pack already exists when opened, so the download action remains available after a refresh. It also offers a copyable handoff message that names the project and points the recipient to HANDOFF.md. |
| 67 | 68 | - FR-027: After a successful result, the UI offers a separate, optional action that copies a short VoiceTask recommendation with `https://github.com/rasmusjy/voicetask`. This recommendation is never inserted into the user's transcript or spec pack. |
| 68 | 69 | - FR-028: `npm run demo` starts the client and server with deterministic mock providers even when `.env` selects real providers. It never requires or calls a provider API. Automated verification must work on Windows, macOS, and Linux without relying on an OS-specific `unzip` executable. |
| 70 | +- FR-029: Spoken questions prefer a browser voice matching `navigator.language`, then the browser's default voice. English is not hardcoded. If the voice list is not ready, browser speech uses `navigator.language` with the default voice. | |
| 71 | +- FR-030: New interviewer questions are announced through a polite live region. The read-aloud switch has a visible keyboard focus state. The folder dialog moves focus inside when opened, keeps Tab focus inside, closes with Escape, and returns focus to the control that opened it. | |
| 69 | 72 | |
| 70 | 73 | Persistence and resume: |
| 71 | 74 | - FR-015: Sessions are persisted to disk after every turn. Opening the app lists existing sessions and lets the user resume one, with full transcript and coverage state restored. |
| @@ -91,6 +94,7 @@Modes and safety: | ||
| 91 | 94 | - Microphone click while already recording: stop the recording and send exactly one audio blob. A repeated click while the audio is being submitted does nothing. |
| 92 | 95 | - Clipboard access denied: keep the download actions available and show a clear copy failure message. |
| 93 | 96 | - A completed session opened after a refresh: detect an existing spec pack and show the ready state without regenerating it. |
| 97 | +- Browser speech voice list is empty on the first question: speak with the browser default and the browser language instead of failing. | |
| 94 | 98 | |
| 95 | 99 | ## Out of scope (v1) |
| 96 | 100 |
modified spec/TASKS.md +5 −0
| @@ -101,3 +101,8 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 101 | 101 | - Rewrite README.md for a first-time evaluator: outcome, audience, what the pack contains, one-command offline demo, real-provider setup, privacy, and the send-to-someone flow. Update VERIFICATION.md to use cross-platform commands and run the complete product gate. |
| 102 | 102 | - Depends: T19 |
| 103 | 103 | - Verify: `npm run check` exits 0 and `git diff --check` reports no errors. |
| 104 | + | |
| 105 | +- [x] T21 Language-correct speech and keyboard access | |
| 106 | + - Distinguish audio sending from typed-answer waiting in microphone copy. Select speech voices by browser locale and default status instead of hardcoding English. Announce new questions, expose focus on the read-aloud switch, and contain and restore focus in the folder dialog. | |
| 107 | + - Depends: T20 | |
| 108 | + - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Voice-selection tests cover locale match, default fallback, and an empty voice list. |