From 704053269dcd26be56dcaeb840904111a4b17f5d Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Mon, 13 Jul 2026 21:05:15 -0700 Subject: [PATCH] Wave-23 C09: screen-reader procedure and native smoke evidence Signed-off-by: KooshaPari Co-authored-by: Cursor --- CONTRIBUTING.md | 23 +++++ crates/sl-viewer/src/app.rs | 1 + docs/a11y/screen-reader-smoke.md | 91 ++++++++++++++++++ docs/a11y/status-regions-and-native-smoke.md | 10 +- .../fixtures/native-webview-smoke.sample.json | 62 +++++++++++++ scripts/record-native-webview-smoke.ps1 | 93 +++++++++++++++++++ tests/visual/harness/a11y.spec.js | 43 +++++++-- 7 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 docs/a11y/screen-reader-smoke.md create mode 100644 docs/ops/fixtures/native-webview-smoke.sample.json create mode 100644 scripts/record-native-webview-smoke.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6bee5be..2120a70f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,29 @@ and any temporary quarantine. The `fuzz-smoke` CI job runs the committed OKF corpus for 10 seconds. Longer local campaigns use `cargo fuzz run okf_roundtrip`. +## Native WebView accessibility smoke + +After a desktop viewer change that affects landmarks, status regions, Help, or +Search recovery, run the checklists in +[`docs/a11y/status-regions-and-native-smoke.md`](docs/a11y/status-regions-and-native-smoke.md) +and [`docs/a11y/screen-reader-smoke.md`](docs/a11y/screen-reader-smoke.md), then +record a machine-readable pass: + +```powershell +# Prefer a worktree-local target when building the viewer in parallel lanes: +$env:CARGO_TARGET_DIR = "$PWD/target-w23-c09" +# cargo run -p sl-viewer … (desktop feature) — exercise the checklist, then: +pwsh -NoProfile -File scripts/record-native-webview-smoke.ps1 ` + -Outcome pass ` + -ScreenReader NVDA ` + -OutPath docs/ops/fixtures/native-webview-smoke.local.json +``` + +Commit only intentional evidence under audit packages; keep +`native-webview-smoke.local.json` untracked unless an audit asks for it. The +checked-in sample is +[`docs/ops/fixtures/native-webview-smoke.sample.json`](docs/ops/fixtures/native-webview-smoke.sample.json). + ## Inclusive Language Checks Run the lightweight seed gate before documentation-heavy changes: diff --git a/crates/sl-viewer/src/app.rs b/crates/sl-viewer/src/app.rs index b66eae02..e443c4b8 100644 --- a/crates/sl-viewer/src/app.rs +++ b/crates/sl-viewer/src/app.rs @@ -869,6 +869,7 @@ fn SessionListWithCompare(props: SessionListWithCompareProps) -> Element { div { class: "session-list", input { class: "search-input", + "aria-label": "Filter sessions", placeholder: "Filter sessions...", value: "{query}", oninput: move |e| query.set(e.value()), diff --git a/docs/a11y/screen-reader-smoke.md b/docs/a11y/screen-reader-smoke.md new file mode 100644 index 00000000..720f26bf --- /dev/null +++ b/docs/a11y/screen-reader-smoke.md @@ -0,0 +1,91 @@ +# Screen-reader smoke (L81.4) + +Manual NVDA / VoiceOver procedure for SessionLedger viewer **screen-reader +compatibility**, plus what CI already proves via axe and ARIA contracts. + +Companion: [`status-regions-and-native-smoke.md`](status-regions-and-native-smoke.md) +(status regions, cognitive copy, native WebView shell checklist). + +## What CI already proves (axe / ARIA) + +| Check | Location | Screen-reader relevance | +|-------|----------|-------------------------| +| WCAG 2.A/2.AA axe scan per tab × 3 widths | `tests/visual/harness/a11y.spec.js` | No critical name/role/value or landmark regressions in the built web viewer | +| Tablist keyboard pattern | same | Arrow/Home/End move selection; `aria-selected` tracks focus | +| Primary control accessible names | same | Theme toggle, Help, search filter fields, Retry reachable by role + name | +| Status / alert live regions | same + fixtures | `role=status` / `role=alert` with `aria-live` on loading, skeleton, error | +| Landmarks | same | `navigation` (“Primary viewer navigation”) + `main` on every production tab | +| Help dialog labelling | same | Dialog `aria-labelledby`, shortcut table columnheaders | +| Inclusive language seed | `scripts/inclusive-language-check.ps1` | User-facing docs/strings avoid deny-list terms | + +CI does **not** run a real screen reader. Treat axe/ARIA as a regression net; use +the checklist below for announcement quality, verbosity, and OS chrome. + +## Prerequisites + +1. Build the web viewer (or desktop with WebView) from a clean worktree. Prefer a + worktree-local target dir so parallel lanes do not collide: + + ```powershell + $env:CARGO_TARGET_DIR = "$PWD/target-w23-c09" + cd crates/sl-viewer + dx build --platform web --release --no-default-features --features web + ``` + +2. For web smoke: serve `tests/visual/harness` (`npm ci` then + `npx playwright test a11y.spec.js`) or open the built assets in a browser with + the SR attached. +3. For native shell: `cargo run -p sl-viewer` with the desktop feature (see native + checklist in the companion doc), then attach NVDA (Windows) or VoiceOver (macOS). + +## NVDA checklist (Windows, ~8 min) + +Use browse mode (NVDA+Space toggles) for landmarks; focus mode for forms. + +1. **Landmarks** — NVDA+F7 → Landmarks: hear **Primary viewer navigation** and + **main**. Tab into the sidebar; confirm “SessionLedger views” tablist. +2. **Tabs** — Focus Bundles; ArrowRight: History is selected and announced; + Home/End reach Bundles / Replay. +3. **Theme toggle** — Tab to **Toggle light and dark theme**; Activate: theme + change is announced or visible without losing focus context. +4. **Help** — Activate **Help (?)**; dialog “Keyboard shortcuts” is announced; + Escape closes and focus returns to Help. +5. **Search filters** — Open Search; Tab through **Since (YYYY-MM-DD)** and + **Model (substring)**; labels are spoken with the fields. +6. **Bundles filter** — On Bundles (after load): find **Filter sessions** by + name; typing filters the list without unexplained focus jumps. +7. **Error + Retry** — `/?fixture=search-error` (web) or fail Search with daemon + down: assertive alert “Something went wrong”; **Retry** is a named button and + activatable with Enter/Space. +8. **Live status** — Live Feed connecting: polite status / “Connecting…” without + flooding (no endless identical announcements every frame). + +## VoiceOver checklist (macOS, ~8 min) + +Use VO+U for the rotor; VO+Left/Right to move. + +1. **Rotor → Landmarks** — Primary viewer navigation + main present. +2. **Rotor → Form Controls** — Theme toggle, Help (?), Search fields, Retry (on + error fixture) appear with the names above. +3. **Tabs** — Interact with the tablist; VO+Right across tabs updates selection + announcement. +4. **Help dialog** — Open Help; VO announces dialog title; Escape dismisses and + restores Help button focus. +5. **Status / alert** — Loading/skeleton fixtures announce busy status; search + error announces assertively; Retry remains in the form-controls rotor. + +## Recording a pass + +Write machine-readable native WebView smoke evidence (schema sample + recorder): + +- Fixture: [`docs/ops/fixtures/native-webview-smoke.sample.json`](../ops/fixtures/native-webview-smoke.sample.json) +- Recorder: `pwsh -NoProfile -File scripts/record-native-webview-smoke.ps1` + +See [CONTRIBUTING.md](../../CONTRIBUTING.md#native-webview-accessibility-smoke) +for maintainer steps. Do not claim platform code-signing here — ADR 0003. + +## Related references + +- [`docs/viewer-hotkeys.md`](../viewer-hotkeys.md) — keyboard contract +- [`docs/a11y/status-regions-and-native-smoke.md`](status-regions-and-native-smoke.md) — status/cognitive + OS shell +- [`docs/VISUAL_SPEC.md`](../VISUAL_SPEC.md) §3–5 — loading, skeleton, and error anatomy diff --git a/docs/a11y/status-regions-and-native-smoke.md b/docs/a11y/status-regions-and-native-smoke.md index a1bfe1f5..7f1e37e8 100644 --- a/docs/a11y/status-regions-and-native-smoke.md +++ b/docs/a11y/status-regions-and-native-smoke.md @@ -46,10 +46,18 @@ OS window chrome is outside the browser harness; this checklist captures what au 5. **Keyboard efficiency** — Press `?` (outside a text field): help overlay opens with shortcut table; `Escape` closes and focus returns to **Help (?)**. 6. **Search escape hatch** — On Search tab, type in a filter, press `Escape`: fields clear, focus stays in the filter. -Record pass/fail and build ID in your audit worksheet. Do not claim Authenticode or platform signing here — see packaging ADR 0003 for the portable trust model. +Record pass/fail and build ID in your audit worksheet, or write machine-readable +evidence with `scripts/record-native-webview-smoke.ps1` (schema sample: +[`docs/ops/fixtures/native-webview-smoke.sample.json`](../ops/fixtures/native-webview-smoke.sample.json)). +Do not claim Authenticode or platform signing here — see packaging ADR 0003 for +the portable trust model. + +For NVDA/VoiceOver announcement checks and what axe/ARIA already cover in CI, +see [`docs/a11y/screen-reader-smoke.md`](screen-reader-smoke.md). ## Related references +- [`docs/a11y/screen-reader-smoke.md`](screen-reader-smoke.md) — L81.4 SR procedure - [`docs/viewer-hotkeys.md`](../viewer-hotkeys.md) — keyboard contract - [`docs/HELP.md`](../HELP.md) — operator help - [`docs/VISUAL_SPEC.md`](../VISUAL_SPEC.md) §3–5 — loading, skeleton, and error anatomy diff --git a/docs/ops/fixtures/native-webview-smoke.sample.json b/docs/ops/fixtures/native-webview-smoke.sample.json new file mode 100644 index 00000000..6d6b9dcd --- /dev/null +++ b/docs/ops/fixtures/native-webview-smoke.sample.json @@ -0,0 +1,62 @@ +{ + "schema": "sessionledger.native-webview-smoke.v1", + "outcome": "pass", + "procedure": "docs/a11y/status-regions-and-native-smoke.md#native-webview-smoke-checklist-manual-5-min", + "screenReaderProcedure": "docs/a11y/screen-reader-smoke.md", + "buildId": "local-sample", + "gitSha": "0000000000000000000000000000000000000000", + "viewer": { + "crate": "sl-viewer", + "platform": "desktop-webview", + "feature": "desktop" + }, + "host": { + "os": "windows", + "osVersion": "10.0.0", + "screenReader": "NVDA", + "screenReaderVersion": "2024.1", + "ci": false + }, + "generatedAt": "2026-07-13T20:00:00.0000000Z", + "checklist": [ + { + "id": "landmarks", + "outcome": "pass", + "detail": "Primary viewer navigation and main reachable; tablist announced" + }, + { + "id": "status_loading", + "outcome": "pass", + "detail": "Live Feed Connecting status + skeleton without layout jump" + }, + { + "id": "error_recovery", + "outcome": "pass", + "detail": "Search error alert plain language; Retry keyboard-focusable" + }, + { + "id": "reduced_motion", + "outcome": "pass", + "detail": "OS reduce motion: spinner/tab transitions not distractingly animated" + }, + { + "id": "keyboard_help", + "outcome": "pass", + "detail": "Help (?) opens shortcut table; Escape returns focus to Help" + }, + { + "id": "search_escape", + "outcome": "pass", + "detail": "Escape clears Search filter fields; focus stays in filter" + }, + { + "id": "sr_primary_controls", + "outcome": "pass", + "detail": "Theme toggle, Help, search filters, Retry announced by accessible name" + } + ], + "notes": [ + "Sample fixture only — replace buildId/gitSha/host when recording a real pass.", + "Does not prove Authenticode or platform signing; see packaging ADR 0003." + ] +} diff --git a/scripts/record-native-webview-smoke.ps1 b/scripts/record-native-webview-smoke.ps1 new file mode 100644 index 00000000..86e70f12 --- /dev/null +++ b/scripts/record-native-webview-smoke.ps1 @@ -0,0 +1,93 @@ +<# +.SYNOPSIS +Record a native WebView accessibility smoke pass as machine-readable JSON. + +.DESCRIPTION +Copies the sample fixture schema, fills build/git/host metadata, and writes an +evidence file maintainers can attach to audits. Does not launch the viewer or a +screen reader — run the checklists in docs/a11y/ first, then record the outcome. + +.EXAMPLE +pwsh -NoProfile -File scripts/record-native-webview-smoke.ps1 ` + -Outcome pass ` + -ScreenReader NVDA ` + -OutPath docs/ops/fixtures/native-webview-smoke.local.json +#> +[CmdletBinding()] +param( + [ValidateSet("pass", "fail", "partial")] + [string]$Outcome = "pass", + + [ValidateSet("NVDA", "VoiceOver", "Narrator", "Orca", "none")] + [string]$ScreenReader = "NVDA", + + [string]$ScreenReaderVersion = "", + + [string]$BuildId = "", + + [string]$OutPath = "", + + [string]$RepoRoot = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($RepoRoot)) { + $RepoRoot = Split-Path -Parent $PSScriptRoot +} + +$samplePath = Join-Path $RepoRoot "docs/ops/fixtures/native-webview-smoke.sample.json" +if (-not (Test-Path -LiteralPath $samplePath -PathType Leaf)) { + throw "Missing sample fixture at '$samplePath'." +} + +Push-Location $RepoRoot +try { + $gitSha = (git rev-parse HEAD 2>$null) + if (-not $gitSha) { + $gitSha = "unknown" + } +} +finally { + Pop-Location +} + +if ([string]::IsNullOrWhiteSpace($BuildId)) { + $BuildId = "local-{0}" -f (Get-Date -Format "yyyyMMddHHmmss") +} + +if ([string]::IsNullOrWhiteSpace($OutPath)) { + $OutPath = Join-Path $RepoRoot "docs/ops/fixtures/native-webview-smoke.local.json" +} + +$sample = Get-Content -LiteralPath $samplePath -Raw | ConvertFrom-Json +$sample.outcome = $Outcome +$sample.buildId = $BuildId +$sample.gitSha = "$gitSha" +$sample.generatedAt = (Get-Date).ToUniversalTime().ToString("o") +$sample.host.os = if ($IsWindows -or $env:OS -match "Windows") { "windows" } + elseif ($IsMacOS) { "macos" } + elseif ($IsLinux) { "linux" } + else { "unknown" } +$sample.host.osVersion = [System.Environment]::OSVersion.Version.ToString() +$sample.host.screenReader = $ScreenReader +$sample.host.screenReaderVersion = $ScreenReaderVersion +$sample.host.ci = [bool]($env:CI -or $env:GITHUB_ACTIONS) + +if ($Outcome -ne "pass") { + foreach ($item in $sample.checklist) { + if ($item.outcome -eq "pass") { + $item.outcome = $Outcome + $item.detail = "Marked $Outcome by recorder; edit per-item detail before filing." + } + } +} + +$outDir = Split-Path -Parent $OutPath +if ($outDir -and -not (Test-Path -LiteralPath $outDir)) { + New-Item -ItemType Directory -Path $outDir | Out-Null +} + +$sample | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $OutPath -Encoding utf8 +Write-Host "ok: native WebView smoke evidence written to $OutPath (outcome=$Outcome, sha=$gitSha)" diff --git a/tests/visual/harness/a11y.spec.js b/tests/visual/harness/a11y.spec.js index 2680fd8a..2ea8842c 100644 --- a/tests/visual/harness/a11y.spec.js +++ b/tests/visual/harness/a11y.spec.js @@ -59,13 +59,13 @@ test("Tab order reaches active tab then active-panel controls", async ({ page }) await expect(page.getByRole("tab", { name: "Search" })).toBeFocused(); await page.keyboard.press("Tab"); - await expect(page.locator(".search-input").first()).toBeFocused(); + await expect(page.getByRole("textbox", { name: "Since (YYYY-MM-DD)" })).toBeFocused(); }); test("Escape clears the search without moving focus", async ({ page }) => { await page.goto("/"); await page.getByRole("tab", { name: "Search" }).click(); - const since = page.locator(".search-input").first(); + const since = page.getByRole("textbox", { name: "Since (YYYY-MM-DD)" }); await since.fill("2026-01-01"); await since.press("Escape"); await expect(since).toHaveValue(""); @@ -75,17 +75,44 @@ test("Escape clears the search without moving focus", async ({ page }) => { test("Help control opens keyboard help and Escape closes it", async ({ page }) => { await page.goto("/"); const helpDialog = page.locator('[data-testid="keyboard-help-dialog"]'); - const helpButton = page.locator("#viewer-help-button"); + const helpButton = page.getByRole("button", { name: "Help (?)" }); await expect(helpDialog).toHaveCount(0); await expect(helpButton).toBeVisible(); + await expect(helpButton).toHaveAttribute("aria-haspopup", "dialog"); + await expect(helpButton).toHaveAttribute("aria-expanded", "false"); await helpButton.click(); await expect(helpDialog).toHaveCount(1); + await expect(helpButton).toHaveAttribute("aria-expanded", "true"); await expect(page.getByRole("heading", { name: "Keyboard shortcuts" })).toBeVisible(); await page.keyboard.press("Escape"); await expect(helpDialog).toHaveCount(0); await expect(helpButton).toBeFocused(); + await expect(helpButton).toHaveAttribute("aria-expanded", "false"); +}); + +test("primary controls expose stable accessible names", async ({ page }) => { + await page.goto("/"); + + const theme = page.getByRole("button", { name: "Toggle light and dark theme" }); + await expect(theme).toBeVisible(); + await expect(theme).toBeEnabled(); + + const help = page.getByRole("button", { name: "Help (?)" }); + await expect(help).toBeVisible(); + await expect(help).toHaveAttribute("aria-controls", "keyboard-help-dialog"); + + // Bundles uses synchronous sample data after the loading gate; wait on role+name. + const filter = page.getByRole("textbox", { name: "Filter sessions" }); + await expect(filter).toBeVisible(); + await expect(filter).toHaveAttribute("placeholder", "Filter sessions..."); + + await page.getByRole("tab", { name: "Search", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Since (YYYY-MM-DD)" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Model (substring)" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Search", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Clear", exact: true })).toBeVisible(); }); test.describe("status regions and cognitive fixtures", () => { @@ -111,12 +138,15 @@ test.describe("status regions and cognitive fixtures", () => { test("search error fixture exposes assertive alert with retry", async ({ page }) => { await page.goto("/?fixture=search-error"); - const error = page.getByTestId("error-state"); + const error = page.getByRole("alert"); await expect(error).toBeVisible(); - await expect(error).toHaveAttribute("role", "alert"); await expect(error).toHaveAttribute("aria-live", "assertive"); await expect(error).toContainText(/something went wrong/i); + const retry = page.getByRole("button", { name: "Retry" }); + await expect(retry).toBeVisible(); + await expect(retry).toBeEnabled(); await expect(page.getByTestId("error-state-retry")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Since (YYYY-MM-DD)" })).toBeVisible(); }); test("stream skeleton fixture exposes labelled feed status and stream skeleton", async ({ page }) => { @@ -173,10 +203,11 @@ test.describe("landmarks and reduced motion", () => { test("help overlay lists every shortcut row for keyboard efficiency", async ({ page }) => { await page.goto("/"); - await page.locator("#viewer-help-button").click(); + await page.getByRole("button", { name: "Help (?)" }).click(); const rows = page.locator(".help-overlay-table tbody tr"); await expect(rows).toHaveCount(9); await expect(page.getByRole("dialog")).toHaveAttribute("aria-labelledby", "help-overlay-title"); await expect(page.getByRole("columnheader", { name: "Shortcut" })).toBeVisible(); await expect(page.getByRole("columnheader", { name: "Action" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Close keyboard help" })).toBeVisible(); });