# Fleet accessibility review — Understand mobile

**Review target:** production Flutter app (`os_tts_mobile/`) — Reader, Settings, Voice selector, Contents — plus cross-cutting state, motion, and token contracts.
**Scope:** screen-reader order and labels, focus, 48dp targets, contrast roles, 2× text, reduced motion, error states, platform contracts — across all states (empty, loaded, synthesizing, playing, paused, buffering, error, quota, model-setup, completion, locked/premium).
**Method:** code-level review guided by the installed `design-critique` accessibility framework (contrast, targets, readability, specificity), `interaction-design`, `design-system`, and `frontend-design` guidance, against the project contract (AGENTS.md), the state package review (`STATE-PACKAGE-REVIEW.md`), and the ferrofluid brand direction (`direction.md`). WCAG 2.2 AA + Android 48dp guidance are the bar; contrast ratios below are computed from the exact token values in `lib/core/design/tokens.dart` and `lib/core/design/ferrofluid_tokens.dart`.
**Stage:** pre-approval fleet review. Read-only — no Flutter files were changed.

## Verdict

**Strong a11y foundation with five critical defects to fix before the redesign lands.** The app already does the hard things: live regions on playback/buffer/error states, adversarial semantics tests, reduced-motion fallbacks, and 2× text-scale tests. But the ferrofluid `quiet` token fails contrast on every surface it touches, two controls miss even the 24dp WCAG minimum, and the Voice selector ships an unlabeled close button.

## Contrast table (computed, reading surfaces)

| Pair | Ratio | Verdict |
| --- | --- | --- |
| Legacy light: `ink` on `paper` | 16.03 | AAA |
| Legacy light: `inkMuted` on `paper` / `paperRaised` | 6.60 / 7.01 | AA / AAA |
| Legacy light: `inkFaint` on `paper` / `paperRaised` | 3.43 / 3.64 | **Fails AA for normal text** — large-only |
| Legacy light: `accent` on `paper` | 3.13 | Large-only; fails AA as body text |
| Legacy dark: `ink`, `inkMuted`, `accent`, `error` on `paper` | 14.25 / 6.56 / 6.62 / 5.28 | AA+ |
| Ferrofluid: `live` on `field` / sheet | 16.90 / 16.36 | AAA |
| Ferrofluid: `wake` on `field` / `fieldLift` / sheet | 6.66 / 6.28 / 6.44 | AA |
| Ferrofluid: `spike` on `field` / `fieldLift` | 8.58 / 8.10 | AAA |
| Ferrofluid: `quiet` on `field` / `fieldLift` / sheet `#0E0D0B` | 2.80 / 2.64 / 2.71 | **FAIL — below even the 3:1 large-text bar** |
| Ferrofluid: `error` on `field` | 5.74 | AA |

## Critical findings

### A1 🔴 Ferrofluid `quiet` (#5C574E) fails WCAG everywhere it is used for text

2.64–2.80:1 against `field`, `fieldLift`, and the sheet background — below the 3:1 large-text minimum and far below the 4.5:1 normal-text bar. It carries real copy:

- Sheet titles via `FlowSheetTitle` (13px uppercase, `flow_sheet.dart` — used by Contents and other bespoke sheets)
- "VOICE" eyebrow in the dock voice card (10px, `player_strip/_voice_row.dart`)
- "Jump" / "Now listening" meta in Contents rows (11px, `contents_sheet.dart`)
- Disabled "RETELLING SETTINGS" affordance foreground (`retelling_view_control.dart`)

`direction.md` defines Quiet as "present but low field strength" — attention intent, not a legibility floor. The token needs a value ≥ 4.5:1 on all three backgrounds (e.g. lighten toward `wake`), or `wake` should take over these text roles.

**Acceptance:**
1. `quiet` on `#070806`, `#10110F`, and `#0E0D0B` each compute ≥ 4.5:1 (verify in a golden/theme unit test asserting the ratio, not a hex change alone).
2. Widget test: `FlowSheetTitle` text style color on a `flow_sheet` background passes a ratio helper ≥ 4.5.
3. No other text role silently regresses — re-run the ratio helper across the full ferro palette matrix in CI-style test form.

### A2 🔴 Voice selector close button has no accessible label

