+ Enter a reviewer and decision reason before approving, rejecting, or
+ deferring this simulator-backed recommendation.
+
setReviewer(event.target.value)}
@@ -111,6 +122,7 @@ export function RecommendationReviewPanel({
+
+ {isSubmitting
+ ? `Recording ${submittingAction} decision...`
+ : canSubmit
+ ? "Decision actions are available."
+ : "Decision actions are disabled until reviewer and reason are entered."}
+
{errorMessage !== null ? (
diff --git a/apps/web/e2e/operations-workbench-demo.spec.ts b/apps/web/e2e/operations-workbench-demo.spec.ts
index 010e49c..4478410 100644
--- a/apps/web/e2e/operations-workbench-demo.spec.ts
+++ b/apps/web/e2e/operations-workbench-demo.spec.ts
@@ -10,6 +10,10 @@ test("walks the simulator-backed Operations Workbench demo path", async ({ page
resetDemoState();
await page.goto("/");
+ await page.keyboard.press("Tab");
+ await expect(page.getByRole("link", { name: "Skip to main content" })).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect(page.locator("#main-content")).toBeFocused();
await expect(page.getByText("Simulator-backed demo data").first()).toBeVisible();
await expect(page.getByText("Synthetic local scenario; not real plant data.").first()).toBeVisible();
await expect(page.getByRole("region", { name: "Local API connection state" })).toBeVisible();
@@ -76,13 +80,19 @@ async function recordDecision(
action: "Approve" | "Reject" | "Defer",
expectedDecision: "approved" | "rejected" | "deferred",
) {
+ await expect(page.getByRole("button", { name: action })).toBeDisabled();
await page.getByLabel("Reviewer name").fill(`quality_engineer_${expectedDecision}`);
await page
.getByLabel("Decision reason")
.fill(`Playwright smoke test recorded a ${expectedDecision} demo decision.`);
- await page.getByRole("button", { name: action }).click();
+ await expect(page.getByRole("button", { name: action })).toBeEnabled();
+ await expect(page.locator("#decision-submit-status")).toContainText(
+ "Decision actions are available.",
+ );
+ await page.getByRole("button", { name: action }).focus();
+ await page.keyboard.press("Enter");
- const feedback = page.getByRole("status");
+ const feedback = page.locator(".decision-result");
await expect(feedback).toContainText(`Demo audit feedback: ${expectedDecision}`);
await expect(feedback).toContainText(`Reviewer: quality_engineer_${expectedDecision}`);
await expect(feedback).toContainText(`Updated status: ${expectedDecision}`);
diff --git a/apps/web/tests/app-shell.test.mjs b/apps/web/tests/app-shell.test.mjs
index 0989b81..12d30da 100644
--- a/apps/web/tests/app-shell.test.mjs
+++ b/apps/web/tests/app-shell.test.mjs
@@ -22,6 +22,8 @@ test("workbench placeholder routes exist", async () => {
test("navigation includes the required demo routes", () => {
const layout = readFileSync(join(root, "app/layout.tsx"), "utf8");
+ assert.match(layout, /Skip to main content/);
+ assert.match(layout, /id="main-content"/);
assert.match(layout, /Overview/);
assert.match(layout, /Detections/);
assert.match(layout, /Recommendations/);
@@ -219,6 +221,52 @@ test("workbench state panels distinguish local demo data gaps from API failures"
assert.match(styles, /min-height: 260px/);
});
+test("accessibility baseline covers landmarks, focus, forms, badges, and timeline order", () => {
+ const layout = readFileSync(join(root, "app/layout.tsx"), "utf8");
+ const demoState = readFileSync(join(root, "app/components/demo-state.tsx"), "utf8");
+ const detail = readFileSync(join(root, "app/detections/[detectionId]/page.tsx"), "utf8");
+ const panel = readFileSync(
+ join(root, "app/recommendations/recommendation-review-panel.tsx"),
+ "utf8",
+ );
+ const styles = readFileSync(join(root, "app/globals.css"), "utf8");
+
+ assert.match(layout, /className="skip-link"/);
+ assert.match(layout, /= 4.5,
+ `${label} contrast should be at least 4.5:1`,
+ );
+ }
+});
+
test("operations workbench docs link to the demo runbook", async () => {
const rootReadme = readFileSync(join(root, "..", "..", "README.md"), "utf8");
const appReadme = readFileSync(join(root, "README.md"), "utf8");
@@ -266,3 +314,28 @@ test("demo polish keeps pages readable without raw JSON panels", () => {
assert.match(styles, /max-width: 1240px/);
assert.match(styles, /metric-grid/);
});
+
+function contrastRatio(foreground, background) {
+ const fg = relativeLuminance(hexToRgb(foreground));
+ const bg = relativeLuminance(hexToRgb(background));
+ const lighter = Math.max(fg, bg);
+ const darker = Math.min(fg, bg);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+function hexToRgb(value) {
+ const normalized = value.replace("#", "");
+ return [0, 2, 4].map((offset) =>
+ Number.parseInt(normalized.slice(offset, offset + 2), 16),
+ );
+}
+
+function relativeLuminance(rgb) {
+ const [red, green, blue] = rgb.map((channel) => {
+ const normalized = channel / 255;
+ return normalized <= 0.03928
+ ? normalized / 12.92
+ : ((normalized + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
+}
diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md
index 222665b..67a88fa 100644
--- a/docs/LEARNING_LOG.md
+++ b/docs/LEARNING_LOG.md
@@ -3418,3 +3418,50 @@ make test-e2e
During rehearsal, intentionally visit a stale detection URL and confirm the
presenter can recover from the visible `Next step:` text without opening docs.
+
+## 2026-05-22 - Workbench accessibility baseline
+
+### What changed
+
+Added a focused accessibility baseline for issue #179. The Workbench now has a
+skip link, a named main content target, stronger visible focus styling, form
+helper/status text for governed recommendation decisions, and clearer semantic
+ordering for the evidence timeline.
+
+### Why it was built that way
+
+The issue asks for a practical demo baseline, not a full design system or WCAG
+certification effort. The change stays in the existing Workbench shell,
+components, styles, smoke test, and runbook so the manufacturer demo remains
+small, keyboard navigable, and understandable.
+
+### How data flows through it
+
+The data flow is unchanged. Process Sentinel detections, evidence,
+recommendations, and RCA/CAPA draft data still come from the local FastAPI demo
+API. The accessibility changes improve how existing states and controls are
+announced and reached.
+
+### How to run it
+
+```bash
+make demo
+make api
+cd apps/web
+npm run dev
+```
+
+### How to test it
+
+```bash
+cd apps/web
+npm test
+npm run lint
+npm run typecheck
+make test-e2e
+```
+
+### What to learn next
+
+Before the external demo, walk the route flow with only the keyboard and record
+any remaining non-blocking accessibility debt in the runbook.
diff --git a/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md b/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md
index e6f9671..bbd38b8 100644
--- a/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md
+++ b/docs/demo/OPERATIONS_WORKBENCH_DEMO_RUNBOOK.md
@@ -64,6 +64,24 @@ http://127.0.0.1:3000
evidence summary, recommended containment, CAPA placeholder, and human
review requirement are visible.
+## Accessibility Baseline
+
+During rehearsal, do a quick keyboard and screen-reader semantics check:
+
+1. Press Tab on the overview page and confirm `Skip to main content` appears.
+2. Use Tab to reach primary navigation, `Open detection`, evidence workflow
+ links, recommendation decision controls, and RCA/CAPA draft actions.
+3. Confirm the recommendation decision buttons remain disabled until reviewer
+ and reason fields are filled, then can be triggered from the keyboard.
+4. Confirm status, severity, and risk badges include visible text and are not
+ explained by color alone.
+5. Confirm the evidence timeline reads oldest to newest and each item exposes a
+ timestamp, title, description, and related IDs.
+
+Remaining non-blocking accessibility debt: this MVP does not claim WCAG
+certification, does not include a full automated accessibility suite, and has
+not gone through formal assistive-technology testing across browsers.
+
## API Recovery Check
If the API is stopped or `NEXT_PUBLIC_API_BASE_URL` points at the wrong port,