diff --git a/entrypoints/settings/tabs/voices/VoicesPanel.tsx b/entrypoints/settings/tabs/voices/VoicesPanel.tsx index 2c8488ed5f..29e0264f38 100644 --- a/entrypoints/settings/tabs/voices/VoicesPanel.tsx +++ b/entrypoints/settings/tabs/voices/VoicesPanel.tsx @@ -21,6 +21,13 @@ export function VoicesPanel() {
Choose the voice that reads replies aloud on each site.
+ {/* Each pill wears its assistant's logo and brand color (#473) so + users orient at a glance — matching the platform cards on + saypi.ai/features/sites-supported. Logos are decorative (the pill + text is the accessible label), hence alt="" + aria-hidden; the + active state is also carried by aria-selected and font weight, so + color is never the only signal. Assets live in public/icons/logos/, + served from the extension root. */}
diff --git a/entrypoints/settings/tabs/voices/voices-controller.ts b/entrypoints/settings/tabs/voices/voices-controller.ts index f38ee2825e..05adfc98be 100644 --- a/entrypoints/settings/tabs/voices/voices-controller.ts +++ b/entrypoints/settings/tabs/voices/voices-controller.ts @@ -73,7 +73,11 @@ export class VoicesController { this.container .querySelectorAll(".voice-host-pill") .forEach((pill) => { - pill.classList.toggle("active", pill.id === `voice-host-${host}`); + const isActive = pill.id === `voice-host-${host}`; + pill.classList.toggle("active", isActive); + // The pills are a tablist; state must be programmatic too, not just + // the brand-color accent (#473). + pill.setAttribute("aria-selected", String(isActive)); }); const [voices, current] = await Promise.all([ diff --git a/entrypoints/settings/tabs/voices/voices.css b/entrypoints/settings/tabs/voices/voices.css index 863d4100d5..95a7a30c06 100644 --- a/entrypoints/settings/tabs/voices/voices.css +++ b/entrypoints/settings/tabs/voices/voices.css @@ -6,6 +6,9 @@ margin: 0.5rem 0 0.75rem; } .voice-host-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; border: 1px solid #d1d5db; border-radius: 9999px; background: transparent; @@ -13,10 +16,29 @@ font-size: 0.85rem; cursor: pointer; } +.voice-host-logo { + width: 16px; + height: 16px; + border-radius: 4px; + object-fit: contain; +} +/* The active pill takes on its assistant's brand palette (#473): Pi's forest + green on cream, Claude's terracotta on ivory. Weight + border + aria-selected + carry the state too — color is an accent, never the only signal. */ .voice-host-pill.active { background: #e5e7eb; font-weight: 600; } +#voice-host-pi.active { + background: #f5efe1; + border-color: #12805c; + color: #0b5c41; +} +#voice-host-claude.active { + background: #f8f0e9; + border-color: #d97757; + color: #ae5630; +} .voice-shelf { margin-bottom: 0.75rem; } diff --git a/public/icons/logos/claude.png b/public/icons/logos/claude.png new file mode 100644 index 0000000000..5057173581 Binary files /dev/null and b/public/icons/logos/claude.png differ diff --git a/public/icons/logos/pi.png b/public/icons/logos/pi.png new file mode 100644 index 0000000000..921e9a7d7c Binary files /dev/null and b/public/icons/logos/pi.png differ diff --git a/test/settings/tabs/VoicesPanel.spec.tsx b/test/settings/tabs/VoicesPanel.spec.tsx index 3da23934c3..cb1061c2b6 100644 --- a/test/settings/tabs/VoicesPanel.spec.tsx +++ b/test/settings/tabs/VoicesPanel.spec.tsx @@ -28,4 +28,23 @@ describe("VoicesPanel", () => { ]); expect(container.querySelector("#voice-catalog")).toBeTruthy(); }); + + it("brands each pill with its assistant's logo while keeping the text label (#473)", () => { + const { container } = render(); + const piLogo = container.querySelector( + "#voice-host-pi img.voice-host-logo" + ); + const claudeLogo = container.querySelector( + "#voice-host-claude img.voice-host-logo" + ); + expect(piLogo?.getAttribute("src")).toBe("/icons/logos/pi.png"); + expect(claudeLogo?.getAttribute("src")).toBe("/icons/logos/claude.png"); + // Decorative images: the pill text stays the accessible label. + expect(piLogo?.getAttribute("alt")).toBe(""); + expect(claudeLogo?.getAttribute("aria-hidden")).toBe("true"); + expect(container.querySelector("#voice-host-pi")?.textContent?.trim()).toBe("Pi"); + expect( + container.querySelector("#voice-host-claude")?.textContent?.trim() + ).toBe("Claude"); + }); }); diff --git a/test/settings/tabs/voices-controller.spec.tsx b/test/settings/tabs/voices-controller.spec.tsx index b74e9255f6..8cfa905b50 100644 --- a/test/settings/tabs/voices-controller.spec.tsx +++ b/test/settings/tabs/voices-controller.spec.tsx @@ -60,6 +60,18 @@ describe("VoicesController", () => { expect(rows.length).toBe(flipDayClaude.length); }); + it("mirrors the active pill into aria-selected, not just the color accent (#473)", async () => { + const { container } = await mount(); + const pi = container.querySelector("#voice-host-pi")!; + const claude = container.querySelector("#voice-host-claude")!; + expect(pi.getAttribute("aria-selected")).toBe("true"); + expect(claude.getAttribute("aria-selected")).toBe("false"); + (claude as HTMLElement).click(); + await flushAsync(); + expect(pi.getAttribute("aria-selected")).toBe("false"); + expect(claude.getAttribute("aria-selected")).toBe("true"); + }); + it("groups a mixed-tier catalog into HD and Everyday shelves", async () => { const { container } = await mount(); (container.querySelector("#voice-host-claude") as HTMLElement).click();