58dd2773a2
* feat(web): show download progress for voice mode Whisper Web model First-time use of voice mode with the Whisper Web engine downloads ~40–240 MB of model files before the first transcription. The existing modelLoad progress was only surfaced via the mic-button tooltip, so the download felt silent — users couldn't tell whether it was progressing, stuck, or how much was left. Adds a compact inline indicator next to the mic button that renders a percent label and a Progress bar while the model is downloading, and a short error label when load fails. The indicator stays hidden for the Web Speech and server-Whisper engines (which don't download models) and clears once the model is ready. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(web): tidy VoiceModelLoadIndicator class names and a11y - drop unused cn() import (single-string className doesn't need it) - drop redundant max-w-[8rem] (w-32 already equals 8rem in tailwind) - include modelLabel in the error state's aria-label so screen readers identify which model failed Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(web): a11y improvements for VoiceModelLoadIndicator - Replace two separate loading/error divs with a single stable role="status" wrapper so AT announces the loading→error transition (newly-inserted live regions are silently ignored by most screen readers). - Move numeric progress to role="progressbar" + aria-valuenow/min/max on the inner div; removes per-tick live-region announcements. - Drop redundant aria-live="polite" (role="status" implies it per ARIA spec). - Align error visible text with aria-label: both now use modelLabel ("Whisper Base failed to load") instead of the generic string. - Standardize tooltip wording to match indicator: "Downloading {model}…" instead of "Loading model…". - Update test: error branch expects model-specific label. * refactor(web): fix nested progressbar and clamp tooltip percent - Remove role="progressbar" + aria-value* from the wrapper div; <Progress> (Radix) already renders its own progressbar — the nested node confused AT. Pass aria-label directly to <Progress> for the accessible name. - Drop outer aria-label on the loading state (Radix progressbar carries it). - Clamp tooltip percent in voice-input-button.tsx to 0–100 and handle NaN (mirrors clampPercent in the indicator, keeps UI consistent on edge values from transformers.js). - Update test: assert aria-label via getByRole("progressbar").getAttribute(). * fix(web): guard tooltip percent against NaN; assert error aria-label in test - Tooltip loading percent: add Number.isFinite guard so NaN from transformers.js progress produces "0%" instead of "NaN%". - Test: assert aria-label on error state root matches modelLabel. * fix(web): add w-32 to error variant to prevent layout shift on state change Loading branch uses w-32 to hold a stable 128px slot in the toolbar. Error branch lacked it, so the mic button shifted on loading→error transition. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
66 lines
2.0 KiB
TypeScript
66 lines
2.0 KiB
TypeScript
"use client";
|
||
|
||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||
import { Progress } from "@kandev/ui/progress";
|
||
|
||
export type VoiceModelLoadIndicatorProps = {
|
||
state: "idle" | "loading" | "ready" | "error";
|
||
/** 0–1 fraction; clamped before rendering. */
|
||
progress: number;
|
||
modelLabel: string;
|
||
};
|
||
|
||
function clampPercent(progress: number): number {
|
||
if (!Number.isFinite(progress)) return 0;
|
||
const pct = Math.round(progress * 100);
|
||
if (pct < 0) return 0;
|
||
if (pct > 100) return 100;
|
||
return pct;
|
||
}
|
||
|
||
export function VoiceModelLoadIndicator({
|
||
state,
|
||
progress,
|
||
modelLabel,
|
||
}: VoiceModelLoadIndicatorProps) {
|
||
if (state === "idle" || state === "ready") return null;
|
||
|
||
const pct = clampPercent(progress);
|
||
|
||
// Single stable role="status" wrapper so AT announces the loading→error
|
||
// transition (a newly-inserted live region would be silently ignored).
|
||
// <Progress> (Radix) already renders its own role="progressbar" — the
|
||
// wrapper div intentionally has no role to avoid nested progressbar nodes.
|
||
return (
|
||
<div
|
||
data-testid="voice-model-load-indicator"
|
||
data-state={state}
|
||
role="status"
|
||
aria-label={state === "error" ? `${modelLabel} failed to load` : undefined}
|
||
className={
|
||
state === "error"
|
||
? "flex items-center gap-1 text-xs text-destructive w-32"
|
||
: "flex items-center gap-1.5 w-32"
|
||
}
|
||
>
|
||
{state === "error" ? (
|
||
<>
|
||
<IconAlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="hidden sm:inline">{modelLabel} failed to load</span>
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||
<span className="hidden sm:inline text-[10px] leading-none text-muted-foreground truncate">
|
||
Downloading {modelLabel}… {pct}%
|
||
</span>
|
||
<Progress
|
||
value={pct}
|
||
className="h-1"
|
||
aria-label={`Downloading ${modelLabel}, ${pct} percent`}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|