Files
MealQuest/docs/A11Y.md
T
dev_agentandClaude Opus 5.5 54b3ba867f
CI / ci (push) Successful in 8m7s
CI / ci (pull_request) Successful in 8m11s
Accessibility pass over every route
Record the pass in docs/A11Y.md: keyboard, focus, names and roles, text
contrast per token pair, hit targets and reduced motion for each route.

Fix the four medium findings, each with a regression test:
- Muted text in neutral-600 (3.5-4.0:1) is now neutral-700; a contrast
  test checks every text token pair and the tokens stylesheets use.
- The bottom bar's focus ring is on-accent, not grape on the dark bar.
- The Kitchen reading text-size ring sits inside the clipped pill.
- The tablet-portrait Quest log chips keep the 44 px tap minimum.

Low findings are listed as follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:51 -04:00

13 KiB
Raw Blame History

Accessibility pass

A pass over every route the PWA serves (the routes in 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, 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 :focus-visible draws a 2 px grape (--color-accent) outline 2 px out, on every focusable element.
Dialogs Sheet 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 A <button role="switch" aria-checked>; Space and Enter flip it; a 44 px hit box around the 40 × 24 track.
Checkboxes Checkbox A native <input type="checkbox"> inside its <label>; the label row is the 44 px target.
Segmented controls Segmented role="radiogroup" of role="radio" buttons with a roving tab stop; arrows, Home and End move the choice.
Icon buttons IconButton A required label names every icon-only button; the 40 px face sits in a 44 px hit box.
Primary nav BottomBar, Rail, Sidebar <nav aria-label="Primary"> of NavLinks; 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, 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 selects 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 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; the swapping-tile and done-step colour assertions in PlanPage.test.tsx and 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.
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.
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.

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.