# Family welcome and onboarding accessibility audit

**Target:** WCAG 2.2 Level AA  
**Method:** WCAG-EM-informed representative evaluation  
**Environment/build:** local development, `main` at `7383dd338`  
**Audit date:** 2026-08-25  
**Primary implementation:** React `/v2/onboarding/*`

## Executive summary

This representative technical audit evaluated the public welcome page, identity and validation, concerns and service-line transitions, address validation, insurance upload, carrier/manual entry and polling, required and optional consents, legal-terms dialog, timed interstitials, invalid links, financial-aid and waitlist states, matching filters and progress, clinician scheduling and confirmation, the Rails/Stripe payment handoff, completion and pending terminals, English/Spanish/Portuguese content, 320 px reflow, landscape orientation, text spacing, forced colors, and end-to-end keyboard paths.

Twenty-five confirmed barriers were found: sixteen serious and nine moderate. The most consequential issues are the keyboard-inoperable insurance-card upload, consent choices with no programmatic selected state or reliable visible keyboard focus, unnamed legal-terms and carrier-search dialogs, missing or inadequate page metadata and language declarations, availability controls that lose their focus indicator in forced colors, and form/SPA state changes that leave keyboard and screen-reader users without reliable context.

This completes the planned Chromium-based technical representative sample, but it is not a WCAG conformance determination. Actual 200% browser zoom, complete non-text contrast review, pointer-cancellation tracing, VoiceOver/NVDA output, disabled-user testing, and rendered legacy-flow parity remain undetermined. The legacy `get-started` implementation is excluded because the synthetic referral is routed to the launched React experience; source review was used only to identify the React/Rails payment boundary.

## Scope and sample

| Flow/state | Why sampled | Methods |
|---|---|---|
| Welcome, English, Spanish, and Portuguese | Public entry, localization | axe, accessibility tree, DOM, screenshots |
| Identity gate, initial and invalid | Required personal-data form | axe, keyboard, errors, 320 px, text spacing |
| Concerns and service line | Custom checkbox cards and SPA navigation | axe, keyboard, focus, DOM |
| Address, empty submit | Multi-field validation | keyboard, accessibility tree, DOM |
| Insurance card upload | File controls and required/optional labels | keyboard, accessibility tree, DOM |
| Consents, required and optional | High-consequence legal choices | axe, keyboard, state semantics, screenshots |
| Legal terms modal | Dialog naming and focus | accessibility tree, Escape/focus restoration |
| Reassurance | Timed navigation and animation | reduced-motion emulation, route observation |
| Invalid signed link | Error recovery terminal | accessibility tree, DOM |
| Insurance carrier, manual entry, unverified, self-pay and polling | Searchable controls, errors and async transitions | keyboard, accessibility tree, DOM, axe, screenshots |
| Financial aid and waitlist | Radio group and dense availability input | keyboard, accessibility tree, DOM, 320 px, forced colors |
| Payment/Stripe handoff | Hybrid Rails and third-party embedded checkout | DOM, accessibility tree, axe in host and iframe, screenshots |
| Matching, clinician profile, schedule, confirmation | Repeated appointment controls and booking decision | keyboard, accessibility tree, DOM, axe, screenshots |
| Completion, waitlist, financial-aid, session-held, safety/RTE, red-flag and no-cost terminals | Primary and branch-ending outcomes | axe, keyboard, accessibility tree, DOM, responsive screenshots |

## Tools

| Tool | Version | Use | Limitation |
|---|---:|---|---|
| agent-browser | 0.9.0 | Real keyboard events, accessibility tree, responsive states, screenshots | Accessibility tree is not screen-reader output |
| Playwright | 1.56.1 | Isolated live-page automation | Chromium only in this pass |
| axe-core | 4.13.0, exact package with registry integrity verified | WCAG A/AA automated rules | Does not evaluate most interaction, context, or announcement quality |
| Source review | commit `7383dd338` | Root-cause and unrendered-state inspection | Source patterns alone do not prove every rendered failure |

The temporary axe package was installed outside the repository with lifecycle scripts disabled. No application dependency was added.

## Results by WCAG 2.2 criterion

Only verified evidence establishes Pass or Fail. Undetermined criteria remain open; no conformance percentage is calculated.

