From 25640ae3cd0f35bc943ffe77d91aa8617bfb4452 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 11:15:23 -0700 Subject: [PATCH] CL-6794: Name Connect/Manage buttons for screen readers Gallery rows shared a bare Connect/Manage/Disconnect verb across every plugin, so assistive tech could not tell which row action was focused. Keep the visible one-word label; put the display name in aria-label on registry cards, MCP presets, and custom MCP rows. --- packages/plugins-ui/src/mcp-preset-cards.tsx | 3 + .../plugins-ui/src/mcp-servers-section.tsx | 1 + packages/plugins-ui/src/plugin-card.tsx | 8 ++- .../plugins-ui/test/mcp-preset-cards.test.tsx | 43 ++++++++++++++ .../test/mcp-servers-section.test.tsx | 59 +++++++++++++++++++ .../plugins-ui/test/plugins-gallery.test.tsx | 12 ++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 packages/plugins-ui/test/mcp-servers-section.test.tsx diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 8f4fedb64..99a88d825 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -123,6 +123,7 @@ function McpPresetCard({ size="sm" confirmLabel="Disconnect" disabled={busy} + aria-label={`Disconnect ${preset.displayName}`} onConfirm={handleDisconnect} > {busy ? "Disconnecting…" : "Disconnect"} @@ -133,6 +134,7 @@ function McpPresetCard({ size="sm" variant="ghost" disabled={busy} + aria-label={`Connect ${preset.displayName}`} onClick={handleConnect} > {busy ? "Connecting…" : "Connect"} @@ -172,6 +174,7 @@ function McpPresetCard({ type="button" size="sm" disabled={busy || token.trim() === ""} + aria-label={`Connect ${preset.displayName}`} onClick={() => { submitConnect(token.trim()); }} diff --git a/packages/plugins-ui/src/mcp-servers-section.tsx b/packages/plugins-ui/src/mcp-servers-section.tsx index 9920ca93c..d6f6827c2 100644 --- a/packages/plugins-ui/src/mcp-servers-section.tsx +++ b/packages/plugins-ui/src/mcp-servers-section.tsx @@ -66,6 +66,7 @@ function ConnectedMcpServerRow({ size="sm" confirmLabel="Disconnect" disabled={busy} + aria-label={`Disconnect ${server.name}`} onConfirm={handleDisconnect} > {busy ? "Disconnecting…" : "Disconnect"} diff --git a/packages/plugins-ui/src/plugin-card.tsx b/packages/plugins-ui/src/plugin-card.tsx index 8fcfaffbf..fa17f20d2 100644 --- a/packages/plugins-ui/src/plugin-card.tsx +++ b/packages/plugins-ui/src/plugin-card.tsx @@ -69,7 +69,13 @@ export function PluginCard({ Connect ) : ( - )} diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 4751f2044..981ee6f83 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -93,6 +93,49 @@ describe("McpPresetCardsSection", () => { ).toBe("0 0 151 182"); }); + // CL-6794: Connect's accessible name must name the preset so a gallery of + // identical "Connect" verbs is distinguishable to a screen reader; the + // visible label stays the single verb. + test("Connect's accessible name includes the preset display name (CL-6794)", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ data: PRESETS }), + )) as unknown as typeof fetch; + + const container = mountSection(); + await settle(); + + const connectExa = container.querySelector('[aria-label="Connect Exa"]'); + expect(connectExa).not.toBeNull(); + expect(connectExa?.textContent?.trim()).toBe("Connect"); + + const connectGranola = container.querySelector( + '[aria-label="Connect Granola"]', + ); + expect(connectGranola).not.toBeNull(); + expect(connectGranola?.textContent?.trim()).toBe("Connect"); + }); + + test("Disconnect's accessible name includes the preset display name (CL-6794)", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: PRESETS.map((p) => + p.slug === "exa" ? { ...p, connected: true } : p, + ), + }), + )) as unknown as typeof fetch; + + const container = mountSection(); + await settle(); + + const disconnectExa = container.querySelector( + '[aria-label="Disconnect Exa"]', + ); + expect(disconnectExa).not.toBeNull(); + expect(disconnectExa?.textContent?.trim()).toBe("Disconnect"); + }); + test("connect calls the preset connect route with the preset's slug", async () => { const calls: { url: string; init?: RequestInit }[] = []; let connected = false; diff --git a/packages/plugins-ui/test/mcp-servers-section.test.tsx b/packages/plugins-ui/test/mcp-servers-section.test.tsx new file mode 100644 index 000000000..e00b6e145 --- /dev/null +++ b/packages/plugins-ui/test/mcp-servers-section.test.tsx @@ -0,0 +1,59 @@ +// Custom MCP rows (CL-6794): already-connected hand-typed servers keep a +// Disconnect affordance whose accessible name must name the server — the +// same "verb + display name" pattern Connect/Manage use on registry and +// preset rows. Visible label stays the single verb. + +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { McpServersSection } from "../src/mcp-servers-section"; + +const realFetch = globalThis.fetch; +let mountedRoots: Root[] = []; +afterEach(() => { + globalThis.fetch = realFetch; + for (const root of mountedRoots) act(() => root.unmount()); + mountedRoots = []; +}); + +const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 10))); + +function mountSection() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + mountedRoots.push(root); + act(() => { + root.render(); + }); + return container; +} + +describe("McpServersSection", () => { + test("Disconnect's accessible name includes the custom server name (CL-6794)", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: [ + { + slug: "my-custom", + name: "My Custom MCP", + url: "https://mcp.example.test/mcp", + }, + ], + }), + )) as unknown as typeof fetch; + + const container = mountSection(); + await settle(); + + const disconnect = container.querySelector( + '[aria-label="Disconnect My Custom MCP"]', + ); + expect(disconnect).not.toBeNull(); + expect(disconnect?.textContent?.trim()).toBe("Disconnect"); + }); +}); diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index 377f78ac3..e47d7dd9e 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -167,6 +167,18 @@ describe("PluginsGallery", () => { '[aria-label="Connect Hugging Face"]', ); expect(connectButton).not.toBeNull(); + // Visible label stays the single verb (CL-6794). + expect(connectButton?.textContent?.trim()).toBe("Connect"); + }); + + test("a connected tool connector's Manage button includes the plugin name in its accessible name (CL-6794)", () => { + const { container } = renderGallery(); + + const manageButton = container.querySelector( + '[aria-label="Manage GitHub"]', + ); + expect(manageButton).not.toBeNull(); + expect(manageButton?.textContent?.trim()).toBe("Manage"); }); test("filters out the old registry card for a connector an MCP preset now fronts", () => {