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..f929fe9
--- /dev/null
+++ b/ui/console/e2e/a11y.spec.ts
@@ -0,0 +1,64 @@
+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 is opened, dismissed and handed back by keyboard alone", async ({ page }) => {
+ await page.goto(`/?base=${encodeURIComponent(service)}`);
+ await expect(page.getByTestId("run-console")).toBeVisible();
+
+ // 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();
+
+ // 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. (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/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..48ed9e5 100644
--- a/ui/kit/src/components/VerbButton.test.tsx
+++ b/ui/kit/src/components/VerbButton.test.tsx
@@ -154,4 +154,32 @@ 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);
+ // 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" }));
+ 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..4b95286 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,28 @@ 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.
+ * 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
+ // 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 +80,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 +115,7 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals
>
{label}
-
@@ -90,6 +125,7 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals
return (
setPhase({ at: "confirming" })}
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;