`voice_selector/_sheet_header.dart` — `IconButton(icon: Icon(Icons.close))` with no `tooltip` and no `Semantics` label. TalkBack/VoiceOver announces only "Button"; a screen-reader user cannot distinguish it from any other unlabeled button. Every other close in the app (Settings "Close settings", Contents "Close Contents", paywall dismiss tooltip) is labeled — this one was missed.

**Acceptance:**
1. `find.bySemanticsLabel('Close voice selector')` (or equivalent) finds exactly one node in a pumped `VoiceSelectorSheet`.
2. Existing pattern consistency: label wording matches the "Close <surface>" family.

### A3 🔴 Contents sheet hides the current section from screen readers

`contents_sheet.dart` `_ContentsList` rows wrap in `Semantics(excludeSemantics: true, label: 'Go to ${entry.title}')`. The exclusion also swallows the "Now listening" meta, so the only cue that a section is current — the `--live` color — is purely visual. A listening-first product must expose "now listening" non-visually.

**Acceptance:**
1. Widget test: the semantics label of the current row (given `currentEntryId`) contains "Now listening" (e.g. "Chapter 3, Now listening. Double tap to jump.").
2. Non-current rows do not gain the current phrasing.
3. One announcement per row — no duplicated label from the excluded child text.

### A4 🔴 "RETELLING SETTINGS" affordance (ferrofluid) is a ~19dp target

`retelling_view_control.dart` — the ferro branch renders a bare `Padding(vertical: 4)` + 11px text with no min-size constraints (the 44dp `ConstrainedBox` exists only in the legacy branch). At 11px type + 8px padding the tappable area is roughly 19×N dp — below the 24dp WCAG 2.5.8 minimum, let alone the app's 44/48dp standard. The row is the only path to Retelling settings from the Reader dock.

**Acceptance:**
1. Widget test on `RetellingViewControl` (ferro theme): `tester.getSize` of the settings node (`retelling-settings-affordance` key / semantics node) ≥ 48×48 logical dp (44 minimum if the 44dp house token is invoked — then assert against `UnderstandLayout.minInteractiveDimension`).
2. Same assertion at `TextScaler.linear(2.0)`.

### A5 🔴 Dock speed chips are ~23–25dp tall targets

`player_strip.dart` `_SpeedChip` — `Padding(horizontal: 10, vertical: 6)` around 11px text, no `minHeight` constraint. Roughly 23–25dp tall; `runSpacing: 2` between chips kills the spacing exception. This fails WCAG 2.5.8 (24dp) and the 48dp product bar. The existing unit test only covers skip/play buttons, so this regressed silently. Note: the settings `Segment` pill (44dp min) shows the correct pattern to copy.

**Acceptance:**
1. Extend `test/unit/widgets/accessibility_test.dart` target-size group: every `_SpeedChip` (ferro dock, keys via `reader-dock-speed-row`) measures ≥ 48 height (or ≥ 44 + documented spacing ≥ 24 — pick one rule and assert it).
2. Wrap layout survives 2.0 text scale without clipping chips (chips may grow, must not shrink below the floor).

## Moderate findings

### B1 🟡 Dock VoiceRow label duplicates the voice name

`player_strip/_voice_row.dart` — `label: 'Voice: ${voice.displayName}. $title. Tap to change voice.'` where `$title` **is** `voice.displayName`. TalkBack reads "Voice: Alice. Alice. Tap to change voice." Also, "Tap to change voice" is a hint baked into the label (screen readers already announce "button, double-tap to activate").

**Acceptance:** label reads exactly "Voice: Alice. On device." with the hint moved to `hint:` or dropped; widget test asserts the label contains the name exactly once.

### B2 🟡 Playback completion is silent for screen readers

`reader_screen.dart:1171` — the live-region announcement switch covers Playing/Paused/Error but not `PlaybackCompleted`; `CompletionBanner` has no `liveRegion`. A listening-first user gets no spoken signal that the text ended. (The state package already demands one announcement per state change.)

**Acceptance:** widget test drives a fake playback notifier to `PlaybackCompleted` and asserts a single live-region announcement ("Finished" + next action), not zero and not duplicates from banner + region both firing.

### B3 🟡 Legacy Reader dock: fixed 188dp overlay reservation breaks at 2× text

`reader_screen.dart` `_kLegacyPlayerStripHeight = 188.0` reserves body space for a dock that grows with text scale in the legacy overlay path. At 2× the strip can exceed the reservation and cover the last paragraphs — the exact failure the state package review called out; still present. The ferrofluid Column layout is the fix pattern.

