Skip to content
Merged
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
12 changes: 11 additions & 1 deletion src/chatbots/Pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
155 changes: 101 additions & 54 deletions src/chatbots/PiVoiceMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>`s, its
* settings cards are `<button>`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,
Expand Down Expand Up @@ -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 `<div>`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"));
}

/**
Expand All @@ -170,40 +222,18 @@ export class PiVoiceMenu extends GridVoiceSelector {
return card.querySelector<HTMLElement>(".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 `<div>` 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 `<button>` 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,
Expand All @@ -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 `<button>`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<HTMLElement>(":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 [];
}
}
10 changes: 9 additions & 1 deletion src/chatbots/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
74 changes: 74 additions & 0 deletions test/chatbots/Pi-VoiceSettingsSelector.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <button> voice cards.
const settingsHTML = `
<div class="flex h-full flex-col">
<div class="mx-auto w-full max-w-2xl flex-1 px-6 py-3 pt-20">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
${Array.from({ length: 8 }, (_, i) => `
<button class="inline-flex items-center h-[56px] w-full rounded-[10px] border border-divider-stroke px-[24px] py-0 bg-secondary-default text-text-secondary">
<span class="text-action-m relative min-w-0 flex-1 truncate text-left">Pi ${i + 1}</span>
</button>`).join("")}
</div>
</div>
</div>`;

// A chat/talk-page-ish DOM: audio controls, NO settings grid.
const talkHTML = `
<div class="order-2 w-auto saypi-audio-controls">
<div class="relative flex flex-col-reverse">
<div class="inline-flex rounded-[100px] bg-fill-default">
<button aria-label="Turn voice off"></button>
<button aria-label="Toggle voice menu"></button>
</div>
</div>
</div>`;

// 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 = `
<div class="mx-auto w-full max-w-4xl px-6 py-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<button><span>Topic A</span></button>
<button><span>Topic B</span></button>
</div>
</div>`;

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);
});
});
40 changes: 4 additions & 36 deletions test/chatbots/PiVoiceMenu-curation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading