Summary

15 / 15 findings fixed and verified by ear (real NVDA)
3½ / 15 flagged by strict React lint and axe-core (lint not run on the Vue app)
6 + 2 of Bob's own fixes rejected by ear · refusals of a prompted regression at commit (the second with --no-verify)

Hear the difference: F-01, the Sign In dialog

Before the fix: after Enter, NVDA says nothing; after Tab it reads Moon, link, heading, level 3 from the page behind the dialog
Before: Enter → silence. Tab → the page behind the dialog.
After the fix: after Enter, NVDA says Sign In, dialog; after Tab it says Close modal, button
After Bob's fix: Enter → "Sign In, dialog".

Findings

F-01

VERIFIED
WCAG
4.1.2 Name, Role, Value (A); 2.4.3 Focus Order (A)
Component
Sign In dialog
Script
ENTER → TAB
Expected
“Sign In, dialog”, then “Close modal, button”

Before

ENTER → (silence — focus remains on page behind dialog)

TAB → Moon, link, heading, level 3

After

ENTER → Sign In, dialog

TAB → Close modal, button

F-02

VERIFIED
WCAG
2.4.3 Focus Order (A)
Component
Sign In dialog
Script
ENTER, TAB ×6, SHIFT+TAB, ESCAPE
Expected
Tab after “Register” wraps to “Close modal, button”. After Escape: “Select Seat Class, button”.

Rejected first fix

TAB after Register → github.com, link (escaped the dialog)

After

TAB after Register → Close modal, button

ESCAPE → Select Seat Class, button

F-03

VERIFIED
WCAG
1.3.1 Info and Relationships (A); 4.1.2 Name, Role, Value (A)
Component
Sign In form fields
Script
ENTER, TAB, TAB, TAB
Expected
“Close modal, button”, then “Name, edit”, then “Email, edit”

Before

TAB → John Doe, edit, required

After

TAB → Name, edit, required

TAB → Email, edit, required

F-04

VERIFIED · FOUND BY SWEEP
WCAG
2.4.6 Headings and Labels (AA); 1.3.1 Info and Relationships (A)
Component
/flights booking buttons
Script
TAB
Expected
“Select Seat Class, Earth to Mars, button”

Rejected first fix

TAB → Select Seat Class, button (route missing: Button dropped aria-label)

After

TAB → Select Seat Class, Earth to Mars, button

F-05

VERIFIED · FOUND BY SWEEP
WCAG
2.4.1 Bypass Blocks (A)
Component
Every page (no skip link)
Script
TAB, ENTER, TAB from top of /flights
Expected
“Skip to main content, same page, link” as first tab stop; Enter moves focus into main

Before

first TAB → Pause animation, button (no skip link)

After

first TAB → Skip to main content, same page, link

ENTER → main landmark, heading, level 1, Available Flights

TAB → Search flights, edit (header skipped)

F-06

VERIFIED · FOUND BY SWEEP
WCAG
1.3.1 Info and Relationships (A); 4.1.2 Name, Role, Value (A); 3.3.2 Labels or Instructions (A)
Component
/flights search field
Script
TAB
Expected
“Search flights, edit”

Before

TAB → Search by origin or destination..., edit (placeholder as name)

After

TAB → Search flights, edit

F-07

VERIFIED · FOUND BY SWEEP
WCAG
4.1.2 Name, Role, Value (A); 2.4.3 Focus Order (A)
Component
Home page: a button nested inside a link, in 3 places (Book a Flight, Explore Flights, Book Your Flight Now)
Script
TAB ×17 from top of /
Expected
Each control is one Tab stop: “Book a Flight, link” once; never “button, link”

Before

TAB → Book a Flight, button, link

TAB → Book a Flight, button, link (same control again)

Rejected first fix

TAB → Book a Flight, button, link (tabIndex=-1 on the inner button did not help)

After

TAB → Book a Flight, link

TAB → Explore Flights, link

TAB → Book Your Flight Now, link

F-08

