From f5a52d5e6d7f9c4e7de21c1d5f6d692b704c74f5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 11:33:38 -0400 Subject: [PATCH] Desktop and tablet settings layout (mock 2e) 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 --- web/src/settings/AISettingsPage.module.css | 23 +++ web/src/settings/AISettingsPage.tsx | 47 +++++- web/src/settings/HouseholdSettingsPage.tsx | 28 +++- web/src/settings/SettingsPage.test.tsx | 5 + web/src/settings/SettingsPage.tsx | 14 +- web/src/settings/SettingsTabs.module.css | 117 ++++++++++++++ web/src/settings/SettingsTabs.test.tsx | 170 +++++++++++++++++++++ web/src/settings/SettingsTabs.tsx | 137 +++++++++++++++++ 8 files changed, 523 insertions(+), 18 deletions(-) create mode 100644 web/src/settings/SettingsTabs.module.css create mode 100644 web/src/settings/SettingsTabs.test.tsx create mode 100644 web/src/settings/SettingsTabs.tsx diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css index 9c477dc..6b0924d 100644 --- a/web/src/settings/AISettingsPage.module.css +++ b/web/src/settings/AISettingsPage.module.css @@ -281,3 +281,26 @@ .cardActions > button { min-height: 52px; } + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +/* AI assistant card on the desktop settings (mock 2e): the card grows to + fill its column and the system prompt grows to fill the card. */ +.fill { + flex: 1; + gap: 18px; +} + +.fill .prompt, +.fill .textarea { + flex: 1; +} + +.fill .textarea { + resize: none; +} diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx index 1be44e5..10fc7fc 100644 --- a/web/src/settings/AISettingsPage.tsx +++ b/web/src/settings/AISettingsPage.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router' import { ApiError } from '../api/index.ts' import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' @@ -10,6 +11,7 @@ import { Select } from '../ui/Select.tsx' import { Sheet } from '../ui/Sheet.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Slider } from '../ui/Slider.tsx' +import { Tag } from '../ui/Tag.tsx' import { TextInput } from '../ui/TextInput.tsx' import { aiKeyPolicy, @@ -39,22 +41,53 @@ const TYPE_MODEL = '\u0000type' // sees the key's last four characters. Recipe search is its own card, saved // on its own. export function AISettingsPage() { - const settings = useAISettings() const [aiDirty, setAIDirty] = useState(false) const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) return (
+ + + +
+ ) +} + +type AISettingsProps = { + // Called with whether the form holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +// AIAssistantCard is the AI assistant's fields as a card, for the desktop +// settings (handoff mock 2e): the system prompt grows to fill the card. +export function AIAssistantCard({ onDirtyChange }: AISettingsProps) { + const titleId = useId() + return ( + +
+

+ AI assistant +

+ Admin +
+ +
+ ) +} + +// AIAssistantSettings is the AI assistant's form once its settings load. +function AIAssistantSettings({ onDirtyChange }: AISettingsProps) { + const settings = useAISettings() + return ( + <> {settings.isPending && } {settings.isError && ( void settings.refetch()}> AI settings could not load: {settings.error.message} )} - {settings.isSuccess && } - - - + {settings.isSuccess && } + ) } @@ -103,10 +136,8 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch { return patch } -type AISettingsFormProps = { +type AISettingsFormProps = AISettingsProps & { saved: AISettings - // Called with whether the form holds unsaved changes. - onDirtyChange: (dirty: boolean) => void } function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) { diff --git a/web/src/settings/HouseholdSettingsPage.tsx b/web/src/settings/HouseholdSettingsPage.tsx index 6d763b9..e9f4f4a 100644 --- a/web/src/settings/HouseholdSettingsPage.tsx +++ b/web/src/settings/HouseholdSettingsPage.tsx @@ -29,21 +29,37 @@ const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', // reminders and weekly AI runs happen at. Every field is edited as a draft // and saved together; only the changed fields are sent. export function HouseholdSettingsPage() { - const settings = useHouseholdSettings() const [dirty, setDirty] = useState(false) return (

Changes apply to everyone in the household.