| Criterion | Level | Result | Evidence and sample note |
|---|---:|---|---|
| 1.1.1 Non-text Content | A | **Fail** | A11Y-023; completion decoration is an unnamed image |
| 1.2.1 Audio-only and Video-only (Prerecorded) | A | N/A | No prerecorded audio-only or video-only media in the sample |
| 1.2.2 Captions (Prerecorded) | A | N/A | No prerecorded synchronized media |
| 1.2.3 Audio Description or Media Alternative (Prerecorded) | A | N/A | No prerecorded synchronized media |
| 1.2.4 Captions (Live) | AA | N/A | No live synchronized media |
| 1.2.5 Audio Description (Prerecorded) | AA | N/A | No prerecorded synchronized media |
| 1.3.1 Info and Relationships | A | **Fail** | A11Y-003, 005, 009, 014, 015, 017, 019 |
| 1.3.2 Meaningful Sequence | A | Pass | DOM/accessibility-tree reading order remained meaningful in sampled pages and dialogs |
| 1.3.3 Sensory Characteristics | A | Pass | Sampled instructions did not require shape, location, sound, or orientation alone |
| 1.3.4 Orientation | AA | Pass | Matching and no-cost states remained operable in 667 × 375 landscape |
| 1.3.5 Identify Input Purpose | AA | **Fail** | A11Y-022; signer name lacks the standard autocomplete purpose |
| 1.4.1 Use of Color | A | **Fail** | A11Y-003; consent selection is exposed visually by color without programmatic state |
| 1.4.2 Audio Control | A | N/A | No automatically playing audio |
| 1.4.3 Contrast (Minimum) | AA | **Fail** | A11Y-024; completion instructions measured 4.44:1 instead of 4.5:1 |
| 1.4.4 Resize Text | AA | Undetermined | 320 px reflow and text-spacing checks passed, but actual 200% browser zoom remains unverified |
| 1.4.5 Images of Text | AA | N/A | No required image-of-text content beyond exempt branding |
| 1.4.10 Reflow | AA | Pass | Identity, consents, waitlist, and matching had no horizontal overflow at 320 CSS px |
| 1.4.11 Non-text Contrast | AA | Undetermined | Forced-colors and selected controls were sampled; a complete visual contrast inventory remains open |
| 1.4.12 Text Spacing | AA | Pass | WCAG spacing overrides produced no clipping or overlap in the representative identity state |
| 1.4.13 Content on Hover or Focus | AA | N/A | No hover/focus-triggered dismissible content was required in the sampled task |
| 2.1.1 Keyboard | A | **Fail** | A11Y-005; insurance-card upload controls are skipped by Tab |
| 2.1.2 No Keyboard Trap | A | Pass | No trap found; dialogs supported Escape and focus restoration in sampled paths |
| 2.1.4 Character Key Shortcuts | A | N/A | No single-character shortcuts |
| 2.2.1 Timing Adjustable | A | **Fail** | A11Y-018; two interstitials advance after three seconds without control |
| 2.2.2 Pause, Stop, Hide | A | N/A | No independently moving content lasting more than five seconds alongside other content in the sample |
| 2.3.1 Three Flashes or Below Threshold | A | Pass | No flashing content observed |
| 2.4.1 Bypass Blocks | A | N/A | Sampled public steps do not place repeated navigation blocks before the primary task |
| 2.4.2 Page Titled | A | **Fail** | A11Y-001 and A11Y-013 |
| 2.4.3 Focus Order | A | **Fail** | A11Y-007; route and consent-panel changes discard focus to body |
| 2.4.4 Link Purpose (In Context) | A | Pass | Sampled support, legal, and recovery links had understandable purpose in context |
| 2.4.5 Multiple Ways | AA | N/A | The page set is a required step-by-step process |
| 2.4.6 Headings and Labels | AA | **Fail** | A11Y-015 and A11Y-016; appointment and disclosure labels are ambiguous |
| 2.4.7 Focus Visible | AA | **Fail** | A11Y-004 and A11Y-011 |
| 2.4.11 Focus Not Obscured (Minimum) | AA | Pass | Focused actions remained visible or scrolled into view in sampled desktop/mobile/landscape states |
| 2.5.1 Pointer Gestures | A | N/A | No multipoint or path-based gesture |
| 2.5.2 Pointer Cancellation | A | Undetermined | Native controls dominate, but pointer-down/up behavior was not exhaustively traced |
| 2.5.3 Label in Name | A | **Fail** | A11Y-005; visible card-side labels are absent from upload control names |
| 2.5.4 Motion Actuation | A | N/A | No motion-triggered functionality |
| 2.5.7 Dragging Movements | AA | N/A | No drag-only interaction |
| 2.5.8 Target Size (Minimum) | AA | Pass | Waitlist cells measured 30 × 30 at 320 px; sampled undersized Back icons satisfied the spacing exception |
| 3.1.1 Language of Page | A | **Fail** | A11Y-002 and A11Y-012 |
| 3.1.2 Language of Parts | AA | **Fail** | A11Y-021; localized flows expose unmarked English controls/content |
| 3.2.1 On Focus | A | Pass | Moving focus alone did not trigger sampled context changes |
| 3.2.2 On Input | A | **Fail** | A11Y-025; insurance special-option selection changes context immediately |
| 3.2.3 Consistent Navigation | AA | Pass | Repeated navigation/support regions retained relative placement in sampled pages |
| 3.2.4 Consistent Identification | AA | Pass | Repeated Back, Continue, support, and selection controls were identified consistently where present |
| 3.2.6 Consistent Help | A | Pass | Repeated phone/email help appeared after primary content in sampled pages |
| 3.3.1 Error Identification | A | **Fail** | A11Y-008 and A11Y-020 |
| 3.3.2 Labels or Instructions | A | Pass | Sampled inputs retained visible labels and format instructions; relationship defects are tracked separately |
| 3.3.3 Error Suggestion | AA | **Fail** | A11Y-008 and A11Y-020 provide generic rather than actionable field guidance |
| 3.3.4 Error Prevention (Legal, Financial, Data) | AA | Pass | Consent and booking provide explicit review/confirmation steps before high-consequence submission |
| 3.3.7 Redundant Entry | A | Pass | Source and task review found saved values hydrated or selectable; no unnecessary re-entry was verified |
| 3.3.8 Accessible Authentication (Minimum) | AA | Pass | Identity verification allowed paste/input assistance and used no puzzle or object-recognition test |
| 4.1.2 Name, Role, Value | A | **Fail** | A11Y-003, 006, 010, 014, 015, 016 |
| 4.1.3 Status Messages | AA | **Fail** | A11Y-008, 014, and 020 |

## Confirmed findings

### A11Y-001 — Every sampled React onboarding view has an empty document title

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 2.4.2 Page Titled (A)
- **Observed:** `document.title` was empty on welcome, identity verification, concerns, service line, address, card upload, consents, and invalid-link states. Axe reported `document-title` on every scanned state.
- **Impact:** Browser tabs, history, and assistive-technology page identification do not distinguish onboarding steps.
- **Root cause:** Onboarding routes export no route metadata; `portal/app/root.tsx:30-34` renders `<Meta />` but receives no onboarding title.
- **Correction:** Define a localized title for the onboarding layout or each step and verify it changes with route navigation.
- **Verification:** Assert the exact title on initial load and after each client-side step transition.

### A11Y-002 — Spanish and Portuguese onboarding content is declared as English

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 3.1.1 Language of Page (A)
- **Observed:** The Spanish and Portuguese welcome pages rendered localized copy while `<html lang="en">` remained unchanged.
- **Impact:** Screen readers and other language-aware tools may apply English pronunciation and processing rules to Spanish and Portuguese content.
- **Root cause:** `portal/app/root.tsx:29` hardcodes `lang="en"`.
- **Correction:** Set the document language from the resolved application locale and update it when locale changes.
- **Verification:** Assert `es`, `pt`, and `en` on representative localized routes.

### A11Y-003 — Optional consent choices expose no selected state or consent-specific name

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 1.3.1 Info and Relationships (A), 1.4.1 Use of Color (A), 4.1.2 Name, Role, Value (A)
- **Observed:** Each consent uses repeated ordinary buttons named only “Yes” and “No.” Selecting one changes colors but adds no `aria-pressed`, `aria-checked`, radio role, or programmatic association with the consent heading.
- **Impact:** A screen-reader user cannot reliably determine which repeated choice belongs to which consent or which answer is selected.
- **Location:** `portal/app/components/onboarding-consents-screen.tsx:185-217`
- **Correction:** Model each pair as a labeled radio group or another standard single-selection control with a programmatic group name and selected value.
- **Verification:** Inspect the accessibility tree and complete all choices using keyboard and screen reader.

### A11Y-004 — Consent Yes/No buttons have no visible keyboard focus indicator

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 2.4.7 Focus Visible (AA)
- **Observed:** A focused Yes button computed to `outline: none` and `box-shadow: none`. Space activated it, but focus location was not visually apparent.
- **Impact:** Keyboard users cannot track which legal decision control will be activated.
- **Location:** `portal/app/components/onboarding-consents-screen.tsx:197-215`
- **Correction:** Use the shared focus-visible treatment with adequate contrast on both selected and unselected states.
- **Verification:** Tab through every consent choice in default and forced-colors modes.

