From 28e9c24fc786df58511d9547ee5d8171097ceec3 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 07:42:48 -0400 Subject: [PATCH] Tablet kitchen reading mode (2a) (Feature 15) (#149) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/dinners/DinnerDetailPage.module.css | 313 +++++++++++++++- web/src/dinners/DinnerDetailPage.tsx | 295 ++++++++++++++- web/src/dinners/KitchenReading.test.tsx | 382 ++++++++++++++++++++ web/src/dinners/keepScreenOn.ts | 10 + web/src/dinners/textSize.ts | 63 ++++ web/src/plan/week.test.ts | 7 +- web/src/plan/week.ts | 7 + web/src/ui/Segmented.test.tsx | 10 + web/src/ui/Segmented.tsx | 4 + 9 files changed, 1063 insertions(+), 28 deletions(-) create mode 100644 web/src/dinners/KitchenReading.test.tsx create mode 100644 web/src/dinners/keepScreenOn.ts create mode 100644 web/src/dinners/textSize.ts 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 ( + +
+

+ Ingredients +

+ + {count} of {ingredients.length} set out + +
+
    + {ingredients.map((ingredient, i) => ( +
  • + toggle(i)} + label={ + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`} + + } + /> + {quantity(ingredient)} +
  • + ))} +
+
+ ) +} + +// 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) => ( +
  1. + + {step} +
  2. + ))} +
+
+ ) +} + +// 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 ( +
+ +
0 ? styles.columns : styles.oneColumn}> + {recipe && dinner.ingredients.length > 0 && } +
+
+ + {dinner.why_picked !== null && ( +

+ Why it was picked: {dinner.why_picked} +

+ )} +
+ {!recipe && } + {recipe && dinner.steps.length > 0 && } + {recipe && dinner.source !== null && dinner.source.siteName !== null && ( + + )} + + {recipe && dinner.steps.length > 0 && ( +
+ + +
+ )} +
+
+
+
+ ) +} + +// 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)}