Skip to content

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).