diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 48dd9e78..1c398ed8 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; @@ -12,6 +12,7 @@ import { usePlatform } from '@/platform/PlatformContext'; export function AudioPlayer() { const platform = usePlatform(); + const volumeLabelId = useId(); const { audioUrl, audioId, @@ -831,6 +832,13 @@ export function AudioPlayer() { disabled={isLoading || duration === 0} className="shrink-0" title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} + aria-label={ + duration === 0 && !isLoading + ? 'Audio not loaded' + : isPlaying + ? 'Pause' + : 'Play' + } > {isPlaying ? : } @@ -845,6 +853,8 @@ export function AudioPlayer() { max={100} step={0.1} className="w-full" + aria-label="Playback position" + aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`} /> )} {isLoading && ( @@ -872,26 +882,33 @@ export function AudioPlayer() { onClick={toggleLoop} className={isLooping ? 'text-primary' : ''} title="Toggle loop" + aria-label={isLooping ? 'Stop looping' : 'Loop'} > {/* Volume Control */} -
+
+ + Volume level, {Math.round(volume * 100)}% +
@@ -902,6 +919,7 @@ export function AudioPlayer() { onClick={handleClose} className="shrink-0" title="Close player" + aria-label="Close player" > diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index a8d556a6..0461e58b 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -300,6 +300,13 @@ export function FloatingGenerateBox({ disabled={isPending || !selectedProfileId} className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200" size="icon" + aria-label={ + isPending + ? 'Generating...' + : !selectedProfileId + ? 'Select a voice profile first' + : 'Generate speech' + } > {isPending ? ( @@ -336,6 +343,11 @@ export function FloatingGenerateBox({ ? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90' : 'bg-card border border-border hover:bg-background/50', )} + aria-label={ + isInstructMode + ? 'Fine tune instructions, on' + : 'Fine tune instructions' + } > diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index e572f69e..74f722b7 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -253,10 +253,17 @@ export function HistoryTable() { return (
{ // Don't trigger play if clicking on textarea or if text is selected const target = e.target as HTMLElement; @@ -265,6 +272,14 @@ export function HistoryTable() { } handlePlay(gen.id, gen.text, gen.profile_id); }} + onKeyDown={(e) => { + const target = e.target as HTMLElement; + if (target.closest('textarea') || target.closest('button')) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handlePlay(gen.id, gen.text, gen.profile_id); + } + }} > {/* Waveform icon */}
@@ -293,6 +308,7 @@ export function HistoryTable() { value={gen.text} className="flex-1 resize-none text-sm text-muted-foreground select-text" readOnly + aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`} />
diff --git a/app/src/components/ServerSettings/ConnectionForm.tsx b/app/src/components/ServerSettings/ConnectionForm.tsx index 9e25a52d..0f56555c 100644 --- a/app/src/components/ServerSettings/ConnectionForm.tsx +++ b/app/src/components/ServerSettings/ConnectionForm.tsx @@ -57,7 +57,11 @@ export function ConnectionForm() { } return ( - + Server Connection diff --git a/app/src/components/ServerSettings/ModelManagement.tsx b/app/src/components/ServerSettings/ModelManagement.tsx index 4a5fd439..a8d9e284 100644 --- a/app/src/components/ServerSettings/ModelManagement.tsx +++ b/app/src/components/ServerSettings/ModelManagement.tsx @@ -278,9 +278,25 @@ interface ModelItemProps { function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) { // Use server's downloading state OR local state (for immediate feedback before server updates) const showDownloading = model.downloading || isDownloading; - + + const statusText = model.loaded + ? 'Loaded' + : showDownloading + ? 'Downloading' + : model.downloaded + ? 'Downloaded' + : 'Not downloaded'; + const sizeText = + model.downloaded && model.size_mb && !showDownloading ? `, ${formatSize(model.size_mb)}` : ''; + const rowLabel = `${model.display_name}, ${statusText}${sizeText}. Use Tab to reach Download or Delete.`; + return ( -
+
{model.display_name} @@ -314,17 +330,27 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M variant="outline" disabled={model.loaded} title={model.loaded ? 'Unload model before deleting' : 'Delete model'} + aria-label={ + model.loaded + ? 'Unload model before deleting' + : `Delete ${model.display_name}` + } >
) : showDownloading ? ( - ) : ( - diff --git a/app/src/components/ServerSettings/ServerStatus.tsx b/app/src/components/ServerSettings/ServerStatus.tsx index 02a94ec2..5bd0b22b 100644 --- a/app/src/components/ServerSettings/ServerStatus.tsx +++ b/app/src/components/ServerSettings/ServerStatus.tsx @@ -10,7 +10,11 @@ export function ServerStatus() { const serverUrl = useServerStore((state) => state.serverUrl); return ( - + Server Status diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx index a3d832aa..b618a750 100644 --- a/app/src/components/ServerSettings/UpdateStatus.tsx +++ b/app/src/components/ServerSettings/UpdateStatus.tsx @@ -20,7 +20,11 @@ export function UpdateStatus() { }, [platform]); return ( - + App Updates diff --git a/app/src/components/StoriesTab/StoryList.tsx b/app/src/components/StoriesTab/StoryList.tsx index ebbd6616..a39a806d 100644 --- a/app/src/components/StoriesTab/StoryList.tsx +++ b/app/src/components/StoriesTab/StoryList.tsx @@ -194,17 +194,29 @@ export function StoryList() { storyList.map((story) => (
setSelectedStoryId(story.id)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedStoryId(story.id); + } + }} >
- +
diff --git a/app/src/components/StoriesTab/StoryTrackEditor.tsx b/app/src/components/StoriesTab/StoryTrackEditor.tsx index 74dbde25..71e33cdd 100644 --- a/app/src/components/StoriesTab/StoryTrackEditor.tsx +++ b/app/src/components/StoriesTab/StoryTrackEditor.tsx @@ -736,6 +736,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { className="h-7 w-7" onClick={handlePlayPause} title="Play/Pause (Space)" + aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'} > {isCurrentlyPlaying ? : } @@ -745,6 +746,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { className="h-7 w-7" onClick={handleStop} disabled={!isCurrentlyPlaying} + aria-label="Stop" > @@ -762,6 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { className="h-7 w-7" onClick={handleSplit} title="Split at playhead (S)" + aria-label="Split at playhead" > @@ -771,6 +774,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { className="h-7 w-7" onClick={handleDuplicate} title="Duplicate (Cmd/Ctrl+D)" + aria-label="Duplicate clip" > @@ -780,6 +784,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { className="h-7 w-7" onClick={handleDelete} title="Delete (Delete/Backspace)" + aria-label="Delete clip" > @@ -789,10 +794,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) { {/* Zoom controls - right side */}
Zoom: - -
diff --git a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx index 3807306f..acebbbd4 100644 --- a/app/src/components/VoiceProfiles/AudioSampleRecording.tsx +++ b/app/src/components/VoiceProfiles/AudioSampleRecording.tsx @@ -140,7 +140,13 @@ export function AudioSampleRecording({

File: {file.name}

- diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e879294f..2f13d957 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -61,6 +61,19 @@ export function ProfileCard({ profile }: ProfileCardProps) { exportProfile.mutate(profile.id); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.closest('button')) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(); + } + }; + + const selectLabel = isSelected + ? `${profile.name}, ${profile.language}. Selected as voice for generation.` + : `${profile.name}, ${profile.language}. Select as voice for generation.`; + return ( <> diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx index 19aa1ca8..a1dee07b 100644 --- a/app/src/components/VoiceProfiles/SampleList.tsx +++ b/app/src/components/VoiceProfiles/SampleList.tsx @@ -102,6 +102,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { className="h-7 w-7 shrink-0" onClick={handlePlayPause} disabled={isLoading} + aria-label={isPlaying ? 'Pause sample' : 'Play sample'} > {isPlaying ? : } @@ -113,6 +114,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { max={100} step={0.1} className="flex-1" + aria-label="Sample playback position" + aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`} />
{formatAudioDuration(currentTime)} @@ -128,6 +131,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) { className="h-7 w-7 shrink-0" onClick={handleStop} title="Stop" + aria-label="Stop playback" > diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index c5dbf0a1..52f2f4cd 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -179,25 +179,36 @@ function VoiceRow({ onDelete, }: VoiceRowProps) { const { data: samples } = useProfileSamples(profile.id); + const sampleCount = samples?.length || 0; + + const rowLabel = `${profile.name}, ${profile.language}, ${generationCount} generations, ${sampleCount} samples. Press Enter to edit.`; return ( -
+ e.stopPropagation()}>{profile.language} e.stopPropagation()}>{generationCount} - e.stopPropagation()}>{samples?.length || 0} + e.stopPropagation()}>{sampleCount} e.stopPropagation()}> ({ @@ -213,7 +224,7 @@ function VoiceRow({ e.stopPropagation()}> - diff --git a/docs/PR-ACCESSIBILITY.md b/docs/PR-ACCESSIBILITY.md new file mode 100644 index 00000000..d9e71ac9 --- /dev/null +++ b/docs/PR-ACCESSIBILITY.md @@ -0,0 +1,70 @@ +# Accessibility: screen reader and keyboard improvements + +## Summary + +Improvements to support screen reader and keyboard users across the main app surfaces: audio player, generation UI, voice selection, history, voices tab, model management, server tab, and stories. + +**Tested with NVDA and Narrator on Windows.** + +--- + +## What changed + +### Audio player (after generating audio) + +- **Play/Pause, Loop, Mute, Close** – `aria-label` added so each control is announced (e.g. "Play", "Pause", "Loop", "Mute", "Close player"). +- **Playback position slider** – `aria-label="Playback position"` and `aria-valuetext` with current/total time (e.g. "0:30 of 2:15"). +- **Volume** – Wrapped in a labelled group; volume slider has an associated screen-reader-only label and `aria-valuetext` for the level (e.g. "Volume level, 75%"). + +### Generation UI (text box and voice choice) + +- **Generate speech** (submit) and **Fine-tune instructions** (sliders) – Icon buttons now have `aria-label` (and state for fine-tune, e.g. "Fine-tune instructions, on"). + +### Voice selection (cards on Generate screen) + +- Each **voice card** is focusable (`tabIndex={0}`), has `role="button"`, and an `aria-label` (e.g. "Prashant, en. Select as voice for generation.") with `aria-pressed` when selected. +- **Enter/Space** on the card selects that voice; tab order is card → Export/Edit/Delete. + +### History list (generated samples) + +- Each **sample row** is focusable with `role="button"` and an `aria-label` (e.g. "Sample from [profile], [duration], [date]. Press Enter to play."); **Enter/Space** plays or restarts. +- **Transcript textarea** has `aria-label` (e.g. "Transcript for sample from [profile], [duration]") so when you focus on the text area, the sample is announced in context. + +### Voices tab (table) + +- Each **voice row** is focusable with `role="button"` and an `aria-label` (e.g. "[Name], [language], [N] generations, [N] samples. Press Enter to edit."); **Enter/Space** opens edit (except when focus is in a control). +- **Actions** dropdown trigger has `aria-label="Actions for [profile name]"`. + +### Model management + +- Each **model row** is a focusable region (`tabIndex={0}`, `role="group"`) with an `aria-label` (e.g. "[Model name], [status], [size]. Use Tab to reach Download or Delete."). +- **Download** and **Delete** (and Downloading) buttons have `aria-label` (e.g. "Download [name]", "Delete [name]"). + +### Server tab (panels) + +- **Server Connection**, **Server Status**, and **App Updates** cards are landmarks: `role="region"`, `aria-label`, and `tabIndex={0}` so each panel is focusable and announced (e.g. "Server Connection", "Server Status", "App Updates"). + +### Stories list + +- Each **story row** is a focusable control (`role="button"`, `tabIndex={0}`) with `aria-label` (e.g. "Story [name], [N] items, [date]. Press Enter to select."); **Enter/Space** selects the story. Actions button has `aria-label="Actions for [story name]"`. + +### Other controls + +- **Story list** – Actions (⋮) button: `aria-label="Actions for [story name]"`. +- **Story track editor** – Play/Pause, Stop, Split, Duplicate, Delete, Zoom in/out: `aria-label` on all icon buttons. +- **Voice profile samples** (SampleList, AudioSampleUpload, AudioSampleRecording, AudioSampleSystem) – Play/Pause and Stop: `aria-label` (e.g. "Play sample", "Pause", "Stop playback"). +- **SampleList** mini sample player – Seek slider has `aria-label="Sample playback position"` and `aria-valuetext` for time. + +--- + +## Testing + +- **Screen readers:** Tested with **NVDA** and **Narrator** on Windows. +- **Keyboard:** Tab order and Enter/Space activation verified for focusable rows and buttons. + +--- + +## Tech note + +- React + TypeScript; Radix UI primitives; labels added via `aria-label`, `aria-labelledby`, `aria-valuetext`, and `role`/`tabIndex` where needed. +- No new dependencies.