Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ function LaunchWindowContent() {
size="lg"
className={` ${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] shrink-0 ${openId === "sources" ? "border-[var(--launch-border-strong)] bg-[var(--launch-hover)]" : ""} `}
title={selectedSource}
aria-label={`${t("recording.source")}: ${selectedSource}`}
>
<MonitorIcon
weight={openId === "sources" ? "fill" : "regular"}
Expand Down Expand Up @@ -279,6 +280,7 @@ function LaunchWindowContent() {
: t("recording.enableMicrophone")
}
className={microphoneEnabled ? "text-accent" : ""}
aria-label={`${t("recording.microphone")}: ${t(microphoneEnabled ? "recording.on" : "recording.off")}`}
>
{microphoneEnabled ? (
<MicrophoneIcon
Expand Down Expand Up @@ -323,6 +325,7 @@ function LaunchWindowContent() {
: t("recording.enableWebcam")
}
className={webcamEnabled ? "text-accent" : ""}
aria-label={`${t("recording.webcam")}: ${t(webcamEnabled ? "recording.on" : "recording.off")}`}
>
{webcamEnabled ? (
<VideoCameraIcon
Expand All @@ -347,6 +350,7 @@ function LaunchWindowContent() {
iconSize="lg"
title={t("recording.countdownDelay")}
className={countdownDelay > 0 ? "text-accent" : ""}
aria-label={`${t("recording.countdownDelay")}: ${countdownDelay === 0 ? t("recording.noDelay") : `${countdownDelay}s`}`}
>
<TimerIcon
weight={openId === "countdown" ? "fill" : "regular"}
Expand All @@ -371,6 +375,7 @@ function LaunchWindowContent() {
}
disabled={countdownActive}
title={t("recording.record")}
aria-label={t("recording.record")}
>
<div className={styles.recDot} />
</Button>
Expand Down Expand Up @@ -420,6 +425,15 @@ function LaunchWindowContent() {
<HudInteractionContext.Provider
value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}
>
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{finalizing
? t("recording.preparing", "Preparing recording")
: recording
? paused
? t("recording.paused")
: t("recording.recordingInProgress")
: ""}
</div>
<div
className="w-full flex justify-center bg-transparent overflow-visible items-end pb-5 pointer-events-none"
style={{ height: "100vh" }}
Expand Down Expand Up @@ -468,6 +482,12 @@ function LaunchWindowContent() {
key={hudMode}
layout={shouldAnimateHudLayout}
className={styles.barState}
role={hudMode === "idle" ? "group" : undefined}
aria-label={
hudMode === "idle"
? t("recording.setup")
: undefined
}
initial={{
opacity: 0,
y: 10,
Expand Down
13 changes: 7 additions & 6 deletions src/components/launch/RecordingControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,16 @@ export function RecordingControls({
>
<House weight="fill" className="size-4" />
</Button>
<div
className="flex items-center gap-3 px-2"
role="status"
aria-label={paused ? t("recording.paused") : t("recording.rec")}
>
<div className="flex items-center gap-3 px-2">
<span
className={`size-2 rounded-full ${paused ? "bg-warning" : `bg-danger ${styles.recDotBlink}`}`}
/>
<span className="min-w-14 text-sm font-medium tabular-nums text-foreground">
<span
role="timer"
aria-live="off"
aria-label={`${paused ? t("recording.paused") : t("recording.recordingInProgress")}: ${formatTime(elapsed)}`}
className="min-w-14 text-sm font-medium tabular-nums text-foreground"
>
{formatTime(elapsed)}
</span>
{paused && (
Expand Down
6 changes: 4 additions & 2 deletions src/components/launch/SourceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const SourceSelectorContent = ({
<ToggleButton
variant="ghost"
isSelected={isSelected}
aria-label={`${source.sourceType === "screen" ? t("recording.screen") : t("recording.window")}: ${source.windowTitle || source.name}`}
key={`${source.id}-${index}`}
className={cn(
"source-selector-item group min-h-[46px] w-full px-3 py-2.5 text-left flex items-center justify-start gap-3",
Expand Down Expand Up @@ -104,8 +105,9 @@ export const SourceSelectorContent = ({

if (loading && !hasAnySources) {
return (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center py-8" role="status">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 source-selector-accent-border" />
<span className="sr-only">{t("sourceSelector.loadingSources")}</span>
</div>
);
}
Expand Down Expand Up @@ -148,7 +150,7 @@ export const SourceSelectorContent = ({
) : null}
</>
) : (
<div className="text-center py-8 text-sm source-selector-muted">
<div className="text-center py-8 text-sm source-selector-muted" role="status">
{t("recording.noSourcesFound")}
</div>
)}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/de/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Sprache",
"paused": "PAUSIERT",
"rec": "AUFNAHME",
"recordingInProgress": "Aufnahme läuft",
"setup": "Aufnahme einrichten",
"source": "Quelle",
"on": "ein",
"off": "aus",
"resume": "Fortsetzen",
"pause": "Pause",
"stop": "Stopp",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/en/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Language",
"paused": "PAUSED",
"rec": "REC",
"recordingInProgress": "Recording in progress",
"setup": "Recording setup",
"source": "Source",
"on": "on",
"off": "off",
"resume": "Resume",
"pause": "Pause",
"stop": "Stop",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/es/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Idioma",
"paused": "PAUSADO",
"rec": "REC",
"recordingInProgress": "Grabación en curso",
"setup": "Configuración de grabación",
"source": "Fuente",
"on": "activado",
"off": "desactivado",
"resume": "Reanudar",
"pause": "Pausa",
"stop": "Detener",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/fr/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Langue",
"paused": "EN PAUSE",
"rec": "REC",
"recordingInProgress": "Enregistrement en cours",
"setup": "Configuration de l'enregistrement",
"source": "Source",
"on": "activé",
"off": "désactivé",
"resume": "Reprendre",
"pause": "Pause",
"stop": "Arrêter",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/it/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Lingua",
"paused": "IN PAUSA",
"rec": "REC",
"recordingInProgress": "Registrazione in corso",
"setup": "Impostazioni di registrazione",
"source": "Sorgente",
"on": "attivo",
"off": "disattivo",
"resume": "Riprendi",
"pause": "Pausa",
"stop": "Stop",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ko/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "언어",
"paused": "일시 정지",
"rec": "녹화 중",
"recordingInProgress": "녹화 진행 중",
"setup": "녹화 설정",
"source": "소스",
"on": "켜짐",
"off": "꺼짐",
"resume": "계속",
"pause": "일시 정지",
"stop": "중지",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/nl/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Taal",
"paused": "GEPAUZEERD",
"rec": "OPN",
"recordingInProgress": "Opname bezig",
"setup": "Opname instellen",
"source": "Bron",
"on": "aan",
"off": "uit",
"resume": "Hervatten",
"pause": "Pauzeren",
"stop": "Stoppen",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/pt-BR/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Idioma",
"paused": "PAUSADO",
"rec": "GRAV",
"recordingInProgress": "Gravação em andamento",
"setup": "Configuração da gravação",
"source": "Fonte",
"on": "ligado",
"off": "desligado",
"resume": "Retomar",
"pause": "Pausar",
"stop": "Parar",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ru/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "Язык",
"paused": "ПАУЗА",
"rec": "ЗАПИСЬ",
"recordingInProgress": "Идёт запись",
"setup": "Настройка записи",
"source": "Источник",
"on": "включено",
"off": "выключено",
"resume": "Возобновить",
"pause": "Пауза",
"stop": "Стоп",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh-CN/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "语言",
"paused": "已暂停",
"rec": "录制中",
"recordingInProgress": "正在录制",
"setup": "录制设置",
"source": "来源",
"on": "开",
"off": "关",
"resume": "恢复",
"pause": "暂停",
"stop": "停止",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh-TW/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
"language": "語言",
"paused": "已暫停",
"rec": "錄製中",
"recordingInProgress": "正在錄製",
"setup": "錄製設定",
"source": "來源",
"on": "開",
"off": "關",
"resume": "繼續",
"pause": "暫停",
"stop": "停止",
Expand Down
64 changes: 64 additions & 0 deletions tests/ui/hud-accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge } from "./bridge";

test("recording setup exposes controls and supports keyboard source selection", async ({
page,
}) => {
await installDesktopBridge(page);
await page.goto("/?windowType=hud-overlay");

const setup = page.getByRole("group", { name: "Recording setup" });
await expect(setup.getByRole("button", { name: "Source: Built-in Display" })).toBeVisible();
await expect(setup.getByRole("button", { name: "Microphone: off" })).toBeVisible();
await expect(setup.getByRole("button", { name: "Webcam: off" })).toBeVisible();
await expect(setup.getByRole("button", { name: "Countdown delay: 3s" })).toBeVisible();
await expect(setup.getByRole("button", { name: "Record", exact: true })).toBeVisible();

const sourceTrigger = setup.getByRole("button", { name: "Source: Built-in Display" });
await sourceTrigger.focus();
await page.keyboard.press("Enter");
const source = page.getByRole("button", { name: "Screen: Built-in Display" });
await expect(source).toBeVisible();
await source.focus();
await page.keyboard.press("Space");
await expect(source).toHaveCount(0);
});

test("source picker announces loading and empty results", async ({ page }) => {
await installDesktopBridge(page);
await page.goto("/?windowType=hud-overlay");
await page.evaluate(() => {
window.electronAPI.getSources = () =>
new Promise((resolve) => {
window.addEventListener("test-resolve-sources", () => resolve([]), { once: true });
});
});

const sourceTrigger = page.getByRole("button", { name: "Source: Built-in Display" });
await sourceTrigger.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("status").filter({ hasText: "Loading sources..." })).toBeVisible();
await page.evaluate(() => window.dispatchEvent(new Event("test-resolve-sources")));
await expect(page.getByRole("status").filter({ hasText: "No sources found" })).toBeVisible();
});

test("recording state is announced separately from the running timer", async ({ page }) => {
await installDesktopBridge(page);
await page.addInitScript(() => {
window.electronAPI.onRecordingStateChanged = (callback) => {
const listener = () => callback({ recording: true, sourceName: "Built-in Display" });
window.addEventListener("test-recording-started", listener);
return () => window.removeEventListener("test-recording-started", listener);
};
});
await page.goto("/?windowType=hud-overlay");
await expect(page.getByRole("group", { name: "Recording setup" })).toBeVisible();
await page.evaluate(() => window.dispatchEvent(new Event("test-recording-started")));

const controls = page.getByRole("group", { name: "Recording controls" });
await expect(controls).toBeVisible();
await expect(page.getByRole("status")).toHaveText("Recording in progress");
await expect(controls.getByRole("timer")).toHaveAttribute("aria-live", "off");
await expect(controls.getByRole("button", { name: "Pause" })).toBeVisible();
await expect(controls.getByRole("button", { name: "Stop" })).toBeVisible();
});
2 changes: 1 addition & 1 deletion tests/ui/hud-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test("recording HUD uses uniform controls and a readable timer", async ({ page }
await page.evaluate(() => window.dispatchEvent(new Event("test-recording-started")));
const controls = page.getByRole("group", { name: "Recording controls" });
await expect(controls).toBeVisible();
await expect(controls.getByRole("status")).toContainText("00:00");
await expect(controls.getByRole("timer")).toContainText("00:00");
await expect(controls.getByRole("button", { name: "Home", exact: true })).toBeVisible();
const sizes = await controls.getByRole("button").evaluateAll((buttons) =>
buttons.map((button) => {
Expand Down