diff --git a/src/chatbots/Pi.ts b/src/chatbots/Pi.ts
index 998d3a1799..baa5700d88 100644
--- a/src/chatbots/Pi.ts
+++ b/src/chatbots/Pi.ts
@@ -110,7 +110,17 @@ class PiAIChatbot extends AbstractChatbot {
}
getVoiceSettingsSelector(): string {
- return "div.mx-auto.w-full.px-6.py-10 > div.grid.grid-cols-2.gap-4";
+ // Pi's Voice settings page (pi.ai/profile/settings) — re-anchored 2026-07-04
+ // (Layer-4 CDP): the content column drifted `px-6 py-10` → `px-6 py-3 pt-20`
+ // and the card grid `grid-cols-2` → `grid-cols-1 gap-4 sm:grid-cols-2`, so
+ // the old literal matched 0 and PiVoiceSettings never decorated (no door).
+ // Anchor on the settings content column (`mx-auto w-full max-w-2xl px-6` —
+ // `max-w-2xl` keeps this off wider surfaces like /discover /threads, which
+ // the every-batch body scan in bootstrap also visits) and its direct-child
+ // responsive card grid. Volatile padding / exact column-count literals are
+ // dropped. Verified live: matches the settings grid, 0 on /talk /discover
+ // /threads.
+ return 'div.mx-auto.w-full.max-w-2xl.px-6 > div.grid.gap-4[class~="sm:grid-cols-2"]';
}
getChatHistory(searchRoot: HTMLElement): HTMLElement {
diff --git a/src/chatbots/PiVoiceMenu.ts b/src/chatbots/PiVoiceMenu.ts
index 7bbe7ac08a..5d0dfcf235 100644
--- a/src/chatbots/PiVoiceMenu.ts
+++ b/src/chatbots/PiVoiceMenu.ts
@@ -8,6 +8,53 @@ import { PI_MENU_CAP } from "../tts/VoiceCuration";
import { Chatbot } from "./Chatbot";
import { UserPreferenceModule } from "../prefs/PreferenceModule";
+/**
+ * Build a "More voices" door for Pi's live voice surfaces. It visually CLONES a
+ * Pi native row/card (`template`) by copying its class list — Pi's own utilities
+ * are already compiled, so its arbitrary classes (`min-h-11`, `h-[56px]`,
+ * `!bg-secondary-default`) apply to the clone; SayPi can't author those itself
+ * (host-injected-arbitrary-Tailwind). A REAL template is required (callers wait
+ * for Pi to populate its rows/cards) — cloning nothing would leave a permanently
+ * unstyled foreign door that the idempotence guard then never re-styles. `tag`
+ * matches the host's native element — Pi's in-chat rows are `
`s, its
+ * settings cards are `
`s — so the door reads as one of them; div doors
+ * get the keyboard operability Pi's own role-less divs lack. Click → the
+ * extension's full Voices catalog.
+ */
+function buildMoreVoicesDoor(
+ template: HTMLElement,
+ tag: "div" | "button"
+): HTMLElement {
+ // Annotate as HTMLElement so addEventListener("keydown") keeps its typed
+ // KeyboardEvent overload (a union createElement return would widen it to Event).
+ const door: HTMLElement = document.createElement(tag);
+ if (tag === "button") (door as HTMLButtonElement).type = "button";
+ door.className = template.className;
+ door.classList.add("saypi-more-voices");
+ if (tag === "div") {
+ door.setAttribute("role", "button");
+ door.tabIndex = 0;
+ }
+
+ const label = document.createElement("span");
+ const templateSpan = template.querySelector("span");
+ if (templateSpan) label.className = templateSpan.className;
+ label.textContent = getMessage("moreVoices");
+ door.appendChild(label);
+
+ const open = () => openSettings("voices");
+ door.addEventListener("click", open);
+ if (tag === "div") {
+ door.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ open();
+ }
+ });
+ }
+ return door;
+}
+
export class PiVoiceMenu extends GridVoiceSelector {
constructor(
chatbot: Chatbot,
@@ -150,7 +197,12 @@ export class PiVoiceMenu extends GridVoiceSelector {
const list = this.findLiveVoiceList(audioControls);
if (!list) return; // collapsed / no list to host the door
if (list.querySelector(".saypi-more-voices")) return; // already present
- list.appendChild(this.createLiveMenuDoor(list));
+ // Pi's in-chat rows are ``s — clone the first one so the door matches.
+ // If the list has no rows yet, wait: the subtree observer re-fires once Pi
+ // populates it (never inject an unstyled, un-cloned door).
+ const template = list.firstElementChild as HTMLElement | null;
+ if (!template) return;
+ list.appendChild(buildMoreVoicesDoor(template, "div"));
}
/**
@@ -170,40 +222,18 @@ export class PiVoiceMenu extends GridVoiceSelector {
return card.querySelector
(".flex.flex-col.gap-1");
}
- /**
- * Build the door as a Pi-native-looking row by CLONING a native row's class
- * list (Pi's own utilities are already compiled, so its arbitrary classes —
- * `min-h-11`, `!bg-secondary-default` — apply to the clone; SayPi can't author
- * those itself, per the host-injected-arbitrary-Tailwind constraint). Pi's
- * rows are role-less `` clickables, so we match with a keyboard-operable
- * div (marginally better a11y than Pi's own rows).
- */
- private createLiveMenuDoor(list: HTMLElement): HTMLElement {
- const template = list.firstElementChild as HTMLElement | null;
- const door = document.createElement("div");
- if (template) door.className = template.className;
- door.classList.add("saypi-more-voices");
- door.setAttribute("role", "button");
- door.tabIndex = 0;
-
- const label = document.createElement("span");
- const templateSpan = template?.querySelector("span");
- if (templateSpan) label.className = templateSpan.className;
- label.textContent = getMessage("moreVoices");
- door.appendChild(label);
-
- const open = () => openSettings("voices");
- door.addEventListener("click", open);
- door.addEventListener("keydown", (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- open();
- }
- });
- return door;
- }
}
+/**
+ * Pi's own Voice settings page (pi.ai/profile/settings). Door-first (#491
+ * sibling): Pi redesigned this into a static `div.grid` of `
` voice
+ * cards, and SayPi's old full-grid render never decorated it (stale selector).
+ * SayPi now adds only the "More voices" door → the extension's full Voices
+ * catalog. Inline SayPi voice rows on this surface are deferred (companion to
+ * the in-chat #497). Unlike the in-chat menu this grid is static, but Pi's
+ * React can still re-render it (e.g. on selection), so the door is re-injected
+ * on grid mutations.
+ */
export class PiVoiceSettings extends GridVoiceSelector {
constructor(
chatbot: Chatbot,
@@ -212,35 +242,52 @@ export class PiVoiceSettings extends GridVoiceSelector {
) {
super(chatbot, userPreferences, element);
this.addIdVoiceMenu(element);
- // One gather-then-render covers what four calls did before: catalog rows,
- // Pi's extra-voice top-up, adoption of Pi's pre-existing native buttons,
- // and selection marking.
- this.refreshMenu();
+ this.ensureSettingsDoor();
+ this.observeSettingsGrid();
}
getId(): string {
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;
+ // Also reached on auth changes via the base refreshMenu → re-ensure the door,
+ // never draw inline voice rows (door-first).
+ protected override renderMenu(): void {
+ this.ensureSettingsDoor();
+ }
+
+ // Door-only surface: no per-row selection to reflect.
+ protected override applySelectedVoice(): void {}
+
+ /**
+ * Inject the "More voices" door as the last card of Pi's settings grid,
+ * cloned from a native card so it renders Pi-native (cards are ``s
+ * here). Idempotent (guarded on `.saypi-more-voices`).
+ */
+ private ensureSettingsDoor(): void {
+ const grid = this.element;
+ if (grid.querySelector(".saypi-more-voices")) return;
+ // Clone a native card for styling. If Pi hasn't rendered its card buttons
+ // yet (empty grid), wait: observeSettingsGrid re-fires when they arrive, so
+ // we never inject an unstyled door that the guard above would then freeze.
+ const template = grid.querySelector(":scope > button");
+ if (!template) return;
+ grid.appendChild(buildMoreVoicesDoor(template, "button"));
+ }
+
+ /**
+ * Pi's React may re-render the grid (e.g. selecting a voice) and drop our
+ * foreign door child. Re-inject on any grid childList change; idempotent, so
+ * the door's own append never loops.
+ */
+ private observeSettingsGrid(): void {
+ const observer = new MutationObserver(() => this.ensureSettingsDoor());
+ observer.observe(this.element, { childList: true });
}
getButtonClasses(): string[] {
- return [
- "flex",
- "items-center",
- "justify-between",
- "rounded-lg",
- "border",
- "px-3",
- "py-5",
- "font-sans",
- "text-body-m-mobile",
- "text-primary-700",
- "border-neutral-500",
- ];
+ // Required by the abstract base; unused on this door-first surface — the
+ // door clones Pi's native card styling rather than authoring its own.
+ return [];
}
}
diff --git a/src/chatbots/bootstrap.ts b/src/chatbots/bootstrap.ts
index 5c8f1dbbfd..63d5e313c4 100644
--- a/src/chatbots/bootstrap.ts
+++ b/src/chatbots/bootstrap.ts
@@ -37,7 +37,6 @@ export class DOMObserver {
const sidebarObs = this.findAndDecorateSidebar(addedElement);
if (sidebarObs.found && sidebarObs.decorated) {
this.findAndDecorateDiscoveryPanel(addedElement);
- this.findAndDecorateVoiceSettings(addedElement);
}
const audioControlsObs =
this.findAndDecorateAudioControls(addedElement);
@@ -89,6 +88,15 @@ export class DOMObserver {
}
});
});
+ // Pi's Voice settings page (pi.ai/profile/settings) has NO chat prompt, so
+ // the content-loaded chain that scans for voice settings never fires there;
+ // and its voice grid lives in the main content, not the sidebar. Decorate it
+ // independently of the sidebar gate, scanning the whole body once per batch
+ // so the grid is found wherever/whenever the SPA renders it. Idempotent
+ // (getElementById guard → cheap early-return once decorated), and a no-op on
+ // hosts whose getVoiceSettingsSelector is empty (ChatGPT) or doesn't match
+ // (Claude).
+ this.findAndDecorateVoiceSettings(document.body);
};
constructor(private chatbot: Chatbot) {
this.voiceMenuUiMgr = new VoiceMenuUIManager(
diff --git a/test/chatbots/Pi-VoiceSettingsSelector.spec.ts b/test/chatbots/Pi-VoiceSettingsSelector.spec.ts
new file mode 100644
index 0000000000..493330a258
--- /dev/null
+++ b/test/chatbots/Pi-VoiceSettingsSelector.spec.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { JSDOM } from "jsdom";
+import { PiAIChatbot } from "../../src/chatbots/Pi";
+
+/**
+ * Pi Voice settings selector (pi.ai/profile/settings) — re-anchored 2026-07-04
+ * (Layer-4 CDP capture). Pi redesigned the page: the content column drifted
+ * `mx-auto w-full px-6 py-10` → `mx-auto w-full max-w-2xl flex-1 px-6 py-3 pt-20`
+ * and the card grid `grid grid-cols-2 gap-4` → `grid grid-cols-1 gap-4
+ * sm:grid-cols-2`, so the old class-literal matched 0 and PiVoiceSettings never
+ * decorated (the "More voices" door never appeared — #491 sibling). Class
+ * strings below are verbatim from the live DOM.
+ */
+
+// The live settings page: content column > card grid of 8 voice cards.
+const settingsHTML = `
+
+
+
+ ${Array.from({ length: 8 }, (_, i) => `
+
+ Pi ${i + 1}
+ `).join("")}
+
+
+
`;
+
+// A chat/talk-page-ish DOM: audio controls, NO settings grid.
+const talkHTML = `
+ `;
+
+// A WIDER surface (e.g. /discover) that also renders a responsive card grid —
+// the every-batch body scan visits it too, so the selector must NOT match it.
+const widerGridHTML = `
+
+
+ Topic A
+ Topic B
+
+
`;
+
+describe("Pi Voice settings selector (#491 sibling — settings-page redesign)", () => {
+ const chatbot = new PiAIChatbot();
+ const q = (html: string, sel: string) =>
+ new JSDOM(html).window.document.querySelectorAll(sel);
+
+ it("the shipped-broken literal matches 0 on the live settings grid (documents the bug)", () => {
+ const old = "div.mx-auto.w-full.px-6.py-10 > div.grid.grid-cols-2.gap-4";
+ expect(q(settingsHTML, old).length).toBe(0);
+ });
+
+ it("the new selector matches the settings card grid exactly once", () => {
+ const matches = q(settingsHTML, chatbot.getVoiceSettingsSelector());
+ expect(matches.length).toBe(1);
+ expect((matches[0] as HTMLElement).classList.contains("grid")).toBe(true);
+ // It's the card grid — its children are the voice cards.
+ expect((matches[0] as HTMLElement).querySelectorAll("button").length).toBe(8);
+ });
+
+ it("does NOT match a chat/talk-page DOM (no spurious decoration — the body scan visits every chatable page)", () => {
+ expect(q(talkHTML, chatbot.getVoiceSettingsSelector()).length).toBe(0);
+ });
+
+ it("does NOT match a wider (max-w-4xl) card grid like /discover — max-w-2xl scopes it to the settings column", () => {
+ expect(q(widerGridHTML, chatbot.getVoiceSettingsSelector()).length).toBe(0);
+ });
+});
diff --git a/test/chatbots/PiVoiceMenu-curation.spec.ts b/test/chatbots/PiVoiceMenu-curation.spec.ts
index 2473e8ebf3..5eeecac71e 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, PiVoiceSettings } from "../../src/chatbots/PiVoiceMenu";
+import { PiVoiceMenu } 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";
@@ -116,41 +116,9 @@ describe("PiVoiceMenu shortlist cap + door", () => {
});
});
-describe("PiVoiceSettings door (#472)", () => {
- function makeSettings(): any {
- const settings = Object.create(PiVoiceSettings.prototype);
- settings.chatbot = { getID: () => "pi" } as any;
- settings.userPreferences = {
- getVoice: vi.fn(async () => null),
- setVoice: vi.fn(async () => {}),
- unsetVoice: vi.fn(async () => {}),
- };
- settings.element = document.createElement("div");
- settings.introduceVoice = vi.fn();
- return settings;
- }
-
- it("appends a 'More voices' door to the uncapped settings grid", () => {
- const settings = makeSettings();
- settings.renderMenu([...piElevenLabs], null);
- expect(customRows(settings.element).length).toBe(piElevenLabs.length); // grid stays uncapped
- const door = settings.element.querySelector(
- "button.saypi-more-voices"
- ) as HTMLButtonElement;
- expect(door).not.toBeNull();
- door.click();
- expect(openSettingsMock).toHaveBeenCalledWith("voices");
- });
-
- it("does not duplicate the door on repeated renders", () => {
- const settings = makeSettings();
- settings.renderMenu([...piElevenLabs], null);
- settings.renderMenu([...piElevenLabs], null);
- expect(
- settings.element.querySelectorAll("button.saypi-more-voices").length
- ).toBe(1);
- });
-});
+// PiVoiceSettings is now door-first (Pi's settings grid gets only the "More
+// voices" door, not inline SayPi rows) — its coverage lives in
+// test/chatbots/PiVoiceSettings-more-voices-door.spec.ts.
describe("PiVoiceMenu tier badge", () => {
it("suffixes premium rows with a quiet HD badge only when tiers coexist", () => {
diff --git a/test/chatbots/PiVoiceSettings-more-voices-door.spec.ts b/test/chatbots/PiVoiceSettings-more-voices-door.spec.ts
new file mode 100644
index 0000000000..9eab671bd3
--- /dev/null
+++ b/test/chatbots/PiVoiceSettings-more-voices-door.spec.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// ConfigModule reads injected env at import time; stub it (mirrors the sibling specs).
+vi.mock("../../src/ConfigModule", () => ({
+ config: {
+ appServerUrl: "https://app.example.com",
+ apiServerUrl: "https://api.saypi.ai",
+ GA_MEASUREMENT_ID: "x",
+ GA_API_SECRET: "x",
+ GA_ENDPOINT: "x",
+ },
+}));
+
+vi.mock("../../src/JwtManager", () => ({
+ getJwtManagerSync: () => ({
+ isAuthenticated: () => true,
+ getClaims: () => ({ ttsQuotaRemaining: 1000 }),
+ }),
+}));
+
+const openSettingsMock = vi.fn();
+vi.mock("../../src/popup/popupopener", () => ({
+ openSettings: (...args: unknown[]) => openSettingsMock(...args),
+}));
+
+import { PiVoiceSettings } from "../../src/chatbots/PiVoiceMenu";
+
+/**
+ * A faithful fixture of pi.ai's CURRENT Voice settings grid (captured live via
+ * Layer-4 CDP, 2026-07-04 — see #491 follow-up). The page renders a `div.grid`
+ * of `` voice cards (each `> span`), unlike the in-chat menu's div rows.
+ * The door is a native-styled `` cloned from a card, appended last.
+ */
+function buildSettingsGrid(): HTMLElement {
+ const grid = document.createElement("div");
+ grid.className = "grid grid-cols-1 gap-4 sm:grid-cols-2";
+ for (let i = 1; i <= 8; i++) {
+ const card = document.createElement("button");
+ card.className =
+ "inline-flex items-center whitespace-nowrap transition-colors h-[56px] w-full min-w-0 max-w-[22.0625rem] rounded-[10px] border border-divider-stroke px-[24px] py-0 bg-secondary-default text-text-secondary";
+ const span = document.createElement("span");
+ span.className = "text-action-m relative min-w-0 flex-1 truncate text-left";
+ span.textContent = `Pi ${i}`;
+ card.appendChild(span);
+ grid.appendChild(card);
+ }
+ return grid;
+}
+
+// Object.create bypasses the heavy DOM-observer constructor; drive the door
+// methods directly with `element` set to the grid fixture.
+function makeSettings(grid: HTMLElement): any {
+ const settings = Object.create(PiVoiceSettings.prototype);
+ settings.chatbot = { getID: () => "pi" };
+ settings.userPreferences = {
+ getVoice: vi.fn(async () => null),
+ setVoice: vi.fn(async () => {}),
+ unsetVoice: vi.fn(async () => {}),
+ };
+ settings.element = grid;
+ return settings;
+}
+
+const door = (grid: HTMLElement) =>
+ grid.querySelector("button.saypi-more-voices");
+
+beforeEach(() => {
+ openSettingsMock.mockReset();
+ document.body.innerHTML = "";
+});
+
+describe("PiVoiceSettings — 'More voices' door on Pi's Voice settings page (#491 follow-up, door-first)", () => {
+ it("injects the door as the last card of Pi's settings grid", () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.ensureSettingsDoor();
+ expect(door(grid)).not.toBeNull();
+ expect(grid.lastElementChild).toBe(door(grid));
+ expect(door(grid)!.querySelector("span")?.textContent?.length).toBeGreaterThan(0);
+ });
+
+ it("renders ONLY the door — no inline SayPi voice rows on this surface (door-first)", () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.ensureSettingsDoor();
+ expect(grid.querySelectorAll(".saypi-custom-voice").length).toBe(0);
+ expect(grid.querySelectorAll(".saypi-restored-voice").length).toBe(0);
+ // Pi's own 8 cards are untouched; the door is the only SayPi addition.
+ expect(grid.querySelectorAll("button.saypi-more-voices").length).toBe(1);
+ expect(grid.children.length).toBe(9);
+ });
+
+ it("clones a native card's styling (button + Pi's compiled classes), not foreign chrome", () => {
+ const grid = buildSettingsGrid();
+ const nativeCard = grid.firstElementChild as HTMLElement;
+ const settings = makeSettings(grid);
+ settings.ensureSettingsDoor();
+ const d = door(grid)!;
+ expect(d.tagName).toBe("BUTTON");
+ expect(d.classList.contains("rounded-[10px]")).toBe(true);
+ expect(d.classList.contains("h-[56px]")).toBe(true);
+ expect(d.querySelector("span")!.className).toBe(
+ nativeCard.querySelector("span")!.className
+ );
+ });
+
+ it("is idempotent: re-ensuring never duplicates the door", () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.ensureSettingsDoor();
+ settings.ensureSettingsDoor();
+ settings.ensureSettingsDoor();
+ expect(grid.querySelectorAll(".saypi-more-voices").length).toBe(1);
+ });
+
+ it("clicking the door opens the extension's Voices settings", () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.ensureSettingsDoor();
+ door(grid)!.click();
+ expect(openSettingsMock).toHaveBeenCalledWith("voices");
+ });
+
+ it("renderMenu (the auth-change path) just re-ensures the door — never draws voice rows", () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.renderMenu([], null);
+ expect(door(grid)).not.toBeNull();
+ expect(grid.querySelectorAll(".saypi-custom-voice").length).toBe(0);
+ });
+
+ it("re-injects the door if Pi's grid re-render drops it (React removing a foreign child)", () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.ensureSettingsDoor();
+ // Simulate Pi re-rendering: the door child is removed.
+ door(grid)!.remove();
+ expect(door(grid)).toBeNull();
+ settings.ensureSettingsDoor();
+ expect(grid.querySelectorAll(".saypi-more-voices").length).toBe(1);
+ });
+
+ it("observeSettingsGrid re-injects through a REAL MutationObserver when Pi drops the door", async () => {
+ const grid = buildSettingsGrid();
+ const settings = makeSettings(grid);
+ settings.observeSettingsGrid();
+ settings.ensureSettingsDoor();
+ expect(door(grid)).not.toBeNull();
+ door(grid)!.remove(); // the removal is itself a childList mutation
+ await new Promise((r) => setTimeout(r, 0)); // let the observer callback run
+ expect(grid.querySelectorAll(".saypi-more-voices").length).toBe(1);
+ });
+
+ it("never injects an unstyled door: waits for Pi's cards, then clones one (empty-grid race)", async () => {
+ // The grid can match the selector before Pi hydrates its cards.
+ const grid = document.createElement("div");
+ grid.className = "grid grid-cols-1 gap-4 sm:grid-cols-2";
+ const settings = makeSettings(grid);
+ settings.observeSettingsGrid();
+ settings.ensureSettingsDoor();
+ expect(door(grid)).toBeNull(); // no template → NO door (never an unstyled one)
+
+ // Pi hydrates a card in a later commit.
+ const card = document.createElement("button");
+ card.className = "h-[56px] rounded-[10px] border";
+ const span = document.createElement("span");
+ span.className = "text-action-m";
+ span.textContent = "Pi 1";
+ card.appendChild(span);
+ grid.appendChild(card); // childList mutation → observer fires
+ await new Promise((r) => setTimeout(r, 0));
+
+ const d = door(grid);
+ expect(d).not.toBeNull(); // now injected...
+ expect(d!.classList.contains("rounded-[10px]")).toBe(true); // ...and native-styled (cloned)
+ });
+});