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>
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
||
import { cleanup, render, screen } from "@testing-library/react";
|
||
import { VoiceModelLoadIndicator } from "./voice-model-load-indicator";
|
||
|
||
const MODEL_LABEL = "Whisper Base";
|
||
const INDICATOR_TESTID = "voice-model-load-indicator";
|
||
|
||
function getIndicator() {
|
||
return screen.getByTestId(INDICATOR_TESTID);
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
describe("VoiceModelLoadIndicator", () => {
|
||
it("renders nothing when state is idle", () => {
|
||
const { container } = render(
|
||
<VoiceModelLoadIndicator state="idle" progress={0} modelLabel={MODEL_LABEL} />,
|
||
);
|
||
expect(container.firstChild).toBeNull();
|
||
});
|
||
|
||
it("renders nothing when state is ready", () => {
|
||
const { container } = render(
|
||
<VoiceModelLoadIndicator state="ready" progress={1} modelLabel={MODEL_LABEL} />,
|
||
);
|
||
expect(container.firstChild).toBeNull();
|
||
});
|
||
|
||
it("renders progress bar and percent text when loading", () => {
|
||
render(<VoiceModelLoadIndicator state="loading" progress={0.42} modelLabel={MODEL_LABEL} />);
|
||
const root = getIndicator();
|
||
expect(root.getAttribute("data-state")).toBe("loading");
|
||
expect(root.textContent).toContain("Downloading Whisper Base… 42%");
|
||
expect(screen.getByRole("progressbar").getAttribute("aria-label")).toBe(
|
||
"Downloading Whisper Base, 42 percent",
|
||
);
|
||
});
|
||
|
||
it("renders an error label when state is error", () => {
|
||
render(<VoiceModelLoadIndicator state="error" progress={0} modelLabel={MODEL_LABEL} />);
|
||
const root = getIndicator();
|
||
expect(root.getAttribute("data-state")).toBe("error");
|
||
expect(root.textContent).toContain(`${MODEL_LABEL} failed to load`);
|
||
expect(root.getAttribute("aria-label")).toBe(`${MODEL_LABEL} failed to load`);
|
||
});
|
||
|
||
it("clamps percent to 0–100 when progress is out of range", () => {
|
||
render(<VoiceModelLoadIndicator state="loading" progress={1.5} modelLabel={MODEL_LABEL} />);
|
||
expect(getIndicator().textContent).toContain("100%");
|
||
cleanup();
|
||
|
||
render(<VoiceModelLoadIndicator state="loading" progress={-0.5} modelLabel={MODEL_LABEL} />);
|
||
expect(getIndicator().textContent).toContain("0%");
|
||
cleanup();
|
||
|
||
render(
|
||
<VoiceModelLoadIndicator state="loading" progress={Number.NaN} modelLabel={MODEL_LABEL} />,
|
||
);
|
||
expect(getIndicator().textContent).toContain("0%");
|
||
});
|
||
});
|