Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 9a183c1984 Scope settings loading tests to their section and document onboarding paths
CI / ci (push) Successful in 8m8s
CI / ci (pull_request) Successful in 8m11s
The /settings/notifications and /settings/ai loading tests passed on any
skeleton, including the webhook and Recipe search cards' own. They now
wait for those sections to settle and check the one skeleton left sits
before them, so removing the held section's skeleton fails the test.

docs/STATES.md listed /onboarding as the Regulars step. It now lists the
Welcome (/onboarding), Regulars (/onboarding/regulars) and Notifications
(/onboarding/notifications) steps, each tested at its own path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:35:40 -04:00
dev_agentandClaude Opus 5.5 832e6b9f7d Document and test every route's empty, loading and error states
CI / ci (push) Successful in 8m10s
CI / ci (pull_request) Successful in 8m15s
docs/STATES.md lists each handoff route and the added Settings screens
with its empty, loading and error state, all built on Skeleton,
ErrorBanner and EmptyState. web/src/states.test.tsx renders the three
variants of every route against a stubbed API, asserting the copy and
pressing Retry.

Task: 322a15db-b2ad-46f4-a2ba-cb3745dd0aff

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:10:15 -04:00
20 changed files with 22 additions and 555 deletions
+2 -3
View File
@@ -82,8 +82,7 @@ sqlc:
$(SQLC) generate
# Fails on any difference from the committed generated code, including new
# or deleted files. A failing git status is itself a failure: its empty
# output would otherwise read as "no drift".
# or deleted files.
sqlc-check: sqlc
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
-113
View File
@@ -1,113 +0,0 @@
# 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. |
-86
View File
@@ -1,86 +0,0 @@
// Text contrast over the design tokens (docs/A11Y.md, "Text contrast"). Every
// colour a stylesheet gives text must be a token listed in TEXT_GROUNDS, and
// every pair listed there must reach WCAG AA's 4.5:1 for normal-size text.
import { describe, expect, it } from 'vitest'
import tokens from './styles/tokens.css?raw'
const stylesheets = import.meta.glob<string>(['./**/*.css', '!./styles/tokens.css'], {
query: '?raw',
import: 'default',
eager: true,
})
// The grounds MealQuest's pages, cards, rows and tinted fills use.
const LIGHT_GROUNDS = [
'--color-bg',
'--color-surface',
'--color-neutral-100',
'--color-neutral-200',
'--color-accent-100',
'--color-accent-200',
'--color-accent-2-100',
'--color-accent-2-200',
]
// Each text colour token, with every ground it is drawn on.
const TEXT_GROUNDS: Record<string, string[]> = {
'--color-text': [...LIGHT_GROUNDS, '--color-accent-2-300'],
'--color-neutral-700': LIGHT_GROUNDS,
'--color-neutral-800': LIGHT_GROUNDS,
'--color-neutral-900': LIGHT_GROUNDS,
'--color-accent': LIGHT_GROUNDS,
'--color-accent-700': LIGHT_GROUNDS,
'--color-accent-800': LIGHT_GROUNDS,
'--color-accent-2-800': LIGHT_GROUNDS,
'--color-accent-2-900': [...LIGHT_GROUNDS, '--color-accent-2-300'],
// Light ink on the grape fills (buttons, the active nav pill, the chosen
// text size) and on the dark ones (the Offline pill).
'--on-accent': ['--color-accent', '--color-accent-800', '--color-neutral-800'],
// The dark pill button and the chosen settings tab.
'--color-bg': ['--color-text', '--color-neutral-800'],
// The bottom bar's inactive items, on the dark bar.
'--color-neutral-400': ['--color-text'],
}
function hex(name: string): string {
const value = new RegExp(`^\\s*${name}:\\s*(#[0-9a-fA-F]{6})\\b`, 'm').exec(tokens)?.[1]
if (value === undefined) throw new Error(`${name} is not a hex colour in tokens.css`)
return value
}
// Relative luminance, per WCAG 2.
function luminance(colour: string): number {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = Number.parseInt(colour.slice(i, i + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
}
function contrast(a: string, b: string): number {
const [light, dark] = [luminance(hex(a)), luminance(hex(b))].sort((x, y) => y - x)
return (light! + 0.05) / (dark! + 0.05)
}
describe('text contrast', () => {
const pairs = Object.entries(TEXT_GROUNDS).flatMap(([text, grounds]) => grounds.map((ground) => [text, ground] as const))
it.each(pairs)('%s on %s reaches 4.5:1', (text, ground) => {
expect(contrast(text, ground)).toBeGreaterThanOrEqual(4.5)
})
it('gives text only the colour tokens checked above', () => {
expect(Object.keys(stylesheets).length).toBeGreaterThan(0)
const unchecked = Object.entries(stylesheets).flatMap(([path, css]) =>
Array.from(css.matchAll(/(?<![\w-])color:\s*([^;}]+)/g))
.map((match) => match[1]!.trim())
.filter((value) => value !== 'inherit')
.filter((value) => {
const token = /^var\((--[\w-]+)\)$/.exec(value)?.[1]
return token === undefined || !(token in TEXT_GROUNDS)
})
.map((value) => `${path}: color: ${value}`),
)
expect(unchecked).toEqual([])
})
})
+2 -9
View File
@@ -341,13 +341,6 @@
box-shadow: none;
}
/* The pill clips anything outside it, so the focus ring sits inside the
size, drawn in on-accent ink: only the chosen size takes focus. */
.readingHeader .textSize > :focus-visible {
outline-color: var(--on-accent);
outline-offset: -4px;
}
.readingHeader .textSize > :nth-child(1) {
font-size: 13px;
}
@@ -440,7 +433,7 @@
}
.setOut {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
text-decoration: line-through;
}
@@ -522,7 +515,7 @@
}
.stepDone {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.stepCurrent {
+1 -16
View File
@@ -325,7 +325,7 @@ describe('Kitchen reading mode', () => {
// The step done is muted, its number a check.
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
fireEvent.click(next)
@@ -381,21 +381,6 @@ describe('Kitchen reading mode', () => {
})
describe('text size', () => {
it('rings the focused size inside the clipped pill, in on-accent ink on the grape', async () => {
serve()
await renderPage()
const medium = screen.getByRole('radio', { name: 'Medium text' })
// A key press makes the next focus a keyboard one, which
// :focus-visible matches.
fireEvent.keyDown(document.body, { key: 'Tab' })
medium.focus()
expect(medium.matches(':focus-visible')).toBe(true)
// The pill hides overflow, so a ring outside the size would be cut off.
expect(declaredStyle(medium).getPropertyValue('outline-offset')).toBe('-4px')
expect(declaredStyle(medium).getPropertyValue('outline-color')).toBe('var(--on-accent)')
expect(declaredStyle(medium).background).toBe('var(--color-accent)')
})
it('is remembered on this device', async () => {
serve()
const view = await renderPage()
+1 -1
View File
@@ -71,7 +71,7 @@
}
.input::placeholder {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.notice {
+1 -7
View File
@@ -203,7 +203,7 @@
.swapping .dinner,
.swapping .startBy {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.rebuildCell {
@@ -410,12 +410,6 @@
list-style: none;
}
/* An upright tablet's week: four days over three, every column wide enough
that nothing scrolls sideways. */
.portraitPage .week {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.column {
min-width: 0;
display: flex;
+1 -5
View File
@@ -8,7 +8,6 @@ import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
import { cacheFallbackHeader } from '../sw/routes.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
@@ -1223,7 +1222,7 @@ describe('PlanPage', () => {
expect(declaredStyle(friday).background).toBe('var(--color-bg)')
expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)')
expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)')
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)')
// "Quest log · N | Regulars · N", on the Quest log first.
expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
@@ -1645,9 +1644,6 @@ describe.each([
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
expect(chip).toHaveTextContent('Lemon orzo')
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
// The 28 px photo makes a 38 px chip; the chip is a button, so it
// keeps the 44 px tap minimum.
expectMinHitArea(within(chip).getByRole('button', { name: /Lemon orzo/ }))
// The strip follows the grid.
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+2 -8
View File
@@ -8,7 +8,7 @@ import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
import { useBreakpoint, useOrientation, type Breakpoint } from '../shell/useBreakpoint.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { EmptyState } from '../ui/EmptyState.tsx'
@@ -673,10 +673,8 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
// breakpoint keeps a draft being followed. From tablet up a dinner is
// dragged from the strip or the panel onto a day of the grid to swap it in.
// An upright tablet lays the week out four days over three.
export function PlanPage() {
const layout = useBreakpoint()
const orientation = useOrientation()
const settings = useHouseholdSettings()
const [params, setParams] = useSearchParams()
const chosen = params.get(WEEK_PARAM)
@@ -701,11 +699,7 @@ export function PlanPage() {
}
return (
<PlanDragProvider>
<div
className={[styles.page, styles[`${layout}Page`], layout === 'tablet' && orientation === 'portrait' && styles.portraitPage]
.filter(Boolean)
.join(' ')}
>
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
<div className={styles.board}>
{content}
{layout === 'tablet' && <QuestLogStrip />}
-5
View File
@@ -190,12 +190,7 @@
padding: 8px;
}
/* The 28 px photo makes a 38 px chip; the chip is a button, so it keeps the
44 px tap minimum. */
.chip.draggable {
box-sizing: border-box;
min-width: 44px;
min-height: 44px;
padding: 3px 12px 3px 3px;
}
+1 -1
View File
@@ -40,7 +40,7 @@
/* A dinner already this week: shown, muted, and not a button. */
.already {
color: var(--color-neutral-700);
color: var(--color-neutral-600);
}
.skeletonRow {
-6
View File
@@ -83,12 +83,6 @@
gap: 20px;
}
/* An upright tablet stacks the columns: Notifications above the AI
assistant and Recipe search. */
.portrait .columns {
grid-template-columns: minmax(0, 1fr);
}
.column {
min-width: 0;
display: flex;
+1 -8
View File
@@ -1,5 +1,4 @@
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
import { Card } from '../ui/Card.tsx'
import { AIAssistantCard } from './AISettingsPage.tsx'
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
@@ -22,10 +21,7 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
// so a draft survives switching tabs, and one leave guard covers them all.
// hidden hides the page but keeps it mounted, drafts and leave guard included.
// On an upright tablet the two columns stack into one.
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
const layout = useBreakpoint()
const orientation = useOrientation()
const [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
@@ -68,10 +64,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
}
return (
<div
className={[styles.page, layout === 'tablet' && orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}
hidden={hidden}
>
<div className={styles.page} hidden={hidden}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
-6
View File
@@ -22,9 +22,3 @@
.desktop .main {
padding: 32px 36px;
}
/* An upright tablet (820 px wide) leaves 724 px beside the rail; slimmer side
padding gives the page a little more of it. */
.tablet.portrait .main {
padding: 32px 28px;
}
+3 -5
View File
@@ -2,17 +2,15 @@ import { Outlet } from 'react-router'
import { BottomBar } from './BottomBar.tsx'
import { Rail } from './Rail.tsx'
import { Sidebar } from './Sidebar.tsx'
import { useBreakpoint, useOrientation } from './useBreakpoint.ts'
import { useBreakpoint } from './useBreakpoint.ts'
import styles from './AppShell.module.css'
// The responsive frame around every page: one nav layout per breakpoint and a
// scrolling main area for the routed page. A portrait viewport is marked so
// the narrower upright tablet keeps its content clear of the rail.
// scrolling main area for the routed page.
export function AppShell() {
const breakpoint = useBreakpoint()
const orientation = useOrientation()
return (
<div className={[styles.shell, styles[breakpoint], orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}>
<div className={`${styles.shell} ${styles[breakpoint]}`}>
{breakpoint === 'mobile' && <BottomBar />}
{breakpoint === 'tablet' && <Rail />}
{breakpoint === 'desktop' && <Sidebar />}
-6
View File
@@ -48,12 +48,6 @@
color: var(--on-accent);
}
/* The app-wide grape focus ring barely shows on the dark bar (1.8:1); the
bar's ring is the light on-accent ink instead. */
.item:focus-visible {
outline-color: var(--on-accent);
}
.label {
font-family: var(--font-body);
font-size: 13px;
-12
View File
@@ -182,18 +182,6 @@ describe('BottomBar', () => {
])
})
it('rings a focused item in on-accent ink, which shows on the dark bar', async () => {
await renderAt('/')
const item = link('Plan')
// A key press makes the next focus a keyboard one, which :focus-visible
// matches; earlier tests' clicks would leave it a pointer one.
fireEvent.keyDown(document.body, { key: 'Tab' })
item.focus()
expect(item).toHaveFocus()
expect(item.matches(':focus-visible')).toBe(true)
expect(declaredStyle(item).getPropertyValue('outline-color')).toBe('var(--on-accent)')
})
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
fireEvent.click(link('Plan'))
-225
View File
@@ -1,225 +0,0 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx'
import planStyles from '../plan/PlanPage.module.css'
import settingsTabsStyles from '../settings/SettingsTabs.module.css'
import {
AI_MODELS,
AI_SETTINGS,
CHAT_WEBHOOK_NONE,
GOOGLE_NOT_CONNECTED,
HOME,
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
ApiProviders,
} from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { dinnerBody } from '../test/dinners.ts'
import { jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import shellStyles from './AppShell.module.css'
import railStyles from './Rail.module.css'
// Every primary route on a tablet held upright (820 × 1180) and on its side
// (1180 × 820): both have the 96 px rail and the tablet layout; upright, the
// shell is marked portrait, the Plan week reflows four days over three, and
// Settings' two columns stack into one.
const PORTRAIT = { width: 820, height: 1180 }
const LANDSCAPE = { width: 1180, height: 820 }
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
const PLAN = {
id: 'plan-1',
week_start: '2026-10-05',
week_label: 'Week 41 · Oct 5 – 11',
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: DAYS.map((date, i) => ({
date,
dinner: { id: `d${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
is_new: false,
start_by: null,
})),
updated_at: '2026-10-04T15:00:00Z',
}
const SUPPLY_RUN = {
weekStart: '2026-10-05',
label: 'Week 41 · Oct 5 – 11',
title: null,
status: 'current',
sent_to_chat_at: null,
chat_send_unconfirmed_at: null,
needed_count: 1,
total_count: 1,
groups: [
{
dinner: { id: 'd0', name: 'Dinner 0' },
date: '2026-10-05',
day: 'Monday',
items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }],
},
],
}
const DETAIL = {
id: 'd1',
name: 'Korean beef bowls',
status: 'regular',
origin: 'regular',
method: 'stovetop',
minutes_total: 30,
difficulty: 'easy',
cuisine: 'korean',
is_favorite: false,
photo_urls: null,
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
steps: ['Brown the beef.'],
source: null,
grounding_status: 'manual',
grounding_error: null,
why_picked: null,
}
// The API every route reads, answered as a settled household's server would.
// Any other request stays pending, so a route that reads more just shows its
// loading state.
const API: Record<string, unknown> = {
'/api/v1/me': ME,
'/api/v1/home': HOME,
'/api/v1/rotation-reviews': { reviews: [] },
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
'/api/v1/settings/ai': AI_SETTINGS,
'/api/v1/settings/ai/models': AI_MODELS,
'/api/v1/settings/recipe-search': RECIPE_SEARCH_SETTINGS,
'/api/v1/settings/notifications': NOTIFICATION_SETTINGS,
'/api/v1/settings/chat-webhook': CHAT_WEBHOOK_NONE,
'/api/v1/integrations/google': GOOGLE_NOT_CONNECTED,
'/api/v1/plans/2026-10-05': PLAN,
'/api/v1/shopping/2026-10-05': SUPPLY_RUN,
'/api/v1/shopping/archive?by=week': { by: 'week', periods: [] },
'/api/v1/suggestions/deck': { suggestions: [], active_batch: null, failed_batch: null },
'/api/v1/dinners?status=regular': { dinners: [dinnerBody('r1', 'Beef tacos')] },
'/api/v1/dinners?status=quest_log': { dinners: [] },
'/api/v1/dinners/d1': DETAIL,
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
const path = (input: Parameters<typeof fetch>[0]) => new URL(String(input), 'https://mealquest.test')
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>((input) => {
const url = path(input)
const key = `${url.pathname}${url.search}`
if (!Object.hasOwn(API, key)) return new Promise<Response>(() => {})
return Promise.resolve(jsonResponse(200, API[key]))
}),
)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.useRealTimers()
vi.unstubAllGlobals()
})
async function renderAt(path: string, { width, height }: { width: number; height: number }) {
mockViewport(width, { height })
render(
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
}
function shell() {
return screen.getByRole('main').parentElement!
}
const ROUTES: [string, string][] = [
['Home', '/'],
['Plan', '/plan?week=2026-10-05'],
['Supply run', '/plan/shopping'],
['Supply archive', '/plan/shopping/archive'],
['Discover', '/discover'],
['Quick dinners', '/quick'],
['Cook from what I have', '/cook-from-pantry'],
['Dinner detail (Kitchen reading mode)', '/dinner/d1'],
['Settings', '/settings'],
['Notifications', '/settings/notifications'],
['AI assistant', '/settings/ai'],
['Household', '/settings/household'],
['Regulars', '/settings/regulars'],
['Calendar', '/settings/calendar'],
]
describe.each([
['portrait', PORTRAIT],
['landscape', LANDSCAPE],
])('tablet %s', (orientation, viewport) => {
const portrait = orientation === 'portrait'
it.each(ROUTES)(`renders %s with the 96 px rail in the tablet ${orientation} layout`, async (_name, path) => {
await renderAt(path, viewport)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(screen.getByRole('navigation', { name: 'Primary' })).toHaveClass(railStyles.rail!)
expect(declaredStyle(screen.getByRole('navigation', { name: 'Primary' })).width).toBe('96px')
expect(shell()).toHaveClass(shellStyles.tablet!)
if (portrait) expect(shell()).toHaveClass(shellStyles.portrait!)
else expect(shell()).not.toHaveClass(shellStyles.portrait!)
expect(await screen.findByRole('heading', { level: 1 })).toBeInTheDocument()
})
it(`lays the Plan week out ${portrait ? 'four days over three' : 'in seven columns'}`, async () => {
await renderAt('/plan?week=2026-10-05', viewport)
const week = await screen.findByRole('list', { name: 'Dinners this week' })
const page = week.closest(`.${planStyles.tabletPage}`)
expect(page).not.toBeNull()
if (portrait) expect(page).toHaveClass(planStyles.portraitPage!)
else expect(page).not.toHaveClass(planStyles.portraitPage!)
expect(declaredStyle(week).gridTemplateColumns).toBe(
portrait ? 'repeat(4, minmax(0, 1fr))' : 'repeat(7, minmax(112px, 1fr))',
)
expect(declaredStyle(week).overflowX).toBe('auto')
})
it(`shows Settings (mock 2e) in ${portrait ? 'one column' : 'two columns'}`, async () => {
await renderAt('/settings', viewport)
const panel = screen.getByRole('tabpanel', { name: 'Notifications & AI' })
const page = panel.closest(`.${settingsTabsStyles.page}`)
if (portrait) expect(page).toHaveClass(settingsTabsStyles.portrait!)
else expect(page).not.toHaveClass(settingsTabsStyles.portrait!)
expect(declaredStyle(panel).gridTemplateColumns).toBe(
portrait ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1.15fr)',
)
})
})
it('reflows when the tablet turns', async () => {
const resize = mockViewport(PORTRAIT.width, { height: PORTRAIT.height })
render(
<InDataRouter path="/plan?week=2026-10-05">
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
)
const week = await screen.findByRole('list', { name: 'Dinners this week' })
expect(shell()).toHaveClass(shellStyles.portrait!)
expect(week.closest(`.${planStyles.tabletPage}`)).toHaveClass(planStyles.portraitPage!)
resize(LANDSCAPE.width, LANDSCAPE.height)
expect(shell()).toHaveClass(shellStyles.tablet!)
expect(shell()).not.toHaveClass(shellStyles.portrait!)
expect(week.closest(`.${planStyles.tabletPage}`)).not.toHaveClass(planStyles.portraitPage!)
})
-19
View File
@@ -7,12 +7,6 @@ export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
export const TABLET_QUERY = '(min-width: 768px)'
export const DESKTOP_QUERY = '(min-width: 1200px)'
export type Orientation = 'portrait' | 'landscape'
// A tablet held upright (820 × 1180) has the rail and the tablet layout but
// far less width than one on its side, so pages reflow for it.
export const PORTRAIT_QUERY = '(orientation: portrait)'
// The layout the current viewport calls for, updated as the viewport crosses
// a breakpoint.
export function useBreakpoint(): Breakpoint {
@@ -35,16 +29,3 @@ export function useBreakpoint(): Breakpoint {
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
)
}
// The viewport's orientation, updated as the device turns.
export function useOrientation(): Orientation {
const [list] = useState(() => window.matchMedia(PORTRAIT_QUERY))
const subscribe = useCallback(
(onChange: () => void) => {
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
},
[list],
)
return useSyncExternalStore(subscribe, () => (list.matches ? 'portrait' : 'landscape'))
}
+7 -14
View File
@@ -3,22 +3,16 @@ import { act } from '@testing-library/react'
type Listener = (event: MediaQueryListEvent) => void
// Replaces window.matchMedia with one that answers min-width / max-width
// queries for a pretend viewport width, orientation: portrait for its height
// (a viewport given no height is landscape), and prefers-reduced-motion:
// reduce with reducedMotion (jsdom has no matchMedia and no layout). Returns
// a function that resizes the pretend viewport and notifies every query
// whose answer changed.
export function mockViewport(
initialWidth: number,
{ reducedMotion = false, height: initialHeight = 0 } = {},
): (width: number, height?: number) => void {
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
// function that resizes the pretend viewport and notifies every query whose
// answer changed.
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
let width = initialWidth
let height = initialHeight
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
function evaluate(query: string): boolean {
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
if (query === '(orientation: portrait)') return height >= width
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
@@ -43,10 +37,9 @@ export function mockViewport(
return list
}
return (nextWidth: number, nextHeight = 0) => {
return (next: number) => {
const before = lists.map(({ list }) => list.matches)
width = nextWidth
height = nextHeight
width = next
act(() => {
lists.forEach(({ list, listeners }, i) => {
if (list.matches === before[i]) return