diff --git a/src/chatbots/ClaudeVoiceMenu.ts b/src/chatbots/ClaudeVoiceMenu.ts index f688c487b2..3f23402cb3 100644 --- a/src/chatbots/ClaudeVoiceMenu.ts +++ b/src/chatbots/ClaudeVoiceMenu.ts @@ -767,10 +767,9 @@ export class ClaudeVoiceMenu extends VoiceSelector { this.menuContent.appendChild(footnote); } - // The door to the full catalog, only when the shortlist hides voices. - if (curated.hiddenCount > 0) { - this.menuContent.appendChild(this.createMoreVoicesItem()); - } + // The door to the full catalog — always present: it is the navigation + // path to the settings catalog, not an overflow marker (#472). + this.menuContent.appendChild(this.createMoreVoicesItem()); // Add subtle separators for easier scanning this.applyItemSeparators(); @@ -815,7 +814,8 @@ export class ClaudeVoiceMenu extends VoiceSelector { /** * The muted final row linking to the full voice catalog in the extension - * settings (AI Chat tab). Rendered only when the shortlist hides voices. + * settings (AI Chat tab). Always rendered — it is the menu's path to the + * catalog, whether or not the shortlist is hiding voices. */ private createMoreVoicesItem(): HTMLDivElement { const item = document.createElement("div"); diff --git a/src/chatbots/PiVoiceMenu.ts b/src/chatbots/PiVoiceMenu.ts index abbcb274c8..218f6d30bd 100644 --- a/src/chatbots/PiVoiceMenu.ts +++ b/src/chatbots/PiVoiceMenu.ts @@ -182,6 +182,12 @@ export class PiVoiceSettings extends VoiceSelector { return "saypi-voice-settings"; } + // Pi's own settings grid is uncapped, but still gets the door — it is the + // only path from this surface to the extension's full voice catalog (#472). + protected override showsMoreVoicesDoor(): boolean { + return true; + } + getButtonClasses(): string[] { return [ "flex", diff --git a/src/tts/VoiceMenu.ts b/src/tts/VoiceMenu.ts index d283a48d18..f8bf4ce3c3 100644 --- a/src/tts/VoiceMenu.ts +++ b/src/tts/VoiceMenu.ts @@ -143,6 +143,16 @@ export abstract class VoiceSelector { return null; } + /** + * Whether this surface renders the "More voices" door to the settings + * catalog. The door is the navigation path to the full catalog, not an + * overflow marker, so capped surfaces always show it; uncapped surfaces + * opt in (Pi's settings grid does — #472). + */ + protected showsMoreVoicesDoor(): boolean { + return this.getCustomVoiceCap() !== null; + } + async refreshMenu(): Promise { // remove all voices from the selector const voiceButtons = Array.from(this.element.querySelectorAll("button")); @@ -180,6 +190,9 @@ export abstract class VoiceSelector { const cap = this.getCustomVoiceCap(); if (cap === null) { this.populateCustomVoices(customVoices, voiceSelector); + if (this.showsMoreVoicesDoor()) { + this.addMoreVoicesDoor(voiceSelector); + } return true; } @@ -195,7 +208,7 @@ export abstract class VoiceSelector { voiceSelector, curated.tiersCoexist ); - if (curated.hiddenCount > 0) { + if (this.showsMoreVoicesDoor()) { this.addMoreVoicesDoor(voiceSelector); } diff --git a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts index 4b04526850..4e6688f1c4 100644 --- a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts +++ b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts @@ -96,11 +96,11 @@ describe("ClaudeVoiceMenu shortlist cap + door (flip-day catalog)", () => { expect(openSettingsMock).toHaveBeenCalled(); }); - it("omits the door row when the whole catalog fits the cap", () => { + it("keeps the door row even when the whole catalog fits the cap (#472)", () => { const menu = makeMenu(); menu.populateVoices(flipDayCatalog.slice(0, 3), menu.element); const door = menu.menuContent.querySelector("[data-action='more-voices']"); - expect(door).toBeNull(); + expect(door).not.toBeNull(); }); it("keeps the Voice off item", () => { diff --git a/test/chatbots/PiVoiceMenu-curation.spec.ts b/test/chatbots/PiVoiceMenu-curation.spec.ts index 9987b5f65f..cbb287b286 100644 --- a/test/chatbots/PiVoiceMenu-curation.spec.ts +++ b/test/chatbots/PiVoiceMenu-curation.spec.ts @@ -23,7 +23,7 @@ vi.mock("../../src/popup/popupopener", () => ({ openSettings: (...args: unknown[]) => openSettingsMock(...args), })); -import { PiVoiceMenu } from "../../src/chatbots/PiVoiceMenu"; +import { PiVoiceMenu, PiVoiceSettings } from "../../src/chatbots/PiVoiceMenu"; import { PI_MENU_CAP } from "../../src/tts/VoiceCuration"; import { ElevenLabsVoice, OpenAIVoice, openAiMockVoices } from "../data/Voices"; import { SpeechSynthesisVoiceRemote } from "../../src/tts/SpeechModel"; @@ -107,12 +107,52 @@ describe("PiVoiceMenu shortlist cap + door", () => { expect(openSettingsMock).toHaveBeenCalled(); }); - it("omits the door and shows everything when the catalog fits the cap (today's 3 voices)", () => { + it("still shows the door when the catalog fits the cap — it's the path to the catalog, not an overflow marker (#472)", () => { const menu = makeMenu(); const selector = document.createElement("div"); menu.populateVoices([...piBuiltIns, ...piElevenLabs], selector); expect(customRows(selector).length).toBe(piElevenLabs.length); - expect(selector.querySelector("button.saypi-more-voices")).toBeNull(); + const door = selector.querySelector( + "button.saypi-more-voices" + ) as HTMLButtonElement; + expect(door).not.toBeNull(); + door.click(); + expect(openSettingsMock).toHaveBeenCalled(); + }); +}); + +describe("PiVoiceSettings door (#472)", () => { + function makeSettings(): any { + const settings = Object.create(PiVoiceSettings.prototype); + settings.chatbot = {} as any; + settings.userPreferences = { + getVoice: vi.fn(async () => null), + setVoice: vi.fn(async () => {}), + unsetVoice: vi.fn(async () => {}), + }; + settings.element = document.createElement("div"); + return settings; + } + + it("appends a 'More voices' door to the uncapped settings grid", () => { + const settings = makeSettings(); + const grid = document.createElement("div"); + settings.populateVoices([...piElevenLabs], grid); + expect(customRows(grid).length).toBe(piElevenLabs.length); // grid stays uncapped + const door = grid.querySelector( + "button.saypi-more-voices" + ) as HTMLButtonElement; + expect(door).not.toBeNull(); + door.click(); + expect(openSettingsMock).toHaveBeenCalled(); + }); + + it("does not duplicate the door on repeated populates", () => { + const settings = makeSettings(); + const grid = document.createElement("div"); + settings.populateVoices([...piElevenLabs], grid); + settings.populateVoices([...piElevenLabs], grid); + expect(grid.querySelectorAll("button.saypi-more-voices").length).toBe(1); }); });