Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 f5a52d5e6d Desktop and tablet settings layout (mock 2e)
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 1m59s
From 768 px /settings shows mock 2e: the title, "Changes apply to everyone
in the household." and sub-tabs Household, Regular dinners and
Notifications & AI. Below 768 px the 1p list navigation is unchanged.

- Notifications & AI: two columns. The left holds a neutral placeholder
  for the Notifications matrix and Google Chat webhook cards (Feature 19);
  the right holds the AI assistant card, the same form as /settings/ai with
  the system prompt growing to fill, and the Recipe search card.
- Household embeds the /settings/household form.
- Regular dinners is a placeholder until Feature 12 builds that screen.

Tabs stay mounted while hidden so drafts survive switching, and one leave
guard covers all of them. The AI and Household forms are split from their
mobile page headers so both layouts render the same components.

Maestro task: b0696e7d-63d5-42bb-9c09-19d1995a55cf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:33:38 -04:00
14 changed files with 25 additions and 189 deletions
@@ -304,10 +304,3 @@
.fill .textarea {
resize: none;
}
/* A form locked while its save or prompt reset is pending, so no edit is
made that the response would then replace. It lays out as if it were not
there. */
.lock {
display: contents;
}
-80
View File
@@ -407,63 +407,6 @@ describe('AISettingsPage', () => {
expect(unload()).toBe(true)
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(prompt()).toBeEnabled()
expect(prompt()).toHaveValue('Keep it cozy.')
await waitFor(() => expect(unload()).toBe(false))
})
it('locks the form while a prompt reset is pending, even with the sheet closed', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'POST /api/v1/settings/ai/reset-prompt': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(slider(), { target: { value: '0.2' } })
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
const confirm = screen.getByRole('dialog', { name: 'Reset the system prompt?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Reset prompt' }))
expect(await within(confirm).findByRole('button', { name: 'Resetting…' })).toBeDisabled()
fireEvent.click(within(confirm).getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(save()).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: DEFAULT_PROMPT }))
await waitFor(() => expect(prompt()).toBeEnabled())
expect(prompt()).toHaveValue(DEFAULT_PROMPT)
expect(slider()).toHaveValue('0.2')
expect(unload()).toBe(true)
fireEvent.change(prompt(), { target: { value: 'After the reset.' } })
expect(prompt()).toHaveValue('After the reset.')
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
@@ -737,29 +680,6 @@ describe('Recipe search card', () => {
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the card while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }))
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(url()).toBeEnabled()
expect(url()).toHaveValue('https://search.example')
await waitFor(() => expect(unload()).toBe(false))
})
it('keeps 44 px tap targets', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
+2 -2
View File
@@ -201,7 +201,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
}
return (
<fieldset className={styles.lock} disabled={save.isPending || resetPrompt.isPending}>
<>
<div className={styles.columns}>
<Select
label="Provider"
@@ -325,7 +325,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
</fieldset>
</>
)
}
@@ -229,9 +229,3 @@
.check {
flex: none;
}
/* A form locked while its save is pending, so no edit is made that the
saved settings would then replace. It lays out as if it were not there. */
.lock {
display: contents;
}
@@ -1,5 +1,5 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
@@ -293,34 +293,6 @@ describe('HouseholdSettingsPage', () => {
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'PATCH /api/v1/settings/household': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of ['Dinner time', 'Day-of reminder', 'Plan draft day', 'Google Chat Supply run time']) {
expect(input(field)).toBeDisabled()
}
expect(screen.getByRole('button', { name: 'Change' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, dinner_time: '18:15' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(input('Dinner time')).toBeEnabled()
expect(input('Dinner time')).toHaveValue('18:15')
await waitFor(() => expect(unload()).toBe(false))
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
+2 -2
View File
@@ -113,7 +113,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
}
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<>
<Section title="Time zone">
<TimeZoneField
value={draft.timezone}
@@ -202,7 +202,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</fieldset>
</>
)
}
+2 -2
View File
@@ -119,7 +119,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
}
return (
<fieldset className={styles.lock} disabled={save.isPending}>
<>
<Segmented
label="Recipe search provider"
options={providerOptions}
@@ -173,7 +173,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</fieldset>
</>
)
}
+4 -8
View File
@@ -1,12 +1,11 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
@@ -40,8 +39,7 @@ function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderSettings renders the settings home on a 390 px phone (mock 1p) in a
// data router (the hidden sub-tabs' leave guard needs one);
// renderSettings renders the settings home on a 390 px phone (mock 1p);
// SettingsTabs.test.tsx covers 768 px and up.
function renderSettings() {
mockViewport(390)
@@ -49,12 +47,12 @@ function renderSettings() {
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path="/settings">
<MemoryRouter initialEntries={['/settings']}>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
@@ -97,11 +95,9 @@ describe('SettingsPage', () => {
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
'/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CALENDAR_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
])
})
+2 -10
View File
@@ -25,17 +25,9 @@ import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx'
// Settings home: the list of settings pages on mobile (mock 1p), one page
// with sub-tabs from 768 px (mock 2e). The sub-tabs stay mounted, hidden, on
// mobile, so an unsaved draft survives the viewport crossing 768 px and its
// leave guard keeps asking before the list navigates away.
// with sub-tabs from 768 px (mock 2e).
export function SettingsPage() {
const mobile = useBreakpoint() === 'mobile'
return (
<>
{mobile && <SettingsList />}
<SettingsTabs hidden={mobile} />
</>
)
return useBreakpoint() === 'mobile' ? <SettingsList /> : <SettingsTabs />
}
// The mobile settings list (handoff mock 1p). Each row opens its settings
+1 -2
View File
@@ -59,8 +59,7 @@
color: var(--color-bg);
}
/* A page's or panel's display would override the hidden attribute. */
.page[hidden],
/* A panel's display would override the hidden attribute. */
.single[hidden],
.columns[hidden] {
display: none;
+5 -28
View File
@@ -1,15 +1,17 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { SettingsPage } from './SettingsPage.tsx'
const MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
type Responder = (body: unknown) => Response
// stubAPI answers "METHOD path" from responders, each called with the
@@ -20,7 +22,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
@@ -165,29 +167,4 @@ describe('SettingsPage across the 768 px breakpoint', () => {
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(1180)
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
target: { value: 'A draft' },
})
resize(390)
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('link', { name: /^AI assistant/ }))
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
resize(1180)
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
const unload = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(unload)
expect(unload.defaultPrevented).toBe(true)
})
})
+2 -3
View File
@@ -19,8 +19,7 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
// Settings at tablet and desktop widths (handoff mock 2e): one page with
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
// so a draft survives switching tabs, and one leave guard covers them all.
// hidden hides the page but keeps it mounted, drafts and leave guard included.
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
export function SettingsTabs() {
const [tab, setTab] = useState<Tab>('notifications-ai')
const [householdDirty, setHouseholdDirty] = useState(false)
const [aiDirty, setAIDirty] = useState(false)
@@ -62,7 +61,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
}
return (
<div className={styles.page} hidden={hidden}>
<div className={styles.page}>
<header className={styles.header}>
<div className={styles.heading}>
<h1 className={styles.title}>Settings</h1>
+3 -3
View File
@@ -1,9 +1,9 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import styles from './BottomBar.module.css'
@@ -74,11 +74,11 @@ function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
<InDataRouter path={path}>
<MemoryRouter initialEntries={[path]}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</InDataRouter>,
</MemoryRouter>,
)
}
+1 -7
View File
@@ -16,10 +16,6 @@ export const AI_SETTINGS = {
updated_at: '2026-10-01T15:00:00.000Z',
}
// AI_MODELS is a GET /api/v1/settings/ai/models body: the model AI_SETTINGS
// chooses.
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
// times in New York.
export const HOUSEHOLD_SETTINGS = {
@@ -53,15 +49,13 @@ export const RECIPE_SEARCH_SETTINGS = {
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
// endpoints do not exist yet.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
}