Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 ef3e7d0e45 Leave controls disabled by a fieldset out of the Sheet focus trap
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m28s
CI / ci (pull_request) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:17:15 -04:00
dev_agentandClaude Opus 5.5 0fa9cfe611 Move Sheet focus only to controls that can take keyboard focus
CI / ci (push) Successful in 1m30s
CI / ci (pull_request) Successful in 1m29s
Review finding MQ-36-01: a hidden first button was chosen as the initial
focus target and as a Tab trap stop, so focus never entered the dialog.
Filter out controls with a negative tabIndex, a hidden/aria-hidden/inert
ancestor, display: none or visibility: hidden; on open, skip any control
whose focus() does not take, falling back to the dialog panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:14:15 -04:00
dev_agentandClaude Opus 5.5 1dd97b123c Add Sheet, ErrorBanner, Skeleton and EmptyState primitives
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high)
CI / ci (pull_request) Successful in 1m28s
CI / ci (push) Successful in 1m28s
Sheet is a portalled modal bottom sheet: role="dialog" with aria-modal
and a required accessible name, focus moved in, trapped and restored on
close, Escape and scrim click call onClose, and the rest of <body> is
inert while open. It rises from translateY(100%) over 320 ms
var(--ease-out-soft) over a scrim fading to 50% neutral-900; under
prefers-reduced-motion it opens in place.

ErrorBanner is a muted neutral-100 Card with grape-700 text and a Retry
Button; Skeleton is a --color-surface pill with a 1.2 s opacity pulse
switched off under reduced motion; EmptyState has a Bagel title, body
and an optional primary Button action.

jsdom never matches prefers-reduced-motion, resolves var() colours to
transparent and does not expand the animation shorthand, so the new
src/test/cssRules.ts reads the CSS Modules' declared rules, the
reduced-motion media block and the hashed @keyframes directly.

