Manual accessibility pass — keyboard + assistive-technology log
US-43 / PR-F1 — Scenario 2 ("Manual assistive-technology pass is performed"). Binds
backend/tests/features/accessibility_conformance.feature and is asserted by
backend/tests/test_accessibility_conformance_bdd.py, which checks this file names
every journey step below and that every finding is either fixed or carries a
remediation date.
Date: 2026-07-19 · Viewport: 390×844 (iPhone 12/13 mini class, the CLAUDE.md
mobile-first baseline) · Driver: Chromium (playwright-core), driven by keyboard
only (Tab / Shift+Tab / Enter / Space / Escape, no mouse) · App under test:
frontend dev build against a locally seeded dev backend (backend/seed_dev.py).
Method
Two things stood in for a real assistive-technology pass, and this section says so plainly rather than implying otherwise:
- Keyboard operability was driven directly and for real — every step below was reached and completed with the keyboard alone, no mouse events.
- What a screen reader would announce is inferred from the browser's own
accessibility tree, captured with Playwright's
ariaSnapshot()(a role/name/state tree equivalent to what NVDA/VoiceOver read from the same DOM), not from running an actual screen reader. No NVDA/JAWS/VoiceOver hardware or license was available in this environment. That gap is logged as a residual finding below rather than reported as tested.
Sign-in (UC-1 / FR-A1, FR-A2)
Operable: Yes. The sign-in action is the only interactive element on the screen; it is the first Tab stop and activates on Enter/Space.
Focus visible: Yes — a 3px solid outline (the app's house :focus-visible
style) appears on Tab.
Accessibility tree (ariaSnapshot()):
- main:
- heading "Wellness Passport" [level=1]
- paragraph: Step through the first portal — survival starts with protection.
- button "Sign in with campus SSO"
- paragraph: SAML single sign-on · no ID number needed
- paragraph: UC-1 · FR-A1 / FR-A2 — opaque SSO subject only, no PHI
A screen reader lands on one button with an unambiguous accessible name; there is nothing else to reach and nothing to misread as a form field (US-1's "no credential inputs" constraint holds for AT the same way it does for sighted use).
Findings: none.
Passport (UC-3 / FR-C2, FR-C3)
Operable: Yes. Every week tile is a native <button>, reachable in DOM order,
each carrying an aria-label of the shape "Week N: <title>, <status>" — status is
read as part of the name, not conveyed by color/icon alone.
Focus visible: Yes, same house :focus-visible ring on every tile.
Accessibility tree excerpt (one week, from the full 7-week grid):
- 'button "Week 2: Vital Screenings, Available"': Week 2 Available Vital
Screenings Blood pressure Check your vitals before the Upside Down does it
for you. A quick blood-pressure screening.
- status "Prize eligibility: Not yet eligible. 0 of 6 required tasks complete.":
Not yet eligible 0 of 6 required tasks complete
The prize-eligibility indicator and the progress countdown are both exposed with
role="status" / a <h1>, so their values are announced as page state rather than
needing to be discovered visually.
Findings: none in the grid itself. See Check-in below for the one defect found opening a tile's detail sheet.
Check-in (UC-3 / FR-C2, FR-C3, FR-D2)
Operable: Yes, end-to-end by keyboard: Tab to an available tile → Enter opens the detail sheet → Tab to "Check in" → Enter submits → the tile re-renders "Checked in" (verified live, not assumed).
Defect found and fixed: opening the week detail sheet did not move focus
into it. role="dialog" aria-modal="true" told assistive technology this was a
modal, but keyboard focus stayed on the tile behind the now-visible backdrop, so
the next Tab walked through the other week tiles (hidden behind the backdrop)
before ever reaching the sheet's own Close/Check-in buttons — a real WCAG 2.4.3
(Focus Order) violation and a broken aria-modal contract (4.1.2, Name Role
Value: the state asserted "modal" but the DOM did not act like one).
The same gap existed in two sibling sheets in the same file (the offline
"connection required" notice and the post-scan result/error sheet) and in the QR
scanner overlay (QrScanner.tsx) — none of the four moved focus on open, and two
of the four (Passport.tsx's offline notice and scan-result sheets) had no
Escape-to-close handler either.
Fix applied (this story, frontend/src/components/Passport/Passport.tsx and
QrScanner.tsx): each sheet now moves focus to its own Close button when it opens
and closes on Escape, matching the pattern CrisisResources.tsx already used
correctly. Re-verified live after the fix:
Focus after dialog opens: button "Close"
Dialog a11y tree:
- 'dialog "Week 2: Vital Screenings"':
- button "Close": ✕
- paragraph: Week 2
- heading "Vital Screenings" [level=2]
- paragraph: Blood pressure
- paragraph: Check your vitals before the Upside Down does it for you. A
quick blood-pressure screening.
- term: Where
- definition: Student Union
- term: When
- definition: Sep 9 – Sep 13
- term: Prize
- definition: Reusable water bottle
- button "Check in"
Dialog Tab stops: Check in (one Tab from Close — no escape into the grid behind it)
Escape closes the dialog: yes
Findings:
| # | Finding | Status |
|---|---|---|
| 1 | Week detail sheet, offline notice, and scan-result sheet (Passport.tsx) opened without moving focus in; two of the three had no Escape handler |
Fixed this story |
| 2 | QR scanner overlay (QrScanner.tsx) opened without moving focus to its Close button |
Fixed this story |
| 3 | The same missing-focus-on-open pattern is present on every admin modal in ChallengeBuilder.tsx (challenge/task/theme forms, duplicate, assessment items, score override, check-in override) |
Documented residual — remediation by 2026-09-30. Out of this story's manual-pass scope (student journey only); the admin list views themselves pass the automated axe sweep (frontend/src/a11y.test.tsx) cleanly, since no modal is opened in that sweep. |
Assessment (UC-3 / FR-E4, FR-E5)
Driven inside a week's detail sheet with assessment items attached (an MCQ and a reflection), reached the same way as Check-in above.
Operable: Yes. The MCQ is a native radio group (<fieldset>/<legend> +
<input type="radio">), so Tab lands once on the group and arrow keys move between
options — standard, correct native behavior. The reflection is a labelled
<textarea>. Both submit buttons are disabled (and so skipped by Tab, correctly)
until the item has an answer, matching the "one attempt" design already covered by
KnowledgeCheck.test.tsx.
Focus visible: Yes on every stop — the textarea and submit button use the house
3px :focus-visible ring; the radio itself shows the browser's native 1px ring,
and its wrapping .option label additionally gets the house 3px ring on
:focus-within, so the visible affordance is at least as strong as everywhere
else, not weaker.
Accessibility tree excerpt:
- heading "Assessment" [level=3]
- group: (fieldset, tag "vision-care") — 2 radio options
- textarea "Your reflection"
The <legend> carries the MCQ prompt text as the group's accessible name, and the
reflection's <label> is programmatically associated via htmlFor/id — both
confirmed present in the DOM the tree above was captured from, not assumed from
reading the source.
Findings: none.
Residual findings requiring real assistive-technology hardware
No NVDA/JAWS/VoiceOver session was available in this environment. Everything above
that depends on inferring announcement behavior from the accessibility tree, rather
than hearing it, is logged here as residual — equally-effective-alternate-access
notes for these are in docs/acr-vpat.md:
| # | Finding | Remediation date |
|---|---|---|
| 4 | No real screen-reader (NVDA/JAWS/VoiceOver) session confirmed announcement behavior for the modal sheets, the MCQ radio group, or the Guide chat's aria-live="polite" transcript — inferred from the accessibility tree only |
2026-10-31 — scheduled with SHS Client Services' assistive-technology lab loan |
| 5 | Color contrast (WCAG 1.4.3), non-text contrast (1.4.11), and text spacing (1.4.12) were not run through a tool — jsdom performs no real layout/paint, so axe-core's color-contrast rule is disabled in frontend/src/a11y.test.tsx, and this manual pass did not include a contrast-analyzer or text-spacing-bookmarklet run |
2026-08-15 — run a contrast analyzer (e.g. axe DevTools browser extension) and the WCAG text-spacing bookmarklet against the built site's theme palettes |
Findings #1 and #2 (the student-journey focus-on-open defects) were fixed in this story rather than deferred — see Check-in above. Finding #3 (the admin modals) is carried above rather than repeated here.
Additional finding from source review (outside the keyboard pass)
Reading every screen's error-rendering path (not something the four journey steps
above triggered) turned up one more gap, fixed in the same commit: all ten inline
error messages in ChallengeBuilder.tsx (save/duplicate/publish/delete failures
across its modals) rendered as a plain <p> with no role="alert", unlike the
equivalent messages in Reports.tsx, LiveOps.tsx, Passport.tsx, and
KnowledgeCheck.tsx, which all already had it. A screen-reader user would have had
to discover an admin-surface error by manually navigating to it rather than being
told about it — WCAG 4.1.3 (Status Messages). Fixed by adding role="alert" to all
ten (frontend/src/components/admin/ChallengeBuilder/ChallengeBuilder.tsx).