### A11Y-005 — Insurance-card photo controls cannot be reached by keyboard

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 2.1.1 Keyboard (A), 1.3.1 Info and Relationships (A), 2.5.3 Label in Name (A)
- **Observed:** Both file inputs use `display: none`; the clickable labels are not focusable. A real Tab sequence skipped both upload controls. The visible “Front of card” and “Back of card (optional)” text sits outside each label and is absent from the input's accessible name.
- **Impact:** Keyboard users cannot upload insurance images and assistive-technology users do not receive the card-side or required-state context. Manual entry is available but does not make the upload function keyboard operable.
- **Location:** `portal/app/components/onboarding-insurance-card-upload-screen.tsx:168-186`
- **Correction:** Use a visually hidden, focusable file input or keyboard-operable button/label pattern; include side, required state, and helper text in the accessible relationship.
- **Verification:** Tab to each upload, open the file chooser from the keyboard, and inspect names/descriptions.

### A11Y-006 — Legal-terms modal is an unnamed dialog

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 4.1.2 Name, Role, Value (A)
- **Observed:** The accessibility tree exposed `dialog` with no name. The popup has no `Dialog.Title`, `aria-label`, or `aria-labelledby`.
- **Impact:** Screen-reader users entering high-consequence legal text are not told what dialog opened.
- **Location:** `portal/app/components/onboarding-consents-screen.tsx:251-287`
- **Correction:** Add a visible `Dialog.Title` connected to the dialog. Give loading and failure replacements appropriate status semantics.
- **Verification:** Confirm the dialog's computed accessible name; verify initial focus, Escape, focus containment, and focus restoration. Escape and restoration to “Read full terms” passed in this sample.

### A11Y-007 — SPA step changes discard focus and provide no new-view context

- **Severity:** Serious
- **Evidence:** Verified observation; criterion mapping should be confirmed with AT testing
- **WCAG:** 2.4.3 Focus Order (A); related SPA context requirements remain human/AT review
- **Observed:** Welcome → identity, verification → concerns, and concerns → service line all left `document.activeElement` on `<body>` after the new view settled. Titles also remained empty.
- **Impact:** Keyboard and screen-reader users lose their interaction point and receive no deterministic indication of the new step.
- **Location:** `portal/app/routes/_client.onboarding.$signedId.tsx:5-36` and route navigation call sites
- **Correction:** Establish one route-change strategy that updates the localized title and moves focus to the new primary heading or a deliberate route-status target without causing duplicate announcements.
- **Verification:** Test browser Back/Forward and every step transition with keyboard, VoiceOver/Safari, and NVDA/Chrome or Firefox.

### A11Y-008 — Address validation leaves focus on Continue and does not associate error help

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 3.3.1 Error Identification (A), 3.3.3 Error Suggestion (AA), 4.1.3 Status Messages (AA)
- **Observed:** Empty submit marked fields `aria-invalid="true"` but left focus on Continue. The generic “Please fill in all required fields” paragraph had no live-region semantics and was not referenced by any field through `aria-describedby`. No field-specific correction was exposed.
- **Impact:** Users must rediscover all failed fields; screen-reader users may not hear that submission failed.
- **Location:** `portal/app/components/onboarding-address-screen.tsx:140-158,202-218`
- **Correction:** Provide field-associated errors, announce or focus an error summary, and move focus to the summary or first invalid control after submit.
- **Verification:** Submit combinations of missing and malformed values and verify focus, announcement, associations, and value preservation.

### A11Y-009 — Invalid or expired links produce a terminal with no heading

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 1.3.1 Info and Relationships (A), with heading usability requiring human review
- **Observed:** The invalid-link state contained only paragraphs and links: no `h1`–`h6`, main landmark, or document title.
- **Impact:** Heading navigation cannot locate or identify the terminal error, and the page lacks a concise programmatic statement of purpose.
- **Location:** `portal/app/components/onboarding-error-screen.tsx:19-29`
- **Correction:** Render a clear `h1`, useful recovery copy, and a descriptive document title.
- **Verification:** Inspect invalid, expired, and feature-disabled variants.

### A11Y-010 — Insurance carrier search opens an unnamed dialog

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 4.1.2 Name, Role, Value (A)
- **Observed:** Opening “Insurance carrier” exposed a popup with `role="dialog"` and no `aria-label` or `aria-labelledby`. The search combobox inside was named, but the containing dialog was not.
- **Impact:** Screen-reader users are not given a programmatic name for the popup context they enter.
- **Location:** `packages/components/src/forms-v2/search-select.tsx:115-155`
- **Correction:** Give the popup a visible or programmatic name derived from its field label, while preserving the names and relationships of the trigger, search input, and listbox.
- **Verification:** Inspect every shared `SearchableSelect` usage and verify popup naming, Escape, keyboard selection, and focus restoration with AT.