VERIFIED · FOUND BY SWEEP
WCAG
2.4.4 Link Purpose (In Context) (A); 1.1.1 Non-text Content (A)
Component
Footer GitHub icon link (axe-core flags this one too: link-name)
Script
TAB ×17 from top of /
Expected
“GitHub, link”

Before

TAB → content info landmark, github.com, link (the raw address)

After

TAB → content info landmark, GitHub, link

N-01

FIXED + HUMAN
WCAG
2.2.2 Pause, Stop, Hide (A)
Component
Animated starfield background, every page

By ear

Pause animation, button

Human check: Starfield freezes on click and resumes; button label toggles to “Resume animation”. Verified visually 2026-09-25.

N-02

VERIFIED
WCAG
4.1.3 Status Messages (AA)
Component
/flights results count while typing in the search field
Script
TAB ×7, then type “Mars”
Expected
The new count is announced politely, without interrupting typing

Before

TYPE → M, a, r, s … then silence (the count changed on screen only)

After

M → Showing 5 flights

a → Showing 4 flights

r, s → letters echoed; typing never interrupted

F-09

VERIFIED
WCAG
2.1.1 Keyboard (A); 3.2.2 On Input (A)
Component
Sign In dialog: a regression Bob's own F-01 fix introduced (focus stolen on every keystroke)
Script
ENTER → TAB → TAB, type “Nobody”, TAB
Expected
The Name field keeps focus while typing; the next Tab says “Email, edit, required”

Before

TYPE → N … then “Sign In, dialog” again: focus jumped out of the Name field

TAB → Close modal, button (only “N” was typed)

After

TYPE → N, o, b, o, d, y

TAB → Email, edit, required

F-10

VERIFIED · FOUND BY /EARSHOT
WCAG
2.4.4 Link Purpose (A); 2.4.6 Headings and Labels (AA)
Component
/destinations/mars: two flight links both named “Book” (found by the one-command /earshot run)
Script
TAB ×9 from top of /destinations/mars
Expected
“Book Earth to Mars, Jan 01, 2099, link”, then “Book Moon to Mars, Jan 07, 2099, link”

Before

TAB → Book, link

TAB → Book, link (which flight?)

First label, re-specified by a human (not an ear rejection)

Bob’s first label: “Book flight Jan 01, 2099 1, link” (a bare flight id); a human rewrote the expectation

After

TAB → Book Earth to Mars, Jan 01, 2099, link

TAB → Book Moon to Mars, Jan 07, 2099, link

F-11

VERIFIED · FOUND BY /EARSHOT
WCAG
4.1.2 Name, Role, Value (A); 1.3.1 Info and Relationships (A)
Component
TodoMVC (second app): todo checkboxes have no name
Script
type two todos, then TAB ×4
Expected
“Buy milk, check box, not checked”

Before

TAB → list, with 2 items, check box, not checked (which todo?)

TAB → check box, not checked

After

TAB → list, with 2 items, Buy milk, check box, not checked

TAB → Walk dog, check box, not checked

F-12

VERIFIED
WCAG
2.1.1 Keyboard (A); 4.1.2 Name, Role, Value (A)
Component
TodoMVC (second app): Delete buttons hidden until mouse hover, so keyboard users cannot delete (heard by a human in the sweep transcript)
Script
type “Buy milk”, then TAB ×3
Expected
“Delete Buy milk, button” after the checkbox

Before

TAB → check box, not checked

TAB → All, link (no Delete button is ever reached)

After

TAB → Buy milk, check box, not checked

TAB → Delete Buy milk, button

F-13

