CI / ci (push) Failing after 6s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
114 lines
13 KiB
Markdown
114 lines
13 KiB
Markdown
# Accessibility pass
|
||
|
||
A pass over every route the PWA serves (the routes in [STATES.md](STATES.md))
|
||
for launch, on 2026-10-10. Each route was checked for:
|
||
|
||
- **Keyboard** — every action reachable and operable with the keyboard alone.
|
||
- **Focus** — a visible focus ring on everything that takes focus.
|
||
- **Names and roles** — what a screen reader announces: switches, checkboxes,
|
||
the current nav item, dialogs and their labels.
|
||
- **Contrast** — text at least 4.5:1 against its ground, for every token pair
|
||
used for text.
|
||
- **Hit targets** — every tap target at least 44 × 44 px.
|
||
- **Reduced motion** — what `prefers-reduced-motion: reduce` turns off.
|
||
|
||
The pass read each screen's markup and stylesheets, computed contrast from
|
||
[tokens.css](../web/src/styles/tokens.css), and checked the fixes' computed
|
||
styles in Chrome. Severity: **high** blocks a task for keyboard or
|
||
screen-reader users; **medium** fails a criterion above but leaves a way
|
||
through; **low** is a gap outside the criteria above, or a polish item. High
|
||
and medium findings are fixed in this pass with a regression test; low ones are
|
||
follow-ups.
|
||
|
||
## Shared building blocks
|
||
|
||
Most of the criteria are met once, in the shared components every screen uses:
|
||
|
||
| Concern | Where | Behaviour |
|
||
| --- | --- | --- |
|
||
| Focus ring | [base.css](../web/src/styles/base.css) | `:focus-visible` draws a 2 px grape (`--color-accent`) outline 2 px out, on every focusable element. |
|
||
| Dialogs | [Sheet](../web/src/ui/Sheet.tsx) | `role="dialog"`, `aria-modal="true"`, and a required `aria-label` or `aria-labelledby` (the type will not compile without one). Opening moves focus in, Tab is trapped, Escape and the scrim close, the page behind is `inert`, and focus returns to the opener. Every sheet is labelled by its visible title. |
|
||
| Toggles | [Toggle](../web/src/ui/Toggle.tsx) | A `<button role="switch" aria-checked>`; Space and Enter flip it; a 44 px hit box around the 40 × 24 track. |
|
||
| Checkboxes | [Checkbox](../web/src/ui/Checkbox.tsx) | A native `<input type="checkbox">` inside its `<label>`; the label row is the 44 px target. |
|
||
| Segmented controls | [Segmented](../web/src/ui/Segmented.tsx) | `role="radiogroup"` of `role="radio"` buttons with a roving tab stop; arrows, Home and End move the choice. |
|
||
| Icon buttons | [IconButton](../web/src/ui/IconButton.tsx) | A required `label` names every icon-only button; the 40 px face sits in a 44 px hit box. |
|
||
| Primary nav | [BottomBar](../web/src/shell/BottomBar.tsx), [Rail](../web/src/shell/Rail.tsx), [Sidebar](../web/src/shell/Sidebar.tsx) | `<nav aria-label="Primary">` of `NavLink`s; the current item has `aria-current="page"`, nested routes (`/plan/shopping`, `/settings/ai`) keep their section current. The bottom bar's inactive items keep their name in a visually hidden label. |
|
||
| Status and errors | [ErrorBanner](../web/src/ui/ErrorBanner.tsx), `role="status"` / `role="alert"` lines | Loading regions are `aria-busy` and named; errors are announced. |
|
||
| Reduced motion | `--motion-scale` in tokens.css; `@media (prefers-reduced-motion: reduce)` in each animated component; `matchMedia` before every Web Animations call | Sheets appear without rising, skeletons stop pulsing, buttons do not scale, toggles and checkboxes do not spring, the nav pill jumps rather than slides, and the Discover deck fades a decided card out with no throw, bump, wiggle or promotion. |
|
||
|
||
## Routes
|
||
|
||
| Route | Keyboard | Focus | Names and roles | Hit targets | Reduced motion |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| App shell (every route) | Nav is links in tab order. | Global ring; **the bottom bar's ring was 1.8:1 on the dark bar — fixed (M2).** | `nav "Primary"`, `aria-current="page"`. | Bottom bar items 62 px tall, ≥ 44 px wide; rail items 56 × 44. | Pill jumps; no label fade. |
|
||
| `/` Home | All tiles, status rows, Recipe, the bell and the Rotation check are buttons or links. | Global ring; tiles, status rows and "We didn't make it" draw a 3 px ring, inside their cards' padding. | Tonight `section`, Status list; the bell is named "Recent updates" and its sheet is labelled by its title. | Recipe pill and "We didn't make it" kept at 44 px. | — |
|
||
| `/onboarding` (all steps) | Buttons and form fields; Add Regular submits on Enter. | Global ring. | `h1` per step; progress bar hidden; "Add Regular" and "Add *pick*" named. | Covered by onboarding tests. | — |
|
||
| `/plan` Draft board (mobile) | **Swap without drag:** each day tile is a button that opens the swap sheet; the sheet's rows are buttons. Previous/Next week, Rebuild week, Push to Calendar are buttons. | Global ring. | Week list "Dinners this week"; swap sheet labelled "Swap *Day*"; Quest log / Regulars radio group. | Tiles ≥ 86 px tall. | Sheet appears without rising. |
|
||
| `/plan` plan manager (tablet, desktop) | **Swap without drag:** every day has a "Swap *Day*" button opening the swap sheet. Dragging also works by keyboard: Enter or Space picks a dinner up, arrows move between days, Enter drops, Escape cancels; a polite live region narrates each step and focus lands on the day's swap button. | Global ring. | Days named by weekday; the panel is "Quest log and Regulars"; each draggable row is described by the keys hint. | **The tablet-portrait Quest log chips were 38 px tall — fixed (M4).** | — |
|
||
| `/plan/shopping` Supply run | Native checkboxes; links to the archive. | Global ring. | Groups named by dinner; native checkbox per item; read-only weeks say "have it" in text, not by colour alone. | Checkbox rows 44 px. | — |
|
||
| `/plan/shopping/archive` | Period radio group; posts are links. | Posts draw a 3 px ring. | Periods named by heading. | — | — |
|
||
| `/discover` | **← Pass, → Try it** anywhere on the page (not while typing, nor with a sheet open); Pass and Try it are also buttons; the Quest log pill opens its sheet. | Global ring. | Only the top card is exposed, labelled by its name; cards behind are `aria-hidden` and `inert`; Try it / Pass labels are shown under the buttons. | Pass and Try it 62 px; Quest log pill 44 px. | Card fades out; no throw, bump, wiggle, snap-back spring or promotion. |
|
||
| `/quick` Quick dinners | Tab radio group; add buttons; More link. | Global ring; More draws a 3 px ring. | Rows named by idea; "Added" is a status. | Covered by Quick dinners tests. | — |
|
||
| `/cook-from-pantry` | Field adds an ingredient on Enter; chips' remove buttons; Find ideas. | The field rings as a whole (`:focus-within`). | Ingredients list "Ingredients on hand"; idea cards named. | Chip remove buttons 44 px. | — |
|
||
| `/dinner/:id` (phone) | Buttons for Favorite, Add to week, back. | Global ring. | Favorite is `aria-pressed`; Add to week sheet labelled. | Favorite 52 px. | — |
|
||
| `/dinner/:id` Kitchen reading mode | ← / → move steps (not while typing, nor with a sheet open); Previous and Next step buttons; text size radio group; ingredient checkboxes. | **The text-size pill clipped its focus ring — fixed (M3).** | Keep screen on is a `role="switch"`; ingredients are native checkboxes; the current step is `aria-current="step"`. | Checkboxes 44 px rows. | — |
|
||
| `/settings` | Rows are links; on tablet, a `tablist` with arrow keys. | Global ring. | Tabs `role="tab"` / `tabpanel`. | Covered by Settings tests. | — |
|
||
| `/settings/notifications` | Every channel is a switch; reminder time button; webhook form. | Global ring. | Each switch named "*Event*: *Channel*"; the matrix is a named group; the webhook sheet is labelled. | Toggle hit box 44 px. | — |
|
||
| `/settings/ai` | Selects, slider, Add/Replace key. | Global ring. | Native `select`s and range input with labels; key sheet labelled. | Covered by AI settings tests. | — |
|
||
| `/settings/household` | Selects; time zone list of buttons. | Global ring. | The chosen time zone is `aria-pressed`. | Zone options 48 px. | — |
|
||
| `/settings/regulars` | Add, edit and favourite buttons; method selects; recipe search. | Global ring. | Favourite is `aria-pressed`; edit sheet labelled. | Covered by Regulars tests. | — |
|
||
| `/settings/calendar` | Connect link; calendar select. | Global ring. | Reconnect problems are `role="alert"`. | — | — |
|
||
|
||
## Text contrast
|
||
|
||
WCAG AA asks 4.5:1 for normal-size text. Every colour a stylesheet gives text,
|
||
on every ground it is drawn on:
|
||
|
||
| Text token | Grounds | Lowest ratio |
|
||
| --- | --- | --- |
|
||
| `--color-text` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200, accent-2-300 | 11.10 (on accent-2-300) |
|
||
| `--color-neutral-700` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200 | 5.22 (on accent-200) |
|
||
| `--color-neutral-800` | the same light grounds | 7.90 |
|
||
| `--color-neutral-900` | the same light grounds | 11.18 |
|
||
| `--color-accent` | the same light grounds | 7.16 |
|
||
| `--color-accent-700` | the same light grounds | 5.76 |
|
||
| `--color-accent-800` | the same light grounds | 8.69 |
|
||
| `--color-accent-2-800` | the same light grounds | 7.51 |
|
||
| `--color-accent-2-900` | the light grounds and accent-2-300 | 9.51 (on accent-2-300) |
|
||
| `--on-accent` | accent, accent-800, neutral-800 | 8.39 (on accent) |
|
||
| `--color-bg` | text, neutral-800 | 9.26 (on neutral-800) |
|
||
| `--color-neutral-400` | text (the bottom bar's inactive items) | 8.28 |
|
||
|
||
`--color-neutral-600` (3.5:1 on surface, 4.0:1 on bg) was used for muted text
|
||
in five places and failed — fixed (M1). [contrast.test.ts](../web/src/contrast.test.ts)
|
||
checks every pair above from tokens.css and fails if any stylesheet gives text a
|
||
colour token not in this table.
|
||
|
||
Non-text: the teal Try it button and teal "+" buttons draw a light icon on
|
||
`--color-accent-2-600` at 3.73:1, which meets the 3:1 asked of icons; their
|
||
names are text elsewhere (the shown label, or `aria-label`).
|
||
|
||
## Findings
|
||
|
||
### Fixed in this pass
|
||
|
||
| # | Severity | Route | Finding | Fix | Regression test |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| M1 | Medium | `/plan`, `/dinner/:id`, `/cook-from-pantry` | Muted text in `--color-neutral-600` at 3.5–4.0:1: the swapping day's dinner and start-by time, Regulars already this week in the swap sheet, ingredients set out and steps done in Kitchen reading mode, and the ingredient field's placeholder. | `--color-neutral-700` (≥ 5.4:1 on bg and surface); still muted against `--color-text`, and set-out ingredients keep their strike-through. | [contrast.test.ts](../web/src/contrast.test.ts); the swapping-tile and done-step colour assertions in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx) and [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
|
||
| M2 | Medium | Every route (phone) | The bottom bar's focus ring was the global grape ring on the near-black bar: 1.8:1, nearly invisible. | The bar's items ring in `--on-accent` (15.4:1 on the bar). | "rings a focused item in on-accent ink" in [BottomBar.test.tsx](../web/src/shell/BottomBar.test.tsx). |
|
||
| M3 | Medium | `/dinner/:id` Kitchen reading mode | The text-size pill hides overflow, so the focus ring of the chosen size — drawn outside it — was cut off top and bottom, and in grape on the grape fill. | The ring sits 4 px inside the size, in `--on-accent` (8.4:1 on grape). Only the chosen size takes focus (roving tab stop). | "rings the focused size inside the clipped pill" in [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
|
||
| M4 | Medium | `/plan` (tablet portrait, 768–1179 px) | The Quest log strip's draggable chips were 38 px tall (28 px photo, 3 px padding, 2 px border). | The chip button keeps a 44 × 44 px minimum. | The strip test's `expectMinHitArea` in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx). |
|
||
|
||
No high findings.
|
||
|
||
### Follow-ups (low)
|
||
|
||
| # | Route | Finding | Suggested follow-up |
|
||
| --- | --- | --- | --- |
|
||
| L1 | Every route with a checkbox | An unchecked checkbox's `--color-neutral-400` border is 1.9:1 on bg — below the 3:1 WCAG asks of a control's boundary. Its label row still names and reveals it. | Darken the border (for example `--color-neutral-600`, 4.0:1) with the design owner. |
|
||
| L2 | Settings, Kitchen reading mode | An off toggle's `--color-neutral-300` track is 1.4:1 on bg; the knob's position and the switch's name carry the state. | Give the off track a ≥ 3:1 edge with the design owner. |
|
||
| L3 | `/discover` | The ← / → shortcuts are not announced to screen readers. | Add `aria-keyshortcuts` to Pass and Try it. |
|
||
| L4 | `/discover` | Deciding the last card replaces Pass and Try it with "All caught up", and focus falls back to the page. | Move focus to "Get more now" when the deck empties. |
|
||
| L5 | `/discover` | A decision is not announced; the next card's name appears silently. | A polite status ("Added to the Quest log" / "Passed") after each decision. |
|
||
| L6 | `/plan` (mobile) | A day tile's name is its day and dinner ("MON Beef tacos"); it does not say it opens the swap sheet. | Add "Swap" to the tile's accessible name. |
|