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..a7ba038
--- /dev/null
+++ b/web/src/shell/AppShell.test.tsx
@@ -0,0 +1,207 @@
+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, outerSize } 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(outerSize(nav(), 'height')).toBe(62)
+ 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(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()
+
+ for (const link of navLinks()) {
+ 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)
+ }
+ 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')
+ // 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)')
+ 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..6d4e044
--- /dev/null
+++ b/web/src/shell/BottomBar.module.css
@@ -0,0 +1,55 @@
+.bar {
+ position: absolute;
+ left: 12px;
+ right: 12px;
+ 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;
+ 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..f9da188
--- /dev/null
+++ b/web/src/shell/Rail.module.css
@@ -0,0 +1,51 @@
+.rail {
+ width: 96px;
+ padding: 24px 0;
+ box-sizing: border-box;
+ 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..4aa11ce
--- /dev/null
+++ b/web/src/shell/Sidebar.module.css
@@ -0,0 +1,78 @@
+.sidebar {
+ width: 208px;
+ padding: 28px 16px;
+ box-sizing: border-box;
+ 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/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,
+ )
+}
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))
+ })
+ })
+ }
+}