web: Accessibility — accessible palette + WCAG AA contrast, labels, names, roles (#1679)

* web: accessible light/dark color palette + dark-mode legibility fixes (#1678)

Ports the design system's accessible palette. Colors are defined once as Mantine
color tuples in theme.ts (shade 0 lightest → 9 darkest); every `--inspector-*`
token in App.css references `--mantine-color-*`, so improving the tuples cascades
through the whole UI.

- theme.ts: full accessible tuple set (inspector/gray/dark/green/red/yellow/blue/
  teal/violet), WCAG-AA at the primary shades. Tuples are the one place literal
  hex belongs — the token source. Primary stays inspector-7 (light) / -8 (dark).
- theme/ActionIcon.ts: default `color: "gray"` so chrome icons (settings, theme
  toggle, sidebar, pin, sort, replay) render neutral grey instead of inheriting
  the blue primary. Icons that set their own color/variant still override.
- App.css: fix light surfaces (`surface-body` gray-0, `surface-card` white — the
  body token pointed at near-black dark-9); add direction/method badge tokens;
  lift the semantic status/log/task/history colors 1–2 steps in the dark scheme
  (the shared `*-6` shade sat too dark on charcoal). Light `:root` keeps `*-6`.
- Badges: MessageDirectionBadge + new MethodBadge element driven by
  `--inspector-badge-*` tokens (deep fill + light text in dark, tinted in light)
  via Mantine `bg`/`c` style props with `autoContrast={false}`; swaps
  `<Badge color="dark">{method}</Badge>` in ProtocolEntry/NetworkEntry (their
  Badge import stays — both still use it for status/category chips). Stories +
  test added for MethodBadge (≥90 coverage gate).

Domain badge semantics live in the element components, not theme files. No raw
hex outside the tuples; no inline styles. Supersedes the reverted #1677.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw

* web: WCAG AA accessibility pass — contrast, labels, names, roles (#1678)

Drives the axe-core ruleset (the engine behind Storybook's a11y addon) to
zero violations across all stories in light and dark mode.

Contrast (805 → 0 hits):
- Darken Mantine `*-light-color` + `dimmed` in light mode (and lift dark
  `dimmed`) via `!important` App.css overrides — Mantine injects these at
  runtime at matching specificity, so specificity alone loses the tie.
- `filledBadgeColor` pins amber (orange/yellow) filled badges to shade 5 so
  `autoContrast` (JS-computed from the theme shade, not CSS vars) picks black.
- `accessibleTextColor` routes colored log/filter text through the scheme-aware
  `-light-color` token so it clears AA on light and dark surfaces.
- `status` Badge variant: amber statuses use black-on-amber (orange text can't
  reach AA on light at any Mantine shade).
- Resource URIs use `-light-color` (blue-4 was 4.38:1 in dark).

Names / roles:
- `aria-dialog-name`: custom-header modals use `Modal.Title` (wires the
  dialog's `aria-labelledby`) instead of a bare `Title` + a root `aria-label`
  (which also tripped `aria-prohibited-attr`).
- `button-name`: a `Modal` theme default labels every built-in close button.
- `label`: `aria-label` on the previously-unlabelled Textarea/Select inputs.
- `scrollable-region-focusable`: keyboard-focusable content Code/ScrollArea
  viewports; long OAuth values wrap instead of scrolling.
- `heading-order`: correct heading levels (keeping visual size via `size`) so
  outlines don't skip a level under the h2 modal/section titles.

Scrollbars: `type="scroll"` ScrollArea theme default — scrollbars auto-hide and
show only while scrolling (a deliberate product choice over the always-visible
a11y default).

`region` is disabled in the Storybook a11y config: isolated component/screen
stories render outside the app's `AppShell` landmarks, so it's a page-level
concern the components can't own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: let the resource template form fill the main-area width (#1678)

Drop the maw=40% cap on the template-form pane so the URI preview and
variable inputs use the full width beside the sidebar, matching the
resource preview pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: left-align + reverse the input-panel action rows (#1678)

Move the footer action buttons to the left (justify flex-start) and put
the primary action first on the Tool / Prompt / Resource-preview /
Resource-template panels — Subscribe, Refresh, View Source on the
preview panel; Execute before Cancel on the tool panel. Shortens the
pointer travel from the sidebar controls / the form fields above to the
actions, which matters most when the monitoring sidebar is hidden and
the form is wide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: match the dark-mode body background to the header tab bar (#1678)

Set the dark `--inspector-surface-body` to `dark-8` (the Mantine
SegmentedControl fill used by the header tab bar) instead of `dark-9`,
mirroring light mode where the body and the tab bar share `gray-1`.
Cards (`gray-9`) still sit a step above the body, and the contrast audit
stays at zero AA violations on the slightly lighter background.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: enforce the a11y gate + fix modal-overlay heading-order (review #1679)

Address the code review:
- Flip the Storybook a11y addon from test: 'todo' to 'error' so axe
  violations fail the play-function tests in CI — the zero-violation
  state is now gated, not point-in-time.
- Enforcing it surfaced a real heading-order skip: the Tools screen's
  h4 'Tools'/'Results' sit under an h2 Modal.Title when a sampling/
  elicitation request modal opens over them. Lift both to h3 (size h4
  preserves the look) so h2→h3 is valid in any DOM order.
- Comment the intentional color+c pairing on the Remove/Disconnect
  danger buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: brighter warning alerts + pin the elicitation form's actions (#1678)

- Add a 'warning' Alert variant: a bright light-yellow fill (yellow-1)
  with crisp near-black text and a bright yellow-5 left accent, instead
  of the muddy brown a tinted yellow alert produced once
  yellow-light-color was darkened to yellow-9 for contrast. Dark mode
  keeps the light-on-dark-tint treatment. Applied to all five yellow
  warning/notice alerts.
- Elicitation form: scroll only the form fields (capped ScrollArea),
  keeping the quoted message, warning, and Cancel/Decline/Submit buttons
  in view. The modal has no other close affordance, so the actions must
  never scroll out of reach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: robustly pin the elicitation form's actions (#1678)

The previous approach capped only the fields' ScrollArea with a fixed
rem reserve for the pinned chrome, which underestimated it on some
viewports — the whole modal then overflowed and the buttons clipped
half-off-screen. Instead cap the whole panel below the modal height and
lay it out as a flex column: the message and the warning + buttons are
pinned (flex: 0 0 auto) and only the fields (flex: 1) shrink and scroll.
Verified the buttons stay within the panel and the modal fits the
viewport down to very short heights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: fix elicitation form scrolling — fields scroll, actions pinned (#1678)

The prior fix capped the panel with a bare max-height, but a flex:1
ScrollArea's viewport won't bound against a max-height (not fixed-height)
parent, so a tall form's fields overflowed unscrollably (clipped) rather
than scrolling. Adopt ToolDetailPanel's pattern instead: the modal gives
its body a bounded flex column, the panel fills it, and the fields use a
flex: "0 1 auto" ScrollArea — which sizes to content when the form is
short (modal stays compact, nothing scrolls) and shrinks + scrolls once
the panel is capped at the modal height, keeping the message, warning,
and Cancel/Decline/Submit pinned in view. Add a ManyFields story (with a
bounded-height decorator mirroring the modal) to cover the scroll case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: fix elicitation scrolling in the real modal (cap fields, not flex)

The flex-column approach (panel flex:1 in a flex-column modal body, fields
flex:"0 1 auto") worked in a fixed-height Storybook decorator but NOT in the
real modal: the modal body is bounded by max-height, not an explicit height,
so the fields' ScrollArea viewport still couldn't bound — a tall form's
fields overflowed and pushed the warning + Cancel/Decline/Submit off-screen.

Drop the flex plumbing (and the modal-body styles) and cap the fields
directly with ScrollArea.Autosize mah=calc(90dvh - 22rem): it sizes to
content when the form is short (modal compact, nothing scrolls) and scrolls
once the fields hit the cap. The 22rem reserve = the measured non-field
chrome (modal header/padding + message + warning + buttons + gaps), so the
whole modal stays within 90dvh and the actions are always in view. Verified
in the real PendingClientRequestModal (new ElicitationFormTall story) for
both short and tall forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: pin the sampling modal's actions too (#1678)

Apply the elicitation-form treatment to SamplingRequestPanel: wrap the
request/response content in a ScrollArea.Autosize capped at
calc(90dvh - 10rem) so it scrolls while the Reject / Send Response buttons
stay pinned in view (the modal has no other close affordance). The 10rem
reserve is the pinned chrome (modal header/padding + the actions + gaps) —
smaller than the elicitation form's 22rem since there's no warning banner.
Add a SamplingTall story (8 messages + preferences) to cover the scroll
case in the real PendingClientRequestModal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-13 18:20:55 -04:00 • committed by GitHub
commit 3d75ef5f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1119 additions and 238 deletions

View file

@ -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 (`<main>`, 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',
},

View file

@ -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 ──────────────────────────────────────────── */

View file

@ -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 (
<Badge color={color} variant="filled" fw={500} c={textColor}>
<Badge color={color} variant="filled" fw={500} autoContrast>
{formatLabel(facet, value)}
</Badge>
);

View file

@ -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<typeof CategoryBadge> = {
title: "Elements/CategoryBadge",
component: CategoryBadge,
};
export default meta;
type Story = StoryObj<typeof CategoryBadge>;
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();
},
};

View file

@ -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(<CategoryBadge category="transport" />);
expect(screen.getByText("transport")).toBeInTheDocument();
});
it("renders the auth category", () => {
renderWithMantine(<CategoryBadge category="auth" />);
expect(screen.getByText("auth")).toBeInTheDocument();
});
});

View file

@ -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<FetchRequestCategory, string> = {
transport: "var(--inspector-badge-transport-bg)",
auth: "var(--inspector-badge-auth-bg)",
};
const FG: Record<FetchRequestCategory, string> = {
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 (
<Badge autoContrast={false} bg={BG[category]} c={FG[category]}>
{category}
</Badge>
);
}

View file

@ -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}
/>
);
}

