Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 1 addition & 59 deletions e2e/stories-a11y.known.json
Original file line number Diff line number Diff line change
@@ -1,59 +1 @@
[
"chat-block-card--risk-levels (light): color-contrast",
"chat-tool-block--long-output-scrolls-in-place (dark): scrollable-region-focusable",
"chat-tool-block--long-output-scrolls-in-place (light): scrollable-region-focusable",
"primitives-badge--workflow-status (dark): role-img-alt",
"primitives-badge--workflow-status (light): role-img-alt",
"primitives-page-panel--scrolling (dark): scrollable-region-focusable",
"primitives-page-panel--scrolling (light): scrollable-region-focusable",
"primitives-select--default (dark): select-name",
"primitives-select--default (light): select-name",
"primitives-select--disabled (dark): select-name",
"primitives-select--disabled (light): select-name",
"primitives-select--disabled-invalid (dark): label-title-only",
"primitives-select--disabled-invalid (dark): select-name",
"primitives-select--disabled-invalid (light): label-title-only",
"primitives-select--disabled-invalid (light): select-name",
"primitives-select--disabled-with-description (dark): label-title-only",
"primitives-select--disabled-with-description (dark): select-name",
"primitives-select--disabled-with-description (light): label-title-only",
"primitives-select--disabled-with-description (light): select-name",
"primitives-select--focus (dark): select-name",
"primitives-select--focus (light): select-name",
"primitives-select--invalid (dark): select-name",
"primitives-select--invalid (light): select-name",
"primitives-select--invalid-with-description (dark): label-title-only",
"primitives-select--invalid-with-description (dark): select-name",
"primitives-select--invalid-with-description (light): label-title-only",
"primitives-select--invalid-with-description (light): select-name",
"primitives-sidebar-panel--channels-panel (dark): listitem",
"primitives-sidebar-panel--channels-panel (light): listitem",
"primitives-sidebar-panel--live-rows (dark): listitem",
"primitives-sidebar-panel--live-rows (light): listitem",
"primitives-textarea--auto-resize (dark): label",
"primitives-textarea--auto-resize (light): label",
"primitives-textarea--disabled (dark): label",
"primitives-textarea--disabled (light): label",
"primitives-textarea--disabled-invalid (dark): label",
"primitives-textarea--disabled-invalid (dark): label-title-only",
"primitives-textarea--disabled-invalid (light): label",
"primitives-textarea--disabled-invalid (light): label-title-only",
"primitives-textarea--disabled-with-description (dark): label",
"primitives-textarea--disabled-with-description (dark): label-title-only",
"primitives-textarea--disabled-with-description (light): label",
"primitives-textarea--disabled-with-description (light): label-title-only",
"primitives-textarea--invalid (dark): label",
"primitives-textarea--invalid (light): label",
"primitives-textarea--invalid-with-description (dark): label",
"primitives-textarea--invalid-with-description (dark): label-title-only",
"primitives-textarea--invalid-with-description (light): label",
"primitives-textarea--invalid-with-description (light): label-title-only",
"primitives-tool-picker--empty (dark): aria-required-children",
"primitives-tool-picker--empty (light): aria-required-children",
"primitives-tool-picker--loading (dark): aria-required-children",
"primitives-tool-picker--loading (light): aria-required-children",
"workflow-horizontal-stepper--many-steps (dark): scrollable-region-focusable",
"workflow-horizontal-stepper--many-steps (light): scrollable-region-focusable",
"workflow-inspector-shell--with-content (dark): role-img-alt",
"workflow-inspector-shell--with-content (light): role-img-alt"
]
[]
2 changes: 1 addition & 1 deletion e2e/stories-a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const STORIES = "http://localhost:61000";

// Existing violations recorded as a baseline to fix later; only a violation
// not listed here fails.
const KNOWN = new Set(known);
const KNOWN = new Set<string>(known);

