Add responsive app shell, routes and navigation
CI / ci (pull_request) Successful in 1m31s
CI / ci (push) Successful in 1m32s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:33:09 -04:00
co-authored by Claude Opus 5.5
parent a453a84231
commit 93e209c302
16 changed files with 691 additions and 19 deletions
+49
View File
@@ -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<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))
})
})
}
}