View file

@ -171,6 +171,9 @@ function PlainTextContent({
<Code
block
p={36}
// A `block` Code has `overflow: auto`, so make it keyboard-scrollable
// (WCAG SC 2.1.1) — it holds only text, with no focusable child.
tabIndex={0}
variant={wrap ? "wrapping" : "nowrap"}
// When not wrapping, the value may be clipped with an ellipsis; expose
// the full text on hover so it's readable without copying.

View file

@ -55,6 +55,7 @@ export function EmbeddableScrollArea({
type="scroll"
offsetScrollbars
styles={styles}
viewportProps={{ tabIndex: 0 }}
>
{children}
</ScrollArea.Autosize>
@ -68,6 +69,7 @@ export function EmbeddableScrollArea({
type="scroll"
offsetScrollbars
styles={styles}
viewportProps={{ tabIndex: 0 }}
>
{children}
</ScrollArea.Autosize>

View file

@ -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)}
>
<ToggleLabel c={color}>{label}</ToggleLabel>
<ToggleLabel c={accessibleTextColor(color)}>{label}</ToggleLabel>
</UnstyledButton>
);
}

View file

@ -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) {
<LogLevelBadge level={params.level} />
{logger}
</MetaRow>
<CompactMessageText c={levelMessageColor[params.level]}>
<CompactMessageText
c={accessibleTextColor(levelMessageColor[params.level])}
>
{message}
</CompactMessageText>
</Stack>
@ -107,7 +110,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) {
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
<LogLevelBadge level={params.level} />
{logger}
<MessageText c={levelMessageColor[params.level]}>{message}</MessageText>
<MessageText c={accessibleTextColor(levelMessageColor[params.level])}>
{message}
</MessageText>
</Group>
);
}