**Acceptance:** legacy-theme widget test at `TextScaler.linear(2.0)` with long text: bottom-most paragraph's render box bottom ≤ top of the PlayerStrip render box (no overlap), and the final paragraph is scrollable into view.

### B4 🟡 Scrubber announces a percentage, not a time

`player_strip/_position_row.dart` — the `Slider` has no `semanticFormatterCallback`; TalkBack/VoiceOver hears "50%" while the visible timecode says "4:04 / 8:08". For an audio app the spoken unit should be time.

**Acceptance:** `semanticFormatterCallback` maps the fraction to `m:ss of m:ss` matching `_fmt`; unit test asserts the semantics value string format.

### B5 🟡 Light-theme contents "current" row uses `accent` (3.13:1) as 14px text

`contents_sheet.dart` — `live = ferro?.live ?? palette.accent`: in the legacy light theme the current entry title renders 14px text at 3.13:1 (fails AA). The visual "live" role needs a text-safe ink in light theme (e.g. `accentInk`, 6.37:1 on `accentSoft`).

**Acceptance:** ratio helper test for the current-row title style in both brand themes ≥ 4.5:1; ferro path unchanged (`live` is AAA).

### B6 🟡 Matter error path stacks two merged semantics labels

`player_strip.dart:292–310` — outer `Semantics(label: <recovery copy>, button: true)` wraps `FerrofluidMatter`, which adds its own `Semantics(label: 'Retry'/'Download', button: true)`. Non-container Semantics merge, so the node can read the full recovery sentence immediately followed by "Retry" — a double announcement risk on the primary recovery control.

**Acceptance:** with the strip in the missing-model error state, the play node's final label is one coherent string ("Synthesis needs X. Download to retry." — single button flag, single tap action); assert via `getSemanticsData()` label + flags in a widget test.

### B7 🟡 Settings sheet is the only major sheet without `namesRoute`

Voice selector, Contents, and selected-range sheets set `namesRoute: true`; `settings_sheet.dart` uses only `Semantics(container: true, label: 'Settings')`. On both platforms the route name is what makes the surface change legible when the sheet opens.

**Acceptance:** pumped `SettingsSheet` semantics tree contains a names-route node labeled "Settings"; extend `test/widget/settings_accessibility_test.dart`.

### B8 🟡 `inkFaint` carries real text at 3.4:1 in light theme

`voice_selector/_sheet_header.dart` "VOICES" (12px, tracking 2.0) and `_first_run_demo_banner.dart` "Welcome to Understand" eyebrow use `inkFaint` — 3.43:1, fails AA for normal text. Decorative chevrons are correctly excluded from semantics elsewhere; these are content.

**Acceptance:** both surfaces' eyebrow style ratio ≥ 4.5:1 (switch to `inkMuted`, 6.60:1, which the BufferStatusLabel already uses with a "WCAG AA" comment).

### B9 🟡 Paragraph mark rail is an 18dp-wide, long-press-only target exposed as a button

`text_display.dart:596` — `Semantics(button: true, label: 'Mark paragraph N')` over an 18×28 rail with `onLongPress`. Below any target floor, and a button whose activation (long-press) is not conveyed by the `button` flag. It is a secondary path (toolbar exists), but a screen-reader user will find and fail it first.

**Acceptance:** either (a) remove the `button` flag and use `onLongPressHint`-style custom action semantics, or (b) widen the hit area to ≥ 48 and keep the flag; add a target-size test.

### B10 🟡 Speed change from the ferro dock chips is not announced

Legacy `SpeedChip` sends `SemanticsService.sendAnnouncement`; `_SpeedChipsRow` chips rely only on the `selected` flag, which TalkBack does not re-announce on tap. Speed is state a listening user cannot see.

**Acceptance:** tapping a dock speed chip produces one announcement ("Speed 1.5") — widget test with a semantics observer or the announcement pattern from `speed_chip.dart`.

## Minor findings

