diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css
index d88d3e6..cff901a 100644
--- a/web/src/dinners/DinnerDetailPage.module.css
+++ b/web/src/dinners/DinnerDetailPage.module.css
@@ -1,8 +1,9 @@
-/* Mock 1k. On mobile the photo and the sheet run full-bleed past the
- shell's 20 px side padding; from tablet up they sit in one column. The
- page is at least as tall as the shell's scroll area (a stretched flex
- item, so its height resolves the percentage) and the sheet grows into
- any room left, so the action bar rests at the bottom of a short recipe. */
+/* Mock 1k on mobile: the photo and the sheet run full-bleed past the
+ shell's 20 px side padding. From tablet up the page is Kitchen reading
+ mode (mock 2a, below). The page is at least as tall as the shell's scroll
+ area (a stretched flex item, so its height resolves the percentage) and
+ the sheet, or the reading view, grows into any room left, so the action
+ bar rests at the bottom of a short recipe. */
.page {
display: flex;
flex-direction: column;
@@ -11,9 +12,11 @@
color: var(--color-text);
}
+/* Kitchen reading mode is no wider than the handoff's tablet frame. */
.tablet,
.desktop {
- max-width: 720px;
+ width: 100%;
+ max-width: 1180px;
margin: 0 auto;
}
@@ -27,12 +30,6 @@
margin: 0 -20px;
}
-.tablet .hero,
-.desktop .hero {
- border-radius: var(--radius-card) var(--radius-card) 0 0;
- overflow: hidden;
-}
-
/* Without a dinner to show, the back button sits alone above the message. */
.backRow {
padding-top: calc(16px + env(safe-area-inset-top));
@@ -249,3 +246,295 @@
height: 32px;
width: 70%;
}
+
+/* Mock 2a, Kitchen reading mode: the header over two columns, the
+ ingredients card (400 px) and the steps. Ingredient and step text scale
+ with the text size chosen: the handoff's sizes are medium. */
+.reading {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+}
+
+.small {
+ --reading-scale: 0.85;
+}
+
+.medium {
+ --reading-scale: 1;
+}
+
+.large {
+ --reading-scale: 1.25;
+}
+
+.readingHeader {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 18px;
+}
+
+.readingHeader .back {
+ position: static;
+ flex: none;
+}
+
+.heading {
+ flex: 1 1 240px;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.kicker {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--color-neutral-700);
+}
+
+.readingTitle {
+ margin: 0;
+ font-family: var(--font-heading);
+ font-size: 36px;
+ font-weight: 400;
+ line-height: 1.05;
+}
+
+.keepOn {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+/* The A / A / A control: an outlined pill, the size chosen in grape, each
+ A drawn at the size it stands for. */
+.readingHeader .textSize {
+ gap: 0;
+ padding: 0;
+ border: 1px solid var(--color-divider);
+ background: transparent;
+ overflow: hidden;
+}
+
+.readingHeader .textSize > * {
+ padding: 0 14px;
+ border-radius: 0;
+ color: var(--color-text);
+}
+
+.readingHeader .textSize > [aria-checked="true"] {
+ background: var(--color-accent);
+ color: var(--on-accent);
+ box-shadow: none;
+}
+
+.readingHeader .textSize > :nth-child(1) {
+ font-size: 13px;
+}
+
+.readingHeader .textSize > :nth-child(2) {
+ font-size: 17px;
+}
+
+.readingHeader .textSize > :nth-child(3) {
+ font-size: 21px;
+}
+
+.columns {
+ flex-grow: 1;
+ display: grid;
+ grid-template-columns: 400px minmax(0, 1fr);
+ gap: 28px;
+}
+
+/* Below a 1024 px viewport the shell leaves too little beside a 400 px
+ card for the steps, so the columns share the width instead. */
+@media (max-width: 1023px) {
+ .columns {
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
+ }
+}
+
+.oneColumn {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.columns .ingredientsCard {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 22px;
+ border-radius: 30px;
+}
+
+.cardHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ margin-bottom: 6px;
+}
+
+.readingSectionTitle {
+ margin: 0;
+ font-family: var(--font-heading);
+ font-size: 22px;
+ font-weight: 400;
+ line-height: 1.15;
+}
+
+.setOutCount {
+ flex: none;
+ white-space: nowrap;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--color-neutral-700);
+}
+
+.checklist {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.checkRow {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 4px 0;
+ border-bottom: 1px solid var(--color-divider);
+}
+
+.checkRow .check {
+ flex: 1;
+ min-width: 0;
+ gap: 14px;
+}
+
+.checkName {
+ font-size: calc(18px * var(--reading-scale));
+ font-weight: 400;
+ line-height: 1.3;
+}
+
+.setOut {
+ color: var(--color-neutral-600);
+ text-decoration: line-through;
+}
+
+.checkQty {
+ flex: none;
+ text-align: right;
+ font-size: calc(15px * var(--reading-scale));
+ color: var(--color-neutral-700);
+}
+
+.method {
+ flex-grow: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.intro {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.introPhoto {
+ flex: 1;
+ min-width: 0;
+ height: 96px;
+}
+
+.introWhy {
+ flex: 1.6;
+ padding: 14px 18px;
+ border-radius: 22px;
+ font-size: 14px;
+}
+
+.readingSteps {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ margin-top: 4px;
+}
+
+.stepList {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.readingStep {
+ display: flex;
+ align-items: flex-start;
+ gap: 16px;
+ padding: 6px 18px;
+ border-radius: 26px;
+ font-size: calc(18px * var(--reading-scale));
+ line-height: 1.45;
+}
+
+.stepCircle {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-pill);
+ background: var(--color-surface);
+ font-family: var(--font-heading);
+ font-size: 18px;
+}
+
+.stepText {
+ padding-top: 6px;
+}
+
+.stepDone {
+ color: var(--color-neutral-600);
+}
+
+.stepCurrent {
+ padding: 18px;
+ background: var(--color-accent-100);
+ box-shadow: inset 0 0 0 2px var(--color-accent-300);
+ font-size: calc(22px * var(--reading-scale));
+}
+
+.stepCurrent .stepCircle {
+ background: var(--color-accent);
+ color: var(--on-accent);
+}
+
+/* The action bar rests at the bottom of the steps column, with Previous
+ and Next step at its right. */
+.reading .bar {
+ margin-top: auto;
+}
+
+.reading .add {
+ flex: none;
+}
+
+.stepButtons {
+ display: flex;
+ gap: 10px;
+ margin-left: auto;
+}
diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx
index 140236c..ab21eb0 100644
--- a/web/src/dinners/DinnerDetailPage.tsx
+++ b/web/src/dinners/DinnerDetailPage.tsx
@@ -1,25 +1,32 @@
import { useQueryClient } from '@tanstack/react-query'
-import { ChevronLeft, Heart } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react'
+import { useCallback, useEffect, useId, useState, type ReactNode } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router'
import { ApiError } from '../api/errors.ts'
import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
import { duration } from '../plan/PlanPage.tsx'
-import { weekdayName } from '../plan/week.ts'
+import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts'
+import { useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
+import { Card } from '../ui/Card.tsx'
+import { Checkbox } from '../ui/Checkbox.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Photo } from '../ui/Photo.tsx'
+import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
+import { Toggle } from '../ui/Toggle.tsx'
import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx'
import { useToggleFavorite, type Difficulty } from './api.ts'
import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts'
import styles from './DinnerDetailPage.module.css'
+import { useKeepScreenOn } from './keepScreenOn.ts'
+import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts'
// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed.
export const REGULAR_DINNERS = '/settings/regulars'
@@ -77,9 +84,15 @@ function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
)
}
-// Recipe is the dinner's ingredients and steps, or what stands in for them:
-// still searching, or the link to fix a recipe that could not be found.
-function Recipe({ dinner }: { dinner: DinnerDetail }) {
+// hasRecipe says whether the dinner's recipe was found or typed in, rather
+// than still being searched for or not found.
+function hasRecipe(dinner: DinnerDetail): boolean {
+ return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed'
+}
+
+// RecipeMissing is what stands in for a recipe still being searched for, or
+// the link to fix one that could not be found.
+function RecipeMissing({ dinner }: { dinner: DinnerDetail }) {
if (dinner.grounding_status === 'failed') {
return (
@@ -90,9 +103,12 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
)
}
- if (dinner.grounding_status === 'pending') {
- return
Still finding the recipe — its ingredients and steps will show here.
- }
+ return
Still finding the recipe — its ingredients and steps will show here.
+}
+
+// Recipe is the dinner's ingredients and steps, or what stands in for them.
+function Recipe({ dinner }: { dinner: DinnerDetail }) {
+ if (!hasRecipe(dinner)) return
return (
<>
{dinner.ingredients.length > 0 && (
@@ -206,12 +222,14 @@ function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) {
// ActionBar is the bar at the bottom of the screen, the page's last child:
// the page fills the scroll area so a short recipe still leaves it at the
// bottom, and it sticks there over a long one. It holds Add to this week
-// and the favorite heart, with what became of either above them.
-function ActionBar({ dinner }: { dinner: DinnerDetail }) {
+// and the favorite heart, with what became of either above them, and in
+// Kitchen reading mode the step buttons after them.
+function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) {
return (
+ {children}
)
}
@@ -251,9 +269,253 @@ function Detail({ dinner }: { dinner: DinnerDetail }) {
)
}
-// DinnerDetailPage is a dinner with its recipe (mock 1k): its photo, or the
-// striped placeholder, under a sheet with Regular or Side quest, difficulty
-// and time, why it was picked, the ingredients and the steps. A dinner whose
+// statusName is the dinner as the household calls it: a Regular, or a Side
+// quest for any dinner not yet one.
+function statusName(dinner: DinnerDetail): string {
+ return dinner.status === 'regular' ? 'Regular' : 'Side quest'
+}
+
+// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side
+// quest", whenever this week's or next week's plan has it. Of the days that
+// do, it is the first from today on in the household's timezone; when every
+// one is already past, it is the latest of them. There is none while the
+// plans are read, nor when neither plan has the dinner.
+function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) {
+ const today = localNow(timezone, new Date()).date
+ const thisWeek = weekStart(today)
+ const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))]
+ const failed = plans.find((plan) => plan.isError)
+ if (failed !== undefined) {
+ return (
+ plans.forEach((plan) => plan.isError && void plan.refetch())}>
+ Couldn't check the plan for this dinner's day. {failed.error.message}
+
+ )
+ }
+ const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id)
+ const day = planned.find((day) => day.date >= today) ?? planned.at(-1)
+ if (day === undefined) return null
+ return (
+
+ {dayAndDate(day.date)} · {statusName(dinner)}
+
+ )
+}
+
+// Kicker is PlannedDay, once the household's timezone is read.
+function Kicker({ dinner }: { dinner: DinnerDetail }) {
+ const settings = useHouseholdSettings()
+ if (settings.isError) {
+ return (
+ void settings.refetch()}>
+ Couldn't load the household settings to find this dinner's day. {settings.error.message}
+
+ )
+ }
+ if (settings.isPending) return null
+ return
+}
+
+const textSizes: SegmentedOption[] = [
+ { value: 'small', label: 'A', ariaLabel: 'Small text' },
+ { value: 'medium', label: 'A', ariaLabel: 'Medium text' },
+ { value: 'large', label: 'A', ariaLabel: 'Large text' },
+]
+
+// ReadingHeader is Kitchen reading mode's header: back, the day the dinner
+// is planned for, its title, Keep screen on and the text size.
+function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType }) {
+ const keepOnId = useId()
+ const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
+ const { failure } = textSize
+ return (
+ <>
+
+
+
+
+
{dinner.name}
+
+
+ Keep screen on
+
+
+
+
+ {failure !== null && (
+
+ {textSizeFailureText[failure.action]}: {failure.message}
+
+ )}
+ >
+ )
+}
+
+// IngredientsCard is the ingredients with a checkbox each, struck through
+// once set out, and how many are. Which are set out is this visit's alone:
+// it is never sent to the server and is not the Supply run's "have it".
+function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) {
+ const titleId = useId()
+ const [setOut, setSetOut] = useState>(() => new Set())
+ const count = ingredients.filter((_, i) => setOut.has(i)).length
+ function toggle(i: number) {
+ setSetOut((before) => {
+ const after = new Set(before)
+ if (!after.delete(i)) after.add(i)
+ return after
+ })
+ }
+ return (
+
+
+
+ )
+}
+
+// Steps is the recipe's steps: those before current done, muted with a
+// check; current highlighted in larger text; the rest still to come.
+function Steps({ steps, current }: { steps: string[]; current: number }) {
+ const titleId = useId()
+ return (
+
+
+ Steps
+
+
+ {steps.map((step, i) => (
+
+
+ {i < current ? : i + 1}
+
+ {step}
+
+ ))}
+
+
+ )
+}
+
+// typing says whether target is a field taking text, where the arrow keys
+// move the caret and are not the screen's to take.
+function typing(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false
+ return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])')
+}
+
+// useCurrentStep is the step being cooked, of count, from the first, with
+// move, which goes back or on by a step and stops at either end. The ← and →
+// keys move it too, unless a text field has focus, a sheet is open over the
+// screen or the key was already taken (a segmented control's arrows).
+function useCurrentStep(count: number): [number, (by: number) => void] {
+ const [step, setStep] = useState(0)
+ const last = count - 1
+ const move = useCallback(
+ (by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))),
+ [last],
+ )
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent) {
+ const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
+ if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
+ if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return
+ event.preventDefault()
+ move(by)
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [move])
+ return [Math.max(0, Math.min(step, last)), move]
+}
+
+// KitchenReading is a dinner as mock 2a shows it on a tablet: the header,
+// then the ingredients to check off beside the photo, why it was picked and
+// the steps, one at a time, over the action bar with Previous and Next step.
+// What is checked and the step reached last only as long as this visit.
+function KitchenReading({ dinner }: { dinner: DinnerDetail }) {
+ const textSize = useTextSize()
+ const [current, move] = useCurrentStep(dinner.steps.length)
+ const recipe = hasRecipe(dinner)
+ const last = dinner.steps.length - 1
+ return (
+
+ )
+}
+
+// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its
+// photo, or the striped placeholder, under a sheet with Regular or Side
+// quest, difficulty and time, why it was picked, the ingredients and the
+// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose
// recipe could not be found links to Settings → Regular dinners to fix it;
// an unknown or removed dinner says it is not found.
export function DinnerDetailPage() {
@@ -292,7 +554,10 @@ export function DinnerDetailPage() {
)
} else {
- body =
+ // Keyed by the dinner, so moving to another dinner starts its visit
+ // afresh: nothing checked, on the first step.
+ body =
+ layout === 'mobile' ? :
}
return
{body}
}
diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx
new file mode 100644
index 0000000..4d53796
--- /dev/null
+++ b/web/src/dinners/KitchenReading.test.tsx
@@ -0,0 +1,382 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { createMemoryRouter, RouterProvider } from 'react-router'
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
+import { declaredStyle } from '../test/cssRules.ts'
+import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
+import { mockViewport } from '../test/matchMedia.ts'
+import { DinnerDetailPage } from './DinnerDetailPage.tsx'
+import { TEXT_SIZE_KEY } from './textSize.ts'
+
+const DINNER = '/api/v1/dinners/d1'
+const SETTINGS = '/api/v1/settings/household'
+const THIS_WEEK = '/api/v1/plans/2026-10-05'
+const NEXT_WEEK = '/api/v1/plans/2026-10-12'
+
+// detail is the Korean beef bowls, a Quest-log Side quest, as GET
+// /api/v1/dinners/{id} sends it.
+function detail(overrides: Record = {}) {
+ return {
+ id: 'd1',
+ name: 'Korean beef bowls',
+ status: 'quest_log',
+ origin: 'suggested',
+ 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' },
+ { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
+ { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
+ ],
+ steps: [
+ 'Rinse the rice and start it cooking.',
+ 'Brown the beef with the garlic and ginger.',
+ 'Stir in the sauce and simmer until glossy.',
+ ],
+ source: null,
+ grounding_status: 'grounded',
+ grounding_error: null,
+ why_picked: 'close cousin of your Beef tacos.',
+ ...overrides,
+ }
+}
+
+// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
+// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
+function planBody(weekStart: string, ...bowlsOn: number[]) {
+ const start = new Date(`${weekStart}T00:00:00Z`).getTime()
+ return {
+ id: `plan-${weekStart}`,
+ week_start: weekStart,
+ status: 'draft',
+ ai_note: null,
+ calendar_synced_at: null,
+ days: Array.from({ length: 7 }, (_, i) => ({
+ date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
+ dinner:
+ bowlsOn.includes(i)
+ ? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
+ : { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
+ is_new: bowlsOn.includes(i),
+ start_by: null,
+ })),
+ }
+}
+
+// serve answers the screen's reads: the dinner, the household settings and
+// both weeks' plans, unless responses says otherwise.
+function serve(responses: Record Response> = {}): Mock {
+ const all: Record Response> = {
+ [DINNER]: () => jsonResponse(200, detail()),
+ [SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
+ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
+ [NEXT_WEEK]: () => errorResponse(404, 'not_found'),
+ ...responses,
+ }
+ const fetchMock = vi.fn((input, init) => {
+ const path = String(input)
+ const respond = Object.hasOwn(all, path) ? all[path] : undefined
+ if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
+ return Promise.resolve(respond())
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ return fetchMock
+}
+
+function reads(fetchMock: Mock, path: string) {
+ return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
+}
+
+// renderPage renders the bowls' detail screen and waits for its title.
+async function renderPage() {
+ const router = createMemoryRouter([{ path: '/dinner/:id', element: }], {
+ initialEntries: ['/dinner/d1'],
+ })
+ const view = render(
+
+
+ ,
+ )
+ await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
+ return view
+}
+
+function steps() {
+ return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
+}
+
+// currentStep is the text of the step marked current.
+function currentStep() {
+ const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
+ expect(current).toHaveLength(1)
+ return current[0]!.textContent
+}
+
+beforeEach(() => {
+ mockViewport(1180)
+ // Wednesday Oct 7, noon in New York.
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ Reflect.deleteProperty(window, 'matchMedia')
+ vi.unstubAllGlobals()
+})
+
+describe('Kitchen reading mode', () => {
+ it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
+ serve()
+ await renderPage()
+
+ const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
+ expect(declaredStyle(title).fontSize).toBe('36px')
+ expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
+ // Not the phone's sheet: no photo hero tags.
+ expect(screen.queryByText('Easy')).not.toBeInTheDocument()
+
+ const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
+ expect(keepOn).toHaveAttribute('aria-checked', 'false')
+ fireEvent.click(keepOn)
+ expect(keepOn).toHaveAttribute('aria-checked', 'true')
+
+ const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
+ expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
+ 'Small text',
+ 'Medium text',
+ 'Large text',
+ ])
+ expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
+
+ expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
+ '400px minmax(0, 1fr)',
+ )
+ expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
+ })
+
+ describe('kicker', () => {
+ it('names the day the plan has the dinner on, and Side quest', async () => {
+ serve()
+ await renderPage()
+ expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
+ })
+
+ it("names a day of next week's plan, and Regular, for a Regular", async () => {
+ serve({
+ [DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
+ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
+ [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
+ })
+ await renderPage()
+ expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
+ })
+
+ it("names a day of this week's plan already past when that is the only day", async () => {
+ // Today is Wednesday; the bowls were on Monday, and are on no day of next week.
+ serve({
+ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
+ [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
+ })
+ await renderPage()
+ expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
+ })
+
+ it('names the first day from today on over a day already past', async () => {
+ serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
+ await renderPage()
+ expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
+ expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
+ })
+
+ it('names the latest day already past when every day is past', async () => {
+ serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
+ await renderPage()
+ expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
+ expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
+ })
+
+ it('is absent when neither week has a plan', async () => {
+ const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
+ await renderPage()
+ await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
+ await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
+ await act(async () => {})
+ expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
+ expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
+ })
+
+ it('says so when the plan cannot be read, with Retry', async () => {
+ let failing = true
+ serve({
+ [THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
+ })
+ await renderPage()
+ expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
+
+ failing = false
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
+ })
+ })
+
+ describe('ingredients', () => {
+ it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
+ const fetchMock = serve()
+ await renderPage()
+ const card = screen.getByRole('region', { name: 'Ingredients' })
+ expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
+
+ const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
+ expect(declaredStyle(beef).width).toBe('22px')
+ expect(declaredStyle(beef).height).toBe('22px')
+ expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
+
+ fireEvent.click(beef)
+ expect(beef).toBeChecked()
+ expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
+ fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
+ expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
+
+ fireEvent.click(beef)
+ expect(beef).not.toBeChecked()
+ expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
+ expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
+
+ // Local to the screen: nothing is sent.
+ expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
+ })
+
+ it('starts afresh on the next visit', async () => {
+ serve()
+ const view = await renderPage()
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
+ view.unmount()
+
+ await renderPage()
+ expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
+ expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
+ })
+ })
+
+ describe('steps', () => {
+ it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
+ serve()
+ await renderPage()
+ const previous = screen.getByRole('button', { name: 'Previous' })
+ const next = screen.getByRole('button', { name: 'Next step' })
+
+ expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
+ expect(previous).toBeDisabled()
+ expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
+ expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
+ expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
+ expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
+
+ fireEvent.click(next)
+ expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
+ // 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-600)')
+ expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
+
+ fireEvent.click(next)
+ expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
+ expect(next).toBeDisabled()
+ fireEvent.click(next)
+ expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
+
+ fireEvent.click(previous)
+ expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
+ })
+
+ it('moves by the ← and → keys, stopping at either end', async () => {
+ serve()
+ await renderPage()
+
+ fireEvent.keyDown(document.body, { key: 'ArrowRight' })
+ expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
+ fireEvent.keyDown(document.body, { key: 'ArrowRight' })
+ fireEvent.keyDown(document.body, { key: 'ArrowRight' })
+ expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
+ fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
+ expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
+ fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
+ fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
+ expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
+
+ // From a checked ingredient too.
+ const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
+ beef.focus()
+ fireEvent.keyDown(beef, { key: 'ArrowRight' })
+ expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
+ })
+
+ it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
+ serve()
+ await renderPage()
+ const field = document.createElement('input')
+ document.body.append(field)
+ field.focus()
+ fireEvent.keyDown(field, { key: 'ArrowRight' })
+ expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
+ field.remove()
+
+ fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
+ expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
+
+ const medium = screen.getByRole('radio', { name: 'Medium text' })
+ fireEvent.keyDown(medium, { key: 'ArrowRight' })
+ expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
+ expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
+ })
+ })
+
+ describe('text size', () => {
+ it('is remembered on this device', async () => {
+ serve()
+ const view = await renderPage()
+ fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
+ expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
+ expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
+ view.unmount()
+
+ await renderPage()
+ expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
+ expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
+ expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
+ '1.25',
+ )
+ })
+
+ it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
+ localStorage.setItem(TEXT_SIZE_KEY, 'huge')
+ serve()
+ await renderPage()
+ expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
+ expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
+
+ fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
+ expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
+ expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
+ })
+ })
+
+ it('shows what stands in for a recipe not found, with no step buttons', async () => {
+ serve({
+ [DINNER]: () =>
+ jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
+ })
+ await renderPage()
+ expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
+ expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
+ })
+})
diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts
new file mode 100644
index 0000000..a221399
--- /dev/null
+++ b/web/src/dinners/keepScreenOn.ts
@@ -0,0 +1,10 @@
+import { useState } from 'react'
+
+// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it
+// is on, and a setter. It starts off on each visit.
+//
+// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only
+// the switch's state; nothing keeps the screen awake yet.
+export function useKeepScreenOn(): [boolean, (on: boolean) => void] {
+ return useState(false)
+}
diff --git a/web/src/dinners/textSize.ts b/web/src/dinners/textSize.ts
new file mode 100644
index 0000000..0875785
--- /dev/null
+++ b/web/src/dinners/textSize.ts
@@ -0,0 +1,63 @@
+import { useState } from 'react'
+
+// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text
+// size chosen in Kitchen reading mode.
+export const TEXT_SIZE_KEY = 'mealquest.readingTextSize'
+
+export const TEXT_SIZES = ['small', 'medium', 'large'] as const
+
+export type TextSize = (typeof TEXT_SIZES)[number]
+
+// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the
+// handoff's 18 px ingredients and steps.
+export const DEFAULT_TEXT_SIZE: TextSize = 'medium'
+
+// TextSizeState is the text size, and the storage failure, if any, that
+// stopped us reading or saving it.
+type TextSizeState = {
+ size: TextSize
+ failure: { action: 'read' | 'write'; message: string } | null
+}
+
+function isTextSize(value: string): value is TextSize {
+ return (TEXT_SIZES as readonly string[]).includes(value)
+}
+
+// Storage can be unavailable (blocked by the browser or privacy settings), in
+// which case using it throws, and can hold a value MealQuest did not save.
+// Either is reported, never treated as a remembered size.
+function readTextSize(): TextSizeState {
+ try {
+ const saved = window.localStorage.getItem(TEXT_SIZE_KEY)
+ if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null }
+ if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`)
+ return { size: saved, failure: null }
+ } catch (err) {
+ return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } }
+ }
+}
+
+function writeTextSize(size: TextSize): TextSizeState {
+ try {
+ window.localStorage.setItem(TEXT_SIZE_KEY, size)
+ return { size, failure: null }
+ } catch (err) {
+ return { size, failure: { action: 'write', message: String(err) } }
+ }
+}
+
+export const textSizeFailureText = {
+ read: 'Could not read the text size chosen on this device',
+ write: 'Could not remember the text size on this device',
+}
+
+// useTextSize is Kitchen reading mode's text size on this device: the size,
+// the storage failure if any, choose, which saves a size, and retry, which
+// reads or saves again whatever failed. A size that could not be saved is
+// still used until the screen is left.
+export function useTextSize() {
+ const [state, setState] = useState(readTextSize)
+ const choose = (size: TextSize) => setState(writeTextSize(size))
+ const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
+ return { ...state, choose, retry }
+}
diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts
index f56af4e..d3ad06d 100644
--- a/web/src/plan/week.test.ts
+++ b/web/src/plan/week.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
+import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
@@ -36,6 +36,11 @@ describe('week helpers', () => {
expect(weekdayName('2026-10-11')).toBe('Sunday')
})
+ it('names the day of a date in full with its month and day', () => {
+ expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
+ expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
+ })
+
it('reads the day of the month of a date', () => {
expect(dayOfMonth('2026-10-05')).toBe(5)
expect(dayOfMonth('2026-10-31')).toBe(31)
diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts
index 41c2552..b2c82d0 100644
--- a/web/src/plan/week.ts
+++ b/web/src/plan/week.ts
@@ -52,6 +52,13 @@ export function weekdayName(date: string): string {
return weekday.format(midnightUTC(date))
}
+const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
+
+// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
+export function dayAndDate(date: string): string {
+ return longDay.format(midnightUTC(date))
+}
+
// monday reads start as midnight UTC, or throws when it is not a Monday.
function monday(start: string): Date {
const t = midnightUTC(start)
diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx
index c107a1e..99c04c8 100644
--- a/web/src/ui/Segmented.test.tsx
+++ b/web/src/ui/Segmented.test.tsx
@@ -83,6 +83,16 @@ describe('Segmented', () => {
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
})
+ it('names an option by its ariaLabel when it has one', () => {
+ const sizes = [
+ { value: 'small', label: 'A', ariaLabel: 'Small text' },
+ { value: 'large', label: 'A', ariaLabel: 'Large text' },
+ ] as const
+ render( {}} />)
+ expect(radio('Small text')).toHaveTextContent('A')
+ expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
+ })
+
it('fails fast when the value is not one of the options', () => {
expect(() =>
render( {}} />),
diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx
index bd53b27..6e943b2 100644
--- a/web/src/ui/Segmented.tsx
+++ b/web/src/ui/Segmented.tsx
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
export type SegmentedOption = {
value: T
label: string
+ // Accessible name, when the visible label alone does not say what the
+ // option is (the A / A / A of a text size).
+ ariaLabel?: string
}
export type SegmentedProps = {
@@ -63,6 +66,7 @@ export function Segmented({ label, options, value, onValueChan
type="button"
role="radio"
aria-checked={checked}
+ aria-label={option.ariaLabel}
tabIndex={checked ? 0 : -1}
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
onClick={() => select(index)}