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
7 changes: 6 additions & 1 deletion docs/rfc/goldpath-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/strategy/ui-standard-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
64 changes: 64 additions & 0 deletions ui/console/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion ui/console/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Goldpath console</title>
Expand Down
1 change: 1 addition & 0 deletions ui/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions ui/console/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ui/kit/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@goldpath/kit — gallery</title>
Expand Down
28 changes: 28 additions & 0 deletions ui/kit/src/components/VerbButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<VerbButton
label="erase"
confirm="Erase?"
note={{ label: "subject key (required)", required: true }}
execute={() => {
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 <body>, 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("");
});
});
42 changes: 39 additions & 3 deletions ui/kit/src/components/VerbButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Banner } from "./Banner";
import type { VerbOutcome } from "../adminResult";

Expand Down Expand Up @@ -42,9 +42,28 @@ type Phase =
*/
export function VerbButton({ label, confirm, execute, onDone, destructive = false, note, quiet = false }: VerbButtonProps) {
const [phase, setPhase] = useState<Phase>({ at: "rest" });
const dialog = useRef<HTMLSpanElement>(null);
const trigger = useRef<HTMLButtonElement>(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 <body> 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;
Expand All @@ -61,7 +80,23 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals

if (phase.at === "confirming") {
return (
<span role="alertdialog" aria-label={`confirm ${label}`} className="inline-flex flex-wrap items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-sm">
<span
role="alertdialog"
aria-label={`confirm ${label}`}
// ESCAPE closes it. An operator who opened a destructive confirm by mistake must
// be able to back out with the key every dialog on earth uses, without hunting
// for a mouse (found by the a11y gate). Keyed on the wrapper so it works whether
// focus is on the note field or a button.
onKeyDown={(event) => {
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"
>
<span>{confirm}</span>
<span className="text-xs text-faint">· audited</span>
{note && (
Expand All @@ -80,7 +115,7 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals
>
{label}
</button>
<button className="rounded-md border border-border px-2 py-0.5 text-xs hover:bg-accent" onClick={() => setPhase({ at: "rest" })}>
<button className="rounded-md border border-border px-2 py-0.5 text-xs hover:bg-accent" onClick={cancel}>
cancel
</button>
</span>
Expand All @@ -90,6 +125,7 @@ export function VerbButton({ label, confirm, execute, onDone, destructive = fals
return (
<span className="inline-flex items-center gap-2">
<button
ref={trigger}
className={`rounded-md border px-3 py-1.5 text-sm font-medium disabled:opacity-50 ${destructive ? "border-danger-border text-danger hover:bg-danger-bg" : "border-border bg-background hover:bg-accent"}`}
disabled={phase.at === "executing"}
onClick={() => setPhase({ at: "confirming" })}
Expand Down
12 changes: 8 additions & 4 deletions ui/kit/src/tokens/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading