Guides

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 in this repository's CI.sr-releasesr-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

Checkbox
Story: forms-checkbox--playground
"Email me new recipes", checkbox, unchecked; then checked after Space
Checklist
Story: lists-checklist--in-context
the first item's name, checkbox and its state; then the other state after Space
Dialog
Story: overlays-dialog--playground
"Edit", button; "Edit recipe", dialog, as it opens; the "Edit" button again after Escape
Select
Story: 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
Story: 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).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): ", 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).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).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-classifysr-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):

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

Claim sources