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 abuttonwithrole="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 plainrole="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.ymldispatches the lane on its release branch withsource_shaset to the release commit, and tags that commit only when the run concludedsuccessfor 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
expector any other error in the run makes it **accessibility**).sr-classify A nightly run opens, or comments on, one issue labelledscreen-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 # WindowsThe 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: readand 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
- 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)