### A11Y-011 — Availability cells lose their focus indicator in forced-colors mode

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 2.4.7 Focus Visible (AA)
- **Observed:** A focused waitlist availability cell computed to `outline: none` and `box-shadow: none` in Chromium forced-colors mode because the implementation removes the outline and relies on a Tailwind ring. The grid places all 91 cells in the Tab order.
- **Impact:** Keyboard users with Windows High Contrast or another forced-colors configuration cannot reliably see which time slot has focus; reaching controls after the grid also requires excessive tabbing.
- **Location:** `packages/components/src/weekly-availability-grid.tsx:107-126`
- **Correction:** Preserve a system-color outline under forced colors. Consider a composite grid or roving-tabindex interaction so arrow keys traverse cells while the grid contributes one Tab stop.
- **Verification:** Check selected and unselected cells with keyboard in Windows High Contrast themes and standard color modes.

### A11Y-012 — Payment handoff page has no declared language

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 3.1.1 Language of Page (A)
- **Observed:** The Rails-rendered payment host page had no `lang` attribute on `<html>`. Axe reported `html-has-lang`. Stripe's embedded document separately declared `lang="en"`.
- **Impact:** Assistive technology cannot reliably select the correct language rules for Cartwheel's payment instructions surrounding the embedded checkout.
- **Correction:** Set the payment layout's document language from the resolved locale and preserve language across the React-to-Rails handoff.
- **Verification:** Assert the host and embedded-document language for English, Spanish, and Portuguese payment routes.

### A11Y-013 — Payment handoff has a generic document title

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 2.4.2 Page Titled (A)
- **Observed:** The payment host page title was only “Cartwheel,” despite the page purpose being payment-information collection.
- **Impact:** Browser tabs, history, and assistive-technology page lists do not identify this onboarding step.
- **Correction:** Provide a localized title that identifies both Cartwheel and the payment-information step.
- **Verification:** Assert the exact host-page title in each supported locale.

### A11Y-014 — Match-search progress exposes contradictory states and does not announce completion

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 1.3.1 Info and Relationships (A), 4.1.2 Name, Role, Value (A), 4.1.3 Status Messages (AA)
- **Observed:** Completed checklist rows simultaneously exposed images named “Loading” and “Done” because CSS opacity and animation changed only the visual presentation. The active row later changed visually and a Continue button appeared, but the checklist had no live or status semantics. The hardcoded English image labels were also exposed in localized flows.
- **Impact:** Screen-reader users receive contradictory progress states and may not be told when matching is ready or when a new action becomes available.
- **Location:** `portal/app/components/onboarding-finding-matches-screen.tsx:90-219,240-280`
- **Correction:** Represent each row with one programmatic status at a time, hide replaced decorative graphics, localize any meaningful state labels, and announce concise progress/completion without replaying the whole checklist.
- **Verification:** Observe the complete success, delayed, fallback, and error sequences with VoiceOver and NVDA; ensure each stage has one current state and completion is announced once.

### A11Y-015 — Appointment time controls omit date, clinician, and selected-state context

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 1.3.1 Info and Relationships (A), 4.1.2 Name, Role, Value (A)
- **Observed:** Matching results exposed many repeated buttons named only “7:00 am,” “7:15 am,” and similar values without a programmatic relationship to the visual clinician card or date label. On the clinician schedule, a preselected time changed only border/background classes; it exposed no `aria-pressed`, `aria-checked`, or equivalent selected state.
- **Impact:** Screen-reader button navigation cannot distinguish appointment choices, and users cannot determine which date/time is currently selected before continuing.
- **Location:** `packages/components/src/clinician-available-times.tsx:32-57`, `packages/components/src/time-chip.tsx:20-39`
- **Correction:** Give each time control a name or description containing the full date and relevant clinician context, group controls under programmatic date/clinician labels, and expose selection with a standard radio or toggle pattern.
- **Verification:** Navigate matching and schedule controls by buttons, groups, and form controls in VoiceOver and NVDA; confirm every choice is unique and the selected slot is announced.

### A11Y-016 — Clinician-profile disclosure controls have ambiguous names and no expanded state

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 2.4.6 Headings and Labels (AA), 4.1.2 Name, Role, Value (A)
- **Observed:** Language and specialty disclosures were named only “+14 more,” “+13 more,” or “Show less.” They exposed no `aria-expanded` or `aria-controls`, and repeated “Show less” labels did not identify which content would collapse.
- **Impact:** Screen-reader users navigating by buttons cannot identify the disclosure's purpose or determine whether its related content is expanded.
- **Location:** `portal/app/routes/_client.onboarding.$signedId.schedule.$clinicianId/route.tsx:348-425`, `packages/components/src/badge.tsx`
- **Correction:** Include the controlled content in each accessible name, expose `aria-expanded`, and connect each button to a stable content container with `aria-controls`.
- **Verification:** Expand and collapse languages and specialties with keyboard and screen reader; confirm purpose, state, and controlled region are announced.