Maestro task: 2f9b64dd-0c26-47b9-b2b8-557bdf506d68

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:09:47 -04:00
26 changed files with 21 additions and 970 deletions
+2 -18
View File
@@ -9,7 +9,6 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.104.1",
"lucide-react": "^1.51.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^7.18.4"
@@ -737,7 +736,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1262,7 +1261,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -1761,21 +1760,6 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-react": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.51.0.tgz",
"integrity": "sha512-9929jimol5y7VbXUlVKcgURMSeIxFJB7dtWCkRAaNnF7KiCRkSkhmqWPUmAmUFYPezNawFk6qvz3Mu0gEnfI4g==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
-1
View File
@@ -14,7 +14,6 @@
},
"dependencies": {
"@tanstack/react-query": "^5.104.1",
"lucide-react": "^1.51.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^7.18.4"
+10 -44
View File
@@ -1,49 +1,15 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it } from 'vitest'
import { mockViewport } from './test/matchMedia.ts'
import App, { routes } from './App.tsx'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
})
function renderAt(path: string) {
render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
)
}
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',
])
describe('App', () => {
it('renders the MealQuest shell', () => {
render(
<MemoryRouter>
<App />
</MemoryRouter>,
)
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
})
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()
},
)
})
+9 -25
View File
@@ -1,27 +1,15 @@
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'
// 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
// Placeholder shell only: no product UI, auth or API calls yet.
function Shell() {
return (
<main>
<h1>MealQuest</h1>
</main>
)
}
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
// MemoryRouter in tests).
@@ -30,11 +18,7 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Routes>
<Route element={<AppShell />}>
{routes.map(({ path, title }) => (
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
))}
</Route>
<Route path="*" element={<Shell />} />
</Routes>
</QueryClientProvider>
)
-9
View File
@@ -1,9 +0,0 @@
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 <h1>{title}</h1>
}
-24
View File
@@ -1,24 +0,0 @@
.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;
}
-207
View File
@@ -1,207 +0,0 @@
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(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
)
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)
})
})
})
-22
View File
@@ -1,22 +0,0 @@
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 (
<div className={`${styles.shell} ${styles[breakpoint]}`}>
{breakpoint === 'mobile' && <BottomBar />}
{breakpoint === 'tablet' && <Rail />}
{breakpoint === 'desktop' && <Sidebar />}
<main className={styles.main}>
<Outlet />
</main>
</div>
)
}
-55
View File
@@ -1,55 +0,0 @@
.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;
}
-23
View File
@@ -1,23 +0,0 @@
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 (
<nav aria-label="Primary" className={styles.bar}>
{navItems.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
{({ isActive }) => (
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
<Icon icon={item.icon} size="nav" />
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
</span>
)}
</NavLink>
))}
</nav>
)
}
-51
View File
@@ -1,51 +0,0 @@
.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);
}
-27
View File
@@ -1,27 +0,0 @@
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 (
<nav aria-label="Primary" className={styles.rail}>
<div className={styles.mark} aria-hidden="true">
M
</div>
{navItems.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
{({ isActive }) => (
<>
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
<Icon icon={item.icon} size="nav" />
</span>
{item.label}
</>
)}
</NavLink>
))}
</nav>
)
}
-78
View File
@@ -1,78 +0,0 @@
.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);
}
-36
View File
@@ -1,36 +0,0 @@
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 (
<aside className={styles.sidebar}>
<p className={styles.wordmark}>MealQuest</p>
<nav aria-label="Primary" className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) => (isActive ? `${styles.item} ${styles.active}` : styles.item)}
>
<Icon icon={item.icon} size="nav" />
{item.label}
</NavLink>
))}
</nav>
<div className={styles.household}>
<span className={styles.avatar} aria-hidden="true">
K
</span>
<p className={styles.householdText}>
<strong>Our kitchen</strong>
<span className={styles.householdNote}>Shared household</span>
</p>
</div>
</aside>
)
}
-20
View File
@@ -1,20 +0,0 @@
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 },
]
-31
View File
@@ -1,31 +0,0 @@
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',
)
}
-1
View File
@@ -1,6 +1,5 @@
/* 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);
-25
View File
@@ -48,28 +48,3 @@ 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,
)
}
-49
View File
@@ -1,49 +0,0 @@
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<Listener> }[] = []
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<Listener>()
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))
})
})
}
}
-15
View File
@@ -1,15 +0,0 @@
.icon {
display: inline-block;
flex: none;
vertical-align: middle;
}
.nav {
width: 22px;
height: 22px;
}
.inline {
width: 16px;
height: 16px;
}
-45
View File
@@ -1,45 +0,0 @@
import { render, screen } from '@testing-library/react'
import { House } from 'lucide-react'
import { describe, expect, it } from 'vitest'
import { Icon } from './Icon.tsx'
function svg(container: HTMLElement) {
return container.querySelector('svg')!
}
describe('Icon', () => {
it('is hidden and unnamed without a label', () => {
const { container } = render(<Icon icon={House} />)
expect(svg(container)).toHaveAttribute('aria-hidden', 'true')
expect(svg(container)).not.toHaveAttribute('aria-label')
expect(screen.queryByRole('img')).toBeNull()
})
it('is a named image when given a label', () => {
const { container } = render(<Icon icon={House} label="Home" />)
expect(svg(container)).not.toHaveAttribute('aria-hidden')
expect(screen.getByRole('img', { name: 'Home' })).toBe(svg(container))
})
it('draws at stroke width 2.75', () => {
const { container } = render(<Icon icon={House} />)
expect(svg(container)).toHaveAttribute('stroke-width', '2.75')
})
it('is 22 px at the nav size', () => {
const { container } = render(<Icon icon={House} size="nav" />)
const style = getComputedStyle(svg(container))
expect([style.width, style.height]).toEqual(['22px', '22px'])
})
it('is 16 px inline by default', () => {
const { container } = render(<Icon icon={House} />)
const style = getComputedStyle(svg(container))
expect([style.width, style.height]).toEqual(['16px', '16px'])
})
it('merges a caller className', () => {
const { container } = render(<Icon icon={House} className="extra" />)
expect(svg(container)).toHaveClass('extra')
})
})
-21
View File
@@ -1,21 +0,0 @@
import type { LucideIcon } from 'lucide-react'
import styles from './Icon.module.css'
export type IconSize = 'nav' | 'inline'
export type IconProps = {
// A lucide-react icon component, e.g. `import { House } from 'lucide-react'`.
icon: LucideIcon
// nav = 22 px (bottom bar, sidebar); inline = 16 px (beside text).
size?: IconSize
// Accessible name. Without one the icon is decorative and hidden.
label?: string
className?: string
}
// Lucide icon at the handoff's stroke width 2.75, sized by the stylesheet.
export function Icon({ icon: LucideGlyph, size = 'inline', label, className }: IconProps) {
const classes = [styles.icon, styles[size], className].filter(Boolean).join(' ')
const a11y = label === undefined ? { 'aria-hidden': true } : { role: 'img', 'aria-label': label }
return <LucideGlyph strokeWidth={2.75} className={classes} {...a11y} />
}
-26
View File
@@ -1,26 +0,0 @@
/* Radius and inset come from the --photo-radius and --photo-inset custom
properties the component sets from its props. Size it with className. */
.frame {
display: block;
margin: var(--photo-inset);
border-radius: var(--photo-radius);
overflow: hidden;
}
.photo {
object-fit: cover;
filter: saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94);
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: repeating-linear-gradient(
135deg,
var(--color-neutral-300) 0 8px,
var(--color-neutral-200) 8px 16px
);
color: var(--color-neutral-700);
font: 11px var(--font-mono);
}
-66
View File
@@ -1,66 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { declaredStyle } from '../test/cssRules.ts'
import { Photo } from './Photo.tsx'
const id = '01JABCDEFGHJKMNPQRSTVWXYZ0'
describe('Photo', () => {
it('offers the 400 and 1200 renditions as a srcset', () => {
render(<Photo id={id} name="Korean beef bowls" />)
const img = screen.getByRole('img', { name: 'Korean beef bowls' })
expect(img).toHaveAttribute('src', `/media/photos/${id}-400.jpg`)
expect(img.getAttribute('srcset')!.split(', ')).toEqual([
`/media/photos/${id}-400.jpg 400w`,
`/media/photos/${id}-1200.jpg 1200w`,
])
})
it('loads lazily with the dinner name as alt text', () => {
render(<Photo id={id} name="Pizza night" />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('loading', 'lazy')
expect(img).toHaveAttribute('alt', 'Pizza night')
})
it('applies the washed filter', () => {
render(<Photo id={id} name="Pizza night" />)
expect(declaredStyle(screen.getByRole('img')).filter).toBe(
'saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)',
)
})
it('takes its radius and inset from props', () => {
render(<Photo id={id} name="Pizza night" radius="var(--radius-lg)" inset="10px" />)
const img = screen.getByRole('img')
const style = declaredStyle(img)
expect(style.borderRadius).toBe('var(--photo-radius)')
expect(style.margin).toBe('var(--photo-inset)')
expect(img.style.getPropertyValue('--photo-radius')).toBe('var(--radius-lg)')
expect(img.style.getPropertyValue('--photo-inset')).toBe('10px')
})
it('shows the striped placeholder when there is no photo', () => {
const { container } = render(<Photo name="Beef tacos" />)
expect(container.querySelector('img')).toBeNull()
const placeholder = screen.getByRole('img', { name: 'Beef tacos' })
expect(placeholder).toHaveTextContent('dish photo')
expect(declaredStyle(placeholder).background.replace(/\s+/g, ' ')).toBe(
'repeating-linear-gradient( 135deg, var(--color-neutral-300) 0 8px, var(--color-neutral-200) 8px 16px )',
)
})
it('falls back to the placeholder when the image fails to load', () => {
const { container } = render(<Photo id={id} name="Beef tacos" />)
fireEvent.error(container.querySelector('img')!)
expect(container.querySelector('img')).toBeNull()
expect(screen.getByRole('img', { name: 'Beef tacos' })).toHaveTextContent('dish photo')
})
it('tries again when given a new id after a failure', () => {
const { container, rerender } = render(<Photo id={id} name="Beef tacos" />)
fireEvent.error(container.querySelector('img')!)
rerender(<Photo id="01JZZZZZZZZZZZZZZZZZZZZZZZ" name="Beef tacos" />)
expect(container.querySelector('img')).toHaveAttribute('src', '/media/photos/01JZZZZZZZZZZZZZZZZZZZZZZZ-400.jpg')
})
})
-49
View File
@@ -1,49 +0,0 @@
import { useState, type CSSProperties } from 'react'
import styles from './Photo.module.css'
export type PhotoProps = {
// Photo id; the server stores /media/photos/{id}-400.jpg and -1200.jpg.
// Without one the striped "dish photo" placeholder is shown.
id?: string
// The dinner name, used as the alt text.
name: string
// Corner radius and the margin from the containing card edge, as CSS lengths.
radius?: string
inset?: string
// The rendered width hint for srcset selection.
sizes?: string
className?: string
style?: CSSProperties
}
// Dinner photo through the Organic "washed" filter. Falls back to the
// striped placeholder when there is no photo or it fails to load.
export function Photo({ id, name, radius = '24px', inset = '0', sizes = '100vw', className, style }: PhotoProps) {
// The id whose image failed, so a new id gets a fresh attempt.
const [failedId, setFailedId] = useState<string>()
const frame = { '--photo-radius': radius, '--photo-inset': inset, ...style } as CSSProperties
if (id === undefined || failedId === id) {
const classes = [styles.frame, styles.placeholder, className].filter(Boolean).join(' ')
return (
<div role="img" aria-label={name} className={classes} style={frame}>
<span aria-hidden="true">dish photo</span>
</div>
)
}
const base = `/media/photos/${encodeURIComponent(id)}`
const classes = [styles.frame, styles.photo, className].filter(Boolean).join(' ')
return (
<img
src={`${base}-400.jpg`}
srcSet={`${base}-400.jpg 400w, ${base}-1200.jpg 1200w`}
sizes={sizes}
loading="lazy"
alt={name}
className={classes}
style={frame}
onError={() => setFailedId(id)}
/>
)
}
-2
View File
@@ -4,10 +4,8 @@ export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
export { Chip, type ChipProps } from './Chip.tsx'
export { EmptyState, type EmptyStateAction, type EmptyStateProps } from './EmptyState.tsx'
export { ErrorBanner, type ErrorBannerProps } from './ErrorBanner.tsx'
export { Icon, type IconProps, type IconSize } from './Icon.tsx'
export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Photo, type PhotoProps } from './Photo.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
export { Sheet, type SheetProps } from './Sheet.tsx'
export { Skeleton, type SkeletonProps } from './Skeleton.tsx'