+ + +
+ ) +} + +type HouseholdSettingsProps = { + // Called with whether the form holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +// HouseholdSettingsSections is the screen's form once its settings load, +// without the screen's header, so the desktop settings (mock 2e) can embed +// it. +export function HouseholdSettingsSections({ onDirtyChange }: HouseholdSettingsProps) { + const settings = useHouseholdSettings() + return ( + <> {settings.isPending && } {settings.isError && ( void settings.refetch()}> Household settings could not load: {settings.error.message} )} - {settings.isSuccess && } - - + {settings.isSuccess && } + ) } @@ -71,10 +87,8 @@ function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdS return patch as HouseholdSettingsPatch } -type HouseholdSettingsFormProps = { +type HouseholdSettingsFormProps = HouseholdSettingsProps & { saved: HouseholdSettings - // Called with whether the form holds unsaved changes. - onDirtyChange: (dirty: boolean) => void } function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) { diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index 5a505bd..c520ecd 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -8,6 +8,7 @@ import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProvid import { declaredStyle } from '../test/cssRules.ts' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' +import { mockViewport } from '../test/matchMedia.ts' import { formatClockTime, modelDisplayName } from './api.ts' import { SettingsPage } from './SettingsPage.tsx' @@ -16,6 +17,7 @@ const AI = AI_SETTINGS afterEach(() => { vi.unstubAllGlobals() + Reflect.deleteProperty(window, 'matchMedia') }) const REGULARS_PATH = '/api/v1/dinners?status=regular' @@ -37,7 +39,10 @@ function CurrentPath() { return

{useLocation().pathname}

} +// renderSettings renders the settings home on a 390 px phone (mock 1p); +// SettingsTabs.test.tsx covers 768 px and up. function renderSettings() { + mockViewport(390) const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage)) return render( diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx index 91e0a28..7d5f066 100644 --- a/web/src/settings/SettingsPage.tsx +++ b/web/src/settings/SettingsPage.tsx @@ -8,6 +8,7 @@ import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' +import { useBreakpoint } from '../shell/useBreakpoint.ts' import { aiStatus, calendarStatus, @@ -21,12 +22,19 @@ import { useRegularDinnersCount, } from './api.ts' import styles from './SettingsPage.module.css' +import { SettingsTabs } from './SettingsTabs.tsx' -// Settings home (handoff mock 1p). Each row opens its settings page and -// shows a live status line. Regular dinners, Google Calendar and +// Settings home: the list of settings pages on mobile (mock 1p), one page +// with sub-tabs from 768 px (mock 2e). +export function SettingsPage() { + return useBreakpoint() === 'mobile' ? : +} + +// The mobile settings list (handoff mock 1p). Each row opens its settings +// page and shows a live status line. Regular dinners, Google Calendar and // Notifications & Google Chat read endpoints their features have not served // yet; until then each shows its not-yet-available state (see api.ts). -export function SettingsPage() { +function SettingsList() { const household = useHouseholdSettings() const ai = useAISettings() const regulars = useRegularDinnersCount() diff --git a/web/src/settings/SettingsTabs.module.css b/web/src/settings/SettingsTabs.module.css new file mode 100644 index 0000000..c447360 --- /dev/null +++ b/web/src/settings/SettingsTabs.module.css @@ -0,0 +1,117 @@ +/* Settings at tablet and desktop widths, handoff mock 2e. */ +.page { + box-sizing: border-box; + min-height: 100%; + display: flex; + flex-direction: column; + gap: 20px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 14px; +} + +.heading { + flex: 1; + min-width: 0; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 40px; + font-weight: 400; + line-height: 1.1; +} + +.shared { + margin: 6px 0 0; + font-family: var(--font-body); + font-size: 14px; + color: var(--color-neutral-800); +} + +.tabs { + display: flex; + gap: 6px; +} + +.tab { + min-width: 44px; + min-height: 44px; + padding: 0 16px; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} + +.selected { + background: var(--color-text); + color: var(--color-bg); +} + +/* A panel's display would override the hidden attribute. */ +.single[hidden], +.columns[hidden] { + display: none; +} + +.single { + max-width: 560px; + display: flex; + flex-direction: column; + gap: 18px; +} + +/* Notifications on the left; the AI assistant and Recipe search on the + right, the AI assistant card growing to fill the column. */ +.columns { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + align-items: stretch; + gap: 20px; +} + +.column { + min-width: 0; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Mock 2e's cards: 24 px padding, 30 px corners. */ +.column > * { + padding: 24px; + border-radius: 30px; +} + +.placeholder { + display: flex; + flex-direction: column; + gap: 14px; +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.hint { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/settings/SettingsTabs.test.tsx b/web/src/settings/SettingsTabs.test.tsx new file mode 100644 index 0000000..b4c2831 --- /dev/null +++ b/web/src/settings/SettingsTabs.test.tsx @@ -0,0 +1,170 @@ +import { QueryClientProvider } from '@tanstack/react-query' +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_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 +// request's decoded JSON body; the settings the page loads answer their +// fixtures unless overridden. Any other request fails the test. +function stubAPI(responders: Record = {}): Mock { + const all: Record = { + '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, MODELS), + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(window, 'matchMedia') +}) + +// renderAt renders /settings at width px in a data router (the page's leave +// guard needs one), after loading /me for the CSRF token writes need. +// It returns the function that resizes the viewport. +async function renderAt(width: number) { + const resize = mockViewport(width) + const api = createApiClient(fakeLocation('/settings'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + render( + + + + + + + , + ) + return resize +} + +const tab = (name: string) => screen.getByRole('tab', { name }) +// panel is the panel a tab controls, shown or hidden. +function panel(name: string): HTMLElement { + const id = tab(name).getAttribute('aria-controls') + const element = id === null ? null : document.getElementById(id) + if (element === null) throw new Error(`tab "${name}" controls no panel`) + expect(element).toHaveAttribute('role', 'tabpanel') + return element +} + +describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { + it('shows the title, the shared line and the sub-tabs, Notifications & AI first', async () => { + stubAPI() + await renderAt(width) + expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument() + expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + const tabs = within(screen.getByRole('tablist', { name: 'Settings sections' })).getAllByRole('tab') + expect(tabs.map((t) => t.textContent)).toEqual(['Household', 'Regular dinners', 'Notifications & AI']) + expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true') + tabs.forEach(expectMinHitArea) + + const columns = panel('Notifications & AI') + expect(columns).toBeVisible() + const [left, right] = Array.from(columns.children) + expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument() + const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' }) + expect(within(ai).getByText('Admin')).toBeInTheDocument() + expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic') + expect(within(ai).getByRole('textbox', { name: 'System prompt' })).toHaveValue(AI_SETTINGS.system_prompt) + expect(within(right as HTMLElement).getByRole('region', { name: 'Recipe search' })).toBeInTheDocument() + }) + + it('switches tabs by click and by arrow keys', async () => { + stubAPI() + await renderAt(width) + + fireEvent.click(tab('Household')) + expect(tab('Household')).toHaveAttribute('aria-selected', 'true') + expect(panel('Household')).toBeVisible() + expect(panel('Notifications & AI')).not.toBeVisible() + expect(await within(panel('Household')).findByRole('button', { name: 'Save changes' })).toBeInTheDocument() + + fireEvent.keyDown(tab('Household'), { key: 'ArrowRight' }) + expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true') + expect(tab('Regular dinners')).toHaveFocus() + expect(panel('Regular dinners')).toBeVisible() + expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument() + expect(panel('Household')).not.toBeVisible() + + fireEvent.keyDown(tab('Regular dinners'), { key: 'End' }) + expect(tab('Notifications & AI')).toHaveAttribute('aria-selected', 'true') + fireEvent.keyDown(tab('Notifications & AI'), { key: 'ArrowRight' }) + expect(tab('Household')).toHaveAttribute('aria-selected', 'true') + expect(tab('Household')).toHaveFocus() + }) + + it('saves the AI assistant card through the AI settings API', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/ai': (body) => jsonResponse(200, { ...AI_SETTINGS, ...(body as object) }), + }) + await renderAt(width) + const ai = within(screen.getByRole('region', { name: 'AI assistant' })) + const prompt = await ai.findByRole('textbox', { name: 'System prompt' }) + const save = ai.getByRole('button', { name: 'Save changes' }) + expect(save).toBeDisabled() + + fireEvent.change(prompt, { target: { value: 'Plan dinners for us.' } }) + fireEvent.click(save) + expect(await ai.findByText('Changes saved.')).toBeInTheDocument() + + const writes = fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + expect(writes.map(([input, init]) => [init?.method, String(input), init?.body])).toEqual([ + ['PATCH', '/api/v1/settings/ai', JSON.stringify({ system_prompt: 'Plan dinners for us.' })], + ]) + expect(prompt).toHaveValue('Plan dinners for us.') + expect(save).toBeDisabled() + }) + + it('keeps an unsaved draft while another tab is shown', async () => { + stubAPI() + await renderAt(width) + const prompt = await screen.findByRole('textbox', { name: 'System prompt' }) + fireEvent.change(prompt, { target: { value: 'A draft' } }) + + fireEvent.click(tab('Household')) + fireEvent.click(tab('Notifications & AI')) + expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft') + }) +}) + +describe('SettingsPage across the 768 px breakpoint', () => { + it('shows the mobile list below 768 px and the sub-tabs from 768 px', 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(767) + expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai') + expect(screen.queryByRole('tablist')).not.toBeInTheDocument() + + resize(768) + expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/settings/SettingsTabs.tsx b/web/src/settings/SettingsTabs.tsx new file mode 100644 index 0000000..0bfc6a4 --- /dev/null +++ b/web/src/settings/SettingsTabs.tsx @@ -0,0 +1,137 @@ +import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' +import { Card } from '../ui/Card.tsx' +import { AIAssistantCard } from './AISettingsPage.tsx' +import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx' +import { LeaveGuard } from './LeaveGuard.tsx' +import { RecipeSearchCard } from './RecipeSearchCard.tsx' +import styles from './SettingsTabs.module.css' + +type Tab = 'household' | 'regulars' | 'notifications-ai' + +const tabs: readonly { value: Tab; label: string }[] = [ + { value: 'household', label: 'Household' }, + { value: 'regulars', label: 'Regular dinners' }, + { value: 'notifications-ai', label: 'Notifications & AI' }, +] + +const step: Record = { 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. +export function SettingsTabs() { + const [tab, setTab] = useState('notifications-ai') + const [householdDirty, setHouseholdDirty] = useState(false) + const [aiDirty, setAIDirty] = useState(false) + const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) + const refs = useRef<(HTMLButtonElement | null)[]>([]) + const id = useId() + const tabId = (value: Tab) => `${id}-tab-${value}` + const panelId = (value: Tab) => `${id}-panel-${value}` + + // Arrow keys move between the tabs, wrapping at the ends; Home and End + // jump to the first and last. Moving selects the tab. + function onKeyDown(event: KeyboardEvent) { + const selected = tabs.findIndex((t) => t.value === tab) + const delta = step[event.key] + let next: number + if (delta !== undefined) next = (selected + delta + tabs.length) % tabs.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = tabs.length - 1 + else return + const nextTab = tabs[next] + if (!nextTab) throw new Error(`Settings tabs: no tab at index ${next}`) + event.preventDefault() + setTab(nextTab.value) + refs.current[next]?.focus() + } + + function panel(value: Tab, className: string | undefined, children: ReactNode) { + return ( + + ) + } + + return ( +
+
+
+

Settings

+

Changes apply to everyone in the household.

+
+
+ {tabs.map(({ value, label }, index) => ( + + ))} +
+
+ + {panel('household', styles.single, )} + + {panel( + 'regulars', + styles.single, + // TODO(Feature 12): embed the Regular dinners screen here once Feature + // 12 builds it. + Managing the household's Regulars arrives in a later update., + )} + + {panel( + 'notifications-ai', + styles.columns, + <> +
+ {/* TODO(Feature 19): the Notifications matrix and Google Chat + webhook cards replace this placeholder. */} + + Notification and Google Chat webhook settings arrive in a later update. + +
+
+ + +
+ , + )} + + +
+ ) +} + +// Placeholder is a neutral card holding a section's place until the feature +// that owns it renders it. +function Placeholder({ title, children }: { title: string; children: ReactNode }) { + const titleId = useId() + return ( + +

+ {title} +

+

{children}

+
+ ) +}