From 93e209c302f4a7eaa9b74535ddca9a07919d2cc5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:33:09 -0400 Subject: [PATCH 1/2] Add responsive app shell, routes and navigation Replace the catch-all placeholder with an AppShell that renders one primary nav per breakpoint (chosen with matchMedia via useBreakpoint): a dark pill bottom bar below 768 px, a 96 px rail with the "M" mark at 768-1199 px, and a 208 px sidebar with the MealQuest wordmark and the "Our kitchen / Shared household" card at 1200 px and up. Nav order is Home, Plan, Discover, Quick, Settings; NavLink marks the active item aria-current="page", with nested Plan and Settings paths keeping their parent current. The mobile scroll area reserves 100 px plus the safe area beneath its content. Register every handoff route plus /settings/household, /settings/regulars and /settings/calendar, each with a placeholder heading. Remove the default body margin so the shell fills the viewport. Tests force each breakpoint with a matchMedia mock and assert the routes, nav order, each layout's structure, aria-current and the mobile bottom padding from the stylesheet. Maestro task: 42a4695a-6bd0-49a1-925e-387858dbee48 Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 54 ++++++-- web/src/App.tsx | 34 +++-- web/src/pages/PlaceholderPage.tsx | 9 ++ web/src/shell/AppShell.module.css | 24 ++++ web/src/shell/AppShell.test.tsx | 199 +++++++++++++++++++++++++++++ web/src/shell/AppShell.tsx | 22 ++++ web/src/shell/BottomBar.module.css | 54 ++++++++ web/src/shell/BottomBar.tsx | 23 ++++ web/src/shell/Rail.module.css | 50 ++++++++ web/src/shell/Rail.tsx | 27 ++++ web/src/shell/Sidebar.module.css | 77 +++++++++++ web/src/shell/Sidebar.tsx | 36 ++++++ web/src/shell/navItems.ts | 20 +++ web/src/shell/useBreakpoint.ts | 31 +++++ web/src/styles/base.css | 1 + web/src/test/matchMedia.ts | 49 +++++++ 16 files changed, 691 insertions(+), 19 deletions(-) create mode 100644 web/src/pages/PlaceholderPage.tsx create mode 100644 web/src/shell/AppShell.module.css create mode 100644 web/src/shell/AppShell.test.tsx create mode 100644 web/src/shell/AppShell.tsx create mode 100644 web/src/shell/BottomBar.module.css create mode 100644 web/src/shell/BottomBar.tsx create mode 100644 web/src/shell/Rail.module.css create mode 100644 web/src/shell/Rail.tsx create mode 100644 web/src/shell/Sidebar.module.css create mode 100644 web/src/shell/Sidebar.tsx create mode 100644 web/src/shell/navItems.ts create mode 100644 web/src/shell/useBreakpoint.ts create mode 100644 web/src/test/matchMedia.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f50d2b2..b05e13c 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,15 +1,49 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' -import App from './App.tsx' +import { afterEach, describe, expect, it } from 'vitest' +import { mockViewport } from './test/matchMedia.ts' +import App, { routes } from './App.tsx' -describe('App', () => { - it('renders the MealQuest shell', () => { - render( - - - , - ) - expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument() +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string) { + render( + + + , + ) +} + +describe('App routes', () => { + it('registers every handoff route plus the extra settings pages', () => { + expect(routes.map((route) => route.path)).toEqual([ + '/', + '/onboarding', + '/plan', + '/plan/shopping', + '/plan/shopping/archive', + '/discover', + '/quick', + '/cook-from-pantry', + '/dinner/:id', + '/settings', + '/settings/notifications', + '/settings/ai', + '/settings/household', + '/settings/regulars', + '/settings/calendar', + ]) }) + + it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + '%s renders its placeholder page inside the shell', + (path, title) => { + mockViewport(390) + renderAt(path) + expect(screen.getByRole('main')).toContainElement(screen.getByRole('heading', { level: 1, name: title })) + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + }, + ) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 9415bc4..5e820e5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,27 @@ import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' +import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { AppShell } from './shell/AppShell.tsx' -// Placeholder shell only: no product UI, auth or API calls yet. -function Shell() { - return ( -
-

MealQuest

-
- ) -} +// Every route the app serves, with the heading its placeholder page shows. +export const routes = [ + { path: '/', title: 'Home' }, + { path: '/onboarding', title: 'Onboarding' }, + { path: '/plan', title: 'Plan' }, + { path: '/plan/shopping', title: 'Supply run' }, + { path: '/plan/shopping/archive', title: 'Supply archive' }, + { path: '/discover', title: 'Discover' }, + { path: '/quick', title: 'Quick dinners' }, + { path: '/cook-from-pantry', title: 'Cook from what I have' }, + { path: '/dinner/:id', title: 'Dinner' }, + { path: '/settings', title: 'Settings' }, + { path: '/settings/notifications', title: 'Notifications' }, + { path: '/settings/ai', title: 'AI assistant' }, + { path: '/settings/household', title: 'Household' }, + { path: '/settings/regulars', title: 'Regulars' }, + { path: '/settings/calendar', title: 'Calendar' }, +] as const // App expects to be rendered inside a router (BrowserRouter in main.tsx, // MemoryRouter in tests). @@ -18,7 +30,11 @@ export default function App() { return ( - } /> + }> + {routes.map(({ path, title }) => ( + } /> + ))} + ) diff --git a/web/src/pages/PlaceholderPage.tsx b/web/src/pages/PlaceholderPage.tsx new file mode 100644 index 0000000..a8d7124 --- /dev/null +++ b/web/src/pages/PlaceholderPage.tsx @@ -0,0 +1,9 @@ +export type PlaceholderPageProps = { + // The route's screen name, shown as the page heading. + title: string +} + +// Stands in for a screen until its feature is built. +export function PlaceholderPage({ title }: PlaceholderPageProps) { + return

{title}

+} diff --git a/web/src/shell/AppShell.module.css b/web/src/shell/AppShell.module.css new file mode 100644 index 0000000..e1eff44 --- /dev/null +++ b/web/src/shell/AppShell.module.css @@ -0,0 +1,24 @@ +.shell { + position: relative; + height: 100dvh; + display: flex; + background: var(--color-bg); +} + +.main { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +/* The bottom bar overlays the last 94 px of the screen (plus the safe area), + so the scroll area reserves at least 100 px beneath its content. */ +.mobile .main { + padding: 0 20px; + padding-bottom: calc(100px + env(safe-area-inset-bottom)); +} + +.tablet .main, +.desktop .main { + padding: 32px 36px; +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx new file mode 100644 index 0000000..539231c --- /dev/null +++ b/web/src/shell/AppShell.test.tsx @@ -0,0 +1,199 @@ +import { render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it } from 'vitest' +import App from '../App.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { mockViewport } from '../test/matchMedia.ts' +import bottomBarStyles from './BottomBar.module.css' +import railStyles from './Rail.module.css' +import sidebarStyles from './Sidebar.module.css' + +const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings'] +const MOBILE = 390 +const TABLET = 1180 +const DESKTOP = 1440 + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, width: number) { + const resize = mockViewport(width) + render( + + + , + ) + return resize +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function navLinks() { + return within(nav()).getAllByRole('link') +} + +function current() { + return navLinks() + .filter((link) => link.getAttribute('aria-current') === 'page') + .map((link) => link.textContent) +} + +describe('AppShell', () => { + it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => { + renderAt('/', width) + expect(navLinks().map((link) => link.textContent)).toEqual(ORDER) + expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([ + '/', + '/plan', + '/discover', + '/quick', + '/settings', + ]) + }) + + it('shows only the mobile bottom bar below 768 px', () => { + renderAt('/', 767) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.queryByText('M')).not.toBeInTheDocument() + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + const bar = declaredStyle(nav()) + expect(bar.position).toBe('absolute') + expect(bar.left).toBe('12px') + expect(bar.right).toBe('12px') + expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))') + expect(bar.height).toBe('62px') + expect(bar.background).toBe('var(--color-text)') + expect(bar.borderRadius).toBe('var(--radius-pill)') + }) + + it('shows the active mobile item as icon and label on a grape pill, inactive items as neutral icons', () => { + renderAt('/discover', MOBILE) + const active = screen.getByRole('link', { name: 'Discover' }) + const pill = active.firstElementChild! + expect(pill).toHaveClass(bottomBarStyles.active!) + expect(declaredStyle(pill).height).toBe('42px') + expect(declaredStyle(pill).background).toBe('var(--color-accent)') + expect(declaredStyle(pill).color).toBe('var(--on-accent)') + expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!) + expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px') + + const inactive = screen.getByRole('link', { name: 'Plan' }) + expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)') + expect(inactive.firstElementChild).not.toHaveClass(bottomBarStyles.active!) + expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!) + }) + + it('makes the full 62 px bar height the mobile tap target', () => { + renderAt('/', MOBILE) + for (const link of navLinks()) { + expect(declaredStyle(link).height).toBe('62px') + expectMinHitArea(link) + } + }) + + it('reserves at least 100 px below the mobile scroll area', () => { + renderAt('/', MOBILE) + const padding = declaredStyle(screen.getByRole('main')).paddingBottom + const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding) + expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull() + expect(Number(reserved![1])).toBeGreaterThanOrEqual(100) + }) + + it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => { + renderAt('/plan', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(railStyles.rail!) + expect(declaredStyle(nav()).width).toBe('96px') + expect(declaredStyle(nav()).background).toBe('var(--color-surface)') + const mark = within(nav()).getByText('M') + expect(declaredStyle(mark).width).toBe('48px') + expect(declaredStyle(mark).background).toBe('var(--color-accent)') + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + for (const link of navLinks()) { + const pill = link.firstElementChild! + expect(declaredStyle(pill).width).toBe('56px') + expect(declaredStyle(pill).height).toBe('36px') + expect(declaredStyle(link).fontSize).toBe('11px') + expect(declaredStyle(link).fontWeight).toBe('700') + expectMinHitArea(link) + } + expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!) + expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!) + }) + + it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => { + renderAt('/quick', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + const sidebar = nav().closest('aside')! + expect(sidebar).toHaveClass(sidebarStyles.sidebar!) + expect(declaredStyle(sidebar).width).toBe('208px') + expect(declaredStyle(sidebar).background).toBe('var(--color-surface)') + expect(declaredStyle(sidebar).padding).toBe('28px 16px') + + const wordmark = within(sidebar).getByText('MealQuest') + expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(wordmark).fontSize).toBe('28px') + expect(declaredStyle(wordmark).color).toBe('var(--color-accent)') + + expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument() + expect(within(sidebar).getByText('Shared household')).toBeInTheDocument() + expect(screen.queryByText('M')).not.toBeInTheDocument() + + const active = screen.getByRole('link', { name: 'Quick' }) + expect(active).toHaveClass(sidebarStyles.active!) + expect(declaredStyle(active).background).toBe('var(--color-accent)') + expect(declaredStyle(active).color).toBe('var(--on-accent)') + expect(declaredStyle(active).fontSize).toBe('15px') + expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!) + // Rows stretch across the sidebar, so only their height needs declaring. + for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px') + }) + + it('switches layout when the viewport crosses a breakpoint', () => { + const resize = renderAt('/', MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + resize(TABLET) + expect(nav()).toHaveClass(railStyles.rail!) + resize(DESKTOP) + expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!) + resize(MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + }) + + describe.each([ + ['mobile', MOBILE], + ['tablet', TABLET], + ['desktop', DESKTOP], + ])('aria-current (%s)', (_layout, width) => { + it.each([ + ['/', ['Home']], + ['/plan', ['Plan']], + ['/plan/shopping', ['Plan']], + ['/plan/shopping/archive', ['Plan']], + ['/discover', ['Discover']], + ['/quick', ['Quick']], + ['/settings', ['Settings']], + ['/settings/notifications', ['Settings']], + ['/settings/ai', ['Settings']], + ['/settings/household', ['Settings']], + ['/settings/regulars', ['Settings']], + ['/settings/calendar', ['Settings']], + ['/onboarding', []], + ['/cook-from-pantry', []], + ['/dinner/abc123', []], + ])('%s marks %j current', (path, expected) => { + renderAt(path, width) + expect(current()).toEqual(expected) + }) + }) +}) diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx new file mode 100644 index 0000000..7baf72b --- /dev/null +++ b/web/src/shell/AppShell.tsx @@ -0,0 +1,22 @@ +import { Outlet } from 'react-router' +import { BottomBar } from './BottomBar.tsx' +import { Rail } from './Rail.tsx' +import { Sidebar } from './Sidebar.tsx' +import { useBreakpoint } from './useBreakpoint.ts' +import styles from './AppShell.module.css' + +// The responsive frame around every page: one nav layout per breakpoint and a +// scrolling main area for the routed page. +export function AppShell() { + const breakpoint = useBreakpoint() + return ( +
+ {breakpoint === 'mobile' && } + {breakpoint === 'tablet' && } + {breakpoint === 'desktop' && } +
+ +
+
+ ) +} diff --git a/web/src/shell/BottomBar.module.css b/web/src/shell/BottomBar.module.css new file mode 100644 index 0000000..92d8cb7 --- /dev/null +++ b/web/src/shell/BottomBar.module.css @@ -0,0 +1,54 @@ +.bar { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(16px + env(safe-area-inset-bottom)); + height: 62px; + padding: 0 6px; + display: flex; + justify-content: space-around; + align-items: center; + border-radius: var(--radius-pill); + background: var(--color-text); +} + +/* The whole bar height is the tap target; the 42 px pill sits inside it. */ +.item { + display: flex; + align-items: center; + justify-content: center; + height: 62px; + min-width: 44px; + color: var(--color-neutral-400); + text-decoration: none; +} + +.pill { + display: flex; + align-items: center; + gap: 6px; + height: 42px; + padding: 0 13px; + border-radius: var(--radius-pill); +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} + +.label { + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +/* Inactive items are icon-only on screen but keep their name for assistive tech. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/web/src/shell/BottomBar.tsx b/web/src/shell/BottomBar.tsx new file mode 100644 index 0000000..14843d4 --- /dev/null +++ b/web/src/shell/BottomBar.tsx @@ -0,0 +1,23 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './BottomBar.module.css' + +// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive +// items show only their icon; the active item adds its label on a grape pill. +export function BottomBar() { + return ( + + ) +} diff --git a/web/src/shell/Rail.module.css b/web/src/shell/Rail.module.css new file mode 100644 index 0000000..d84eefd --- /dev/null +++ b/web/src/shell/Rail.module.css @@ -0,0 +1,50 @@ +.rail { + width: 96px; + padding: 24px 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + background: var(--color-surface); +} + +.mark { + width: 48px; + height: 48px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 22px; +} + +.item { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + min-width: 56px; + min-height: 44px; + color: var(--color-neutral-700); + font-size: 11px; + font-weight: 700; + text-decoration: none; +} + +.pill { + width: 56px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} diff --git a/web/src/shell/Rail.tsx b/web/src/shell/Rail.tsx new file mode 100644 index 0000000..221bef4 --- /dev/null +++ b/web/src/shell/Rail.tsx @@ -0,0 +1,27 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Rail.module.css' + +// Tablet nav: a narrow icon rail with the "M" mark on top. +export function Rail() { + return ( + + ) +} diff --git a/web/src/shell/Sidebar.module.css b/web/src/shell/Sidebar.module.css new file mode 100644 index 0000000..05f223c --- /dev/null +++ b/web/src/shell/Sidebar.module.css @@ -0,0 +1,77 @@ +.sidebar { + width: 208px; + padding: 28px 16px; + display: flex; + flex-direction: column; + background: var(--color-surface); +} + +.wordmark { + margin: 0; + padding: 0 12px 22px; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + color: var(--color-accent); +} + +.nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.item { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 12px 16px; + box-sizing: border-box; + border-radius: var(--radius-pill); + color: var(--color-neutral-800); + font-size: 15px; + font-weight: 700; + text-decoration: none; +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} + +.household { + margin-top: auto; + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 22px; + background: var(--color-bg); +} + +.avatar { + width: 36px; + height: 36px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); + font-family: var(--font-heading); + font-size: 15px; +} + +.householdText { + margin: 0; + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.3; +} + +.householdNote { + color: var(--color-neutral-700); +} diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx new file mode 100644 index 0000000..9374af2 --- /dev/null +++ b/web/src/shell/Sidebar.tsx @@ -0,0 +1,36 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Sidebar.module.css' + +// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared +// household card at the foot. +export function Sidebar() { + return ( + + ) +} diff --git a/web/src/shell/navItems.ts b/web/src/shell/navItems.ts new file mode 100644 index 0000000..8b22f63 --- /dev/null +++ b/web/src/shell/navItems.ts @@ -0,0 +1,20 @@ +import type { LucideIcon } from 'lucide-react' +import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react' + +export type NavItem = { + to: string + label: string + icon: LucideIcon + // Match the path exactly; otherwise nested paths (/plan/shopping, + // /settings/ai) also mark the item current. + end: boolean +} + +// The primary nav, in the same order in every layout. +export const navItems: readonly NavItem[] = [ + { to: '/', label: 'Home', icon: House, end: true }, + { to: '/plan', label: 'Plan', icon: Calendar, end: false }, + { to: '/discover', label: 'Discover', icon: Compass, end: false }, + { to: '/quick', label: 'Quick', icon: Zap, end: false }, + { to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false }, +] diff --git a/web/src/shell/useBreakpoint.ts b/web/src/shell/useBreakpoint.ts new file mode 100644 index 0000000..28b9f48 --- /dev/null +++ b/web/src/shell/useBreakpoint.ts @@ -0,0 +1,31 @@ +import { useCallback, useState, useSyncExternalStore } from 'react' + +export type Breakpoint = 'mobile' | 'tablet' | 'desktop' + +// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px, +// desktop ≥ 1200 px. +export const TABLET_QUERY = '(min-width: 768px)' +export const DESKTOP_QUERY = '(min-width: 1200px)' + +// The layout the current viewport calls for, updated as the viewport crosses +// a breakpoint. +export function useBreakpoint(): Breakpoint { + const [lists] = useState(() => ({ + tablet: window.matchMedia(TABLET_QUERY), + desktop: window.matchMedia(DESKTOP_QUERY), + })) + const subscribe = useCallback( + (onChange: () => void) => { + lists.tablet.addEventListener('change', onChange) + lists.desktop.addEventListener('change', onChange) + return () => { + lists.tablet.removeEventListener('change', onChange) + lists.desktop.removeEventListener('change', onChange) + } + }, + [lists], + ) + return useSyncExternalStore(subscribe, () => + lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile', + ) +} diff --git a/web/src/styles/base.css b/web/src/styles/base.css index ac545c1..dc83c17 100644 --- a/web/src/styles/base.css +++ b/web/src/styles/base.css @@ -1,5 +1,6 @@ /* App-wide base styles. Values come only from tokens.css. */ body { + margin: 0; background: var(--color-bg); color: var(--color-text); font-family: var(--font-body); diff --git a/web/src/test/matchMedia.ts b/web/src/test/matchMedia.ts new file mode 100644 index 0000000..0bb1a0b --- /dev/null +++ b/web/src/test/matchMedia.ts @@ -0,0 +1,49 @@ +import { act } from '@testing-library/react' + +type Listener = (event: MediaQueryListEvent) => void + +// Replaces window.matchMedia with one that answers min-width / max-width +// queries for a pretend viewport width (jsdom has no matchMedia and no +// layout). Returns a function that resizes the pretend viewport and notifies +// every query whose answer changed. +export function mockViewport(initialWidth: number): (width: number) => void { + let width = initialWidth + const lists: { list: MediaQueryList; listeners: Set }[] = [] + + function evaluate(query: string): boolean { + const min = /\(min-width:\s*(\d+)px\)/.exec(query) + const max = /\(max-width:\s*(\d+)px\)/.exec(query) + if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`) + return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1])) + } + + window.matchMedia = (query: string) => { + const listeners = new Set() + const list = { + media: query, + get matches() { + return evaluate(query) + }, + onchange: null, + addEventListener: (_type: string, listener: Listener) => listeners.add(listener), + removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener), + addListener: (listener: Listener) => listeners.add(listener), + removeListener: (listener: Listener) => listeners.delete(listener), + dispatchEvent: () => true, + } as unknown as MediaQueryList + lists.push({ list, listeners }) + return list + } + + return (next: number) => { + const before = lists.map(({ list }) => list.matches) + width = next + act(() => { + lists.forEach(({ list, listeners }, i) => { + if (list.matches === before[i]) return + const event = { matches: list.matches, media: list.media } as MediaQueryListEvent + listeners.forEach((listener) => listener(event)) + }) + }) + } +} -- 2.55.0 From f922b4457eb1dad88b784463607da6a5e8621a77 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:36:21 -0400 Subject: [PATCH 2/2] Keep shell sidebar at 208 px including its padding The desktop sidebar declared width: 208px with 16px side padding under content-box sizing, so it rendered 240 px wide. Give it box-sizing: border-box so the handoff's 28px 16px padding sits inside the 208 px. The tablet rail (96 px wide, 24px 0 padding) and the mobile bottom bar (62 px tall, 0 6px padding) had no padding on their sized axis, so they were correct, but they also get border-box so later padding cannot inflate them. The "M" mark (48 px) and rail pills (56x36) have no padding or border. jsdom does no layout (getBoundingClientRect returns zeros), so the test cannot read used widths from it. A new outerSize() helper in src/test/cssRules.ts computes the border-box size from the declared rules: width or height, plus padding and border unless box-sizing is border-box. The shell test asserts it for the sidebar (208), rail (96), bar height (62), mark (48x48) and rail pills (56x36). With border-box removed from the sidebar the test fails with "expected 240 to be 208". Co-Authored-By: Claude Opus 5.5 --- web/src/shell/AppShell.test.tsx | 10 +++++++++- web/src/shell/BottomBar.module.css | 1 + web/src/shell/Rail.module.css | 1 + web/src/shell/Sidebar.module.css | 1 + web/src/test/cssRules.ts | 25 +++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 539231c..a7ba038 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -2,7 +2,7 @@ import { render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it } from 'vitest' import App from '../App.tsx' -import { declaredStyle } from '../test/cssRules.ts' +import { declaredStyle, outerSize } from '../test/cssRules.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' import bottomBarStyles from './BottomBar.module.css' @@ -69,6 +69,7 @@ describe('AppShell', () => { expect(bar.right).toBe('12px') expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))') expect(bar.height).toBe('62px') + expect(outerSize(nav(), 'height')).toBe(62) expect(bar.background).toBe('var(--color-text)') expect(bar.borderRadius).toBe('var(--radius-pill)') }) @@ -111,9 +112,12 @@ describe('AppShell', () => { expect(screen.getAllByRole('navigation')).toHaveLength(1) expect(nav()).toHaveClass(railStyles.rail!) expect(declaredStyle(nav()).width).toBe('96px') + expect(outerSize(nav(), 'width')).toBe(96) expect(declaredStyle(nav()).background).toBe('var(--color-surface)') const mark = within(nav()).getByText('M') expect(declaredStyle(mark).width).toBe('48px') + expect(outerSize(mark, 'width')).toBe(48) + expect(outerSize(mark, 'height')).toBe(48) expect(declaredStyle(mark).background).toBe('var(--color-accent)') expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() @@ -122,6 +126,8 @@ describe('AppShell', () => { const pill = link.firstElementChild! expect(declaredStyle(pill).width).toBe('56px') expect(declaredStyle(pill).height).toBe('36px') + expect(outerSize(pill, 'width')).toBe(56) + expect(outerSize(pill, 'height')).toBe(36) expect(declaredStyle(link).fontSize).toBe('11px') expect(declaredStyle(link).fontWeight).toBe('700') expectMinHitArea(link) @@ -138,6 +144,8 @@ describe('AppShell', () => { expect(declaredStyle(sidebar).width).toBe('208px') expect(declaredStyle(sidebar).background).toBe('var(--color-surface)') expect(declaredStyle(sidebar).padding).toBe('28px 16px') + // The 16 px side padding sits inside the 208 px, not outside it. + expect(outerSize(sidebar, 'width')).toBe(208) const wordmark = within(sidebar).getByText('MealQuest') expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)') diff --git a/web/src/shell/BottomBar.module.css b/web/src/shell/BottomBar.module.css index 92d8cb7..6d4e044 100644 --- a/web/src/shell/BottomBar.module.css +++ b/web/src/shell/BottomBar.module.css @@ -5,6 +5,7 @@ bottom: calc(16px + env(safe-area-inset-bottom)); height: 62px; padding: 0 6px; + box-sizing: border-box; display: flex; justify-content: space-around; align-items: center; diff --git a/web/src/shell/Rail.module.css b/web/src/shell/Rail.module.css index d84eefd..f9da188 100644 --- a/web/src/shell/Rail.module.css +++ b/web/src/shell/Rail.module.css @@ -1,6 +1,7 @@ .rail { width: 96px; padding: 24px 0; + box-sizing: border-box; display: flex; flex-direction: column; align-items: center; diff --git a/web/src/shell/Sidebar.module.css b/web/src/shell/Sidebar.module.css index 05f223c..4aa11ce 100644 --- a/web/src/shell/Sidebar.module.css +++ b/web/src/shell/Sidebar.module.css @@ -1,6 +1,7 @@ .sidebar { width: 208px; padding: 28px 16px; + box-sizing: border-box; display: flex; flex-direction: column; background: var(--color-surface); diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts index e8413aa..3260596 100644 --- a/web/src/test/cssRules.ts +++ b/web/src/test/cssRules.ts @@ -48,3 +48,28 @@ export function animationKeyframes(element: Element): CSSKeyframesRule { if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`) return keyframes } + +function px(value: string, what: string): number { + if (value === '') return 0 + const match = /^(\d+(?:\.\d+)?)px$/.exec(value) + if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`) + return Number(match[1]) +} + +// The border-box size the element's declared rules give it on one axis: +// width or height, plus padding and border unless box-sizing is border-box. +// jsdom does no layout (getBoundingClientRect is all zeros), so this is the +// way to catch padding that would push a fixed box past its specified size. +export function outerSize(element: Element, axis: 'width' | 'height'): number { + const style = declaredStyle(element) + const size = px(style.getPropertyValue(axis), axis) + if (style.boxSizing === 'border-box') return size + const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom'] + return sides.reduce( + (total, side) => + total + + px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) + + px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`), + size, + ) +} -- 2.55.0