# The screen-reader lane

Ouro UI's components are read by real screen readers before every release: VoiceOver on macOS and NVDA on Windows, driven through [Guidepup](https://www.guidepup.dev/) in this repository's CI.[^sr-release][^sr-runners] This page lists what the lane checks, how a failure is classified, how to run it, and why it may change a runner's settings.

## What it checks

Each spec opens one story from a static build of this repository's Storybook, waits for the component to render, starts the screen reader, moves it into the page and asserts what it says: the control's name, its role and its state, then the change after a key press.[^sr-stories]

| Spec | Story | What the screen reader must say |
| --- | --- | --- |
| Checkbox | `forms-checkbox--playground` | "Email me new recipes", checkbox, unchecked; then checked after Space |
| Checklist | `lists-checklist--in-context` | the first item's name, checkbox and its state; then the other state after Space |
| Dialog | `overlays-dialog--playground` | "Edit", button; "Edit recipe", dialog, as it opens; the "Edit" button again after Escape |
| Select | `forms-select--playground` | "Course", combo box, collapsed; the list as it opens; "Main", then "Side"; "Course" with "Side" once chosen, read from the focused control |
| Toast | `feedback-toast--playground` | "Save recipe", button; "Recipe saved" from the live region after Enter |

The Checklist spec skips, with that reason, until `registry/ouro/components/ouro/checklist/` exists; once it does, a missing story fails the lane. Before either screen reader starts, the `stories` project checks every story id against the built Storybook's `index.json`.[^sr-stories]