test("every story is free of serious and critical axe violations", async ({
page,
Expand Down
15 changes: 15 additions & 0 deletions scripts/contrast-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ function checksFor(tokens) {
}
}

// 3b. The status-text tokens are text by contract (a medium/high RiskBadge
// label is `warn`), so they clear text contrast on the same surfaces.
for (const text of ["ok", "warn"]) {
for (const surface of ["background", "card"]) {
if (has(text) && has(surface)) {
checks.push([
`${text} on ${surface}`,
tokens[text],
tokens[surface],
AA_TEXT,
]);
}
}
}

// 4. Control boundaries and the focus ring must clear the UI-component threshold.
for (const line of ["input", "ring"]) {
for (const surface of ["background", "card"]) {
Expand Down
8 changes: 4 additions & 4 deletions src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@
state needs to read as "good"/"caution" beyond what --success/--destructive
already carry (those are fills). --warn also colours a medium/high
RiskBadge; low risk stays --muted-foreground so only the levels worth
flagging draw the eye. Matches Workbench's app.css exactly, so a block
lifted from a consumer app keeps its colours. */
--ok: #37904a;
--warn: #b7791f;
flagging draw the eye. Both clear 4.5:1 on the page and the card (the
contrast gate checks them as text). */
--ok: #2f7c40;
--warn: #966319;

/* The chrome's elevation. The design is flat — floating chrome separates
itself with --border-strong, not depth — so the token ships as `none`
Expand Down
5 changes: 4 additions & 1 deletion src/ui/horizontal-stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,12 @@ export function HorizontalStepper({

return (
<div className={cn("border-b border-border bg-card px-6 py-5", className)}>
{/* Focusable so a rail too long for the screen scrolls by keyboard; the
ring is inset because the edge mask hides anything drawn outside. */}
<ol
aria-label="Workflow progress"
className="flex items-center gap-2 overflow-x-auto [mask-image:linear-gradient(to_right,transparent,black_12px,black_calc(100%-12px),transparent)] md:gap-3"
tabIndex={0}
className="flex items-center gap-2 overflow-x-auto focus-visible:-outline-offset-2 [mask-image:linear-gradient(to_right,transparent,black_12px,black_calc(100%-12px),transparent)] md:gap-3"
>
{steps.map((step, index) => (
<li
Expand Down
6 changes: 5 additions & 1 deletion src/ui/page-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ export function PagePanel({
const fill = surface === "card" ? "bg-card" : "bg-background";
return (
<div className={cn("flex h-full overflow-hidden", fill)}>
{/* A scrolling panel takes focus so the keyboard can scroll it even when
it holds no focusable content; the ring is inset because the outer
fill clips anything drawn outside the panel. */}
<section
data-slot="page-panel"
tabIndex={scroll ? 0 : undefined}
className={cn(
"flex flex-col",
"flex flex-col focus-visible:-outline-offset-2",
fill,
sizing,
overflow,
Expand Down
3 changes: 3 additions & 0 deletions src/ui/sidebar-item-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export type SidebarItemRowProps = {
* that is not colour-only (a `StatusDot`, a count), not rely on this
* component to supply one.
*
* The row is an `<li>`: render it inside a `SidebarPanelSection` or another
* `<ul>`.
*
* Not `ui/sidebar`'s `SidebarItem` — that's the single-column family's row.
*/
export function SidebarItemRow({
Expand Down
27 changes: 27 additions & 0 deletions src/ui/sidebar-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { act } from "react";
import { createRoot } from "react-dom/client";

import { SidebarItemRow } from "./sidebar-item-row.js";
import { SidebarPanelSection } from "./sidebar-panel.js";

test("SidebarPanelSection gives its rows a labelled list parent", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<SidebarPanelSection label="Channels">
<SidebarItemRow name="general" />
<SidebarItemRow name="eng" />
</SidebarPanelSection>,
);
});
const list = host.querySelector("ul");
expect(list?.getAttribute("aria-label")).toBe("Channels");
const rows = host.querySelectorAll("li");
expect(rows.length).toBe(2);
for (const row of rows) expect(row.parentElement).toBe(list);
act(() => root.unmount());
host.remove();
});
37 changes: 35 additions & 2 deletions src/ui/sidebar-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { cn } from "../lib/utils.js";

/**
* The second column's shell. Composable parts, like `ui/sidebar` — a consumer
* assembles `SidebarPanelHeader`, `SidebarPanelPins`, `SidebarPanelBody` and
* `SidebarPanelFooter` in whatever combination the active page calls for, and
* assembles `SidebarPanelHeader`, `SidebarPanelPins`, `SidebarPanelBody`,
* `SidebarPanelSection` and `SidebarPanelFooter` in whatever combination the active page calls for, and
* the root only supplies the width, the border and the flex column.
*/
export function SidebarPanel({
Expand Down Expand Up @@ -84,6 +84,39 @@ export function SidebarPanelBody({
);
}

export type SidebarPanelSectionProps = React.ComponentProps<"ul"> & {
/** A visible heading that also names the list for assistive tech. */
readonly label?: string;
};

/**
* The list that holds `SidebarItemRow`s, in `SidebarPanelPins` or
* `SidebarPanelBody`. Each row is an `<li>`, so it needs this `<ul>` around it
* for a screen reader to announce the list and its length.
*/
export function SidebarPanelSection({
label,
className,
children,
...props
}: SidebarPanelSectionProps) {
return (
<div data-slot="sidebar-panel-section" className={className}>
{label === undefined ? null : (
<p
aria-hidden
className="px-2 py-1 text-[11px] font-semibold tracking-[0.12em] text-muted-foreground uppercase"
>
{label}
</p>
)}
<ul aria-label={label} className="flex flex-col" {...props}>
{children}
</ul>
</div>
);
}

/** Not `ui/sidebar`'s `SidebarFooter` — that's the single-column family's footer slot. */
export function SidebarPanelFooter({
className,
Expand Down
37 changes: 37 additions & 0 deletions src/ui/status-dot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { act, createElement } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";

import { StatusDot } from "./status-dot.js";

function mount(props: ComponentProps<typeof StatusDot>) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(createElement(StatusDot, props));
});
return {
dot: () => container.firstElementChild as HTMLElement,
unmount: () => root.unmount(),
};
}

describe("StatusDot", () => {
test("a label makes the dot a named image", () => {
const { dot, unmount } = mount({ label: "Running" });
expect(dot().getAttribute("role")).toBe("img");
expect(dot().getAttribute("aria-label")).toBe("Running");
expect(dot().hasAttribute("aria-hidden")).toBe(false);
unmount();
});

test("an empty label hides the dot instead of announcing an unnamed image", () => {
const { dot, unmount } = mount({ label: "" });
expect(dot().hasAttribute("role")).toBe(false);
expect(dot().hasAttribute("aria-label")).toBe(false);
expect(dot().getAttribute("aria-hidden")).toBe("true");
unmount();
});
});
14 changes: 10 additions & 4 deletions src/ui/status-dot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export type StatusDotTone =
| "danger";

export type StatusDotProps = {
/** Names the state for assistive tech. Required — a bare colour says nothing. */
/**
* Names the state for assistive tech. Required — a bare colour says nothing.
* Pass `""` when adjacent text already names the state (a status `Badge`, a
* step title): the dot is then decorative and hidden, as `<img alt="">` is.
*/
readonly label: string;
/** Adds a ring that pulses outward: this state is live and still changing. */
readonly live?: boolean;
Expand Down Expand Up @@ -45,7 +49,8 @@ const TONE_CLASS: Record<StatusDotTone, string> = {
/**
* A state marker carrying its own accessible name — a coloured dot with no
* label is invisible to a screen reader, so the label is required rather than
* optional.
* optional. An empty label opts out explicitly: the dot leaves the
* accessibility tree instead of announcing an unnamed image.
*
* The `live` ring is a separate `aria-hidden` element: motion says "still
* changing" to people who can see it, and the label already says so to those
Expand All @@ -65,8 +70,9 @@ export function StatusDot({
}: StatusDotProps) {
return (
<span
role="img"
aria-label={label}
{...(label
? { role: "img", "aria-label": label }
: { "aria-hidden": true })}
className={cn(
"relative inline-flex shrink-0",
SIZE_CLASS[size],
Expand Down
15 changes: 15 additions & 0 deletions src/ui/tool-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ describe("ToolBlock", () => {
mounted.unmount();
});

test("detail boxes take focus so the keyboard can scroll long output", () => {
const mounted = render(
<ToolBlock
name="search"
input={{ query: "invoices" }}
state={{ status: "output-available", output: "3 results" }}
defaultOpen
/>,
);
const boxes = [...mounted.container.querySelectorAll("pre")];
expect(boxes.length).toBe(2);
for (const box of boxes) expect(box.tabIndex).toBe(0);
mounted.unmount();
});

test("carries its status as a data attribute for state-based styling", () => {
const mounted = render(
<ToolBlock name="search" state={{ status: "running" }} />,
Expand Down
7 changes: 5 additions & 2 deletions src/ui/tool-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,11 @@ function Detail({ title, children }: { title: string; children: string }) {
{title}
</p>
{/* Scrolls in its own box: a long tool result must never make the page
scroll sideways. */}
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-2 font-mono text-[11px] whitespace-pre-wrap">
scroll sideways. Focusable so the keyboard can scroll it too. */}
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md bg-muted p-2 font-mono text-[11px] whitespace-pre-wrap"
>
{children}
</pre>
</div>
Expand Down
20 changes: 20 additions & 0 deletions src/ui/tool-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,26 @@ describe("ToolPicker", () => {
unmount();
});

test("status copy sits outside the listbox, which hides while it has no options", () => {
for (const props of [{ loading: true }, { items: [] }]) {
const { container, unmount } = mount(props);
const listbox = container.querySelector(
'[role="listbox"]',
) as HTMLElement;
const status = container.querySelector('[role="status"]');
expect(listbox.hidden).toBe(true);
expect(listbox.childElementCount).toBe(0);
expect(status).not.toBeNull();
expect(listbox.contains(status)).toBe(false);
expect(
container
.querySelector('[role="combobox"]')
?.getAttribute("aria-expanded"),
).toBe("false");
unmount();
}
});

test("a search with no hits shows no-matches copy", () => {
const { container, unmount } = mount();
const input = container.querySelector("input") as HTMLInputElement;
Expand Down
Loading
Loading