VERIFIED
WCAG
4.1.2 Name, Role, Value (A)
Component
Uptime Kuma (third app, a real product): the logo is a silent Tab stop (discovered by Bob's /earshot run)
Script
from the top of the dashboard: TAB ×2
Expected
“Status Pages, link” on the second Tab

Before

TAB → banner landmark, Uptime Kuma, same page, link, current page

TAB → (nothing: focus sits on the unnamed logo object; Bob's first fix, aria-hidden only, still sounded like this and was rejected)

After

TAB → banner landmark, Uptime Kuma, same page, link, current page

TAB → list, with 3 items, Status Pages, link

F-14

VERIFIED
WCAG
4.1.2 Name, Role, Value (A); 2.4.3 Focus Order (A)
Component
Uptime Kuma: the heartbeat chart is a second Tab stop inside every monitor link (discovered by Bob's /earshot run; guard test F-14b keeps the chart reachable where it is not in a link)
Script
from the top of the dashboard: TAB ×16
Expected
the Tab after “Galaxium … link” goes straight to “TodoMVC … link”

Before

TAB → 100%, Galaxium, Heartbeat history: 15 checks, 15 up, 0 down, link

TAB → Heartbeat history: 15 checks, 15 up, 0 down, graphic, clickable, link

TAB → 100%, TodoMVC, Heartbeat history: 15 checks, 15 up, 0 down, link

After

TAB → 100%, Galaxium, Heartbeat history: 30 checks, 30 up, 0 down, link

TAB → 100%, TodoMVC, Heartbeat history: 30 checks, 30 up, 0 down, link

Discovery: /sweep and /earshot

Bob tabbed through pages by ear (25 Tabs from the top, real NVDA) while explore subagents located suspects in parallel: seven sweeps that wrote proposals, on six pages of three apps, including one headless run from Bob Shell (three more runs hit the wrong page or mode, or could not write). They produced 27 proposals.

A human accepted 12 as 9 findings: three on /flights (F-04 identical booking buttons, F-05 no skip link, F-06 search field named by its placeholder), four on the Home page as F-07 (a button nested in a link, three places) and F-08 (unnamed GitHub link), F-10 on /destinations/mars (two links both named "Book"), F-11 on TodoMVC (unnamed checkboxes), and F-13 and F-14 on Uptime Kuma's dashboard (a silent Tab stop on the logo, and a second Tab stop inside every monitor link); six Uptime Kuma status filters (one bug) were deferred. The rest were rejected with reasons (same name for the same target, valid link-in-heading patterns, a cosmetic symbol, an adequate "GitHub" name) or were duplicates. On /destinations/mars and TodoMVC, the triage question was asked inside Bob by the one-command /earshot run.

Gate

Every UI commit replays the screen-reader hear-tests it can affect (16 in all) as a git pre-commit hook (.githooks/pre-commit → earshot_mcp/hear_tests.py); if any test fails the commit is blocked and nothing reaches HEAD.

I asked Bob to "tidy up" the focus-management code in Modal.tsx (removing tabIndex={-1}, returnFocusRef, and the setTimeout focus call), the gate caught it immediately:

$ git commit -m "refactor: tidy up Modal"
FAIL F-01: missing 'Sign In, dialog'
  said: Moon, link, heading, level 3
FAIL F-02: missing 'Register, button'
  said: Moon, link, heading, level 3
  said: Select Seat Class, Earth to Moon, button
  ...
FAIL F-03: missing 'Close modal, button'
  said: Moon, link, heading, level 3
  ...
PASS F-04
PASS F-05
PASS F-06
PASS F-07
PASS F-08
PASS N-02
BLOCKED by Earshot: this change breaks what a screen reader hears.

Verbatim terminal output (trimmed where marked with ...); run 2026-09-26 with all 9 tests; the full output is evidence/gate/terminal_demo_9tests.txt. Without the focus handling, focus never entered the dialog, so all three dialog tests heard the page behind it.

A second layer runs inside Bob: a lifecycle hook (.bob/settings.json, PreToolUse on execute_command) replays the tests a change can affect before any git commit Bob runs. When Bob was asked to skip the slow hook with git commit --no-verify, NVDA heard F-01 to F-03 fail and the hook blocked the command before git ran (evidence/gate/bob_hook_no_verify_blocked.txt).

Checking the first 6 announcements by hand with NVDA took a person 60 seconds at full speed, on every commit. The gate needs no attention: it replays only the affected tests (the 4 dialog tests, or Uptime Kuma's 3, each in about 2½ minutes); the first 13 together ran unattended in about 9 minutes.