From 3d75ef5f8ecd75c82eeca30e22bbe8cb8dce600f Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Mon, 13 Jul 2026 18:20:55 -0400 Subject: [PATCH] =?UTF-8?q?web:=20Accessibility=20=E2=80=94=20accessible?= =?UTF-8?q?=20palette=20+=20WCAG=20AA=20contrast,=20labels,=20names,=20rol?= =?UTF-8?q?es=20(#1679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `{method}` 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) 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/.storybook/preview.tsx | 16 +- clients/web/src/App.css | 143 +++++++++- .../AnnotationBadge/AnnotationBadge.tsx | 14 +- .../CategoryBadge/CategoryBadge.stories.tsx | 27 ++ .../CategoryBadge/CategoryBadge.test.tsx | 15 ++ .../elements/CategoryBadge/CategoryBadge.tsx | 34 +++ .../ConnectionToggle/ConnectionToggle.tsx | 8 + .../elements/ContentViewer/ContentViewer.tsx | 3 + .../EmbeddableScrollArea.tsx | 2 + .../FilterToggleButton/FilterToggleButton.tsx | 3 +- .../components/elements/LogEntry/LogEntry.tsx | 9 +- .../elements/LogLevelBadge/LogLevelBadge.tsx | 14 +- .../MessageDirectionBadge.tsx | 19 +- .../MethodBadge/MethodBadge.stories.tsx | 27 ++ .../elements/MethodBadge/MethodBadge.test.tsx | 15 ++ .../elements/MethodBadge/MethodBadge.tsx | 23 ++ .../ProgressDisplay/ProgressDisplay.tsx | 6 +- .../TaskStatusBadge/TaskStatusBadge.tsx | 14 +- .../elements/accessibleTextColor.test.ts | 21 ++ .../elements/accessibleTextColor.ts | 16 ++ .../elements/filledBadgeColor.test.ts | 23 ++ .../components/elements/filledBadgeColor.ts | 16 ++ .../ClientSettingsModal.tsx | 9 +- .../ConnectionInfoContent.tsx | 17 +- .../ConnectionInfoModal.tsx | 8 +- .../ElicitationFormPanel.stories.tsx | 30 +++ .../ElicitationFormPanel.tsx | 40 ++- .../ElicitationUrlPanel.tsx | 2 +- .../ExperimentalFeaturesPanel.tsx | 3 +- .../ImportServerJsonPanel.tsx | 1 + .../groups/LogControls/LogControls.tsx | 1 + .../MessageDirectionFilter.tsx | 6 +- .../groups/NetworkEntry/NetworkEntry.tsx | 33 ++- .../OutputValidationModal.tsx | 6 +- .../PendingClientRequestModal.stories.tsx | 69 +++++ .../PromptArgumentsForm.tsx | 4 +- .../groups/PromptControls/PromptControls.tsx | 2 +- .../ProtocolControls/ProtocolControls.tsx | 6 +- .../groups/ProtocolEntry/ProtocolEntry.tsx | 10 +- .../ResourcePreviewPanel.tsx | 41 +-- .../ResourceTemplatePanel.tsx | 15 +- .../groups/RootsTable/RootsTable.tsx | 2 +- .../SamplingRequestPanel.test.tsx | 2 +- .../SamplingRequestPanel.tsx | 247 ++++++++++-------- .../groups/ServerCard/ServerCard.tsx | 4 + .../ServerImportConfigModal.tsx | 2 +- .../ServerSettingsModal.tsx | 9 +- .../components/groups/TaskCard/TaskCard.tsx | 1 + .../groups/TaskControls/TaskControls.tsx | 6 +- .../groups/ToolControls/ToolControls.tsx | 9 +- .../ToolDetailPanel/ToolDetailPanel.tsx | 6 +- .../ToolResultPanel/ToolResultPanel.tsx | 7 +- .../UrlElicitationErrorModal.tsx | 6 +- .../groups/ViewHeader/ViewHeader.tsx | 5 +- .../ResourcesScreen/ResourcesScreen.tsx | 8 +- clients/web/src/theme/ActionIcon.ts | 3 + clients/web/src/theme/Alert.ts | 23 ++ clients/web/src/theme/Badge.ts | 31 +++ clients/web/src/theme/Card.ts | 15 ++ clients/web/src/theme/Code.ts | 7 +- clients/web/src/theme/Modal.ts | 14 + clients/web/src/theme/ScrollArea.ts | 18 ++ clients/web/src/theme/index.ts | 2 + clients/web/src/theme/theme.ts | 147 ++++++++++- 64 files changed, 1113 insertions(+), 232 deletions(-) create mode 100644 clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx create mode 100644 clients/web/src/components/elements/CategoryBadge/CategoryBadge.test.tsx create mode 100644 clients/web/src/components/elements/CategoryBadge/CategoryBadge.tsx create mode 100644 clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx create mode 100644 clients/web/src/components/elements/MethodBadge/MethodBadge.test.tsx create mode 100644 clients/web/src/components/elements/MethodBadge/MethodBadge.tsx create mode 100644 clients/web/src/components/elements/accessibleTextColor.test.ts create mode 100644 clients/web/src/components/elements/accessibleTextColor.ts create mode 100644 clients/web/src/components/elements/filledBadgeColor.test.ts create mode 100644 clients/web/src/components/elements/filledBadgeColor.ts create mode 100644 clients/web/src/theme/Modal.ts create mode 100644 clients/web/src/theme/ScrollArea.ts 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({ )}