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:
parent
e2a97c1b8a
commit
3d75ef5f8e
64 changed files with 1119 additions and 238 deletions
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
16
clients/web/src/components/elements/accessibleTextColor.ts
Normal file
16
clients/web/src/components/elements/accessibleTextColor.ts
Normal 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
Loading…
Reference in a new issue