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

View file

@ -1,5 +1,6 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import { Badge } from "@mantine/core";
import type { Role } from "@modelcontextprotocol/sdk/types.js";
import { filledBadgeColor } from "../filledBadgeColor";
export type AnnotationFacet =
| "audience"
@ -52,11 +53,14 @@ function formatLabel(
}
export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) {
const colorScheme = useComputedColorScheme();
const color = colorMap[facet];
const textColor = colorScheme === "dark" ? "black" : "white";
const color = filledBadgeColor(colorMap[facet]);
// `autoContrast` picks black or white text per the fill's luminance in each
// scheme, so the label stays legible (WCAG AA) on both the lighter light-mode
// fills and the darker dark-mode `-filled` shades — unlike a fixed
// scheme→black/white mapping, which inverted the contrast in dark mode.
// Amber fills are pinned to shade 5 first (see `filledBadgeColor`).
return (
<Badge color={color} variant="filled" fw={500} c={textColor}>
<Badge color={color} variant="filled" fw={500} autoContrast>
{formatLabel(facet, value)}
</Badge>
);

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { CategoryBadge } from "./CategoryBadge";
const meta: Meta<typeof CategoryBadge> = {
title: "Elements/CategoryBadge",
component: CategoryBadge,
};
export default meta;
type Story = StoryObj<typeof CategoryBadge>;
export const Transport: Story = {
args: { category: "transport" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("transport")).toBeInTheDocument();
},
};
export const Auth: Story = {
args: { category: "auth" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("auth")).toBeInTheDocument();
},
};

View file

