profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

Improve speech and keyboard access

commit 29c4639

9 changed files with +137 and −17

Jump to a changed file
  1. client/src/App.css +5 −0
  2. client/src/components/FolderBrowser.tsx +42 −3
  3. client/src/components/PushToTalkButton.tsx +10 −2
  4. client/src/components/QuestionCard.tsx +1 −1
  5. client/src/tts.test.ts +40 −0
  6. client/src/tts.ts +27 −9
  7. spec/PLAN.md +2 −1
  8. spec/SPEC.md +5 −1
  9. 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.