Tablet kitchen reading mode (2a) (Feature 15) #149

9 changed files with 1063 additions and 28 deletions
+301 -12
View File
@@ -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;
}
+280 -15
View File
@@ -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 (
<section className={styles.missing} aria-label="No recipe">
@@ -90,9 +103,12 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
</section>
)
}
if (dinner.grounding_status === 'pending') {
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
}
// Recipe is the dinner's ingredients and steps, or what stands in for them.
function Recipe({ dinner }: { dinner: DinnerDetail }) {
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
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 (
<div className={styles.bar}>
<AddToWeek dinner={dinner} />
<FavoriteHeart dinner={dinner} />
{children}
</div>
)
}
@@ -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 (
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
Couldn't check the plan for this dinner's day. {failed.error.message}
</ErrorBanner>
)
}
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 (
<p className={styles.kicker}>
{dayAndDate(day.date)} · {statusName(dinner)}
</p>
)
}
// Kicker is PlannedDay, once the household's timezone is read.
function Kicker({ dinner }: { dinner: DinnerDetail }) {
const settings = useHouseholdSettings()
if (settings.isError) {
return (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings to find this dinner's day. {settings.error.message}
</ErrorBanner>
)
}
if (settings.isPending) return null
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
}
const textSizes: SegmentedOption<TextSize>[] = [
{ 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<typeof useTextSize> }) {
const keepOnId = useId()
const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
const { failure } = textSize
return (
<>
<header className={styles.readingHeader}>
<BackButton />
<div className={styles.heading}>
<Kicker dinner={dinner} />
<h1 className={styles.readingTitle}>{dinner.name}</h1>
</div>
<div className={styles.keepOn}>
<span id={keepOnId}>Keep screen on</span>
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
</div>
<Segmented
label="Text size"
options={textSizes}
value={textSize.size}
onValueChange={textSize.choose}
className={styles.textSize}
/>
</header>
{failure !== null && (
<ErrorBanner onRetry={textSize.retry}>
{textSizeFailureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}
// 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<ReadonlySet<number>>(() => 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 (
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
<div className={styles.cardHead}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Ingredients
</h2>
<span className={styles.setOutCount}>
{count} of {ingredients.length} set out
</span>
</div>
<ul className={styles.checklist}>
{ingredients.map((ingredient, i) => (
<li key={i} className={styles.checkRow}>
<Checkbox
className={styles.check}
checked={setOut.has(i)}
onChange={() => toggle(i)}
label={
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
{ingredient.name ?? ingredient.raw_text}
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
</span>
}
/>
<span className={styles.checkQty}>{quantity(ingredient)}</span>
</li>
))}
</ul>
</Card>
)
}
// 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 (
<section aria-labelledby={titleId} className={styles.readingSteps}>
<h2 id={titleId} className={styles.readingSectionTitle}>
Steps
</h2>
<ol className={styles.stepList}>
{steps.map((step, i) => (
<li
key={i}
aria-current={i === current ? 'step' : undefined}
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
.filter(Boolean)
.join(' ')}
>
<span className={styles.stepCircle} aria-hidden="true">
{i < current ? <Icon icon={Check} /> : i + 1}
</span>
<span className={styles.stepText}>{step}</span>
</li>
))}
</ol>
</section>
)
}
// 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 (
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
<ReadingHeader dinner={dinner} textSize={textSize} />
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
<div className={styles.method}>
<div className={styles.intro}>
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
{dinner.why_picked !== null && (
<p className={[styles.why, styles.introWhy].join(' ')}>
<b>Why it was picked:</b> {dinner.why_picked}
</p>
)}
</div>
{!recipe && <RecipeMissing dinner={dinner} />}
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
{recipe && dinner.source !== null && dinner.source.siteName !== null && (
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
)}
<ActionBar dinner={dinner}>
{recipe && dinner.steps.length > 0 && (
<div className={styles.stepButtons}>
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
<Icon icon={ChevronLeft} />
Previous
</Button>
<Button disabled={current === last} onClick={() => move(1)}>
<Icon icon={ChevronRight} />
Next step
</Button>
</div>
)}
</ActionBar>
</div>
</div>
</div>
)
}
// 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() {
</section>
)
} else {
body = <Detail dinner={dinner.data} />
// Keyed by the dinner, so moving to another dinner starts its visit
// afresh: nothing checked, on the first step.
body =
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
}
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
}
+382
View File
@@ -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<string, unknown> = {}) {
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<string, () => Response> = {}): Mock<typeof fetch> {
const all: Record<string, () => 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<typeof fetch>((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<typeof fetch>, 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: <DinnerDetailPage /> }], {
initialEntries: ['/dinner/d1'],
})
const view = render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
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()
})
})
+10
View File
@@ -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)
}
+63
View File
@@ -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<TextSizeState>(readTextSize)
const choose = (size: TextSize) => setState(writeTextSize(size))
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
return { ...state, choose, retry }
}
+6 -1
View File
@@ -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)
+7
View File
@@ -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)
+10
View File
@@ -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(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
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(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
+4
View File
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
export type SegmentedOption<T extends string> = {
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<T extends string> = {
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ 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)}