@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CategoryBadge } from "./CategoryBadge";
describe("CategoryBadge", () => {
it("renders the transport category", () => {
renderWithMantine(<CategoryBadge category="transport" />);
expect(screen.getByText("transport")).toBeInTheDocument();
});
it("renders the auth category", () => {
renderWithMantine(<CategoryBadge category="auth" />);
expect(screen.getByText("auth")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,34 @@
import { Badge } from "@mantine/core";
import type { FetchRequestCategory } from "@inspector/core/mcp/types.js";
export interface CategoryBadgeProps {
/**
* Network request category: `transport` (MCP protocol traffic, blue) or
* `auth` (OAuth discovery/token requests, violet).
*/
category: FetchRequestCategory;
}
const BG: Record<FetchRequestCategory, string> = {
transport: "var(--inspector-badge-transport-bg)",
auth: "var(--inspector-badge-auth-bg)",
};
const FG: Record<FetchRequestCategory, string> = {
transport: "var(--inspector-badge-transport-fg)",
auth: "var(--inspector-badge-auth-fg)",
};
/**
* Badge tagging a Network entry's request category — `transport` (blue) or
* `auth` (violet). Surfaces come from `--inspector-badge-*` tokens: a tinted
* fill in light mode, a deep saturated fill with light text in dark mode
* (matching the Protocol direction badges). Used by `NetworkEntry`.
*/
export function CategoryBadge({ category }: CategoryBadgeProps) {
return (
<Badge autoContrast={false} bg={BG[category]} c={FG[category]}>
{category}
</Badge>
);
}

View file

@ -5,12 +5,19 @@ export interface ConnectionToggleProps {
status: ConnectionStatus;
disabled?: boolean;
onToggle: () => void;
/**
* Accessible name for the switch (it renders no visible label). Callers pass
* a server-specific label, e.g. `Connect or disconnect "Alpha"`; defaults to
* a generic label so the control is never unlabeled (WCAG `label`).
*/
"aria-label"?: string;
}
export function ConnectionToggle({
status,
disabled = false,
onToggle,
"aria-label": ariaLabel = "Toggle server connection",
}: ConnectionToggleProps) {
const isConnected = status === "connected";
const isConnecting = status === "connecting";
@ -21,6 +28,7 @@ export function ConnectionToggle({
checked={isConnected || isConnecting}
disabled={disabled || isConnecting}
onChange={onToggle}
aria-label={ariaLabel}
/>
);
}

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { Text, UnstyledButton } from "@mantine/core";
import { accessibleTextColor } from "../accessibleTextColor";
export interface FilterToggleButtonProps {
/** Visible label and accessible name for the toggle. */
@ -38,7 +39,7 @@ export function FilterToggleButton({
aria-pressed={active}
onClick={() => onToggle(!active)}
>
<ToggleLabel c={color}>{label}</ToggleLabel>
<ToggleLabel c={accessibleTextColor(color)}>{label}</ToggleLabel>
</UnstyledButton>
);
}

View file

@ -4,6 +4,7 @@ import type {
LoggingMessageNotification,
} from "@modelcontextprotocol/sdk/types.js";
import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge";
import { accessibleTextColor } from "../accessibleTextColor";
export interface LogEntryData {
receivedAt: Date;
@ -95,7 +96,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) {
<LogLevelBadge level={params.level} />
{logger}
</MetaRow>
<CompactMessageText c={levelMessageColor[params.level]}>
<CompactMessageText
c={accessibleTextColor(levelMessageColor[params.level])}
>
{message}
</CompactMessageText>
</Stack>
@ -107,7 +110,9 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) {
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
<LogLevelBadge level={params.level} />
{logger}
<MessageText c={levelMessageColor[params.level]}>{message}</MessageText>
<MessageText c={accessibleTextColor(levelMessageColor[params.level])}>
{message}
</MessageText>
</Group>
);
}

View file

@ -1,5 +1,6 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import { Badge } from "@mantine/core";
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
import { filledBadgeColor } from "../filledBadgeColor";
export interface LogLevelBadgeProps {
level: LoggingLevel;
@ -19,12 +20,17 @@ const levelColor: Record<LoggingLevel, string> = {
const boldLevels: Set<LoggingLevel> = new Set(["alert", "emergency"]);
export function LogLevelBadge({ level }: LogLevelBadgeProps) {
const colorScheme = useComputedColorScheme();
const textColor = colorScheme === "dark" ? "black" : "white";
const fw = boldLevels.has(level) ? 500 : undefined;
// `autoContrast` keeps the label legible (WCAG AA) on both the light-mode
// fills and the darker dark-mode `-filled` shades — see AnnotationBadge.
return (
<Badge color={levelColor[level]} variant="filled" fw={fw} c={textColor}>
<Badge
color={filledBadgeColor(levelColor[level])}
variant="filled"
fw={fw}
autoContrast
>
{level}
</Badge>
);

View file

@ -14,20 +14,29 @@ const LABEL: Record<MessageDirectionBadgeProps["direction"], string> = {
incoming: "server → client",
};
const BG: Record<MessageDirectionBadgeProps["direction"], string> = {
outgoing: "var(--inspector-badge-outgoing-bg)",
incoming: "var(--inspector-badge-incoming-bg)",
};
const FG: Record<MessageDirectionBadgeProps["direction"], string> = {
outgoing: "var(--inspector-badge-outgoing-fg)",
incoming: "var(--inspector-badge-incoming-fg)",
};
/**
* Dual-state badge showing which way a Protocol/Network entry traveled. Outgoing
* (client → server) is green; incoming (server → client) is purple — not yellow,
* which (paired with green) reads as caution/ok status rather than direction.
* Shared by `ProtocolEntry` and `NetworkEntry`.
* Surfaces come from `--inspector-badge-*` tokens: a tinted fill in light mode,
* a deep saturated fill with light text in dark mode. Shared by `ProtocolEntry`
* and `NetworkEntry`.
*/
export function MessageDirectionBadge({
direction,
}: MessageDirectionBadgeProps) {
return (
<Badge
color={direction === "outgoing" ? "green" : "violet"}
variant="light"
>
<Badge autoContrast={false} bg={BG[direction]} c={FG[direction]}>
{LABEL[direction]}
</Badge>
);

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { MethodBadge } from "./MethodBadge";
const meta: Meta<typeof MethodBadge> = {
title: "Elements/MethodBadge",
component: MethodBadge,
};
export default meta;
type Story = StoryObj<typeof MethodBadge>;
export const ToolsList: Story = {
args: { method: "tools/list" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("tools/list")).toBeInTheDocument();
},
};
export const TasksList: Story = {
args: { method: "tasks/list" },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("tasks/list")).toBeInTheDocument();
},
};

View file

@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { MethodBadge } from "./MethodBadge";
describe("MethodBadge", () => {
it("renders the method name", () => {
renderWithMantine(<MethodBadge method="tools/list" />);
expect(screen.getByText("tools/list")).toBeInTheDocument();
});
it("renders an arbitrary method string verbatim", () => {
renderWithMantine(<MethodBadge method="notifications/message" />);
expect(screen.getByText("notifications/message")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,23 @@
import { Badge } from "@mantine/core";
export interface MethodBadgeProps {
/** Protocol/RPC method name, e.g. "tools/list". */
method: string;
}
/**
* Badge labelling a Protocol/Network entry's method. A neutral charcoal chip with
* light text, driven by `--inspector-badge-method-*` so it stays legible (not a
* washed-out pale fill) in dark mode. Shared by `ProtocolEntry` and `NetworkEntry`.
*/
export function MethodBadge({ method }: MethodBadgeProps) {
return (
<Badge
autoContrast={false}
bg="var(--inspector-badge-method-bg)"
c="var(--inspector-badge-method-fg)"
>
{method}
</Badge>
);
}

View file

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

View file

@ -1,5 +1,6 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import { Badge } from "@mantine/core";
import type { TaskStatus } from "@modelcontextprotocol/sdk/types.js";
import { filledBadgeColor } from "../filledBadgeColor";
export interface TaskStatusBadgeProps {
status: TaskStatus;
@ -22,11 +23,14 @@ const statusLabel: Record<TaskStatus, string> = {
};
export function TaskStatusBadge({ status }: TaskStatusBadgeProps) {
const colorScheme = useComputedColorScheme();
const textColor = colorScheme === "dark" ? "black" : "white";
// `autoContrast` keeps the label legible (WCAG AA) on both the light-mode
// fills and the darker dark-mode `-filled` shades — see AnnotationBadge.
return (
<Badge color={statusColor[status]} variant="filled" c={textColor}>
<Badge
color={filledBadgeColor(statusColor[status])}
variant="filled"
autoContrast
>
{statusLabel[status]}
</Badge>
);

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { accessibleTextColor } from "./accessibleTextColor";
describe("accessibleTextColor", () => {
it("maps a color name to its scheme-aware -light-color variable", () => {
expect(accessibleTextColor("yellow")).toBe(
"var(--mantine-color-yellow-light-color)",
);
expect(accessibleTextColor("red")).toBe(
"var(--mantine-color-red-light-color)",
);
});
it("passes dimmed through unchanged (already AA-tuned)", () => {
expect(accessibleTextColor("dimmed")).toBe("dimmed");
});
it("passes undefined through (inherit default text color)", () => {
expect(accessibleTextColor(undefined)).toBeUndefined();
});
});

View file

@ -0,0 +1,16 @@
/**
* Maps a bare Mantine color name to its scheme-aware `*-light-color` variable
* for use as **colored text**. A bare `c="yellow"` resolves to a mid `filled`
* shade that fails WCAG AA on light surfaces (amber/green/red text on white or
* on the `inspector-light` selected-chip tint land at ~3–4:1). The `-light-color`
* variable is scheme-aware — the app darkens it to shade 8/9 in light mode (see
* the `App.css` light-scheme block) and Mantine keeps a lighter shade in dark
* mode — so the same token clears AA against both light and dark backgrounds.
*
* `"dimmed"` is passed through unchanged (already AA-tuned to gray-7 / dark-1 in
* `App.css`), as is `undefined` (inherit the default body text color).
*/
export function accessibleTextColor(color?: string): string | undefined {
if (!color || color === "dimmed") return color;
return `var(--mantine-color-${color}-light-color)`;
}

View file

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

View file

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