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
23 changes: 23 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions crates/sl-viewer/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
91 changes: 91 additions & 0 deletions docs/a11y/screen-reader-smoke.md
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion docs/a11y/status-regions-and-native-smoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
62 changes: 62 additions & 0 deletions docs/ops/fixtures/native-webview-smoke.sample.json
Original file line number Diff line number Diff line change
@@ -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."
]
}
93 changes: 93 additions & 0 deletions scripts/record-native-webview-smoke.ps1
Original file line number Diff line number Diff line change
@@ -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)"
43 changes: 37 additions & 6 deletions tests/visual/harness/a11y.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand All @@ -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", () => {
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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();
});
Loading