{facets.map(({ id, labelKey, icon: Icon }) => (
-
+ // The name is the pane's title; the tip says what the pane holds. They are two keys
+ // because the title is also the pane's heading and cannot carry the list.
+
+
+
))}
-
+
+
+
{clipPickerOpen && clips.length > 1 ? (
({
+ status: "idle" as "idle" | "running",
+ request: vi.fn(),
+}));
+
+vi.mock("@/lib/ai-edition/store/transcriptionStore", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useAssetTranscriptions: () => ({
+ "asset-1": { assetId: "asset-1", status: transcription.status },
+ }),
+ useTranscriptionStore: (select: (state: { request: unknown }) => unknown) =>
+ select({ request: transcription.request }),
+ };
+});
+
+// An echoing translator: the assertions read against keys, not prose that drifts with copy edits.
+vi.mock("@/contexts/I18nContext", () => ({
+ useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
+ useScopedT: () => (key: string) => key,
+}));
+
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
+
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { assetSchema, createEmptyDocument } from "@/lib/ai-edition/schema";
+import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
+import { MediaStage } from "./MediaStage";
+
+class StubResizeObserver {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
+
+beforeEach(() => {
+ vi.stubGlobal("ResizeObserver", StubResizeObserver);
+ transcription.status = "idle";
+ transcription.request.mockClear();
+ const document = createEmptyDocument({ projectId: "p", title: "t" });
+ useProjectStore.setState({
+ projectId: "p",
+ document: {
+ ...document,
+ assets: [assetSchema.parse({ id: "asset-1", label: "take", originalPath: "/rec/take.mp4" })],
+ },
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ useProjectStore.setState({ document: null });
+});
+
+async function openDetail() {
+ render(
+
+ undefined)} />
+ ,
+ );
+ fireEvent.click(await screen.findByRole("button", { name: /take/ }));
+ return screen.findByRole("button", { name: "mediaStage.regenerate" });
+}
+
+describe("MediaStage Regenerate button", () => {
+ it("asks for a new transcription, and says what it redoes in its tooltip", async () => {
+ const regenerate = await openDetail();
+ expect(regenerate).not.toHaveAttribute("title");
+ act(() => regenerate.focus());
+ expect((await screen.findByRole("tooltip")).textContent).toBe("mediaStage.regenerateTip");
+ fireEvent.click(regenerate);
+ expect(transcription.request).toHaveBeenCalledWith("asset-1", "auto");
+ });
+
+ it("stays focusable and keeps its tooltip while a transcription runs, and fires nothing", async () => {
+ transcription.status = "running";
+ const regenerate = await openDetail();
+
+ // Greyed and announced as disabled, but not natively disabled.
+ expect(regenerate).toHaveAttribute("aria-disabled", "true");
+ expect(regenerate).not.toBeDisabled();
+ expect(regenerate).toHaveStyle({ opacity: "0.6", cursor: "not-allowed" });
+ expect(regenerate.querySelector("svg")).toHaveClass("animate-spin");
+
+ act(() => regenerate.focus());
+ expect(regenerate).toHaveFocus();
+ expect((await screen.findByRole("tooltip")).textContent).toBe("mediaStage.regenerateTip");
+
+ fireEvent.click(regenerate);
+ expect(transcription.request).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/ai-edition/v4/MediaStage.tsx b/src/components/ai-edition/v4/MediaStage.tsx
index df2ee7f68..bb30e2115 100644
--- a/src/components/ai-edition/v4/MediaStage.tsx
+++ b/src/components/ai-edition/v4/MediaStage.tsx
@@ -1,6 +1,7 @@
import { ArrowDown, Film, Plus, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
+import { Tooltip } from "@/components/ui/tooltip";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import type { AxcutAsset, AxcutTranscript, TranscriptLanguageCode } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
@@ -420,28 +421,36 @@ export function MediaStage({
))}
-
+ {/* A bare refresh icon beside a language list: the tip says what it redoes. */}
+
+
+
diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx
index aefa16bf7..b79308df3 100644
--- a/src/components/ai-edition/v4/RecStage.test.tsx
+++ b/src/components/ai-edition/v4/RecStage.test.tsx
@@ -1,13 +1,27 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
-import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TooltipProvider } from "@/components/ui/tooltip";
import { RecStage } from "./RecStage";
vi.mock("@/contexts/I18nContext", () => ({
useScopedT: () => (key: string) => key,
}));
+// Whether the native helper can leave the system cursor out of the pixels. Its own answer (and
+// the HUD's) is tested with the function it calls; here it is a switch.
+const editableCursor = vi.hoisted(() => ({ available: true }));
+vi.mock("@/hooks/useEditableCursorAvailable", () => ({
+ useEditableCursorAvailable: () => editableCursor.available,
+}));
+
+class StubResizeObserver {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
+
const microphoneHook = vi.hoisted(() => ({
call: vi.fn(),
value: {
@@ -93,10 +107,35 @@ function stubRecordingPrefs(
function renderRecStage() {
const onStartRecording = vi.fn();
- const view = render(
);
+ const view = render(
+
+
+ ,
+ );
return { onStartRecording, ...view };
}
+/** A row's On/Off pill: it is named by the row's label, and `aria-pressed` says On or Off. */
+function pill(rowLabelKey: string) {
+ return screen.getByRole("button", { name: rowLabelKey });
+}
+
+/** Opens a pill's tooltip the way the keyboard does (focus opens it at once) and returns what
+ * it says, or null when it opens nothing. */
+async function tooltipOn(control: HTMLElement) {
+ act(() => control.focus());
+ try {
+ const text = (await screen.findByRole("tooltip", {}, { timeout: 150 })).textContent;
+ act(() => control.blur());
+ await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
+ return text;
+ } catch {
+ return null;
+ } finally {
+ act(() => control.blur());
+ }
+}
+
describe("RecStage controls", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -110,9 +149,12 @@ describe("RecStage controls", () => {
isReady: true,
error: null,
};
+ editableCursor.available = true;
+ vi.stubGlobal("ResizeObserver", StubResizeObserver);
});
afterEach(() => {
+ vi.unstubAllGlobals();
cleanup();
(window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
});
@@ -204,9 +246,8 @@ describe("RecStage controls", () => {
await waitFor(() => expect(screen.queryByTestId("rec-auto-zoom-button")).toBeNull());
expect(screen.queryByText("rec.autoZoom")).toBeNull();
- const cursorLabel = screen.getByText("rec.cursorHighlight");
- if (!cursorLabel.parentElement) throw new Error("cursor highlight row is missing");
- fireEvent.click(within(cursorLabel.parentElement).getByRole("button", { name: "rec.off" }));
+ expect(pill("rec.editableCursor")).toHaveAttribute("aria-pressed", "false");
+ fireEvent.click(pill("rec.editableCursor"));
expect(await screen.findByTestId("rec-auto-zoom-button")).toHaveAttribute(
"aria-pressed",
"true",
@@ -313,7 +354,11 @@ describe("RecStage controls", () => {
isLoading: false,
isReady: true,
};
- rerender(
);
+ rerender(
+
+
+ ,
+ );
expect(audioMeter.call).toHaveBeenLastCalledWith({ enabled: true, deviceId: undefined });
});
@@ -329,7 +374,11 @@ describe("RecStage controls", () => {
devices: [],
error: "enumeration failed",
};
- rerender(
);
+ rerender(
+
+
+ ,
+ );
expect(screen.getByText("rec.microphoneUnavailable")).toHaveAttribute(
"title",
"enumeration failed",
@@ -340,15 +389,15 @@ describe("RecStage controls", () => {
stubRecordingPrefs({ micEnabled: true });
renderRecStage();
await screen.findByText("rec.noMicrophoneFound");
- const row = screen.getByText("rec.microphone").closest("div");
- if (!row?.parentElement) throw new Error("microphone row is missing");
- const toggle = within(row.parentElement).getByRole("button", { name: "rec.on" });
+ const toggle = pill("rec.microphone");
+ expect(toggle).toHaveAttribute("aria-pressed", "true");
microphoneHook.call.mockClear();
fireEvent.click(toggle);
await waitFor(() =>
expect(microphoneHook.call).toHaveBeenLastCalledWith(false, undefined, undefined),
);
- fireEvent.click(within(row.parentElement).getByRole("button", { name: "rec.off" }));
+ expect(toggle).toHaveAttribute("aria-pressed", "false");
+ fireEvent.click(toggle);
await waitFor(() =>
expect(microphoneHook.call).toHaveBeenLastCalledWith(true, undefined, undefined),
);
@@ -359,9 +408,12 @@ describe("RecStage controls", () => {
Object.assign(window.electronAPI as object, { getPlatform: () => "win32" });
renderRecStage();
const label = await screen.findByText("rec.hideDesktopIcons");
- expect(label).toHaveAttribute("title", "rec.hideDesktopIconsHintWindows");
- if (!label.parentElement) throw new Error("desktop icons row is missing");
- fireEvent.click(within(label.parentElement).getByRole("button", { name: "rec.off" }));
+ // The tooltip is on the pill, not the label: the pill is the control. No native title.
+ expect(label).not.toHaveAttribute("title");
+ const desktopIcons = pill("rec.hideDesktopIcons");
+ expect(desktopIcons).not.toHaveAttribute("title");
+ expect(await tooltipOn(desktopIcons)).toBe("rec.hideDesktopIconsHintWindows");
+ fireEvent.click(desktopIcons);
await waitFor(() => expect(setRecordingPrefs).toHaveBeenCalledWith({ hideDesktopIcons: true }));
cleanup();
@@ -369,7 +421,7 @@ describe("RecStage controls", () => {
stubRecordingPrefs({});
Object.assign(window.electronAPI as object, { getPlatform: () => "linux" });
renderRecStage();
- await screen.findByText("rec.cursorHighlight");
+ await screen.findByText("rec.editableCursor");
expect(screen.queryByText("rec.hideDesktopIcons")).toBeNull();
});
@@ -385,9 +437,8 @@ describe("RecStage controls", () => {
});
renderRecStage();
// The saved prefs have landed once system audio reads on.
- const systemAudioRow = (await screen.findByText("rec.systemAudio")).parentElement;
- if (!systemAudioRow) throw new Error("system audio row is missing");
- await within(systemAudioRow).findByRole("button", { name: "rec.on" });
+ await screen.findByText("rec.systemAudio");
+ await waitFor(() => expect(pill("rec.systemAudio")).toHaveAttribute("aria-pressed", "true"));
expect(screen.queryByText("rec.microphone")).toBeNull();
expect(microphoneHook.call).toHaveBeenLastCalledWith(false, undefined, undefined);
@@ -489,3 +540,110 @@ describe("RecStage controls", () => {
expect(selectedSourceListeners).toEqual([]);
});
});
+
+describe("RecStage names and tooltips", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ recordingPrefsListeners = [];
+ selectedSourceListeners = [];
+ microphoneHook.value = {
+ devices: [],
+ selectedDeviceId: "default",
+ setSelectedDeviceId: vi.fn(),
+ isLoading: false,
+ isReady: true,
+ error: null,
+ };
+ editableCursor.available = true;
+ vi.stubGlobal("ResizeObserver", StubResizeObserver);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ cleanup();
+ (window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
+ });
+
+ async function renderReady(prefs: Record
= {}) {
+ const stubs = stubRecordingPrefs({ cursorCaptureMode: "editable-overlay", ...prefs });
+ renderRecStage();
+ await waitFor(() => expect(stubs.getRecordingPrefs).toHaveBeenCalled());
+ await screen.findByText("rec.editableCursor");
+ return stubs;
+ }
+
+ // The cursor row is the HUD's term: "Cursor highlight" suggested a halo, and the HUD's own
+ // toggle says "Editable cursor" for the same setting.
+ it("calls the cursor row the Editable cursor, as the HUD does", async () => {
+ await renderReady();
+ expect(screen.getByText("rec.editableCursor")).toBeInTheDocument();
+ expect(screen.queryByText("rec.cursorHighlight")).toBeNull();
+ });
+
+ // A pill that reads "On" or "Off" names no control: the row's label is its name, and
+ // aria-pressed carries the state, so a screen reader never hears "Off, pressed".
+ it("names every On/Off pill by its row, and says which state with aria-pressed", async () => {
+ await renderReady({ systemAudioEnabled: true, hideDesktopIcons: false });
+ Object.assign(window.electronAPI as object, { getPlatform: () => "win32" });
+ for (const row of [
+ "rec.systemAudio",
+ "rec.microphone",
+ "rec.camera",
+ "rec.editableCursor",
+ "rec.autoZoom",
+ ]) {
+ const control = pill(row);
+ expect(control).toHaveAttribute("aria-pressed");
+ expect(control).not.toHaveAttribute("title");
+ expect(control).toHaveTextContent(/^rec\.(on|off)$/);
+ }
+ expect(pill("rec.systemAudio")).toHaveAttribute("aria-pressed", "true");
+ expect(pill("rec.camera")).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("explains system audio and the editable cursor, in a tooltip on the pill", async () => {
+ await renderReady();
+ expect(await tooltipOn(pill("rec.systemAudio"))).toBe("rec.systemAudioTip");
+ expect(await tooltipOn(pill("rec.editableCursor"))).toBe("rec.editableCursorTip");
+ });
+
+ // To the left a tooltip sits on the label the user is reading, and below the last row it covers
+ // Start recording: it opens above the pill.
+ it("opens the row tooltips above their pill, never over the row's own label", async () => {
+ stubRecordingPrefs({ cursorCaptureMode: "editable-overlay" });
+ // Windows offers the desktop icons row too, so all three tooltips can be checked.
+ Object.assign(window.electronAPI as object, { getPlatform: () => "win32" });
+ renderRecStage();
+ await screen.findByText("rec.hideDesktopIcons");
+ for (const row of ["rec.systemAudio", "rec.editableCursor", "rec.hideDesktopIcons"]) {
+ const control = pill(row);
+ act(() => control.focus());
+ await screen.findByRole("tooltip");
+ expect(
+ document.querySelector('[data-slot="tooltip-content"]')?.getAttribute("data-side"),
+ ).toBe("top");
+ act(() => control.blur());
+ await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
+ }
+ });
+
+ // Decided: the rest are labelled and say what they do, so a tooltip would repeat the label.
+ it("gives the microphone, camera and auto-zoom pills no tooltip", async () => {
+ await renderReady();
+ expect(await tooltipOn(pill("rec.microphone"))).toBeNull();
+ expect(await tooltipOn(pill("rec.camera"))).toBeNull();
+ expect(await tooltipOn(pill("rec.autoZoom"))).toBeNull();
+ });
+
+ // Without its native helper the browser records, and it always draws the system cursor into
+ // the video: there is nothing to switch, and Auto-zoom reads what the editable cursor records.
+ it("hides the cursor row and the auto-zoom row when capture falls back to the browser", async () => {
+ editableCursor.available = false;
+ stubRecordingPrefs({ cursorCaptureMode: "editable-overlay", autoZoomEnabled: true });
+ renderRecStage();
+ await screen.findByText("rec.systemAudio");
+ expect(screen.queryByText("rec.editableCursor")).toBeNull();
+ expect(screen.queryByText("rec.autoZoom")).toBeNull();
+ expect(screen.queryByTestId("rec-auto-zoom-button")).toBeNull();
+ });
+});
diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx
index aeb1a99a2..658ed6b4d 100644
--- a/src/components/ai-edition/v4/RecStage.tsx
+++ b/src/components/ai-edition/v4/RecStage.tsx
@@ -12,12 +12,14 @@ import {
VolumeX,
ZoomIn,
} from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import { AudioLevelMeter } from "@/components/ui/audio-level-meter";
+import { Tooltip } from "@/components/ui/tooltip";
import { useScopedT } from "@/contexts/I18nContext";
import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter";
import { useCameraDevices } from "@/hooks/useCameraDevices";
import { useCameraPreviewStream } from "@/hooks/useCameraPreviewStream";
+import { useEditableCursorAvailable } from "@/hooks/useEditableCursorAvailable";
import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices";
import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource";
import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils";
@@ -245,7 +247,12 @@ export function RecStage({
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
const visibleSources = sourceTab === "screen" ? screenSources : windowSources;
- const cursorHighlight = prefs.cursorCaptureMode === "editable-overlay";
+ const editableCursor = prefs.cursorCaptureMode === "editable-overlay";
+ const editableCursorAvailable = useEditableCursorAvailable();
+ // Names for the On/Off pills: the row label is the accessible name, and `aria-pressed` says
+ // which state it is in. A pill named "On" or "Off" says which control it is nowhere.
+ const rowLabels = useId();
+ const rowLabelId = (row: string) => `${rowLabels}-${row}`;
// macOS leaves the icons out of the capture; Windows covers them for the take. Linux
// records through the portal, which offers neither, so the row would do nothing there.
const platform = getPlatform();
@@ -345,23 +352,30 @@ export function RecStage({
)}
-
+
{prefs.systemAudioEnabled ? : }
{t("rec.systemAudio")}
-
+ {/* "System audio" is jargon for what the computer plays: the one row whose label
+ alone does not say what it records. The row tooltips open above their pill: to
+ the left they would sit on the label being read, and below the last row they
+ would cover Start recording. */}
+
+
+
{canRecordMicrophone() ? (
-
+
{prefs.micEnabled ? : }
{t("rec.microphone")}
@@ -406,6 +420,7 @@ export function RecStage({