### A11Y-017 — Shared wizard pages can begin at heading level 2

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 1.3.1 Info and Relationships (A)
- **Observed:** “Confirm your booking” was the page's primary heading but rendered as `h2`; the page contained no `h1`. “How it works” was another `h2`, making both appear as peer sections rather than exposing the first as the page title. The shared wizard layout also provided no `main` landmark.
- **Impact:** Heading navigation communicates a misleading document hierarchy and makes the primary purpose less predictable.
- **Location:** `packages/components/src/layouts/wizard-page.tsx:39-60`
- **Correction:** Let each route supply a semantic `h1` for its page title and use lower levels only for subsections. Add one `main` landmark around the primary page content.
- **Verification:** Inspect heading outlines and landmarks for every route using the shared wizard layout, including terminal and error states.

### A11Y-018 — Timed interstitials advance after three seconds without user control

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 2.2.1 Timing Adjustable (A)
- **Observed:** The reassurance screen and post-insurance “You're in the right place” interstitial each navigated away after three seconds. Users could not turn off, adjust, extend, pause, or manually advance the limit. Reduced-motion preference did not stop the reassurance animation or timeout.
- **Impact:** Users who need more time to read, understand, magnify, or translate content can lose it before finishing and may be disoriented by the unsolicited route change.
- **Location:** `portal/app/components/onboarding-reassurance-screen.tsx:5-47`, `portal/app/routes/_client.onboarding.$signedId.insurance._index/route.tsx` (`INTERSTITIAL_DWELL_MS` and navigation effect)
- **Correction:** Remove the automatic limit and provide a clear Continue action, or offer a conforming mechanism to pause/extend it. Respect reduced-motion for nonessential animation independently.
- **Verification:** Confirm content remains until the user advances it; test keyboard, magnification, reduced motion, and screen readers.

### A11Y-019 — Several option sets have no programmatic group name

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 1.3.1 Info and Relationships (A)
- **Observed:** Joint-custody, insurance self-report, service-line, and consent choices expose individually named controls but do not connect their visual question to the radio, checkbox, or button group with `fieldset`/`legend`, `aria-labelledby`, or an equivalent name. Rendered accessibility trees showed unnamed radio groups.
- **Impact:** Screen-reader users navigating by form controls lose the question that gives repeated answers such as Yes, No, or No preference their meaning.
- **Location:** `portal/app/components/onboarding-joint-custody-screen.tsx:147-192`, `portal/app/components/onboarding-insurance-self-report-screen.tsx:61-94`, `portal/app/components/onboarding-service-line-screen.tsx:85-126`, `portal/app/components/onboarding-consents-screen.tsx`
- **Correction:** Use native `fieldset`/`legend` where practical or give each group a stable accessible name derived from its visible prompt.
- **Verification:** Navigate each set by form controls with VoiceOver and NVDA and confirm the question is announced with the options.

### A11Y-020 — Manual-insurance errors are not associated, focused, or announced

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 3.3.1 Error Identification (A), 3.3.3 Error Suggestion (AA), 4.1.3 Status Messages (AA)
- **Observed:** Submitting manual insurance with an empty Member ID displayed “Please fill in all required fields,” left focus on Get cost estimate, and set `aria-invalid` without connecting the input to the visible error. The error had no alert/live semantics. Shared Announcement implementations likewise provide no status role for mutation failures.
- **Impact:** Screen-reader and keyboard users may not know submission failed, which field needs correction, or how to recover.
- **Location:** `portal/app/components/onboarding-insurance-manual-entry-screen.tsx:104-167`, `packages/components/src/announcement.tsx:79-105`, `packages/components/src/forms-v2/announcement.tsx:48-57`
- **Correction:** Add field-specific associated messages, announce or focus an error summary, and move focus to the summary or first invalid field. Give asynchronously inserted failures appropriate alert/status semantics.
- **Verification:** Trigger required-field, mutation, upload, and network failures and verify focus, associations, announcement, and retained input.

### A11Y-021 — Localized flows expose hardcoded English content and accessible names