| ID | Sev | Finding | Fix direction |
| --- | --- | --- | --- |
| C1 | 🟢 | Disabled matter play button conveys no `enabled: false` state (only visual 0.38 opacity) | Add `enabled` flag to the FerrofluidMatter semantics wrapper |
| C2 | 🟢 | `PlaybackError` live-region copy is just "Playback failed" — no reason or next step | Fold `statusMessage` (or a short reason) into the announcement |
| C3 | 🟢 | `BufferStatusLabel`'s `AnimatedSwitcher` runs its 150ms cross-fade even under `disableAnimations` | Duration-zero when reduced, matching `_ChromeLastSlot` |
| C4 | 🟢 | Settings `SegmentRow` group lacks a `Semantics(container: true, label:)` association like the dock's "Playback speed" row | Mirror the dock pattern |
| C5 | 🟢 | Target-size standard is inconsistent app-wide: skip/play 48+4, `Segment` 44, `_SpeedChip` ~24, preview button 48 | One constant (`minInteractiveDimension` + explicit 48 for dock transport) asserted in tests |
| C6 | 🟢 | Settings accessibility destination exposes only Clean markdown + text size; "Accessibility" naming overpromises | Either add reduced-motion/high-contrast follow-through or rename (product copy decision — owner) |
| C7 | 🟢 | Voice selector `SheetHeader` "VOICES" is the only header without `Semantics(header: true)` (Settings header has it) | Add header flag for consistent rotor navigation |

## Platform contracts

- **Route announcements:** inconsistent — 3 sheets `namesRoute`, Settings and several `showModalBottomSheet` surfaces rely on framework defaults. One rule: every modal surface names its route.
- **Announcements:** `SemanticsService.sendAnnouncement` used in 4 places (retelling switch, speed chip ×2 paths) — fine on both platforms; keep announcements to one per state change (see B2, B6).
- **Android back:** Reader `PopScope` only intercepts reply-listening; system back exits normally — correct, no trap.
- **Share sheet / OS surfaces:** not faked in-app (per state package rule) — nothing to flag.
- **Purchase outcomes (paywall):** success/cancel/error do not announce via live region (matches the state-package gap "purchase success/cancel/error need real entitlement outcomes") — fold into the B2-style single-announcement rule when paywall states land.

## What works well

- Real live-region discipline: buffer status (Preparing→Buffering→silence), skip announcements, quota/model banners (`AppBanner` severity-driven), Contents unavailable, playback started/paused/error.
- Existing adversarial tests: slider announced exactly once, no Stop control in the tree, 48dp assertions for skip/play.
- Reduced motion is genuinely wired: `FerrofluidMatter` (t=0 static), `BreathingDot` static dot, `_ChromeLastSlot` duration-zero, chrome orchestrator honors `disableAnimations`, shimmer skipped.
- 2× text scale: Settings has a dedicated test (title, close, deep destinations, destructive actions reachable); `SegmentRow` label stacks above pills; `PositionRow` timecode uses a bounded FittedBox.
- Decorative icons are consistently `ExcludeSemantics`'d; transport buttons carry tooltips; the semantics container on the speed row ("Playback speed") is the right group pattern.

## Priority order

1. **A1 quiet contrast** — token-level fix, unblocks every ferrofluid text surface (A1 acceptance first; B5/B8 ride the same ratio-helper test).
2. **A4 + A5 target floors** — one sizing rule + tests across dock controls.
3. **A2 unlabeled close, A3 contents current-state** — label-level fixes, cheap once tests exist.
4. **B2 completion silence + B6 double label** — announcement correctness on the two most important playback moments.
5. **B3 legacy 2× occlusion** — already scheduled direction (Column layout); keep the widget test as the gate.

---

*Fleet review, `task_35e57f7200ea`. Evidence: direct code reading of `reader_screen.dart`, `player_strip.dart` (+ `_position_row`, `_skip_button`, `_voice_row`, `_buffer_status_label`), `contents_sheet.dart`, `voice_selector_sheet.dart` (+ `voice_selector/` parts), `settings_sheet.dart` (+ `settings/` parts), `retelling_view_control.dart`, `text_display.dart`, `flow_sheet.dart`, `app_banner.dart`, `quota_banner.dart`, `completion_banner.dart`, `breathing_dot.dart`, `ferrofluid_matter.dart`, tokens (`tokens.dart`, `ferrofluid_tokens.dart`), and tests (`test/unit/widgets/accessibility_test.dart`, `test/widget/settings_accessibility_test.dart`). Contrast ratios computed from token hex values (WCAG 2.x relative-luminance formula). No app files were modified.*