VoiceOver reads WebKit on `macos-15`; NVDA reads Chromium on `windows-2025`. Each matcher accepts both readers' words (VoiceOver says "unchecked" where NVDA says "not checked", and "checkbox" where NVDA says "check box"), so the specs hold for both, but it must find a control's name and its role in one sentence of one spoken phrase (`inOneSentence` in `registry/ouro/e2e/screen-reader/phrases.ts`).[^sr-one-sentence] Phrases are never joined, VoiceOver's hints ("You are currently on a button…", "To display a list of options…") are cut off first, and the reader's item text, which can run on into the next control, is never counted.[^sr-hints] So "Shakshuka Name edit text" (the dialog's text field) with a hint saying "button" does not pass for the Edit button; NVDA's "clickable, Edit, button, collapsed, opens dialog" does (`scripts/sr-phrases.test.mjs` holds both).

The specs wait for speech, never for a fixed time.[^sr-no-sleep] NVDA's phrases come from Guidepup's capture, which logs everything NVDA says.[^sr-nvda] VoiceOver lets AppleScript read only its last phrase, and Guidepup's capture reads it again only after waiting as long as that phrase takes to say: after a long hint, it missed the short phrase that named the newly focused control ("Edit dialog pop up button") and read the next hint, so the Dialog spec failed at random. On VoiceOver, `heard` instead watches the last phrase every 20 ms from one osascript process, and logs every phrase it read (`watchVoiceOver` in `screen-reader.ts`).[^sr-watch] Before each action the watch waits for VoiceOver to be quiet: no new phrase for 2 s (at most 10 s), as a person listens before pressing the next key (Enter pressed while VoiceOver was still speaking opened the dialog without VoiceOver ever saying its name).[^sr-ready] It then has VoiceOver say "Speech check" (`MARK` in `phrases.ts`) and, after another quiet second with that still its last phrase, runs the action; VoiceOver can drop or talk over MARK, so it is said again, three times in all (`readyForAction`).[^sr-ready] Only phrases after that last MARK count, so nothing said before the action does, and a control VoiceOver was already on still counts when it says it again as focus arrives. Then it checks each new phrase as it is read, for up to 15 s (`speechVerdict` in `phrases.ts`): a phrase that never came is an accessibility failure, and only a watcher that failed before the deadline (osascript could not run, or exited with an error) is infrastructure.[^sr-verdict] The watcher's own limit is longer than a whole test, so no check outlives it.[^sr-outlives]

VoiceOver starts for each test, as @guidepup/playwright's fixture starts it, and each VoiceOver test has a WebKit of its own: with one browser for every test, VoiceOver missed the Dialog's name as it opened once Checklist's test ran before it ([ourostack/ouro-ui#119](https://github.com/ourostack/ouro-ui/issues/119)).[^sr-119] NVDA starts once for each Playwright worker and quits after its last test (`registry/ouro/e2e/screen-reader/nvda.ts`): `nvda.stop()` returns while NVDA is still unloading its helper from the browser, and quitting between tests failed a passing test's context close ("browserContext.close: Test ended.") or took the shared browser down, so the next test could not open a context.[^sr-nvda-worker] Before each test NVDA is moved to the page as Guidepup's `navigateToWebContent` does, and the test fails as **infrastructure** when NVDA still reports another window (once it read "Start, window" and the test hung).[^sr-nvda-window] Each test closes its own browser context, and stopping a screen reader gives up after 30 s; anything that goes wrong there is logged as `[sr] teardown:` and does not fail a test that passed.[^sr-teardown]

## Known findings

- VoiceOver with WebKit says Select's value without its last three characters ([ourostack/ouro-ui#113](https://github.com/ourostack/ouro-ui/issues/113)): ", Choose a cou, Course, list box pop up" for the placeholder "Choose a course", and ", S, Course" once "Side" is chosen, both as focus arrives and when asked to describe the control (VO-F4).[^sr-113] It reads the options whole ("Main (2 of 4)"), and NVDA reads the value whole ("Course, combo box, Side, collapsed").[^sr-113] The Select spec accepts that clipped form on VoiceOver only, and only with the label in the same sentence, and logs it as `[sr] finding (ourostack/ouro-ui#113):`; the next step is to tell WebKit's reading of a `button` with `role="combobox"` apart from the markup Base UI renders.
- VoiceOver at times does not announce a Checkbox's or a Checklist row's new state after Space: for a Checkbox it says nothing, and for a Checklist row only the checkbox's hint ("You are currently on a checkbox…"), though the box has changed and keeps the focus ([ourostack/ouro-ui#118](https://github.com/ourostack/ouro-ui/issues/118)).[^sr-118] It says nothing in the same way for a native `<input type="checkbox">` and a plain `role="checkbox"`, so the miss is VoiceOver's.[^sr-118] On VoiceOver only, and for that step only (`heardOrDescribed`), each spec then asks VoiceOver to describe the focused box (VO-F4), and that one phrase must give its name, the checkbox role and the new state; it logs `[sr] finding: ourostack/ouro-ui#118`.[^sr-118] A miss in which VoiceOver said the old state, a missing or wrong description, and a box whose state did not change in the page are still failures, and NVDA must still announce the change.[^sr-118]
- VoiceOver at times does not say the Dialog's name as it opens: it says only the focused field ("Shakshuka"), then the field's hint ([ourostack/ouro-ui#119](https://github.com/ourostack/ouro-ui/issues/119)).[^sr-119] On VoiceOver only, and for the opening step only, the spec then has VoiceOver stop interacting with the field once (VO-Shift-Up, which the field's hint offers as "exit this group"), moving the VoiceOver cursor, not the focus, to the dialog, and describes the item in the cursor (VO-F3) up to three times: one phrase must give "Edit recipe" and "web dialog".[^sr-119] Describing the focused field (VO-F4) names only the field.[^sr-119] It logs `[sr] finding: ourostack/ouro-ui#119`, still checks that the focus is inside the dialog, and a missing or wrong description is still an accessibility failure.[^sr-119]
- As the Select list opens, VoiceOver can read the first option before it has its name ("selectable (null)"); the spec only logs that moment and checks each move once the list is open.[^sr-select]

## When it runs

- Nightly, on main (`.github/workflows/screen-reader.yml`).
- Before every release: `release.yml` dispatches the lane on its release branch with `source_sha` set to the release commit, and tags that commit only when the run concluded `success` for that exact SHA.[^sr-release]
- By hand: `gh workflow run screen-reader.yml --ref <branch> -f source_sha=<the branch's tip SHA>`. The lane refuses any other commit.

It never runs for a pull request.[^sr-triggers]

## How a failure is classified

`scripts/sr-classify.mjs` reads the log and the exit code of `pnpm test:sr`:

- **accessibility**: an expected utterance was missing or wrong, or a story the lane opens is missing.[^sr-classify] This fails the run and blocks a release.
- **infrastructure**: VoiceOver or NVDA did not start or could not be controlled, a TCC prompt appeared, a test timed out before the screen reader said anything, the Storybook server never became ready, the runner image was broken, or every error in the run is the browser failing to open or close a test's context, which only the fixtures around a test do (a missing utterance, a failed `expect` or any other error in the run makes it **accessibility**).[^sr-classify] A nightly run opens, or comments on, one issue labelled `screen-reader-infra` (`scripts/sr-infra-issue.sh`); a release dispatches the lane once more and stops after a second infrastructure failure, with that issue.[^sr-classify][^sr-release-gate]

## Running it

On a machine set up for screen reader automation (`npx @guidepup/setup setup`, then `npx @guidepup/setup install` in this repository):

```sh
pnpm test:sr --project voiceover   # macOS
pnpm test:sr --project nvda        # Windows
```

The config (`playwright.screen-reader.config.ts`) builds Storybook into `.tmp/storybook-sr` and serves it on port 6006, runs one test at a time, headed, and never retries.[^sr-config] The main end-to-end run (`pnpm test:e2e`) ignores `*.sr.spec.ts`.

## Security

Guidepup's setup changes the operating system: on macOS it enables VoiceOver's AppleScript control and writes the TCC database so the runner may automate it; on Windows it installs a portable NVDA. So the lane:

- runs only on GitHub-hosted runners, which are thrown away after the job (a step fails on any other runner);
- checks out only the commit it was given, and only when that commit is the tip of the dispatched branch, so it never runs code fetched by SHA from a fork;[^sr-tip]
- runs only from its schedule (main) or a dispatch, which needs write access to the repository, and never for a pull request;[^sr-triggers]
- holds `contents: read` and no secrets, with every action pinned to a commit; only the job that files the nightly issue may write issues, and it runs no Guidepup.[^sr-permissions]

[^sr-release]: .github/workflows/release.yml verify:testExists(scripts/workflows.test.mjs::a release passes CI (with shadcn@latest), then the screen-reader lane on its own commit, before it tags) verify:testExists(scripts/workflows.test.mjs::the release's screen-reader gate refuses a run for another commit, and a lane that never starts)
[^sr-runners]: .github/workflows/screen-reader.yml verify:testExists(scripts/workflows.test.mjs::the screen-reader lane runs VoiceOver on macos-15 and NVDA on windows-2025, at the commit it was given)
[^sr-stories]: registry/ouro/e2e/screen-reader/stories.sr.setup.ts verify:fileLine(registry/ouro/e2e/screen-reader/stories.sr.setup.ts, /request\.get\("index\.json"\)/)
[^sr-nvda]: registry/ouro/e2e/screen-reader/nvda.ts verify:fileLine(registry/ouro/e2e/screen-reader/nvda.ts, /capture: "initial"/)
[^sr-watch]: registry/ouro/e2e/screen-reader/screen-reader.ts verify:fileLine(registry/ouro/e2e/screen-reader/screen-reader.ts, /every 20 ms from one/)
[^sr-ready]: registry/ouro/e2e/screen-reader/phrases.ts verify:fileLine(registry/ouro/e2e/screen-reader/phrases.ts, /quietFor\(watch\.stamps, watch\.now\(\), 2_000\), 10_000/) verify:fileLine(registry/ouro/e2e/screen-reader/phrases.ts, /three times in all/) verify:fileLine(registry/ouro/e2e/screen-reader/phrases.ts, /export const MARK = "Speech check"/)
[^sr-verdict]: registry/ouro/e2e/screen-reader/screen-reader.ts; registry/ouro/e2e/screen-reader/phrases.ts verify:fileLine(registry/ouro/e2e/screen-reader/screen-reader.ts, /timeout = 15_000/) verify:fileLine(registry/ouro/e2e/screen-reader/phrases.ts, /export function speechVerdict/)
[^sr-teardown]: registry/ouro/e2e/screen-reader/screen-reader.ts verify:fileLine(registry/ouro/e2e/screen-reader/screen-reader.ts, /resolve\(false\), 30_000/) verify:testExists(scripts/sr-classify.test.mjs::a test that passed and then failed to close its browser context is infrastructure, with the reason)
[^sr-113]: https://github.com/ourostack/ouro-ui/issues/113; https://github.com/ourostack/ouro-ui/actions/runs/39207545376 verify:urlOk(https://github.com/ourostack/ouro-ui/issues/113) verify:fileLine(registry/ouro/e2e/screen-reader/select.sr.spec.ts, /ourostack\/ouro-ui#113/)
[^sr-118]: https://github.com/ourostack/ouro-ui/issues/118 verify:urlOk(https://github.com/ourostack/ouro-ui/issues/118) verify:fileLine(registry/ouro/e2e/screen-reader/checklist.sr.spec.ts, /ourostack\/ouro-ui#118/) verify:fileLine(registry/ouro/e2e/screen-reader/checkbox.sr.spec.ts, /ourostack\/ouro-ui#118/)
[^sr-119]: https://github.com/ourostack/ouro-ui/issues/119; registry/ouro/e2e/screen-reader/screen-reader.ts verify:urlOk(https://github.com/ourostack/ouro-ui/issues/119) verify:fileLine(registry/ouro/e2e/screen-reader/screen-reader.ts, /ourostack\/ouro-ui#119/)
[^sr-classify]: scripts/sr-classify.mjs verify:testExists(scripts/sr-classify.test.mjs::an expected utterance that never came stays accessibility, recorded or beside a teardown error)
[^sr-release-gate]: .github/workflows/release.yml verify:testExists(scripts/workflows.test.mjs::the release's screen-reader gate stops after a second infrastructure failure, with the issue)
[^sr-config]: playwright.screen-reader.config.ts verify:fileLine(playwright.screen-reader.config.ts, /workers: 1,/) verify:fileLine(playwright.screen-reader.config.ts, /retries: 0,/) verify:fileLine(playwright.screen-reader.config.ts, /--output-dir \.tmp\/storybook-sr/) verify:testExists(scripts/workflows.test.mjs::the main Playwright config never runs a screen-reader spec, and the screen-reader config runs only those, one at a time)
[^sr-permissions]: .github/workflows/screen-reader.yml verify:testExists(scripts/workflows.test.mjs::the screen-reader lane reads the repository and nothing more, and holds no secret) verify:testExists(scripts/workflows.test.mjs::every action is pinned to a full commit SHA, with its version in a comment)
[^sr-one-sentence]: test:scripts/sr-phrases.test.mjs::the checkbox's name, role and state come from one sentence; test:scripts/sr-phrases.test.mjs::every spec's matcher goes through inOneSentence, and the lane counts only speech verify:testExists(scripts/sr-phrases.test.mjs::the checkbox's name, role and state come from one sentence) verify:testExists(scripts/sr-phrases.test.mjs::every spec's matcher goes through inOneSentence, and the lane counts only speech)
[^sr-hints]: test:scripts/sr-phrases.test.mjs::VoiceOver's usage hints are dropped before matching; test:scripts/sr-phrases.test.mjs::the Edit trigger's name and role must be in one spoken sentence, not in hints or a text field; test:scripts/sr-phrases.test.mjs::every spec's matcher goes through inOneSentence, and the lane counts only speech verify:testExists(scripts/sr-phrases.test.mjs::VoiceOver's usage hints are dropped before matching) verify:testExists(scripts/sr-phrases.test.mjs::the Edit trigger's name and role must be in one spoken sentence, not in hints or a text field) verify:testExists(scripts/sr-phrases.test.mjs::every spec's matcher goes through inOneSentence, and the lane counts only speech)
[^sr-no-sleep]: test:scripts/sr-phrases.test.mjs::the specs wait for speech, never for a fixed time verify:testExists(scripts/sr-phrases.test.mjs::the specs wait for speech, never for a fixed time)
[^sr-outlives]: test:scripts/sr-phrases.test.mjs::the VoiceOver watcher outlives every check: its own limit is longer than a whole test verify:testExists(scripts/sr-phrases.test.mjs::the VoiceOver watcher outlives every check: its own limit is longer than a whole test)
[^sr-nvda-worker]: registry/ouro/e2e/screen-reader/nvda.ts; test:scripts/sr-classify.test.mjs::a test that passed and then failed to close its browser context is infrastructure, with the reason; test:scripts/sr-classify.test.mjs::a test whose browser was gone before it began is infrastructure: NVDA's quit took the shared browser with it verify:fileLine(registry/ouro/e2e/screen-reader/nvda.ts, /one NVDA for each Playwright worker/) verify:testExists(scripts/sr-classify.test.mjs::a test that passed and then failed to close its browser context is infrastructure, with the reason) verify:testExists(scripts/sr-classify.test.mjs::a test whose browser was gone before it began is infrastructure: NVDA's quit took the shared browser with it)
[^sr-nvda-window]: test:scripts/sr-phrases.test.mjs::NVDA is reading the page only when the window it reports is the page's, in the browser; registry/ouro/e2e/screen-reader/nvda.ts verify:testExists(scripts/sr-phrases.test.mjs::NVDA is reading the page only when the window it reports is the page's, in the browser) verify:fileLine(registry/ouro/e2e/screen-reader/nvda.ts, /navigateToWebContent/)
[^sr-select]: registry/ouro/e2e/screen-reader/select.sr.spec.ts verify:fileLine(registry/ouro/e2e/screen-reader/select.sr.spec.ts, /each move once it is open must be read/)
[^sr-triggers]: test:scripts/workflows.test.mjs::the screen-reader lane runs nightly and on dispatch with a required source_sha, never for a pull request verify:testExists(scripts/workflows.test.mjs::the screen-reader lane runs nightly and on dispatch with a required source_sha, never for a pull request)
[^sr-tip]: .github/workflows/screen-reader.yml; test:scripts/workflows.test.mjs::the screen-reader lane runs VoiceOver on macos-15 and NVDA on windows-2025, at the commit it was given verify:fileLine(.github/workflows/screen-reader.yml, /is not the tip of the dispatched branch/) verify:testExists(scripts/workflows.test.mjs::the screen-reader lane runs VoiceOver on macos-15 and NVDA on windows-2025, at the commit it was given)