- **Severity:** Serious
- **Evidence:** Verified
- **WCAG:** 3.1.2 Language of Parts (AA)
- **Observed:** The Spanish welcome FAQ exposed its close control as “Close.” Matching filters, loading/error text, and date/time formatters include hardcoded English; shared loading status uses “Loading...” instead of the localized visible context. Completion formatters force `en-US` output in Spanish and Portuguese flows.
- **Impact:** Families using Spanish or Portuguese can encounter controls and essential scheduling information they cannot understand or that assistive technology pronounces using the wrong language rules.
- **Location:** `portal/app/routes/_client.onboarding.$signedId.welcome/route.tsx:184-194,231-241`, `portal/app/routes/_client.onboarding.$signedId.matching.$serviceLineId/route.tsx:232-339,438-475`, `portal/app/routes/_client.onboarding.$signedId.complete/route.tsx:24-46`, `packages/components/src/loading-view.tsx`, `packages/components/src/spinner.tsx`
- **Correction:** Route all user-facing and accessible strings through i18n, format dates/times with the active locale, and use `lang` only for intentionally retained foreign-language parts.
- **Verification:** Complete matching, scheduling, consent, payment, and completion in Spanish and Portuguese while inspecting visible copy and computed names.

### A11Y-022 — Signer-name input does not identify its autocomplete purpose

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 1.3.5 Identify Input Purpose (AA)
- **Observed:** The legal signer's full-name input collects a standard personal-data purpose but has no `autocomplete="name"` token.
- **Impact:** Browser autofill and assistive personalization cannot reliably identify or supply the signer's name.
- **Location:** `portal/app/components/onboarding-consents-screen.tsx:684-698`
- **Correction:** Add the standards-defined `name` autocomplete token, confirming that the field represents the person currently completing the consent.
- **Verification:** Inspect the rendered token and test browser autofill without changing legal validation behavior.

### A11Y-023 — Completion decoration is exposed as an unnamed image

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 1.1.1 Non-text Content (A)
- **Observed:** Axe reported `svg-img-alt` for the full-card celebration SVG, which renders `role="img"` with an empty `aria-label`.
- **Impact:** Assistive technology encounters an unexplained image in the completion outcome.
- **Location:** `portal/app/components/onboarding-complete-screen.tsx:102-104`
- **Correction:** Treat the celebration as decorative with `aria-hidden="true"` and no image role, or provide approved meaningful alternative text if product determines it conveys information.
- **Verification:** Re-run axe and inspect the completion accessibility tree.

### A11Y-024 — Completion instruction text misses minimum contrast

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 1.4.3 Contrast (Minimum) (AA)
- **Observed:** Axe measured the 12 px email/Healthie instruction text at 4.44:1 (`#697073` on `#f3f0ed`), below the required 4.5:1 for normal text.
- **Impact:** Users with low vision or reduced contrast sensitivity may have difficulty reading required post-booking instructions.
- **Location:** `portal/app/components/onboarding-complete-screen.tsx:249-257`
- **Correction:** Use a foreground/background pair that clears 4.5:1 with margin for rendering variation.
- **Verification:** Re-measure the rendered state and check default, zoomed, and forced-colors presentation.

### A11Y-025 — Insurance special options navigate immediately when selected

- **Severity:** Moderate
- **Evidence:** Verified
- **WCAG:** 3.2.2 On Input (A)
- **Observed:** Selecting “I don't have insurance” or “Self-pay” inside the Insurance carrier combobox immediately changes screen or route rather than setting a value for the existing Continue action. A keyboard search for a carrier can therefore navigate when a pinned special option is selected.
- **Impact:** Keyboard, screen-reader, and cognitive-access users can be disoriented by an unexpected context change during selection.
- **Location:** `portal/app/components/onboarding-insurance-zip-lookup-screen.tsx:118-137`
- **Correction:** Store the selection and require the existing Continue action, or clearly advise users before the control that selecting those options changes context immediately.
- **Verification:** Select every carrier and special option with pointer, keyboard, and screen reader and confirm context changes only as communicated.

## Human and product review observations

- Real VoiceOver and NVDA testing remains required for route announcements, progress status, and error timing. Accessibility-tree inspection is not a screen-reader test.
- Several icon-only Back controls measured 10 × 24 CSS px. In sampled layouts they were isolated enough to satisfy WCAG 2.5.8's spacing exception, but enlarging them would improve touch usability.
- The waitlist's 91 individually tabbable slots are technically keyboard-operable but create substantial fatigue. A grid or roving-tabindex model should receive usability testing with keyboard and switch users.

## Passing observations in sampled states

