diff --git a/clients/web/.storybook/preview.tsx b/clients/web/.storybook/preview.tsx index 29527c4b..a17e18d8 100644 --- a/clients/web/.storybook/preview.tsx +++ b/clients/web/.storybook/preview.tsx @@ -77,7 +77,21 @@ const preview: Preview = { }, }, a11y: { - test: 'todo', + // `'error'` fails the Storybook play-function tests (part of `npm run ci`) + // on any axe violation, so the zero-violation state this PR reached is + // enforced going forward rather than being a point-in-time result. + test: 'error', + // Stories render presentational components (and whole screens) in + // isolation, outside the app's `AppShell` — which is what provides the + // page landmarks (`
`, nav) in the real app. The `region` rule + // ("all content must live inside a landmark") is therefore a page-level + // concern that can't be satisfied in Storybook isolation and would fire on + // essentially every story; it's covered by the app shell at runtime, not + // by the components under test. Disable it here so the a11y panel surfaces + // only rules the components can actually own. + config: { + rules: [{ id: 'region', enabled: false }], + }, }, layout: 'centered', }, diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 3a254bb9..3c4e03bf 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -20,11 +20,30 @@ --inspector-text-primary: var(--mantine-color-text); --inspector-text-secondary: var(--mantine-color-dimmed); --inspector-text-inverse: var(--mantine-color-white); + /* Danger action text (Remove / Disconnect subtle buttons). A deep red on + light surfaces; a lighter red on the dark card/header (see dark override) — + `red-6` fell just under WCAG AA on both. */ + --inspector-danger-text: var(--mantine-color-red-7); /* ── Surfaces ──────────────────────────────────────────── */ - --inspector-surface-body: var(--mantine-color-dark-9); - --inspector-surface-card: var(--mantine-color-gray-0); - --inspector-surface-code: var(--mantine-color-white); + /* Light-grey app background so the white cards read as raised. Drives + Mantine's `--mantine-color-body` (the actual page background), which + otherwise defaults to pure white. */ + --inspector-surface-body: var(--mantine-color-gray-1); + --inspector-surface-card: var(--mantine-color-white); + /* Recessed list-item surface (Protocol/Network message cards): light-grey in + light mode so the entries read as sunk into their white panel; keeps the + standard card tone in dark (overridden below). */ + --inspector-surface-inset: var(--mantine-color-gray-1); + /* Code/content surfaces match the light-grey body so they stay distinct + against the white cards (the Code block has no border of its own). Dark + mode keeps the darker `dark-9` inset below. */ + --inspector-surface-code: var(--inspector-surface-body); + /* Raised content surface used for content views that sit ON a grey inset + message card (Protocol/Network): white in light mode so they read as + raised above the grey card — the inverse of the grey-on-white insets + elsewhere. Applied via the Card `inset` variant (see theme/Card.ts). */ + --inspector-surface-code-oncard: var(--mantine-color-white); /* Subtle inset surface for modal call-outs / summary panels. Mantine's `default-hover` already adapts to color scheme, so no dark override. */ --inspector-surface-subtle: var(--mantine-color-default-hover); @@ -77,15 +96,133 @@ /* ── History ───────────────────────────────────────────── */ --inspector-history-success: var(--mantine-color-green-6); --inspector-history-error: var(--mantine-color-red-6); + + /* ── Protocol / Network badges ─────────────────────────── */ + /* Direction (client→server / server→client) and method chips. Light mode + keeps the tinted "light variant" look via Mantine's *-light tokens. */ + --inspector-badge-outgoing-bg: var(--mantine-color-green-light); + --inspector-badge-outgoing-fg: var(--mantine-color-green-light-color); + --inspector-badge-incoming-bg: var(--mantine-color-violet-light); + --inspector-badge-incoming-fg: var(--mantine-color-violet-light-color); + /* Method: a neutral gray chip that adapts to the scheme — light-gray with + dark text in light mode, dark-gray with light text in dark mode (see the + dark override below). Matches the surrounding surface family. */ + --inspector-badge-method-bg: var(--mantine-color-gray-2); + --inspector-badge-method-fg: var(--mantine-color-gray-7); + /* Network request category chips — `transport` (blue) / `auth` (violet). Same + treatment as the direction badges: a tinted light-variant fill here, a deep + saturated fill with light text in dark mode (below). */ + --inspector-badge-transport-bg: var(--mantine-color-blue-light); + --inspector-badge-transport-fg: var(--mantine-color-blue-light-color); + --inspector-badge-auth-bg: var(--mantine-color-violet-light); + --inspector-badge-auth-fg: var(--mantine-color-violet-light-color); } /* Dark mode overrides — tokens that need adjustment in dark scheme */ [data-mantine-color-scheme="dark"] { + /* Charcoal body matched to the header's segmented tab control (`dark-8`, the + Mantine SegmentedControl fill) — mirrors light mode, where the body and the + tab bar share `gray-1`. Cards (`gray-9`) still sit a step above it. */ + --inspector-surface-body: var(--mantine-color-dark-8); --inspector-surface-card: var(--mantine-color-gray-9); + /* Dark keeps the message cards at the standard card tone (they already + contrast against the darker panel/body), so only light mode goes grey. */ + --inspector-surface-inset: var(--mantine-color-gray-9); + /* On a dark message card, content views stay the darker `dark-9` inset (they + already contrast against the `gray-9` card) — only light mode inverts to + white. */ + --inspector-surface-code-oncard: var(--mantine-color-dark-9); --inspector-surface-code: var(--mantine-color-dark-9); --inspector-border-subtle: var(--mantine-color-gray-4); --inspector-input-background: var(--mantine-color-dark-9); --inspector-scrollbar-thumb: rgba(255, 255, 255, 0.4); + + /* The semantic status/log/task colors use the same `*-6` shade in both + schemes, which sits too dark on charcoal — lift them one to two steps so + they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */ + + /* Status */ + --inspector-status-connected: var(--mantine-color-green-4); + --inspector-status-connecting: var(--mantine-color-yellow-4); + --inspector-status-error: var(--mantine-color-red-4); + + /* Log levels (RFC 5424) */ + --inspector-log-info: var(--mantine-color-blue-4); + --inspector-log-notice: var(--mantine-color-teal-4); + --inspector-log-warning: var(--mantine-color-yellow-4); + --inspector-log-error: var(--mantine-color-red-4); + --inspector-log-critical: var(--mantine-color-red-5); + --inspector-log-alert: var(--mantine-color-red-6); + --inspector-log-emergency: var(--mantine-color-red-7); + + /* Task status */ + --inspector-task-running: var(--mantine-color-blue-4); + --inspector-task-completed: var(--mantine-color-green-4); + --inspector-task-failed: var(--mantine-color-red-4); + --inspector-task-cancelled: var(--mantine-color-yellow-4); + + /* History */ + --inspector-history-success: var(--mantine-color-green-4); + --inspector-history-error: var(--mantine-color-red-4); + + /* Protocol/Network badges — solid fills with light text on charcoal (the + `*-light` tints from `:root` render washed-out here). Direction: a deep + green (distinct from the brighter green-6 "OK" status chip) and a saturated + violet; method: a dark-slate chip a step above the card. */ + --inspector-badge-outgoing-bg: var(--mantine-color-green-8); + --inspector-badge-outgoing-fg: var(--mantine-color-green-0); + --inspector-badge-incoming-bg: var(--mantine-color-violet-6); + --inspector-badge-incoming-fg: var(--mantine-color-violet-0); + --inspector-badge-method-bg: var(--mantine-color-dark-5); + --inspector-badge-method-fg: var(--mantine-color-gray-2); + /* Category chips: deep blue (transport) / violet (auth) fills with light + text, matching the server→client direction badge. */ + --inspector-badge-transport-bg: var(--mantine-color-blue-6); + --inspector-badge-transport-fg: var(--mantine-color-blue-0); + --inspector-badge-auth-bg: var(--mantine-color-violet-6); + --inspector-badge-auth-fg: var(--mantine-color-violet-0); + + /* ── A11y contrast (dark) ──────────────────────────────── */ + /* Mantine's dark `dimmed` (dark-2) drops below AA on the lighter dark + surfaces (selected/hover rows); lift it one step. `!important` because + Mantine injects this var at runtime at this same specificity and would + otherwise win the cascade tie (see the light-scheme block below). */ + --mantine-color-dimmed: var(--mantine-color-dark-1) !important; + /* Lighter danger red for the dark card/header (deep red would fail AA). */ + --inspector-danger-text: var(--mantine-color-red-4); +} + +/* A11y contrast (light): Mantine sets these per-scheme on a selector more + specific than `:root`, so override them at matching specificity (App.css + loads after Mantine, so it wins). Darkens the `dimmed` secondary text and the + `*-light` variant text so both clear WCAG AA (4.5:1) on light surfaces — + fixes ~600 of the axe color-contrast hits (dimmed text + tinted + badges/buttons/alerts). Dark mode keeps Mantine's lighter light-variant text. +*/ +:root[data-mantine-color-scheme="light"] { + /* `!important`: Mantine injects the `*-light-color` variables at runtime at + the same specificity as this rule, so it wins the cascade tie without this. + A justified override of a third-party variable to meet WCAG AA — not a lint + workaround. `dimmed` already wins on specificity but is flagged for parity. */ + --mantine-color-dimmed: var(--mantine-color-gray-7) !important; + --mantine-color-inspector-light-color: var( + --mantine-color-inspector-8 + ) !important; + --mantine-color-blue-light-color: var(--mantine-color-blue-8) !important; + --mantine-color-green-light-color: var(--mantine-color-green-8) !important; + --mantine-color-teal-light-color: var(--mantine-color-teal-8) !important; + --mantine-color-red-light-color: var(--mantine-color-red-8) !important; + --mantine-color-violet-light-color: var(--mantine-color-violet-8) !important; + --mantine-color-orange-light-color: var(--mantine-color-orange-8) !important; + --mantine-color-yellow-light-color: var(--mantine-color-yellow-9) !important; +} + +/* The app background. Mantine paints `body` with `--mantine-color-body` (pure + white in light mode); override it here — App.css loads after Mantine's + styles, so this wins — so the light-grey `--inspector-surface-body` shows and + the white cards read as raised. Scheme-aware via the token's dark override. */ +body { + background-color: var(--inspector-surface-body); } /* ── Animations ──────────────────────────────────────────── */ diff --git a/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx b/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx index 9f1f08f9..03e31e16 100644 --- a/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx +++ b/clients/web/src/components/elements/AnnotationBadge/AnnotationBadge.tsx @@ -1,5 +1,6 @@ -import { Badge, useComputedColorScheme } from "@mantine/core"; +import { Badge } from "@mantine/core"; import type { Role } from "@modelcontextprotocol/sdk/types.js"; +import { filledBadgeColor } from "../filledBadgeColor"; export type AnnotationFacet = | "audience" @@ -52,11 +53,14 @@ function formatLabel( } export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) { - const colorScheme = useComputedColorScheme(); - const color = colorMap[facet]; - const textColor = colorScheme === "dark" ? "black" : "white"; + const color = filledBadgeColor(colorMap[facet]); + // `autoContrast` picks black or white text per the fill's luminance in each + // scheme, so the label stays legible (WCAG AA) on both the lighter light-mode + // fills and the darker dark-mode `-filled` shades — unlike a fixed + // scheme→black/white mapping, which inverted the contrast in dark mode. + // Amber fills are pinned to shade 5 first (see `filledBadgeColor`). return ( - + {formatLabel(facet, value)} ); diff --git a/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx new file mode 100644 index 00000000..f9118c49 --- /dev/null +++ b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { CategoryBadge } from "./CategoryBadge"; + +const meta: Meta = { + title: "Elements/CategoryBadge", + component: CategoryBadge, +}; + +export default meta; +type Story = StoryObj; + +export const Transport: Story = { + args: { category: "transport" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("transport")).toBeInTheDocument(); + }, +}; + +export const Auth: Story = { + args: { category: "auth" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("auth")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/CategoryBadge/CategoryBadge.test.tsx b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.test.tsx new file mode 100644 index 00000000..065276bb --- /dev/null +++ b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { CategoryBadge } from "./CategoryBadge"; + +describe("CategoryBadge", () => { + it("renders the transport category", () => { + renderWithMantine(); + expect(screen.getByText("transport")).toBeInTheDocument(); + }); + + it("renders the auth category", () => { + renderWithMantine(); + expect(screen.getByText("auth")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx new file mode 100644 index 00000000..a968c4ac --- /dev/null +++ b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx @@ -0,0 +1,34 @@ +import { Badge } from "@mantine/core"; +import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; + +export interface CategoryBadgeProps { + /** + * Network request category: `transport` (MCP protocol traffic, blue) or + * `auth` (OAuth discovery/token requests, violet). + */ + category: FetchRequestCategory; +} + +const BG: Record = { + transport: "var(--inspector-badge-transport-bg)", + auth: "var(--inspector-badge-auth-bg)", +}; + +const FG: Record = { + transport: "var(--inspector-badge-transport-fg)", + auth: "var(--inspector-badge-auth-fg)", +}; + +/** + * Badge tagging a Network entry's request category — `transport` (blue) or + * `auth` (violet). Surfaces come from `--inspector-badge-*` tokens: a tinted + * fill in light mode, a deep saturated fill with light text in dark mode + * (matching the Protocol direction badges). Used by `NetworkEntry`. + */ +export function CategoryBadge({ category }: CategoryBadgeProps) { + return ( + + {category} + + ); +} diff --git a/clients/web/src/components/elements/ConnectionToggle/ConnectionToggle.tsx b/clients/web/src/components/elements/ConnectionToggle/ConnectionToggle.tsx index 9ab70e2b..3d152076 100644 --- a/clients/web/src/components/elements/ConnectionToggle/ConnectionToggle.tsx +++ b/clients/web/src/components/elements/ConnectionToggle/ConnectionToggle.tsx @@ -5,12 +5,19 @@ export interface ConnectionToggleProps { status: ConnectionStatus; disabled?: boolean; onToggle: () => void; + /** + * Accessible name for the switch (it renders no visible label). Callers pass + * a server-specific label, e.g. `Connect or disconnect "Alpha"`; defaults to + * a generic label so the control is never unlabeled (WCAG `label`). + */ + "aria-label"?: string; } export function ConnectionToggle({ status, disabled = false, onToggle, + "aria-label": ariaLabel = "Toggle server connection", }: ConnectionToggleProps) { const isConnected = status === "connected"; const isConnecting = status === "connecting"; @@ -21,6 +28,7 @@ export function ConnectionToggle({ checked={isConnected || isConnecting} disabled={disabled || isConnecting} onChange={onToggle} + aria-label={ariaLabel} /> ); } diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx index 263ee895..64087d9a 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx @@ -171,6 +171,9 @@ function PlainTextContent({ {children} @@ -68,6 +69,7 @@ export function EmbeddableScrollArea({ type="scroll" offsetScrollbars styles={styles} + viewportProps={{ tabIndex: 0 }} > {children} diff --git a/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx b/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx index 42161b65..1f7af2e0 100644 --- a/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx +++ b/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx @@ -1,4 +1,5 @@ import { Text, UnstyledButton } from "@mantine/core"; +import { accessibleTextColor } from "../accessibleTextColor"; export interface FilterToggleButtonProps { /** Visible label and accessible name for the toggle. */ @@ -38,7 +39,7 @@ export function FilterToggleButton({ aria-pressed={active} onClick={() => onToggle(!active)} > - {label} + {label} ); } diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 7587f9a3..2f1c63f4 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -4,6 +4,7 @@ import type { LoggingMessageNotification, } from "@modelcontextprotocol/sdk/types.js"; import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge"; +import { accessibleTextColor } from "../accessibleTextColor"; export interface LogEntryData { receivedAt: Date; @@ -95,7 +96,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) { {logger} - + {message} @@ -107,7 +110,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) { {formatTimestamp(receivedAt)} {logger} - {message} + + {message} + ); } diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx index 12168ae0..5c587ded 100644 --- a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx +++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx @@ -1,5 +1,6 @@ -import { Badge, useComputedColorScheme } from "@mantine/core"; +import { Badge } from "@mantine/core"; import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; +import { filledBadgeColor } from "../filledBadgeColor"; export interface LogLevelBadgeProps { level: LoggingLevel; @@ -19,12 +20,17 @@ const levelColor: Record = { const boldLevels: Set = new Set(["alert", "emergency"]); export function LogLevelBadge({ level }: LogLevelBadgeProps) { - const colorScheme = useComputedColorScheme(); - const textColor = colorScheme === "dark" ? "black" : "white"; const fw = boldLevels.has(level) ? 500 : undefined; + // `autoContrast` keeps the label legible (WCAG AA) on both the light-mode + // fills and the darker dark-mode `-filled` shades — see AnnotationBadge. return ( - + {level} ); diff --git a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx index 785456cb..890dd332 100644 --- a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx +++ b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.tsx @@ -14,20 +14,29 @@ const LABEL: Record = { incoming: "server → client", }; +const BG: Record = { + outgoing: "var(--inspector-badge-outgoing-bg)", + incoming: "var(--inspector-badge-incoming-bg)", +}; + +const FG: Record = { + outgoing: "var(--inspector-badge-outgoing-fg)", + incoming: "var(--inspector-badge-incoming-fg)", +}; + /** * Dual-state badge showing which way a Protocol/Network entry traveled. Outgoing * (client → server) is green; incoming (server → client) is purple — not yellow, * which (paired with green) reads as caution/ok status rather than direction. - * Shared by `ProtocolEntry` and `NetworkEntry`. + * Surfaces come from `--inspector-badge-*` tokens: a tinted fill in light mode, + * a deep saturated fill with light text in dark mode. Shared by `ProtocolEntry` + * and `NetworkEntry`. */ export function MessageDirectionBadge({ direction, }: MessageDirectionBadgeProps) { return ( - + {LABEL[direction]} ); diff --git a/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx b/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx new file mode 100644 index 00000000..91e3abb5 --- /dev/null +++ b/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { MethodBadge } from "./MethodBadge"; + +const meta: Meta = { + title: "Elements/MethodBadge", + component: MethodBadge, +}; + +export default meta; +type Story = StoryObj; + +export const ToolsList: Story = { + args: { method: "tools/list" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("tools/list")).toBeInTheDocument(); + }, +}; + +export const TasksList: Story = { + args: { method: "tasks/list" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("tasks/list")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/MethodBadge/MethodBadge.test.tsx b/clients/web/src/components/elements/MethodBadge/MethodBadge.test.tsx new file mode 100644 index 00000000..a2167c7b --- /dev/null +++ b/clients/web/src/components/elements/MethodBadge/MethodBadge.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MethodBadge } from "./MethodBadge"; + +describe("MethodBadge", () => { + it("renders the method name", () => { + renderWithMantine(); + expect(screen.getByText("tools/list")).toBeInTheDocument(); + }); + + it("renders an arbitrary method string verbatim", () => { + renderWithMantine(); + expect(screen.getByText("notifications/message")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx b/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx new file mode 100644 index 00000000..130601d8 --- /dev/null +++ b/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx @@ -0,0 +1,23 @@ +import { Badge } from "@mantine/core"; + +export interface MethodBadgeProps { + /** Protocol/RPC method name, e.g. "tools/list". */ + method: string; +} + +/** + * Badge labelling a Protocol/Network entry's method. A neutral charcoal chip with + * light text, driven by `--inspector-badge-method-*` so it stays legible (not a + * washed-out pale fill) in dark mode. Shared by `ProtocolEntry` and `NetworkEntry`. + */ +export function MethodBadge({ method }: MethodBadgeProps) { + return ( + + {method} + + ); +} diff --git a/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx b/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx index 1e03791d..a2bc8fb9 100644 --- a/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx +++ b/clients/web/src/components/elements/ProgressDisplay/ProgressDisplay.tsx @@ -39,7 +39,11 @@ export function ProgressDisplay({ params, elapsed }: ProgressDisplayProps) { {params.message && {params.message}} {formatPercent(percent)} - + {elapsed && {elapsed}} ); diff --git a/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.tsx b/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.tsx index 549b1ff3..531c8cb3 100644 --- a/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.tsx +++ b/clients/web/src/components/elements/TaskStatusBadge/TaskStatusBadge.tsx @@ -1,5 +1,6 @@ -import { Badge, useComputedColorScheme } from "@mantine/core"; +import { Badge } from "@mantine/core"; import type { TaskStatus } from "@modelcontextprotocol/sdk/types.js"; +import { filledBadgeColor } from "../filledBadgeColor"; export interface TaskStatusBadgeProps { status: TaskStatus; @@ -22,11 +23,14 @@ const statusLabel: Record = { }; export function TaskStatusBadge({ status }: TaskStatusBadgeProps) { - const colorScheme = useComputedColorScheme(); - const textColor = colorScheme === "dark" ? "black" : "white"; - + // `autoContrast` keeps the label legible (WCAG AA) on both the light-mode + // fills and the darker dark-mode `-filled` shades — see AnnotationBadge. return ( - + {statusLabel[status]} ); diff --git a/clients/web/src/components/elements/accessibleTextColor.test.ts b/clients/web/src/components/elements/accessibleTextColor.test.ts new file mode 100644 index 00000000..2c4a2331 --- /dev/null +++ b/clients/web/src/components/elements/accessibleTextColor.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { accessibleTextColor } from "./accessibleTextColor"; + +describe("accessibleTextColor", () => { + it("maps a color name to its scheme-aware -light-color variable", () => { + expect(accessibleTextColor("yellow")).toBe( + "var(--mantine-color-yellow-light-color)", + ); + expect(accessibleTextColor("red")).toBe( + "var(--mantine-color-red-light-color)", + ); + }); + + it("passes dimmed through unchanged (already AA-tuned)", () => { + expect(accessibleTextColor("dimmed")).toBe("dimmed"); + }); + + it("passes undefined through (inherit default text color)", () => { + expect(accessibleTextColor(undefined)).toBeUndefined(); + }); +}); diff --git a/clients/web/src/components/elements/accessibleTextColor.ts b/clients/web/src/components/elements/accessibleTextColor.ts new file mode 100644 index 00000000..79e5da83 --- /dev/null +++ b/clients/web/src/components/elements/accessibleTextColor.ts @@ -0,0 +1,16 @@ +/** + * Maps a bare Mantine color name to its scheme-aware `*-light-color` variable + * for use as **colored text**. A bare `c="yellow"` resolves to a mid `filled` + * shade that fails WCAG AA on light surfaces (amber/green/red text on white or + * on the `inspector-light` selected-chip tint land at ~3–4:1). The `-light-color` + * variable is scheme-aware — the app darkens it to shade 8/9 in light mode (see + * the `App.css` light-scheme block) and Mantine keeps a lighter shade in dark + * mode — so the same token clears AA against both light and dark backgrounds. + * + * `"dimmed"` is passed through unchanged (already AA-tuned to gray-7 / dark-1 in + * `App.css`), as is `undefined` (inherit the default body text color). + */ +export function accessibleTextColor(color?: string): string | undefined { + if (!color || color === "dimmed") return color; + return `var(--mantine-color-${color}-light-color)`; +} diff --git a/clients/web/src/components/elements/filledBadgeColor.test.ts b/clients/web/src/components/elements/filledBadgeColor.test.ts new file mode 100644 index 00000000..f7efed55 --- /dev/null +++ b/clients/web/src/components/elements/filledBadgeColor.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { filledBadgeColor } from "./filledBadgeColor"; + +describe("filledBadgeColor", () => { + it("pins amber fills to shade 5 so autoContrast picks black text", () => { + expect(filledBadgeColor("yellow")).toBe("yellow.5"); + expect(filledBadgeColor("orange")).toBe("orange.5"); + }); + + it("returns every other color unchanged", () => { + for (const c of [ + "blue", + "green", + "red", + "teal", + "grape", + "violet", + "gray", + ]) { + expect(filledBadgeColor(c)).toBe(c); + } + }); +}); diff --git a/clients/web/src/components/elements/filledBadgeColor.ts b/clients/web/src/components/elements/filledBadgeColor.ts new file mode 100644 index 00000000..75f8a608 --- /dev/null +++ b/clients/web/src/components/elements/filledBadgeColor.ts @@ -0,0 +1,16 @@ +/** + * Amber fills (`orange` / `yellow`) at Mantine's default filled shade land right + * at `autoContrast`'s luminance threshold, so it picks WHITE text that fails + * WCAG AA on amber (~2–4:1 in both schemes). Pinning those two colors to shade 5 + * keeps the fill bright while moving `autoContrast` decisively onto BLACK text + * (~9–10:1). Every other color is returned unchanged — its default + * filled + `autoContrast` pairing already clears AA. + * + * Used by the filled semantic badges (annotation / task-status / log-level) so + * the amber-contrast fix lives in one place rather than each color map. + */ +export function filledBadgeColor(color: string): string { + if (color === "yellow") return "yellow.5"; + if (color === "orange") return "orange.5"; + return color; +} diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx index adf956bd..33b39b00 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core"; +import { CloseButton, Group, Modal, Stack } from "@mantine/core"; import { ListToggle } from "../../elements/ListToggle/ListToggle"; import { ClientSettingsForm, @@ -75,10 +75,11 @@ export function ClientSettingsModal({ variant="subtle" onToggle={handleToggleAll} /> - + {/* `Modal.Title` names the dialog (wires `aria-labelledby`). */} + <Modal.Title ta="center" flex={1}> Client Settings - - + + Client ID - {oauth.clientId} + {oauth.clientId} )} {oauth.clientRegistrationKind && ( @@ -228,7 +239,7 @@ export function ConnectionInfoContent({ {oauth.authUrl && ( Auth URL - {oauth.authUrl} + {oauth.authUrl} )} {oauth.scopes && oauth.scopes.length > 0 && ( diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx index 12324984..8852c6f1 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx @@ -1,4 +1,4 @@ -import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core"; +import { CloseButton, Group, Modal, Stack } from "@mantine/core"; import type { ClientCapabilities, InitializeResult, @@ -38,9 +38,9 @@ export function ConnectionInfoModal({ > - - Connection Info - + {/* `Modal.Title` (not a bare `Title`) registers the modal's + accessible name — it wires the dialog's `aria-labelledby`. */} + Connection Info [name, { type: "string" as const, title: name }]), + ), + }, +} satisfies ElicitRequestFormParams; + export const SimpleForm: Story = { args: { request: dbRequest }, }; +export const ManyFields: Story = { + args: { request: manyFieldsRequest }, +}; + export const WithEnums: Story = { args: { request: sslRequest }, }; diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx index 092e9eb6..98185b64 100644 --- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx +++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx @@ -1,4 +1,12 @@ -import { Alert, Button, Divider, Group, Stack, Text } from "@mantine/core"; +import { + Alert, + Button, + Divider, + Group, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; import type { ElicitRequestFormParams } from "@modelcontextprotocol/sdk/types.js"; import { hasMissingRequiredFields, @@ -28,6 +36,20 @@ const QuotedMessage = Text.withProps({ fs: "italic", }); +// Only the form fields scroll; the message stays above and the warning + action +// buttons stay below (in normal flow), always in view — the modal has no other +// close affordance, so the actions must never scroll out of reach. Capping the +// fields at the modal's max height (≈90dvh) minus the ~22rem of non-field chrome +// (modal header/padding + message + warning + buttons + gaps) keeps the whole +// modal within the viewport. `ScrollArea.Autosize` sizes to content when the +// form is short (nothing scrolls, modal stays compact) and scrolls once the +// fields hit the cap. +const FieldScroll = ScrollArea.Autosize.withProps({ + mah: "calc(90dvh - 22rem)", + scrollbars: "y", + offsetScrollbars: true, +}); + function formatQuoted(text: string): string { return `\u201C${text}\u201D`; } @@ -53,13 +75,15 @@ export function ElicitationFormPanel({ {formatQuoted(request.message)} - - + + + + {formatWarning(serverName)} diff --git a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx index df69fcf3..fc995f37 100644 --- a/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx +++ b/clients/web/src/components/groups/ElicitationUrlPanel/ElicitationUrlPanel.tsx @@ -89,7 +89,7 @@ export function ElicitationUrlPanel({ )} {formatRequestId(requestId)} - + This will open an external URL. Verify the domain before proceeding. diff --git a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx index 6150a0b9..4117dea6 100644 --- a/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx +++ b/clients/web/src/components/groups/ExperimentalFeaturesPanel/ExperimentalFeaturesPanel.tsx @@ -177,7 +177,7 @@ export function ExperimentalFeaturesPanel({ return ( - + These features are non-standard and may change or be removed. @@ -309,6 +309,7 @@ export function ExperimentalFeaturesPanel({ )}