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
2 changed files with 1119 additions and 238 deletions

View file

@ -162,7 +162,7 @@ describe("SamplingRequestPanel", () => {
/>,
);
const textarea = screen.getByRole("textbox", {
name: "",
name: "Response",
}) as HTMLTextAreaElement;
expect(textarea.value).toBe("");
});

View file

@ -4,6 +4,7 @@ import {
Divider,
Group,
Paper,
ScrollArea,
Select,
Stack,
Text,
@ -45,11 +46,34 @@ function serializeJson(value: unknown): string {
return JSON.stringify(value);
}
// Section headings. `order: 3` (not 5) keeps them one level below the host
// modal's `h2` title so the outline doesn't skip a level (axe `heading-order`);
// `size: "h5"` preserves the original small visual size.
const SectionTitle = Title.withProps({ order: 3, size: "h5" });
const HintText = Text.withProps({
size: "sm",
c: "dimmed",
});
// Only the request/response content scrolls; the Reject / Send Response actions
// stay pinned 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
// content at the modal's max height (≈90dvh) minus the ~10rem of non-content
// chrome (modal header/padding + the pinned actions + gaps) keeps the whole
// modal within the viewport; `ScrollArea.Autosize` sizes to content when the
// request is short (nothing scrolls) and scrolls once it hits the cap. (Mirrors
// ElicitationFormPanel.)
const ContentScroll = ScrollArea.Autosize.withProps({
mah: "calc(90dvh - 10rem)",
scrollbars: "y",
offsetScrollbars: true,
});
// Inner column for the scrolled content, so `ContentScroll` wraps a single
// child and the sections keep their `md` spacing.
const ContentStack = Stack.withProps({ gap: "md" });
const PreferencesContainer = Paper.withProps({
p: "sm",
withBorder: true,
@ -83,121 +107,136 @@ export function SamplingRequestPanel({
return (
<Stack gap="md">
<HintText>The server is requesting an LLM completion.</HintText>
<ContentScroll>
<ContentStack>
<HintText>The server is requesting an LLM completion.</HintText>
<Title order={5}>Messages:</Title>
{messages.map((message, index) => (
<MessageBubble key={index} index={index} message={message} />
))}
<SectionTitle>Messages:</SectionTitle>
{messages.map((message, index) => (
<MessageBubble key={index} index={index} message={message} />
))}
{modelPreferences && (
<PreferencesContainer>
<Stack gap="xs">
<Title order={5}>Model Preferences:</Title>
{hints.length > 0 && (
<Group gap="xs">
<Text size="sm">Hints:</Text>
{hints.map((hint) => (
<Badge key={hint}>{hint}</Badge>
))}
</Group>
)}
{modelPreferences?.costPriority !== undefined && (
<Text size="sm">
Cost Priority: {formatPriority(modelPreferences.costPriority)}
</Text>
)}
{modelPreferences?.speedPriority !== undefined && (
<Text size="sm">
Speed Priority: {formatPriority(modelPreferences.speedPriority)}
</Text>
)}
{modelPreferences?.intelligencePriority !== undefined && (
<Text size="sm">
Intelligence Priority:{" "}
{formatPriority(modelPreferences.intelligencePriority)}
</Text>
)}
</Stack>
</PreferencesContainer>
)}
{modelPreferences && (
<PreferencesContainer>
<Stack gap="xs">
<SectionTitle>Model Preferences:</SectionTitle>
{hints.length > 0 && (
<Group gap="xs">
<Text size="sm">Hints:</Text>
{hints.map((hint) => (
<Badge key={hint}>{hint}</Badge>
))}
</Group>
)}
{modelPreferences?.costPriority !== undefined && (
<Text size="sm">
Cost Priority:{" "}
{formatPriority(modelPreferences.costPriority)}
</Text>
)}
{modelPreferences?.speedPriority !== undefined && (
<Text size="sm">
Speed Priority:{" "}
{formatPriority(modelPreferences.speedPriority)}
</Text>
)}
{modelPreferences?.intelligencePriority !== undefined && (
<Text size="sm">
Intelligence Priority:{" "}
{formatPriority(modelPreferences.intelligencePriority)}
</Text>
)}
</Stack>
</PreferencesContainer>
)}
<Title order={5}>Parameters:</Title>
<Text size="sm">
Max Tokens: {formatOptional(maxTokens, "not specified")}
</Text>
<Text size="sm">
Stop Sequences:{" "}
{stopSequences ? serializeJson(stopSequences) : "not specified"}
</Text>
<Text size="sm">
Temperature: {formatOptional(temperature, "not specified")}
</Text>
<SectionTitle>Parameters:</SectionTitle>
<Text size="sm">
Max Tokens: {formatOptional(maxTokens, "not specified")}
</Text>
<Text size="sm">
Stop Sequences:{" "}
{stopSequences ? serializeJson(stopSequences) : "not specified"}
</Text>
<Text size="sm">
Temperature: {formatOptional(temperature, "not specified")}
</Text>
{includeContext && (
<Group gap="xs">
<Text size="sm">Include Context:</Text>
<Badge>{includeContext}</Badge>
</Group>
)}
{includeContext && (
<Group gap="xs">
<Text size="sm">Include Context:</Text>
<Badge>{includeContext}</Badge>
</Group>
)}
<Divider />
<Divider />
<Title order={5}>Response:</Title>
<Textarea
value={
draftResult.content.type === "text" ? draftResult.content.text : ""
}
onChange={(event) =>
onResultChange({
...draftResult,
content: { type: "text", text: event.currentTarget.value },
})
}
autosize
minRows={3}
rightSectionPointerEvents="auto"
rightSection={
draftResult.content.type === "text" && draftResult.content.text ? (
<ClearButton
onClick={() =>
<SectionTitle>Response:</SectionTitle>
<Textarea
aria-label="Response"
value={
draftResult.content.type === "text"
? draftResult.content.text
: ""
}
onChange={(event) =>
onResultChange({
...draftResult,
content: { type: "text", text: event.currentTarget.value },
})
}
autosize
minRows={3}
rightSectionPointerEvents="auto"
rightSection={
draftResult.content.type === "text" &&
draftResult.content.text ? (
<ClearButton
onClick={() =>
onResultChange({
...draftResult,
content: { type: "text", text: "" },
})
}
/>
) : null
}
/>
<Group>
<TextInput
label="Model Used"
value={draftResult.model}
onChange={(event) =>
onResultChange({
...draftResult,
content: { type: "text", text: "" },
model: event.currentTarget.value,
})
}
rightSectionPointerEvents="auto"
rightSection={
draftResult.model ? (
<ClearButton
onClick={() =>
onResultChange({ ...draftResult, model: "" })
}
/>
) : null
}
/>
<Select
label="Stop Reason"
data={["endTurn", "stopSequence", "maxTokens"]}
value={draftResult.stopReason ?? null}
onChange={(value) =>
onResultChange({
...draftResult,
stopReason: value ?? undefined,
})
}
/>
) : null
}
/>
<Group>
<TextInput
label="Model Used"
value={draftResult.model}
onChange={(event) =>
onResultChange({ ...draftResult, model: event.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
draftResult.model ? (
<ClearButton
onClick={() => onResultChange({ ...draftResult, model: "" })}
/>
) : null
}
/>
<Select
label="Stop Reason"
data={["endTurn", "stopSequence", "maxTokens"]}
value={draftResult.stopReason ?? null}
onChange={(value) =>
onResultChange({
...draftResult,
stopReason: value ?? undefined,
})
}
/>
</Group>
</Group>
</ContentStack>
</ContentScroll>
<Group justify="flex-end">
<RejectButton onClick={onReject} disabled={busy}>
Reject