View file

@ -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<LoggingLevel, string> = {
const boldLevels: Set<LoggingLevel> = 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 (
<Badge color={levelColor[level]} variant="filled" fw={fw} c={textColor}>
<Badge
color={filledBadgeColor(levelColor[level])}
variant="filled"
fw={fw}
autoContrast
>
{level}
</Badge>
);

View file

@ -14,20 +14,29 @@ const LABEL: Record<MessageDirectionBadgeProps["direction"], string> = {
incoming: "server → client",
};
const BG: Record<MessageDirectionBadgeProps["direction"], string> = {
outgoing: "var(--inspector-badge-outgoing-bg)",
incoming: "var(--inspector-badge-incoming-bg)",
};
const FG: Record<MessageDirectionBadgeProps["direction"], string> = {
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 (
<Badge
color={direction === "outgoing" ? "green" : "violet"}
variant="light"
>
<Badge autoContrast={false} bg={BG[direction]} c={FG[direction]}>
{LABEL[direction]}
</Badge>
);

View file

@ -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<typeof MethodBadge> = {
title: "Elements/MethodBadge",
component: MethodBadge,
};
export default meta;
type Story = StoryObj<typeof MethodBadge>;
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();
},
};

View file

@ -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(<MethodBadge method="tools/list" />);
expect(screen.getByText("tools/list")).toBeInTheDocument();
});
it("renders an arbitrary method string verbatim", () => {
renderWithMantine(<MethodBadge method="notifications/message" />);
expect(screen.getByText("notifications/message")).toBeInTheDocument();
});
});

View file

@ -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 (
<Badge
autoContrast={false}
bg="var(--inspector-badge-method-bg)"
c="var(--inspector-badge-method-fg)"
>
{method}
</Badge>
);
}

View file

@ -39,7 +39,11 @@ export function ProgressDisplay({ params, elapsed }: ProgressDisplayProps) {
{params.message && <ProgressLabel>{params.message}</ProgressLabel>}
<ProgressLabel>{formatPercent(percent)}</ProgressLabel>
</Group>
<Progress value={percent} size="sm" />
<Progress
value={percent}
size="sm"
aria-label={params.message ? `${params.message} progress` : "Progress"}
/>
{elapsed && <ElapsedText>{elapsed}</ElapsedText>}
</Stack>
);

View file

@ -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<TaskStatus, string> = {
};
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 (
<Badge color={statusColor[status]} variant="filled" c={textColor}>
<Badge
color={filledBadgeColor(statusColor[status])}
variant="filled"
autoContrast
>
{statusLabel[status]}
</Badge>
);

View file

@ -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();
});
});

View file

@ -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)`;
}

Some files were not shown because too many files have changed in this diff Show more