From 2c072f95127f5f3e639701449359c5b9b48782f8 Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Mon, 27 Jul 2026 13:52:08 +0300 Subject: [PATCH 1/2] =?UTF-8?q?test(console):=20an=20accessibility=20gate?= =?UTF-8?q?=20=E2=80=94=20and=20the=20two=20real=20defects=20it=20found=20?= =?UTF-8?q?immediately?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operations console is used at 3am, over a remote session, by whoever is on call: being operable by keyboard on an imperfect screen is a correctness property, not polish. The smoke now runs axe (WCAG 2.1 A/AA, serious + critical only) over every panel and over the confirm dialog, where destructive verbs are decided. Its first run found two real defects, both now fixed: 1. The secondary inks failed the contrast threshold. `--faint` was 2.56:1 on white and `--muted-foreground` 4.44:1 on the surface — so a large share of the console's supporting text (ages, counts, hashes, timestamps) was below AA on a normal screen. Both are re-picked against the SURFACE, the darker of the two backgrounds they sit on, with the measured ratios written next to them; the dark theme's faint (3.70:1) too. 2. ESCAPE did not close the confirm dialog. An operator who opened `erase` by mistake had to find the mouse. Escape now cancels — and forgets the note typed into it, so a half-written subject key cannot ride along on the next attempt. Focus moves to the dialog when it opens (once, deliberately: my first attempt re-focused on every render and yanked the caret out of the note field between keystrokes — caught by the kit's own tests before it left the branch). The page also declares its language. Console smoke: 15/15. kit 64, console 101. Co-Authored-By: Claude Opus 5 --- docs/rfc/goldpath-console.md | 7 ++- docs/strategy/ui-standard-v1.md | 5 +- ui/console/e2e/a11y.spec.ts | 57 +++++++++++++++++++++++ ui/console/index.html | 2 +- ui/console/package.json | 1 + ui/console/pnpm-lock.yaml | 19 ++++++++ ui/kit/index.html | 2 +- ui/kit/src/components/VerbButton.test.tsx | 26 +++++++++++ ui/kit/src/components/VerbButton.tsx | 35 ++++++++++++-- ui/kit/src/tokens/tokens.css | 12 +++-- 10 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 ui/console/e2e/a11y.spec.ts diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md index 5d57653..a220e11 100644 --- a/docs/rfc/goldpath-console.md +++ b/docs/rfc/goldpath-console.md @@ -64,7 +64,12 @@ custom-develop ON, with the same kit, the same way they add features to the back Kit: component tests (vitest) on the composites (keyset table paging, verb button's refusal surface, state mapping), under a COVERAGE FLOOR that CI enforces (kit 95/90, console 97/85 statements/branches) — the floor exists because the gaps that hid real - bugs here were branches nobody had thought to exercise, not tests anyone had deleted. Console: the smoke drives THREE real apps — one open, + bugs here were branches nobody had thought to exercise, not tests anyone had deleted. + ACCESSIBILITY is part of the bar, not a later polish pass: every panel and the confirm + dialog are checked with axe (WCAG 2.1 A/AA, serious + critical) in the same smoke. An + ops console is used at 3am over a remote session, keyboard-only, on whatever screen is + to hand — its first run found the secondary text below the contrast threshold and a + confirm dialog that Escape could not close. Console: the smoke drives THREE real apps — one open, one behind the auth floor, one tenant-scoped — because how the console behaves when it is REFUSED is as much a claim as how it behaves when it is welcome; plus a service that dies mid-session. Runnable on demand (`.github/workflows/console-smoke.yml`), not only diff --git a/docs/strategy/ui-standard-v1.md b/docs/strategy/ui-standard-v1.md index 987e0a9..00f7e5d 100644 --- a/docs/strategy/ui-standard-v1.md +++ b/docs/strategy/ui-standard-v1.md @@ -13,7 +13,10 @@ Lifted verbatim from Mockifyr `ui/src/index.css` — the kit vendors the same st - **Layered neutrals**, light: `--app #ffffff` (frame) → `--surface #f5f5f7` (body) → `--background #ffffff` (cards) → `--muted #ececef` → borders `#e6e6e9/#d7d7db`. - Text: `--foreground #18181b`, `--muted-foreground #71717a`, `--faint #a1a1aa`. + Text: `--foreground #18181b`, `--muted-foreground #65656c`, `--faint #6a6a72`. + Both secondary inks clear WCAG AA (4.5:1) against the SURFACE, not just against white — + the axe gate in the console smoke is what says so, and it is what corrected the original + pair (4.44 and 2.56) on 2026-07-27. - **Accent = near-black**: `--primary #18181b` on light, `#fafafa` on dark — actions, active states, CTA. Swap `--primary` to re-skin; NOTHING else changes. - **Semantic status ramp is deliberately separate from the accent**: success/warning/ diff --git a/ui/console/e2e/a11y.spec.ts b/ui/console/e2e/a11y.spec.ts new file mode 100644 index 0000000..2ec081a --- /dev/null +++ b/ui/console/e2e/a11y.spec.ts @@ -0,0 +1,57 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +/** + * The accessibility gate. An operations console is used at 3am by whoever is on call, + * often over a remote session with a keyboard and no mouse — so this is a correctness + * property, not a nicety. + * + * WCAG 2.1 A + AA, serious and critical violations only: the ramp of "moderate" findings + * (landmark preferences, heading order in a dense panel) is advice, and a gate that fails + * on advice gets muted, which costs the real findings too. + */ +const service = process.env.GOLDPATH_SERVICE_URL ?? "http://localhost:5310"; + +const PANELS = [ + { nav: "Runs", ready: "run-console" }, + { nav: "Bulk intake", ready: "bulk-panel" }, + { nav: "Campaigns", ready: "campaign-panel" }, + { nav: "Notifications", ready: "notification-panel" }, + { nav: "Archival", ready: "archival-panel" }, +] as const; + +async function violations(page: import("@playwright/test").Page) { + const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); + return result.violations + .filter((violation) => violation.impact === "serious" || violation.impact === "critical") + .map((violation) => `${violation.id} (${violation.impact}) — ${violation.nodes.length} node(s): ${violation.help}`); +} + +test.describe("the console is operable without a mouse or a perfect screen", () => { + for (const panel of PANELS) { + test(`${panel.nav} has no serious accessibility violation`, async ({ page }) => { + await page.goto(`/?base=${encodeURIComponent(service)}`); + await page.getByRole("button", { name: panel.nav }).click(); + await expect(page.getByTestId(panel.ready)).toBeVisible(); + + expect(await violations(page)).toEqual([]); + }); + } + + test("a confirm dialog traps nothing and is reachable by keyboard alone", async ({ page }) => { + await page.goto(`/?base=${encodeURIComponent(service)}`); + await expect(page.getByTestId("run-console")).toBeVisible(); + + await page.locator("li", { hasText: "SmokeJob" }).getByRole("button", { name: "trigger" }).first().click(); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + + // The dialog itself must be clean — this is where a destructive verb is confirmed. + expect(await violations(page)).toEqual([]); + + // And it must be dismissible from the keyboard: an operator who opened it by mistake + // should not have to hunt for the mouse. + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + }); +}); diff --git a/ui/console/index.html b/ui/console/index.html index d2bc38f..ddc73b6 100644 --- a/ui/console/index.html +++ b/ui/console/index.html @@ -1,5 +1,5 @@ - + Goldpath console diff --git a/ui/console/package.json b/ui/console/package.json index 8d6ccb6..4285d81 100644 --- a/ui/console/package.json +++ b/ui/console/package.json @@ -16,6 +16,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@axe-core/playwright": "4.10.1", "@goldpath/kit": "link:../kit", "@playwright/test": "1.50.1", "@tailwindcss/vite": "^4.3.2", diff --git a/ui/console/pnpm-lock.yaml b/ui/console/pnpm-lock.yaml index 44fefb6..a1ed9ca 100644 --- a/ui/console/pnpm-lock.yaml +++ b/ui/console/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@axe-core/playwright': + specifier: 4.10.1 + version: 4.10.1(playwright-core@1.50.1) '@goldpath/kit': specifier: link:../kit version: link:../kit @@ -72,6 +75,11 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@axe-core/playwright@4.10.1': + resolution: {integrity: sha512-EV5t39VV68kuAfMKqb/RL+YjYKhfuGim9rgIaQ6Vntb2HgaCaau0h98Y3WEUqW1+PbdzxDtDNjFAipbtZuBmEA==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -855,6 +863,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + axe-core@4.10.3: + resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} + engines: {node: '>=4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1786,6 +1798,11 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@axe-core/playwright@4.10.1(playwright-core@1.50.1)': + dependencies: + axe-core: 4.10.3 + playwright-core: 1.50.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2416,6 +2433,8 @@ snapshots: asynckit@0.4.0: {} + axe-core@4.10.3: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} diff --git a/ui/kit/index.html b/ui/kit/index.html index 36cdc36..b01a40e 100644 --- a/ui/kit/index.html +++ b/ui/kit/index.html @@ -1,5 +1,5 @@ - + @goldpath/kit — gallery diff --git a/ui/kit/src/components/VerbButton.test.tsx b/ui/kit/src/components/VerbButton.test.tsx index ef361aa..2ad1269 100644 --- a/ui/kit/src/components/VerbButton.test.tsx +++ b/ui/kit/src/components/VerbButton.test.tsx @@ -154,4 +154,30 @@ describe("the verb button (ui-standard-v1 §3/§4 — confirm-before-verb, verba // something unexpected — two different stories, told differently. expect(await screen.findByText(/unexpected 503 — check the service logs/)).toBeInTheDocument(); }); + + it("Escape backs out of the confirm — and forgets the reason typed into it", async () => { + let fired = 0; + render( + { + fired += 1; + return Promise.resolve(ok("erased")); + }} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: "erase" })); + await userEvent.type(within(screen.getByRole("alertdialog")).getByLabelText("subject key (required)"), "customer:9"); + await userEvent.keyboard("{Escape}"); + + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(fired).toBe(0); + + // Re-opening starts clean: a half-typed subject must not ride along on the next try. + await userEvent.click(screen.getByRole("button", { name: "erase" })); + expect(within(screen.getByRole("alertdialog")).getByLabelText("subject key (required)")).toHaveValue(""); + }); }); diff --git a/ui/kit/src/components/VerbButton.tsx b/ui/kit/src/components/VerbButton.tsx index 44909d1..417734a 100644 --- a/ui/kit/src/components/VerbButton.tsx +++ b/ui/kit/src/components/VerbButton.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Banner } from "./Banner"; import type { VerbOutcome } from "../adminResult"; @@ -42,9 +42,22 @@ type Phase = */ export function VerbButton({ label, confirm, execute, onDone, destructive = false, note, quiet = false }: VerbButtonProps) { const [phase, setPhase] = useState({ at: "rest" }); + const dialog = useRef(null); const [reason, setReason] = useState(""); const missingReason = note?.required === true && reason.trim().length === 0; + /** Leaving the confirm without firing — the same exit whichever way the operator took. */ + const cancel = () => { + setPhase({ at: "rest" }); + setReason(""); + }; + + // Focus moves to the dialog ONCE, when it opens: re-focusing on every render would + // yank the caret out of the note field between keystrokes. + useEffect(() => { + if (phase.at === "confirming") dialog.current?.focus(); + }, [phase.at]); + const run = async () => { setPhase({ at: "executing" }); let outcome: VerbOutcome; @@ -61,7 +74,23 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals if (phase.at === "confirming") { return ( - + { + if (event.key === "Escape") { + event.stopPropagation(); + cancel(); + } + }} + ref={dialog} + tabIndex={-1} + className="inline-flex flex-wrap items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-sm outline-none" + > {confirm} · audited {note && ( @@ -80,7 +109,7 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals > {label} - diff --git a/ui/kit/src/tokens/tokens.css b/ui/kit/src/tokens/tokens.css index 0e83d93..31e0106 100644 --- a/ui/kit/src/tokens/tokens.css +++ b/ui/kit/src/tokens/tokens.css @@ -16,8 +16,12 @@ --background: #ffffff; /* cards / panels */ --foreground: #18181b; /* text primary */ --muted: #ececef; /* segmented control / hover / chips — a touch darker than the surface so it reads on it */ - --muted-foreground: #71717a; - --faint: #a1a1aa; + /* Both secondary inks clear WCAG AA (4.5:1) against the SURFACE, which is the darker + of the two backgrounds they sit on — measured by the a11y gate, not chosen by eye: + the old pair read 4.44 and 2.56, so half the console's supporting text was below the + threshold on a normal screen, never mind a glare-lit one at 3am. */ + --muted-foreground: #65656c; /* 5.78 on cards · 5.31 on the surface */ + --faint: #6a6a72; /* 5.36 on cards · 4.92 on the surface */ --border: #e6e6e9; --border-strong: #d7d7db; --input: #e6e6e9; @@ -50,8 +54,8 @@ --background: #17171c; /* cards / panels — lifted above the surface */ --foreground: #f4f4f5; --muted: #202027; - --muted-foreground: #a1a1aa; - --faint: #71717a; + --muted-foreground: #a1a1aa; /* 6.97 on cards · 7.41 on the surface */ + --faint: #8f8f99; /* 5.58 on cards · 5.93 on the surface — the old #71717a was 3.70 */ --border: #26262b; --border-strong: #37373e; --input: #26262b; From bc4ce468816c99caad5546e75165bda9917eed21 Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Mon, 27 Jul 2026 13:57:00 +0300 Subject: [PATCH 2/2] fix(kit): hand focus back when the confirm closes (review R3/R5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escape unmounted the dialog while it still held focus, so the browser dropped focus to — a keyboard operator who backed out of a destructive confirm lost their place on the page. Focus now returns to the button that opened it. The a11y test's title also claimed more than it checked: it opened the dialog with a click. It now opens from the keyboard, dismisses from the keyboard, and asserts the hand-back — and the title says only what the test proves (the dialog is inline, not a modal overlay, so there is no focus trap to assert, and the comment says so). 15/15. Co-Authored-By: Claude Opus 5 --- ui/console/e2e/a11y.spec.ts | 13 ++++++++++--- ui/kit/src/components/VerbButton.test.tsx | 2 ++ ui/kit/src/components/VerbButton.tsx | 9 ++++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/ui/console/e2e/a11y.spec.ts b/ui/console/e2e/a11y.spec.ts index 2ec081a..f929fe9 100644 --- a/ui/console/e2e/a11y.spec.ts +++ b/ui/console/e2e/a11y.spec.ts @@ -38,11 +38,14 @@ test.describe("the console is operable without a mouse or a perfect screen", () }); } - test("a confirm dialog traps nothing and is reachable by keyboard alone", async ({ page }) => { + test("a confirm dialog is opened, dismissed and handed back by keyboard alone", async ({ page }) => { await page.goto(`/?base=${encodeURIComponent(service)}`); await expect(page.getByTestId("run-console")).toBeVisible(); - await page.locator("li", { hasText: "SmokeJob" }).getByRole("button", { name: "trigger" }).first().click(); + // Opened from the KEYBOARD — the mouse is not assumed anywhere in this journey. + const trigger = page.locator("li", { hasText: "SmokeJob" }).getByRole("button", { name: "trigger" }).first(); + await trigger.focus(); + await page.keyboard.press("Enter"); const dialog = page.getByRole("alertdialog"); await expect(dialog).toBeVisible(); @@ -50,8 +53,12 @@ test.describe("the console is operable without a mouse or a perfect screen", () expect(await violations(page)).toEqual([]); // And it must be dismissible from the keyboard: an operator who opened it by mistake - // should not have to hunt for the mouse. + // should not have to hunt for the mouse. (The dialog is inline, not a modal overlay, + // so there is no focus TRAP to assert — the page behind it stays reachable by design.) await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0); + + // And focus lands back on the button that opened it: the operator keeps their place. + await expect(trigger).toBeFocused(); }); }); diff --git a/ui/kit/src/components/VerbButton.test.tsx b/ui/kit/src/components/VerbButton.test.tsx index 2ad1269..48ed9e5 100644 --- a/ui/kit/src/components/VerbButton.test.tsx +++ b/ui/kit/src/components/VerbButton.test.tsx @@ -175,6 +175,8 @@ describe("the verb button (ui-standard-v1 §3/§4 — confirm-before-verb, verba expect(screen.queryByRole("alertdialog")).toBeNull(); expect(fired).toBe(0); + // Focus goes back where the operator was — not to , at the top of the page. + await waitFor(() => expect(screen.getByRole("button", { name: "erase" })).toHaveFocus()); // Re-opening starts clean: a half-typed subject must not ride along on the next try. await userEvent.click(screen.getByRole("button", { name: "erase" })); diff --git a/ui/kit/src/components/VerbButton.tsx b/ui/kit/src/components/VerbButton.tsx index 417734a..4b95286 100644 --- a/ui/kit/src/components/VerbButton.tsx +++ b/ui/kit/src/components/VerbButton.tsx @@ -43,13 +43,19 @@ type Phase = export function VerbButton({ label, confirm, execute, onDone, destructive = false, note, quiet = false }: VerbButtonProps) { const [phase, setPhase] = useState({ at: "rest" }); const dialog = useRef(null); + const trigger = useRef(null); const [reason, setReason] = useState(""); const missingReason = note?.required === true && reason.trim().length === 0; - /** Leaving the confirm without firing — the same exit whichever way the operator took. */ + /** + * Leaving the confirm without firing — the same exit whichever way the operator took. + * Focus goes BACK to the button that opened it: the dialog is about to unmount, and a + * keyboard operator whose focus falls to has lost their place on the page. + */ const cancel = () => { setPhase({ at: "rest" }); setReason(""); + requestAnimationFrame(() => trigger.current?.focus()); }; // Focus moves to the dialog ONCE, when it opens: re-focusing on every render would @@ -119,6 +125,7 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals return (