- Identity fields had persistent programmatic labels.
- Empty identity submission associated each “Required” message and focused the first invalid field.
- Custom concern checkboxes supported Space, exposed checked state, and had a visible focus ring.
- Sampled identity and consent states had no horizontal overflow at 320 CSS px.
- The identity state tolerated WCAG text-spacing overrides without detected clipping.
- Legal-terms dialog closed with Escape and restored focus to its trigger.
- The carrier SearchableSelect trigger and search field had accessible names; the popup opened and accepted keyboard input.
- Waitlist time cells exposed button names and `aria-pressed`, worked with Space, reflowed without horizontal overflow at 320 CSS px, and measured 30 × 30 CSS px at that width.
- Stripe's checkout iframe had the title “Embedded checkout,” declared English, exposed payment controls, and produced no axe violations inside the sampled iframe state; two color-contrast checks remained incomplete.
- Matching and no-cost states reflowed without horizontal overflow in 667 × 375 landscape and matching also passed at 320 CSS px.
- Insurance polling exposed a labeled progressbar with numeric minimum, maximum, and current value.
- Axe found no additional confirmed automated violations beyond the findings listed above. Automated coverage does not establish manual-criterion conformance.

## Human and assistive-technology handoff

| Task | Required method | What to observe |
|---|---|---|
| Welcome → identity → concerns → service line | VoiceOver/Safari and NVDA/Chrome or Firefox | Route change context, heading announcement, focus placement |
| Address empty/partial submit | Same AT combinations | Error announcement, invalid fields, recovery order |
| Optional consents and legal terms | Same AT combinations plus keyboard-only | Consent group names/state, modal name, focus containment/restoration |
| Insurance card photo and manual fallback | Keyboard-only and screen reader | Reachability, file chooser, side/required descriptions |
| Spanish and Portuguese entry | Native-language screen-reader review | Pronunciation, untranslated names/errors, language switching |
| Match-search progress and completion | VoiceOver/Safari and NVDA/Chrome or Firefox | One current state per row, concise progress announcements, Continue availability |
| Matching results and clinician schedule | Same AT combinations plus keyboard-only | Unique date/time names, clinician grouping, selected slot, disclosure names/states |
| Entire primary task | Disabled-user testing where feasible | Comprehension, fatigue, error recovery, confidence in consent and completion |

## Legacy and React comparison

| Task/pattern | Legacy result | React result | Interpretation |
|---|---|---|---|
| Family welcome/onboarding | Not rendered in this audit; the synthetic referral is routed by the launch flag into React | Full representative sample completed | No regression/improvement claim is made without equivalent rendered legacy evidence |
| Payment collection | Rails host plus embedded Stripe remains in the React journey | Missing host language and generic title; embedded Stripe sample had no axe violations | Hybrid boundary needs shared locale/title ownership |

## Remediation priorities

1. Restore core keyboard operation for insurance uploads and reliable focus after every navigation/panel transition.
2. Fix shared semantics: document titles/language, named dialogs/groups, selected states, status/error announcements, and appointment labels.
3. Correct consent accessibility because it carries legal consequences: group names, state, focus, signer autocomplete, and dialog naming.
4. Remove uncontrolled timed transitions and honor reduced-motion preferences.
5. Localize all visible and accessible downstream content, including date/time formatting and loading states.
6. Correct completion contrast/non-text defects and add deterministic regression tests for every verified DOM failure.
7. Complete VoiceOver, NVDA, zoom, non-text contrast, and disabled-user validation after remediation.

## Limitations and outstanding human validation

- Actual 200% browser zoom remains undetermined; 320 CSS px reflow is not represented as a substitute in the criterion result.
- Full non-text/UI-state contrast review and exhaustive pointer-cancellation testing remain undetermined.
- VoiceOver/Safari, NVDA/Chrome or Firefox, speech input, screen magnification, and disabled-user testing were not performed.
- Payment return/error and rare backend timeout branches were not submitted through external systems; the host and loaded Stripe state were sampled safely.
- Legacy `get-started` pages were not rendered because the launch flag routes the synthetic referral into React. No legacy parity conclusion is made.
- Screenshots and browser-tree inspection cannot establish actual assistive-technology announcements.

## Verification log

All findings are open on build `7383dd338`. No remediation code was changed during the audit. Automated, keyboard/manual, and human/AT reruns remain pending for A11Y-001 through A11Y-025.

## Test-data and privacy controls

- One synthetic local family/referral and seeded clinicians/availability were used.
- Signed IDs, cookies, Stripe client secrets, and synthetic identity values are excluded from filenames, report text, manifest content, and the final HTML artifact.
- Screenshots contain only synthetic local data; URLs and browser chrome are not included.
- Temporary executable tooling was pinned outside the repository and raw retained results contain no signed URL.

## Evidence

Screenshots are stored in `~/screenshots/family-onboarding-a11y/`. They use synthetic local data and exclude signed URLs from filenames and report text.

The structured source for the final portable HTML artifact is `tmp/accessibility-audits/family-onboarding-2026-08-25/audit-manifest.json`. At finalization, referenced evidence will be copied into an artifact-local `assets/` directory.
