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
2 changed files with 1119 additions and 238 deletions
|
|
@ -162,7 +162,7 @@ describe("SamplingRequestPanel", () => {
|
|||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox", {
|
||||
name: "",
|
||||
name: "Response",
|
||||
}) as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue