diff --git a/e2e/stories-a11y.known.json b/e2e/stories-a11y.known.json index b7e368d8..fe51488c 100644 --- a/e2e/stories-a11y.known.json +++ b/e2e/stories-a11y.known.json @@ -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" -] +[] diff --git a/e2e/stories-a11y.spec.ts b/e2e/stories-a11y.spec.ts index 90a82644..9439c5b1 100644 --- a/e2e/stories-a11y.spec.ts +++ b/e2e/stories-a11y.spec.ts @@ -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(known); test("every story is free of serious and critical axe violations", async ({ page, diff --git a/scripts/contrast-test.mjs b/scripts/contrast-test.mjs index 46a63780..c2f8be22 100644 --- a/scripts/contrast-test.mjs +++ b/scripts/contrast-test.mjs @@ -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"]) { diff --git a/src/theme.css b/src/theme.css index 61cb7a4d..37b7e322 100644 --- a/src/theme.css +++ b/src/theme.css @@ -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` diff --git a/src/ui/horizontal-stepper.tsx b/src/ui/horizontal-stepper.tsx index 07f7964a..b4c2fb38 100644 --- a/src/ui/horizontal-stepper.tsx +++ b/src/ui/horizontal-stepper.tsx @@ -48,9 +48,12 @@ export function HorizontalStepper({ return (
+ {/* Focusable so a rail too long for the screen scrolls by keyboard; the + ring is inset because the edge mask hides anything drawn outside. */}
    {steps.map((step, index) => (
  1. + {/* 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. */}
    `: render it inside a `SidebarPanelSection` or another + * `
      `. + * * Not `ui/sidebar`'s `SidebarItem` — that's the single-column family's row. */ export function SidebarItemRow({ diff --git a/src/ui/sidebar-panel.test.tsx b/src/ui/sidebar-panel.test.tsx new file mode 100644 index 00000000..57eab1cd --- /dev/null +++ b/src/ui/sidebar-panel.test.tsx @@ -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( + + + + , + ); + }); + 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(); +}); diff --git a/src/ui/sidebar-panel.tsx b/src/ui/sidebar-panel.tsx index e8a57d91..dadc20fc 100644 --- a/src/ui/sidebar-panel.tsx +++ b/src/ui/sidebar-panel.tsx @@ -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({ @@ -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 `
    • `, so it needs this `
        ` around it + * for a screen reader to announce the list and its length. + */ +export function SidebarPanelSection({ + label, + className, + children, + ...props +}: SidebarPanelSectionProps) { + return ( +
        + {label === undefined ? null : ( +

        + {label} +

        + )} +
          + {children} +
        +
        + ); +} + /** Not `ui/sidebar`'s `SidebarFooter` — that's the single-column family's footer slot. */ export function SidebarPanelFooter({ className, diff --git a/src/ui/status-dot.test.tsx b/src/ui/status-dot.test.tsx new file mode 100644 index 00000000..531c6342 --- /dev/null +++ b/src/ui/status-dot.test.tsx @@ -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) { + 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(); + }); +}); diff --git a/src/ui/status-dot.tsx b/src/ui/status-dot.tsx index 85234379..23ad72c8 100644 --- a/src/ui/status-dot.tsx +++ b/src/ui/status-dot.tsx @@ -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 `` is. + */ readonly label: string; /** Adds a ring that pulses outward: this state is live and still changing. */ readonly live?: boolean; @@ -45,7 +49,8 @@ const TONE_CLASS: Record = { /** * 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 @@ -65,8 +70,9 @@ export function StatusDot({ }: StatusDotProps) { return ( { mounted.unmount(); }); + test("detail boxes take focus so the keyboard can scroll long output", () => { + const mounted = render( + , + ); + 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( , diff --git a/src/ui/tool-block.tsx b/src/ui/tool-block.tsx index 289c5c08..3aa58015 100644 --- a/src/ui/tool-block.tsx +++ b/src/ui/tool-block.tsx @@ -223,8 +223,11 @@ function Detail({ title, children }: { title: string; children: string }) { {title}

        {/* Scrolls in its own box: a long tool result must never make the page - scroll sideways. */} -
        +          scroll sideways. Focusable so the keyboard can scroll it too. */}
        +      
                 {children}
               
diff --git a/src/ui/tool-picker.test.tsx b/src/ui/tool-picker.test.tsx index 015a9e2e..0000e5eb 100644 --- a/src/ui/tool-picker.test.tsx +++ b/src/ui/tool-picker.test.tsx @@ -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; diff --git a/src/ui/tool-picker.tsx b/src/ui/tool-picker.tsx index f1792e9e..b08d5f9a 100644 --- a/src/ui/tool-picker.tsx +++ b/src/ui/tool-picker.tsx @@ -157,6 +157,7 @@ export function ToolPicker({ const emptyCatalog = items.length === 0; const noMatches = !emptyCatalog && filtered.length === 0; + const hasOptions = !loading && filtered.length > 0; return (
+ {/* Status copy sits beside the listbox, not in it: a listbox may only own + options and groups, so it is hidden while it has none. */} + {loading ? ( +

+ Loading… +

+ ) : emptyCatalog ? ( +
+ {empty ?? ( + + )} +
+ ) : noMatches ? ( +

+ No tools match — try a different search. +

+ ) : null} + ); diff --git a/stories/primitives/select.stories.tsx b/stories/primitives/select.stories.tsx index 155cbd59..b021deb6 100644 --- a/stories/primitives/select.stories.tsx +++ b/stories/primitives/select.stories.tsx @@ -1,3 +1,4 @@ +import type * as React from "react"; import { useState } from "react"; import { Select } from "../../src/ui/select.js"; @@ -10,58 +11,67 @@ const options = [ { value: "haiku", label: "Haiku" }, ]; +const optionElements = options.map((option) => ( + +)); + +function Field({ children }: { readonly children: React.ReactNode }) { + return ( + + ); +} + export const Default = () => { const [value, setValue] = useState("sonnet"); return ( - + + + ); }; export const Focus = () => ( - + + + ); export const Disabled = () => ( - + + + ); export const Invalid = () => ( - + + + ); export const DisabledWithDescription = () => (
- + + +

Model selection is locked for this workspace.

@@ -70,17 +80,15 @@ export const DisabledWithDescription = () => ( export const InvalidWithDescription = () => (
- + + +

Choose a model available on your plan.

@@ -89,18 +97,16 @@ export const InvalidWithDescription = () => ( export const DisabledInvalid = () => (
- + + +

Choose a model available on your plan.

diff --git a/stories/primitives/sidebar-rail-and-panel.stories.tsx b/stories/primitives/sidebar-rail-and-panel.stories.tsx index ad8750f6..e8c71c85 100644 --- a/stories/primitives/sidebar-rail-and-panel.stories.tsx +++ b/stories/primitives/sidebar-rail-and-panel.stories.tsx @@ -10,6 +10,7 @@ import { SidebarPanelFooter, SidebarPanelHeader, SidebarPanelPins, + SidebarPanelSection, } from "../../src/ui/sidebar-panel.js"; import { StatusDot } from "../../src/ui/status-dot.js"; @@ -28,36 +29,42 @@ export const ChannelsPanel = () => { } /> - } - name="#launch-week" - selected={selectedId === "launch-week"} - onSelect={() => setSelectedId("launch-week")} - /> + + } + name="#launch-week" + selected={selectedId === "launch-week"} + onSelect={() => setSelectedId("launch-week")} + /> + - {[ - { id: "general", name: "general", unread: true }, - { id: "eng", name: "eng", unread: false }, - { id: "design", name: "design", unread: false }, - ].map((channel) => ( + + {[ + { id: "general", name: "general", unread: true }, + { id: "eng", name: "eng", unread: false }, + { id: "design", name: "design", unread: false }, + ].map((channel) => ( + } + name={channel.name} + unread={channel.unread} + meta={ + channel.unread ? 3 : undefined + } + selected={selectedId === channel.id} + onSelect={() => setSelectedId(channel.id)} + /> + ))} } - name={channel.name} - unread={channel.unread} - meta={channel.unread ? 3 : undefined} - selected={selectedId === channel.id} - onSelect={() => setSelectedId(channel.id)} + leading={} + name="Priya Shah" + meta={} + selected={selectedId === "priya"} + onSelect={() => setSelectedId("priya")} /> - ))} - } - name="Priya Shah" - meta={} - selected={selectedId === "priya"} - onSelect={() => setSelectedId("priya")} - /> +
diff --git a/stories/primitives/textarea.stories.tsx b/stories/primitives/textarea.stories.tsx index 1577f0ba..44828153 100644 --- a/stories/primitives/textarea.stories.tsx +++ b/stories/primitives/textarea.stories.tsx @@ -1,39 +1,59 @@ +import type * as React from "react"; import { useState } from "react"; import { Textarea } from "../../src/ui/textarea.js"; export default { title: "Primitives / Textarea" }; +function Field({ children }: { readonly children: React.ReactNode }) { + return ( + + ); +} + export const Default = () => { const [value, setValue] = useState(""); return ( -