a11y: screen reader and keyboard improvements

- Audio player: aria-labels for Play/Pause, Loop, Mute, Close; labelled playback and volume sliders
- Generation: aria-labels for Generate speech and Fine tune instructions buttons
- Voice cards: focusable, labelled, Enter/Space to select
- History rows: focusable, labelled, Enter/Space to play; transcript textarea labelled
- Voices tab: focusable rows, labelled, Enter/Space to edit; Actions button labelled
- Model management: focusable model rows and labelled Download/Delete buttons
- Server tab: regions with aria-label and tabIndex for Connection, Status, App Updates
- Stories: focusable story rows, labelled, Enter/Space to select; Actions and track editor buttons labelled
- Voice profile samples: Play/Pause/Stop and mini-player slider labelled

Tested with NVDA and Narrator on Windows. See docs/PR-ACCESSIBILITY.md for full description.

Made-with: Cursor
This commit is contained in:
Richard Orme
2026-03-07 12:02:33 -08:00
parent 38bf96ff20
commit 19a28bf6c5
16 changed files with 256 additions and 23 deletions
+20 -2
View File
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react'; 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 WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
@@ -12,6 +12,7 @@ import { usePlatform } from '@/platform/PlatformContext';
export function AudioPlayer() { export function AudioPlayer() {
const platform = usePlatform(); const platform = usePlatform();
const volumeLabelId = useId();
const { const {
audioUrl, audioUrl,
audioId, audioId,
@@ -831,6 +832,13 @@ export function AudioPlayer() {
disabled={isLoading || duration === 0} disabled={isLoading || duration === 0}
className="shrink-0" className="shrink-0"
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={
duration === 0 && !isLoading
? 'Audio not loaded'
: isPlaying
? 'Pause'
: 'Play'
}
> >
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />} {isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button> </Button>
@@ -845,6 +853,8 @@ export function AudioPlayer() {
max={100} max={100}
step={0.1} step={0.1}
className="w-full" className="w-full"
aria-label="Playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/> />
)} )}
{isLoading && ( {isLoading && (
@@ -872,26 +882,33 @@ export function AudioPlayer() {
onClick={toggleLoop} onClick={toggleLoop}
className={isLooping ? 'text-primary' : ''} className={isLooping ? 'text-primary' : ''}
title="Toggle loop" title="Toggle loop"
aria-label={isLooping ? 'Stop looping' : 'Loop'}
> >
<Repeat className="h-4 w-4" /> <Repeat className="h-4 w-4" />
</Button> </Button>
{/* Volume Control */} {/* Volume Control */}
<div className="flex items-center gap-2 shrink-0 w-[120px]"> <div className="flex items-center gap-2 shrink-0 w-[120px]" role="group" aria-label="Volume">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setVolume(volume > 0 ? 0 : 1)} onClick={() => setVolume(volume > 0 ? 0 : 1)}
className="h-8 w-8" className="h-8 w-8"
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
> >
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />} {volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button> </Button>
<span id={volumeLabelId} className="sr-only">
Volume level, {Math.round(volume * 100)}%
</span>
<Slider <Slider
value={[volume * 100]} value={[volume * 100]}
onValueChange={handleVolumeChange} onValueChange={handleVolumeChange}
max={100} max={100}
step={1} step={1}
className="flex-1" className="flex-1"
aria-labelledby={volumeLabelId}
aria-valuetext={`${Math.round(volume * 100)}%`}
/> />
</div> </div>
@@ -902,6 +919,7 @@ export function AudioPlayer() {
onClick={handleClose} onClick={handleClose}
className="shrink-0" className="shrink-0"
title="Close player" title="Close player"
aria-label="Close player"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</Button> </Button>
@@ -300,6 +300,13 @@ export function FloatingGenerateBox({
disabled={isPending || !selectedProfileId} 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" 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" size="icon"
aria-label={
isPending
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'
}
> >
{isPending ? ( {isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -336,6 +343,11 @@ export function FloatingGenerateBox({
? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90' ? 'bg-accent text-accent-foreground border border-accent hover:bg-accent/90'
: 'bg-card border border-border hover:bg-background/50', : 'bg-card border border-border hover:bg-background/50',
)} )}
aria-label={
isInstructMode
? 'Fine tune instructions, on'
: 'Fine tune instructions'
}
> >
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
</Button> </Button>
@@ -253,10 +253,17 @@ export function HistoryTable() {
return ( return (
<div <div
key={gen.id} key={gen.id}
role="button"
tabIndex={0}
className={cn( className={cn(
'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full', 'flex items-stretch gap-4 h-26 border rounded-md p-3 bg-card hover:bg-muted/70 transition-colors text-left w-full',
isCurrentlyPlaying && 'bg-muted/70', isCurrentlyPlaying && 'bg-muted/70',
)} )}
aria-label={
isCurrentlyPlaying
? `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Playing. Press Enter to restart.`
: `Sample from ${gen.profile_name}, ${formatDuration(gen.duration)}, ${formatDate(gen.created_at)}. Press Enter to play.`
}
onMouseDown={(e) => { onMouseDown={(e) => {
// Don't trigger play if clicking on textarea or if text is selected // Don't trigger play if clicking on textarea or if text is selected
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -265,6 +272,14 @@ export function HistoryTable() {
} }
handlePlay(gen.id, gen.text, gen.profile_id); handlePlay(gen.id, gen.text, gen.profile_id);
}} }}
onKeyDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest('textarea')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handlePlay(gen.id, gen.text, gen.profile_id);
}
}}
> >
{/* Waveform icon */} {/* Waveform icon */}
<div className="flex items-center shrink-0"> <div className="flex items-center shrink-0">
@@ -293,6 +308,7 @@ export function HistoryTable() {
value={gen.text} value={gen.text}
className="flex-1 resize-none text-sm text-muted-foreground select-text" className="flex-1 resize-none text-sm text-muted-foreground select-text"
readOnly readOnly
aria-label={`Transcript for sample from ${gen.profile_name}, ${formatDuration(gen.duration)}`}
/> />
</div> </div>
@@ -57,7 +57,11 @@ export function ConnectionForm() {
} }
return ( return (
<Card> <Card
role="region"
aria-label="Server Connection"
tabIndex={0}
>
<CardHeader> <CardHeader>
<CardTitle>Server Connection</CardTitle> <CardTitle>Server Connection</CardTitle>
</CardHeader> </CardHeader>
@@ -278,9 +278,25 @@ interface ModelItemProps {
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) { function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
// Use server's downloading state OR local state (for immediate feedback before server updates) // Use server's downloading state OR local state (for immediate feedback before server updates)
const showDownloading = model.downloading || isDownloading; 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 ( return (
<div className="flex items-center justify-between p-3 border rounded-lg"> <div
className="flex items-center justify-between p-3 border rounded-lg"
role="group"
tabIndex={0}
aria-label={rowLabel}
>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm">{model.display_name}</span> <span className="font-medium text-sm">{model.display_name}</span>
@@ -314,17 +330,27 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
variant="outline" variant="outline"
disabled={model.loaded} disabled={model.loaded}
title={model.loaded ? 'Unload model before deleting' : 'Delete model'} title={model.loaded ? 'Unload model before deleting' : 'Delete model'}
aria-label={
model.loaded
? 'Unload model before deleting'
: `Delete ${model.display_name}`
}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
) : showDownloading ? ( ) : showDownloading ? (
<Button size="sm" variant="outline" disabled> <Button size="sm" variant="outline" disabled aria-label={`${model.display_name} downloading`}>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Downloading... Downloading...
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={onDownload} variant="outline"> <Button
size="sm"
onClick={onDownload}
variant="outline"
aria-label={`Download ${model.display_name}`}
>
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
Download Download
</Button> </Button>
@@ -10,7 +10,11 @@ export function ServerStatus() {
const serverUrl = useServerStore((state) => state.serverUrl); const serverUrl = useServerStore((state) => state.serverUrl);
return ( return (
<Card> <Card
role="region"
aria-label="Server Status"
tabIndex={0}
>
<CardHeader> <CardHeader>
<CardTitle>Server Status</CardTitle> <CardTitle>Server Status</CardTitle>
</CardHeader> </CardHeader>
@@ -20,7 +20,11 @@ export function UpdateStatus() {
}, [platform]); }, [platform]);
return ( return (
<Card> <Card
role="region"
aria-label="App Updates"
tabIndex={0}
>
<CardHeader> <CardHeader>
<CardTitle>App Updates</CardTitle> <CardTitle>App Updates</CardTitle>
</CardHeader> </CardHeader>
+15 -7
View File
@@ -194,17 +194,24 @@ export function StoryList() {
storyList.map((story) => ( storyList.map((story) => (
<div <div
key={story.id} key={story.id}
role="button"
tabIndex={0}
className={cn( className={cn(
'h-24 p-4 border rounded-2xl transition-colors group flex items-center', 'h-24 p-4 border rounded-2xl transition-colors group flex items-center cursor-pointer',
selectedStoryId === story.id && 'bg-muted border-primary', selectedStoryId === story.id && 'bg-muted border-primary',
)} )}
aria-label={`Story ${story.name}, ${story.item_count} ${story.item_count === 1 ? 'item' : 'items'}, ${formatDate(story.updated_at)}. Press Enter to select.`}
onClick={() => setSelectedStoryId(story.id)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedStoryId(story.id);
}
}}
> >
<div className="flex items-start justify-between gap-2 w-full min-w-0"> <div className="flex items-start justify-between gap-2 w-full min-w-0">
<button <div className="flex-1 min-w-0 text-left overflow-hidden">
type="button"
className="flex-1 min-w-0 text-left cursor-pointer overflow-hidden"
onClick={() => setSelectedStoryId(story.id)}
>
<h3 className="font-medium truncate">{story.name}</h3> <h3 className="font-medium truncate">{story.name}</h3>
{story.description && ( {story.description && (
<p className="text-sm text-muted-foreground mt-1 truncate"> <p className="text-sm text-muted-foreground mt-1 truncate">
@@ -218,7 +225,7 @@ export function StoryList() {
<span>•</span> <span>•</span>
<span>{formatDate(story.updated_at)}</span> <span>{formatDate(story.updated_at)}</span>
</div> </div>
</button> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -226,6 +233,7 @@ export function StoryList() {
size="icon" size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={`Actions for ${story.name}`}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
@@ -736,6 +736,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7" className="h-7 w-7"
onClick={handlePlayPause} onClick={handlePlayPause}
title="Play/Pause (Space)" title="Play/Pause (Space)"
aria-label={isCurrentlyPlaying ? 'Pause' : 'Play'}
> >
{isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isCurrentlyPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button> </Button>
@@ -745,6 +746,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7" className="h-7 w-7"
onClick={handleStop} onClick={handleStop}
disabled={!isCurrentlyPlaying} disabled={!isCurrentlyPlaying}
aria-label="Stop"
> >
<Square className="h-3 w-3" /> <Square className="h-3 w-3" />
</Button> </Button>
@@ -762,6 +764,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7" className="h-7 w-7"
onClick={handleSplit} onClick={handleSplit}
title="Split at playhead (S)" title="Split at playhead (S)"
aria-label="Split at playhead"
> >
<Scissors className="h-4 w-4" /> <Scissors className="h-4 w-4" />
</Button> </Button>
@@ -771,6 +774,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7" className="h-7 w-7"
onClick={handleDuplicate} onClick={handleDuplicate}
title="Duplicate (Cmd/Ctrl+D)" title="Duplicate (Cmd/Ctrl+D)"
aria-label="Duplicate clip"
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
@@ -780,6 +784,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
className="h-7 w-7" className="h-7 w-7"
onClick={handleDelete} onClick={handleDelete}
title="Delete (Delete/Backspace)" title="Delete (Delete/Backspace)"
aria-label="Delete clip"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -789,10 +794,22 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
{/* Zoom controls - right side */} {/* Zoom controls - right side */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Zoom:</span> <span className="text-xs text-muted-foreground">Zoom:</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomOut}> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleZoomOut}
aria-label="Zoom out"
>
<Minus className="h-3 w-3" /> <Minus className="h-3 w-3" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleZoomIn}> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleZoomIn}
aria-label="Zoom in"
>
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
</div> </div>
@@ -140,7 +140,13 @@ export function AudioSampleRecording({
</div> </div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p> <p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}> <Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button> </Button>
<Button <Button
@@ -77,7 +77,13 @@ export function AudioSampleSystem({
</div> </div>
<p className="text-sm text-muted-foreground text-center">File: {file.name}</p> <p className="text-sm text-muted-foreground text-center">File: {file.name}</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" size="icon" variant="outline" onClick={onPlayPause}> <Button
type="button"
size="icon"
variant="outline"
onClick={onPlayPause}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button> </Button>
<Button <Button
@@ -110,6 +110,7 @@ export function AudioSampleUpload({
variant="outline" variant="outline"
onClick={onPlayPause} onClick={onPlayPause}
disabled={isValidating} disabled={isValidating}
aria-label={isPlaying ? 'Pause' : 'Play'}
> >
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />} {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button> </Button>
@@ -61,6 +61,17 @@ export function ProfileCard({ profile }: ProfileCardProps) {
exportProfile.mutate(profile.id); exportProfile.mutate(profile.id);
}; };
const handleKeyDown = (e: React.KeyboardEvent) => {
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 ( return (
<> <>
<Card <Card
@@ -69,6 +80,11 @@ export function ProfileCard({ profile }: ProfileCardProps) {
isSelected && 'ring-2 ring-primary shadow-md', isSelected && 'ring-2 ring-primary shadow-md',
)} )}
onClick={handleSelect} onClick={handleSelect}
tabIndex={0}
role="button"
aria-label={selectLabel}
aria-pressed={isSelected}
onKeyDown={handleKeyDown}
> >
<CardHeader className="p-3 pb-2"> <CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 text-base font-medium"> <CardTitle className="flex items-center gap-1.5 text-base font-medium">
@@ -102,6 +102,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
onClick={handlePlayPause} onClick={handlePlayPause}
disabled={isLoading} disabled={isLoading}
aria-label={isPlaying ? 'Pause sample' : 'Play sample'}
> >
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />} {isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5 ml-0.5" />}
</Button> </Button>
@@ -113,6 +114,8 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
max={100} max={100}
step={0.1} step={0.1}
className="flex-1" className="flex-1"
aria-label="Sample playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/> />
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]"> <div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0 min-w-[70px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span> <span className="font-mono">{formatAudioDuration(currentTime)}</span>
@@ -128,6 +131,7 @@ function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
className="h-7 w-7 shrink-0" className="h-7 w-7 shrink-0"
onClick={handleStop} onClick={handleStop}
title="Stop" title="Stop"
aria-label="Stop playback"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
+24 -3
View File
@@ -179,9 +179,30 @@ function VoiceRow({
onDelete, onDelete,
}: VoiceRowProps) { }: VoiceRowProps) {
const { data: samples } = useProfileSamples(profile.id); 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.`;
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('[role="combobox"]') || target.closest('[role="listbox"]')) {
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEdit();
}
};
return ( return (
<TableRow className="cursor-pointer" onClick={onEdit}> <TableRow
className="cursor-pointer"
onClick={onEdit}
tabIndex={0}
role="button"
aria-label={rowLabel}
onKeyDown={handleKeyDown}
>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0"> <div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
@@ -197,7 +218,7 @@ function VoiceRow({
</TableCell> </TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell> <TableCell onClick={(e) => e.stopPropagation()}>{profile.language}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell> <TableCell onClick={(e) => e.stopPropagation()}>{generationCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>{samples?.length || 0}</TableCell> <TableCell onClick={(e) => e.stopPropagation()}>{sampleCount}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<MultiSelect <MultiSelect
options={channels.map((ch) => ({ options={channels.map((ch) => ({
@@ -213,7 +234,7 @@ function VoiceRow({
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" aria-label={`Actions for ${profile.name}`}>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
+70
View File
@@ -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 focus in the text area 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.