From 37b590ded910431c6b471e8219fc3609e83d218a Mon Sep 17 00:00:00 2001 From: Ross Cadogan Date: Thu, 2 Jul 2026 22:48:34 +0100 Subject: [PATCH] feat(settings): promote Voices to its own top-level tab (#471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dictation is voice-in; Voices is voice-out — they belong at the same level of the settings nav. The voice catalog (host pills + per-host shelves) moves out of the AI Chat tab into a new Voices tab (VoicesPanel + VoicesTab + the relocated VoicesController), and the in-page menus' "More voices" doors now deep-link to it via openSettings("voices"). chat.css contained only voice-catalog styles, so it moves wholesale to voices.css. Closes #471 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012DZR4Vp9B1RiKVrkfYiM1o --- _locales/en/messages.json | 4 ++ e2e/specs/settings.e2e.ts | 2 +- entrypoints/settings/components/tabs.ts | 1 + entrypoints/settings/index.html | 5 ++ entrypoints/settings/index.ts | 2 + entrypoints/settings/tabs/chat/ChatPanel.tsx | 30 ------------ entrypoints/settings/tabs/chat/index.ts | 9 ---- .../settings/tabs/voices/VoicesPanel.tsx | 46 +++++++++++++++++++ entrypoints/settings/tabs/voices/index.ts | 32 +++++++++++++ .../{chat => voices}/voices-controller.ts | 2 +- .../tabs/{chat/chat.css => voices/voices.css} | 4 +- src/chatbots/ClaudeVoiceMenu.ts | 4 +- src/tts/VoiceMenu.ts | 2 +- .../chatbots/ClaudeVoiceMenu-curation.spec.ts | 6 +-- test/chatbots/PiVoiceMenu-curation.spec.ts | 6 +-- test/settings/tabs/ChatPanel.spec.tsx | 14 ++---- test/settings/tabs/VoicesPanel.spec.tsx | 31 +++++++++++++ test/settings/tabs/voices-controller.spec.tsx | 6 +-- 18 files changed, 139 insertions(+), 67 deletions(-) create mode 100644 entrypoints/settings/tabs/voices/VoicesPanel.tsx create mode 100644 entrypoints/settings/tabs/voices/index.ts rename entrypoints/settings/tabs/{chat => voices}/voices-controller.ts (99%) rename entrypoints/settings/tabs/{chat/chat.css => voices/voices.css} (93%) create mode 100644 test/settings/tabs/VoicesPanel.spec.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d35f7fb933..11a8d4395e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1580,6 +1580,10 @@ "message": "Dictation", "description": "Tab label for the dictation settings section." }, + "tabVoices": { + "message": "Voices", + "description": "Tab label for the voices settings section (per-site voice catalog)." + }, "tabAbout": { "message": "About", "description": "Tab label for the about section." diff --git a/e2e/specs/settings.e2e.ts b/e2e/specs/settings.e2e.ts index 94528e4037..11b9d21043 100644 --- a/e2e/specs/settings.e2e.ts +++ b/e2e/specs/settings.e2e.ts @@ -18,7 +18,7 @@ import { test, expect } from "../fixtures/extension"; * gate — see e2e/README.md). */ -const TABS = ["general", "chat", "dictation", "about"] as const; +const TABS = ["general", "chat", "dictation", "voices", "about"] as const; test.describe("settings page (Preact migration)", () => { test("bootstraps the header and every tab panel without uncaught errors", async ({ diff --git a/entrypoints/settings/components/tabs.ts b/entrypoints/settings/components/tabs.ts index 9e46911cd8..8ef5a82343 100644 --- a/entrypoints/settings/components/tabs.ts +++ b/entrypoints/settings/components/tabs.ts @@ -28,6 +28,7 @@ export class TabNavigator { chat: 'bot-message-square', general: 'settings', dictation: 'mic', + voices: 'audio-lines', about: 'info' }; diff --git a/entrypoints/settings/index.html b/entrypoints/settings/index.html index bc2635379b..89aeecb905 100644 --- a/entrypoints/settings/index.html +++ b/entrypoints/settings/index.html @@ -17,6 +17,7 @@ +
@@ -32,6 +33,10 @@ + + diff --git a/entrypoints/settings/index.ts b/entrypoints/settings/index.ts index bae6eddaad..0fd04ac27a 100644 --- a/entrypoints/settings/index.ts +++ b/entrypoints/settings/index.ts @@ -7,6 +7,7 @@ import { TabNavigator } from "./components/tabs"; import { GeneralTab } from "./tabs/general"; import { ChatTab } from "./tabs/chat"; import { DictationTab } from "./tabs/dictation"; +import { VoicesTab } from "./tabs/voices"; import { AboutTab } from "./tabs/about"; import { replaceI18n } from "./shared/i18n"; import type { TabController } from "./shared/types"; @@ -69,6 +70,7 @@ class SettingsApp { this.tabs.set('general', new GeneralTab(document.querySelector('#tab-general')!)); this.tabs.set('chat', new ChatTab(document.querySelector('#tab-chat')!)); this.tabs.set('dictation', new DictationTab(document.querySelector('#tab-dictation')!)); + this.tabs.set('voices', new VoicesTab(document.querySelector('#tab-voices')!)); this.tabs.set('about', new AboutTab(document.querySelector('#tab-about')!)); // Determine which tab to show initially: a one-shot deep link (set by diff --git a/entrypoints/settings/tabs/chat/ChatPanel.tsx b/entrypoints/settings/tabs/chat/ChatPanel.tsx index 9955bcda6b..e5535bfe01 100644 --- a/entrypoints/settings/tabs/chat/ChatPanel.tsx +++ b/entrypoints/settings/tabs/chat/ChatPanel.tsx @@ -154,36 +154,6 @@ export function ChatPanel() { - {/* Full voice catalog — the destination of the in-page menus' "More - voices…" door. Static skeleton only; VoicesController renders the - per-host catalog into #voice-catalog after mount. */} -
- - Voices - -
- Choose the voice that reads replies aloud on each site. -
-
- - -
-
-
); } diff --git a/entrypoints/settings/tabs/chat/index.ts b/entrypoints/settings/tabs/chat/index.ts index a34908e99c..beb5bc261b 100644 --- a/entrypoints/settings/tabs/chat/index.ts +++ b/entrypoints/settings/tabs/chat/index.ts @@ -3,14 +3,11 @@ import { TabController } from '../../shared/types'; import { getStoredValue, setStoredValue } from '../../shared/storage'; import { sendMessageToActiveTab } from '../../shared/messaging'; import { SubmitModeController } from './submit-mode-controller'; -import { VoicesController } from './voices-controller'; import { mountInto, unmountFrom } from '../../../../src/ui/preact/mount'; import { ChatPanel } from './ChatPanel'; -import './chat.css'; export class ChatTab implements TabController { private submitModeController: SubmitModeController | null = null; - private voicesController: VoicesController | null = null; constructor(public container: HTMLElement) {} @@ -24,11 +21,6 @@ export class ChatTab implements TabController { await this.setupInterruptions(); await this.setupAutoReadAloud(); await this.setupSubmitMode(); - // Voice catalog loads over the network — don't block tab init on it. - this.voicesController = new VoicesController(this.container); - this.voicesController.init().catch((error) => { - console.error('Failed to load the voice catalog:', error); - }); } /** @@ -37,7 +29,6 @@ export class ChatTab implements TabController { */ destroy(): void { this.submitModeController = null; - this.voicesController = null; unmountFrom(this.container); } diff --git a/entrypoints/settings/tabs/voices/VoicesPanel.tsx b/entrypoints/settings/tabs/voices/VoicesPanel.tsx new file mode 100644 index 0000000000..2c8488ed5f --- /dev/null +++ b/entrypoints/settings/tabs/voices/VoicesPanel.tsx @@ -0,0 +1,46 @@ +/** + * Voices-tab panel (Preact). + * + * Static skeleton only — VoicesController renders the per-host catalog into + * #voice-catalog after mount and wires the host pills by id, so the ids and + * classes here (`#voices-preference`, `#voice-host-pills`, `#voice-host-pi`, + * `#voice-host-claude`, `.voice-host-pill`, `#voice-catalog`) are load-bearing. + * + * This tab sits alongside Dictation deliberately: Dictation is voice-in, + * Voices is voice-out (#471). It is also the destination of the in-page voice + * menus' "More voices…" door (openSettings("voices")). + */ +export function VoicesPanel() { + return ( + <> +

+ Voices +

+ +
+
+ Choose the voice that reads replies aloud on each site. +
+
+ + +
+
+
+ + ); +} diff --git a/entrypoints/settings/tabs/voices/index.ts b/entrypoints/settings/tabs/voices/index.ts new file mode 100644 index 0000000000..bba13fec95 --- /dev/null +++ b/entrypoints/settings/tabs/voices/index.ts @@ -0,0 +1,32 @@ +import { h } from 'preact'; +import { TabController } from '../../shared/types'; +import { VoicesController } from './voices-controller'; +import { mountInto, unmountFrom } from '../../../../src/ui/preact/mount'; +import { VoicesPanel } from './VoicesPanel'; +import './voices.css'; + +export class VoicesTab implements TabController { + private voicesController: VoicesController | null = null; + + constructor(public container: HTMLElement) {} + + async init(): Promise { + // Render the panel with Preact, then let VoicesController fill the + // catalog — it loads over the network, so don't block tab init on it. + mountInto(this.container, h(VoicesPanel, {})); + + this.voicesController = new VoicesController(this.container); + this.voicesController.init().catch((error) => { + console.error('Failed to load the voice catalog:', error); + }); + } + + /** + * Clean up when the tab is destroyed. Defensive — the orchestrator keeps + * tabs mounted (init runs once), but unmount the Preact tree if ever called. + */ + destroy(): void { + this.voicesController = null; + unmountFrom(this.container); + } +} diff --git a/entrypoints/settings/tabs/chat/voices-controller.ts b/entrypoints/settings/tabs/voices/voices-controller.ts similarity index 99% rename from entrypoints/settings/tabs/chat/voices-controller.ts rename to entrypoints/settings/tabs/voices/voices-controller.ts index a50fc9ceaf..f38ee2825e 100644 --- a/entrypoints/settings/tabs/chat/voices-controller.ts +++ b/entrypoints/settings/tabs/voices/voices-controller.ts @@ -33,7 +33,7 @@ function defaultDeps(): VoiceCatalogDeps { } /** - * Renders the full per-host voice catalog in the settings AI Chat tab — the + * Renders the full per-host voice catalog in the settings Voices tab — the * "shelf" layer of the voice-selection architecture: HD and Everyday groups, * every catalog voice listed, selection per host. The in-page menus stay * capped; this surface absorbs the catalog's growth. diff --git a/entrypoints/settings/tabs/chat/chat.css b/entrypoints/settings/tabs/voices/voices.css similarity index 93% rename from entrypoints/settings/tabs/chat/chat.css rename to entrypoints/settings/tabs/voices/voices.css index 43dcb1701d..863d4100d5 100644 --- a/entrypoints/settings/tabs/chat/chat.css +++ b/entrypoints/settings/tabs/voices/voices.css @@ -1,6 +1,4 @@ -/* Chat tab specific styles */ - -/* Voices section — the full per-host catalog behind the in-page menus' +/* Voices tab — the full per-host catalog behind the in-page menus' "More voices…" door */ #voice-host-pills { display: flex; diff --git a/src/chatbots/ClaudeVoiceMenu.ts b/src/chatbots/ClaudeVoiceMenu.ts index ddd351ae27..2187f7b541 100644 --- a/src/chatbots/ClaudeVoiceMenu.ts +++ b/src/chatbots/ClaudeVoiceMenu.ts @@ -837,7 +837,7 @@ export class ClaudeVoiceMenu extends VoiceSelector { /** * The muted final row linking to the full voice catalog in the extension - * settings (AI Chat tab). Always rendered — it is the menu's path to the + * settings (Voices tab). Always rendered — it is the menu's path to the * catalog, whether or not the shortlist is hiding voices. */ private createMoreVoicesItem(): HTMLDivElement { @@ -858,7 +858,7 @@ export class ClaudeVoiceMenu extends VoiceSelector { item.textContent = getMessage("moreVoices"); item.addEventListener("click", () => { this.toggleMenu(); - openSettings("chat"); + openSettings("voices"); }); return item; } diff --git a/src/tts/VoiceMenu.ts b/src/tts/VoiceMenu.ts index 6978597d1f..3467ca30e9 100644 --- a/src/tts/VoiceMenu.ts +++ b/src/tts/VoiceMenu.ts @@ -355,7 +355,7 @@ export abstract class VoiceSelector { ); door.textContent = getMessage("moreVoices"); door.addEventListener("click", () => { - openSettings("chat"); + openSettings("voices"); }); } const customButtons = voiceSelector.querySelectorAll( diff --git a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts index 4e6688f1c4..53ce8a9c05 100644 --- a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts +++ b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts @@ -93,7 +93,7 @@ describe("ClaudeVoiceMenu shortlist cap + door (flip-day catalog)", () => { "[data-action='more-voices']" ) as HTMLElement; door.click(); - expect(openSettingsMock).toHaveBeenCalled(); + expect(openSettingsMock).toHaveBeenCalledWith("voices"); }); it("keeps the door row even when the whole catalog fits the cap (#472)", () => { @@ -198,13 +198,13 @@ describe("ClaudeVoiceMenu current-voice pinning", () => { expect(rows[0].dataset.voiceName).toBe("Lucy"); }); - it("passes the AI Chat tab as the door's settings destination", () => { + it("passes the Voices tab as the door's settings destination (#471)", () => { const menu = makeMenu(); menu.populateVoices(flipDayCatalog, menu.element); const door = menu.menuContent.querySelector( "[data-action='more-voices']" ) as HTMLElement; door.click(); - expect(openSettingsMock).toHaveBeenCalledWith("chat"); + expect(openSettingsMock).toHaveBeenCalledWith("voices"); }); }); diff --git a/test/chatbots/PiVoiceMenu-curation.spec.ts b/test/chatbots/PiVoiceMenu-curation.spec.ts index cbb287b286..0c064b057b 100644 --- a/test/chatbots/PiVoiceMenu-curation.spec.ts +++ b/test/chatbots/PiVoiceMenu-curation.spec.ts @@ -104,7 +104,7 @@ describe("PiVoiceMenu shortlist cap + door", () => { ) as HTMLButtonElement; expect(door).not.toBeNull(); door.click(); - expect(openSettingsMock).toHaveBeenCalled(); + expect(openSettingsMock).toHaveBeenCalledWith("voices"); }); it("still shows the door when the catalog fits the cap — it's the path to the catalog, not an overflow marker (#472)", () => { @@ -117,7 +117,7 @@ describe("PiVoiceMenu shortlist cap + door", () => { ) as HTMLButtonElement; expect(door).not.toBeNull(); door.click(); - expect(openSettingsMock).toHaveBeenCalled(); + expect(openSettingsMock).toHaveBeenCalledWith("voices"); }); }); @@ -144,7 +144,7 @@ describe("PiVoiceSettings door (#472)", () => { ) as HTMLButtonElement; expect(door).not.toBeNull(); door.click(); - expect(openSettingsMock).toHaveBeenCalled(); + expect(openSettingsMock).toHaveBeenCalledWith("voices"); }); it("does not duplicate the door on repeated populates", () => { diff --git a/test/settings/tabs/ChatPanel.spec.tsx b/test/settings/tabs/ChatPanel.spec.tsx index 00331df46c..51956ad4a9 100644 --- a/test/settings/tabs/ChatPanel.spec.tsx +++ b/test/settings/tabs/ChatPanel.spec.tsx @@ -51,17 +51,9 @@ describe("ChatPanel", () => { ).toBeTruthy(); }); - it("renders the Voices section with host pills and the catalog container VoicesController targets", () => { + it("no longer hosts the Voices section — it moved to its own tab (#471)", () => { const { container } = render(); - expect(container.querySelector("#voices-preference")).toBeTruthy(); - expect( - container - .querySelector("#voices-preference [data-i18n='voicesSectionTitle']"), - ).toBeTruthy(); - const pills = [ - ...container.querySelectorAll("#voice-host-pills button.voice-host-pill"), - ]; - expect(pills.map((p) => p.id)).toEqual(["voice-host-pi", "voice-host-claude"]); - expect(container.querySelector("#voice-catalog")).toBeTruthy(); + expect(container.querySelector("#voices-preference")).toBeNull(); + expect(container.querySelector("#voice-catalog")).toBeNull(); }); }); diff --git a/test/settings/tabs/VoicesPanel.spec.tsx b/test/settings/tabs/VoicesPanel.spec.tsx new file mode 100644 index 0000000000..3da23934c3 --- /dev/null +++ b/test/settings/tabs/VoicesPanel.spec.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/preact"; +import { VoicesPanel } from "../../../entrypoints/settings/tabs/voices/VoicesPanel"; + +afterEach(() => cleanup()); + +describe("VoicesPanel", () => { + it("renders the heading and description with their i18n keys", () => { + const { container } = render(); + expect( + container.querySelector(".panel-heading[data-i18n='voicesSectionTitle']"), + ).toBeTruthy(); + expect( + container.querySelector( + "#voices-preference [data-i18n='voicesSectionDescription']", + ), + ).toBeTruthy(); + }); + + it("renders the host pills and catalog container VoicesController targets", () => { + const { container } = render(); + const pills = [ + ...container.querySelectorAll("#voice-host-pills button.voice-host-pill"), + ]; + expect(pills.map((p) => p.id)).toEqual([ + "voice-host-pi", + "voice-host-claude", + ]); + expect(container.querySelector("#voice-catalog")).toBeTruthy(); + }); +}); diff --git a/test/settings/tabs/voices-controller.spec.tsx b/test/settings/tabs/voices-controller.spec.tsx index 4399b5822b..b74e9255f6 100644 --- a/test/settings/tabs/voices-controller.spec.tsx +++ b/test/settings/tabs/voices-controller.spec.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, cleanup } from "@testing-library/preact"; -import { ChatPanel } from "../../../entrypoints/settings/tabs/chat/ChatPanel"; -import { VoicesController } from "../../../entrypoints/settings/tabs/chat/voices-controller"; +import { VoicesPanel } from "../../../entrypoints/settings/tabs/voices/VoicesPanel"; +import { VoicesController } from "../../../entrypoints/settings/tabs/voices/voices-controller"; import { ElevenLabsVoice, claudeMockVoices, @@ -28,7 +28,7 @@ function makeDeps(overrides: Partial> = {}) { } async function mount(deps = makeDeps()) { - const { container } = render(); + const { container } = render(); const controller = new VoicesController(container as HTMLElement, deps as any); await controller.init(); return { container: container as HTMLElement, controller, deps };