diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts
new file mode 100644
index 0000000..e8413aa
--- /dev/null
+++ b/web/src/test/cssRules.ts
@@ -0,0 +1,50 @@
+// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
+// (css: true). jsdom's getComputedStyle cannot answer two things these
+// primitives need checked: it resolves var() colours to transparent, and it
+// evaluates only "all" and "screen" media queries, so styles inside
+// @media (prefers-reduced-motion: reduce) never apply.
+
+function cssRules(): CSSRule[] {
+ return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
+}
+
+function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
+ for (const rule of rules) {
+ if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
+ for (const property of Array.from(rule.style)) {
+ style.setProperty(property, rule.style.getPropertyValue(property))
+ }
+ }
+}
+
+// The declarations the stylesheets give the element, as written. Read the
+// shorthands the CSS uses (background, animation): jsdom does not expand
+// animation, nor any shorthand holding a var(). Later rules win; specificity
+// is not considered.
+export function declaredStyle(element: Element): CSSStyleDeclaration {
+ const style = document.createElement('div').style
+ collect(element, cssRules(), style)
+ return style
+}
+
+// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
+export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
+ const style = document.createElement('div').style
+ for (const rule of cssRules()) {
+ if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
+ collect(element, Array.from(rule.cssRules), style)
+ }
+ }
+ return style
+}
+
+// The @keyframes the element's animation runs. CSS Modules hash keyframe
+// names, so this follows the element's own animation-name.
+export function animationKeyframes(element: Element): CSSKeyframesRule {
+ const [name] = declaredStyle(element).animation.split(' ')
+ const keyframes = cssRules().find(
+ (rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
+ )
+ if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
+ return keyframes
+}
diff --git a/web/src/ui/EmptyState.module.css b/web/src/ui/EmptyState.module.css
new file mode 100644
index 0000000..c9ef4f1
--- /dev/null
+++ b/web/src/ui/EmptyState.module.css
@@ -0,0 +1,30 @@
+.empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-8) var(--space-4);
+ color: var(--color-text);
+ text-align: center;
+}
+
+.title {
+ margin: 0;
+ font-family: var(--font-heading);
+ font-size: 24px;
+ font-weight: 400;
+ line-height: 1.15;
+}
+
+.body {
+ margin: 0;
+ max-width: 32ch;
+ font-family: var(--font-body);
+ font-size: 15px;
+ line-height: 1.45;
+ color: var(--color-neutral-700);
+}
+
+.action {
+ margin-top: var(--space-2);
+}
diff --git a/web/src/ui/EmptyState.test.tsx b/web/src/ui/EmptyState.test.tsx
new file mode 100644
index 0000000..c861752
--- /dev/null
+++ b/web/src/ui/EmptyState.test.tsx
@@ -0,0 +1,27 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { expectMinHitArea } from '../test/hitArea.ts'
+import { EmptyState } from './EmptyState.tsx'
+
+describe('EmptyState', () => {
+ it('shows a Bagel title and body without an action by default', () => {
+ render(Swipe Try it on a Side quest to save it here.)
+ const title = screen.getByRole('heading', { name: 'Quest log is empty' })
+ expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)')
+ expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument()
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('renders the optional primary action and calls its handler', () => {
+ const onClick = vi.fn()
+ render(
+
+ Add the dinners you make most.
+ ,
+ )
+ const action = screen.getByRole('button', { name: 'Add a dinner' })
+ expectMinHitArea(action)
+ fireEvent.click(action)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/src/ui/EmptyState.tsx b/web/src/ui/EmptyState.tsx
new file mode 100644
index 0000000..0c0ecab
--- /dev/null
+++ b/web/src/ui/EmptyState.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from 'react'
+import { Button } from './Button.tsx'
+import styles from './EmptyState.module.css'
+
+export type EmptyStateAction = {
+ label: string
+ onClick: () => void
+}
+
+export type EmptyStateProps = {
+ title: string
+ children: ReactNode
+ // Optional primary action, rendered as a primary Button.
+ action?: EmptyStateAction
+}
+
+// Bagel title, body text and an optional primary action.
+export function EmptyState({ title, children, action }: EmptyStateProps) {
+ return (
+
+ {title}
+ {children}
+ {action && (
+
+ )}
+
+ )
+}
diff --git a/web/src/ui/ErrorBanner.module.css b/web/src/ui/ErrorBanner.module.css
new file mode 100644
index 0000000..e0ffc8d
--- /dev/null
+++ b/web/src/ui/ErrorBanner.module.css
@@ -0,0 +1,16 @@
+.banner {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ background: var(--color-neutral-100);
+}
+
+.message {
+ flex: 1;
+ margin: 0;
+ color: var(--color-accent-700);
+ font-family: var(--font-body);
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1.35;
+}
diff --git a/web/src/ui/ErrorBanner.test.tsx b/web/src/ui/ErrorBanner.test.tsx
new file mode 100644
index 0000000..811b5e5
--- /dev/null
+++ b/web/src/ui/ErrorBanner.test.tsx
@@ -0,0 +1,32 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { declaredStyle } from '../test/cssRules.ts'
+import { expectMinHitArea } from '../test/hitArea.ts'
+import { ErrorBanner } from './ErrorBanner.tsx'
+
+describe('ErrorBanner', () => {
+ it('announces the message and calls onRetry from the Retry button', () => {
+ const onRetry = vi.fn()
+ render(Couldn't load the Supply run.)
+ expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.")
+ const retry = screen.getByRole('button', { name: 'Retry' })
+ expectMinHitArea(retry)
+ fireEvent.click(retry)
+ expect(onRetry).toHaveBeenCalledTimes(1)
+ })
+
+ it('uses a caller-supplied retry label', () => {
+ render(
+ {}} retryLabel="Rebuild week">
+ The plan didn't save.
+ ,
+ )
+ expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
+ })
+
+ it('is a neutral-100 card with grape-700 text', () => {
+ render( {}}>Offline)
+ expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
+ expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)')
+ })
+})
diff --git a/web/src/ui/ErrorBanner.tsx b/web/src/ui/ErrorBanner.tsx
new file mode 100644
index 0000000..1274bf9
--- /dev/null
+++ b/web/src/ui/ErrorBanner.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react'
+import { Button } from './Button.tsx'
+import { Card } from './Card.tsx'
+import styles from './ErrorBanner.module.css'
+
+export type ErrorBannerProps = {
+ // What went wrong, in the caller's words.
+ children: ReactNode
+ onRetry: () => void
+ retryLabel?: string
+}
+
+// Muted neutral-100 card with grape-700 text and a Retry button.
+export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/web/src/ui/Sheet.module.css b/web/src/ui/Sheet.module.css
new file mode 100644
index 0000000..dcb5cd8
--- /dev/null
+++ b/web/src/ui/Sheet.module.css
@@ -0,0 +1,57 @@
+.root {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+}
+
+.scrim {
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
+ animation: fade 320ms var(--ease-out-soft);
+}
+
+.panel {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ max-height: 90dvh;
+ overflow-y: auto;
+ padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+ background: var(--color-bg);
+ color: var(--color-text);
+ box-shadow: var(--shadow-lg);
+ transform: translateY(0);
+ animation: rise 320ms var(--ease-out-soft);
+}
+
+.panel:focus {
+ outline: none;
+}
+
+@keyframes rise {
+ from {
+ transform: translateY(100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .scrim,
+ .panel {
+ animation: none;
+ }
+}
diff --git a/web/src/ui/Sheet.test.tsx b/web/src/ui/Sheet.test.tsx
new file mode 100644
index 0000000..14564b9
--- /dev/null
+++ b/web/src/ui/Sheet.test.tsx
@@ -0,0 +1,183 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import '../styles/tokens.css'
+import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
+import { Sheet } from './Sheet.tsx'
+import styles from './Sheet.module.css'
+
+function Harness({ withControls = true }: { withControls?: boolean }) {
+ const [open, setOpen] = useState(false)
+ return (
+ <>
+
+ setOpen(false)}>
+ Grouped by dinner
+ {withControls && (
+ <>
+
+
+ >
+ )}
+
+ >
+ )
+}
+
+function openSheet() {
+ const opener = screen.getByRole('button', { name: 'Open Supply run' })
+ opener.focus()
+ fireEvent.click(opener)
+ return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) }
+}
+
+function scrim() {
+ const element = document.querySelector(`.${styles.scrim}`)
+ if (!element) throw new Error('scrim not rendered')
+ return element
+}
+
+describe('Sheet', () => {
+ it('renders nothing while closed', () => {
+ render()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('is a labelled modal dialog', () => {
+ render()
+ const { dialog } = openSheet()
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
+ })
+
+ it('accepts aria-labelledby as its accessible name', () => {
+ render(
+ <>
+
Quest log
+ {}}>
+ Nothing yet
+
+ >,
+ )
+ expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument()
+ })
+
+ it('moves focus to the first control and restores it to the opener on close', () => {
+ render()
+ const { opener } = openSheet()
+ expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
+ fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ expect(opener).toHaveFocus()
+ })
+
+ it('focuses the dialog itself when it has no focusable control', () => {
+ render()
+ const { dialog } = openSheet()
+ expect(dialog).toHaveFocus()
+ })
+
+ it('wraps Tab and Shift+Tab inside the sheet', () => {
+ render()
+ openSheet()
+ const first = screen.getByRole('button', { name: 'Try it' })
+ const last = screen.getByRole('button', { name: 'Pass' })
+
+ last.focus()
+ fireEvent.keyDown(last, { key: 'Tab' })
+ expect(first).toHaveFocus()
+
+ fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
+ expect(last).toHaveFocus()
+ })
+
+ it('keeps focus on the dialog when Tab has nowhere to go', () => {
+ render()
+ const { dialog } = openSheet()
+ const tab = fireEvent.keyDown(dialog, { key: 'Tab' })
+ expect(tab).toBe(false) // default prevented
+ expect(dialog).toHaveFocus()
+ })
+
+ it('closes on Escape', () => {
+ const onClose = vi.fn()
+ render(
+
+
+ ,
+ )
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' })
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('closes on a scrim click but not on a panel click', () => {
+ const onClose = vi.fn()
+ render(
+
+ Grouped by dinner
+ ,
+ )
+ fireEvent.click(screen.getByRole('dialog'))
+ fireEvent.click(screen.getByText('Grouped by dinner'))
+ expect(onClose).not.toHaveBeenCalled()
+ fireEvent.click(scrim())
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('makes the background inert while open and restores it on close', () => {
+ const { container } = render()
+ openSheet()
+ const root = screen.getByRole('dialog').parentElement!
+ expect(root.parentElement).toBe(document.body)
+ expect(container).toHaveAttribute('inert')
+ expect(root).not.toHaveAttribute('inert')
+
+ fireEvent.click(scrim())
+ expect(container).not.toHaveAttribute('inert')
+ })
+
+ it('leaves an already-inert background element inert after close', () => {
+ const other = document.createElement('div')
+ other.setAttribute('inert', '')
+ document.body.append(other)
+ try {
+ render()
+ openSheet()
+ fireEvent.click(scrim())
+ expect(other).toHaveAttribute('inert')
+ } finally {
+ other.remove()
+ }
+ })
+
+ it('rises from 100% to 0 over 320 ms with the soft ease-out', () => {
+ render()
+ const { dialog } = openSheet()
+ expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
+ const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft')
+ expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)')
+ const rise = animationKeyframes(dialog)
+ expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)')
+ expect(rise.findRule('to')?.style.transform).toBe('translateY(0)')
+ })
+
+ it('fades the scrim in to 50% neutral-900', () => {
+ render()
+ openSheet()
+ const style = declaredStyle(scrim())
+ expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)')
+ expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
+ const fade = animationKeyframes(scrim())
+ expect(fade.findRule('from')?.style.opacity).toBe('0')
+ expect(fade.findRule('to')?.style.opacity).toBe('1')
+ })
+
+ it('opens in place with no rise or fade under reduced motion', () => {
+ render()
+ const { dialog } = openSheet()
+ expect(reducedMotionStyle(dialog).animation).toBe('none')
+ expect(reducedMotionStyle(scrim()).animation).toBe('none')
+ expect(declaredStyle(dialog).transform).toBe('translateY(0)')
+ })
+})
diff --git a/web/src/ui/Sheet.tsx b/web/src/ui/Sheet.tsx
new file mode 100644
index 0000000..d1db410
--- /dev/null
+++ b/web/src/ui/Sheet.tsx
@@ -0,0 +1,97 @@
+import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import styles from './Sheet.module.css'
+
+// The dialog's accessible name: exactly one of a label or a labelling element.
+type SheetName =
+ | { 'aria-label': string; 'aria-labelledby'?: never }
+ | { 'aria-labelledby': string; 'aria-label'?: never }
+
+export type SheetProps = SheetName & {
+ open: boolean
+ onClose: () => void
+ children: ReactNode
+}
+
+const focusableSelector = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+function focusables(panel: HTMLElement): HTMLElement[] {
+ return Array.from(panel.querySelectorAll(focusableSelector))
+}
+
+// Modal bottom sheet. Portalled to so everything else there can be made
+// inert while it is open. Focus moves in on open, Tab is trapped inside, and
+// focus returns to the opener on close. Escape and a scrim click call onClose.
+export function Sheet({ open, onClose, children, ...name }: SheetProps) {
+ const rootRef = useRef(null)
+ const panelRef = useRef(null)
+
+ useEffect(() => {
+ const root = rootRef.current
+ const panel = panelRef.current
+ if (!open || !root || !panel) return
+
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
+ const background = Array.from(document.body.children).filter(
+ (element) => element !== root && !element.hasAttribute('inert'),
+ )
+ for (const element of background) element.setAttribute('inert', '')
+ ;(focusables(panel)[0] ?? panel).focus()
+
+ return () => {
+ for (const element of background) element.removeAttribute('inert')
+ opener?.focus()
+ }
+ }, [open])
+
+ if (!open) return null
+
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ event.stopPropagation()
+ onClose()
+ return
+ }
+ if (event.key !== 'Tab' || !panelRef.current) return
+ const items = focusables(panelRef.current)
+ const first = items[0]
+ const last = items[items.length - 1]
+ if (!first || !last) {
+ event.preventDefault()
+ return
+ }
+ const active = document.activeElement
+ if (event.shiftKey && (active === first || active === panelRef.current)) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+
+ return createPortal(
+ ,
+ document.body,
+ )
+}
diff --git a/web/src/ui/Skeleton.module.css b/web/src/ui/Skeleton.module.css
new file mode 100644
index 0000000..8647c14
--- /dev/null
+++ b/web/src/ui/Skeleton.module.css
@@ -0,0 +1,24 @@
+.skeleton {
+ display: block;
+ width: 100%;
+ height: 16px;
+ border-radius: var(--radius-pill);
+ background: var(--color-surface);
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.45;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skeleton {
+ animation: none;
+ }
+}
diff --git a/web/src/ui/Skeleton.test.tsx b/web/src/ui/Skeleton.test.tsx
new file mode 100644
index 0000000..a588018
--- /dev/null
+++ b/web/src/ui/Skeleton.test.tsx
@@ -0,0 +1,36 @@
+import { render } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
+import { Skeleton } from './Skeleton.tsx'
+
+function skeleton(container: HTMLElement) {
+ return container.firstElementChild as HTMLElement
+}
+
+describe('Skeleton', () => {
+ it('is a hidden pill in --color-surface', () => {
+ const { container } = render()
+ const element = skeleton(container)
+ expect(element).toHaveAttribute('aria-hidden', 'true')
+ const style = declaredStyle(element)
+ expect(style.borderRadius).toBe('var(--radius-pill)')
+ expect(style.background).toBe('var(--color-surface)')
+ })
+
+ it('pulses its opacity over 1.2 s', () => {
+ const { container } = render()
+ expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite'])
+ expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45')
+ })
+
+ it('does not pulse under reduced motion', () => {
+ const { container } = render()
+ expect(reducedMotionStyle(skeleton(container)).animation).toBe('none')
+ })
+
+ it('merges a caller className and forwards style', () => {
+ const { container } = render()
+ expect(skeleton(container)).toHaveClass('line')
+ expect(skeleton(container).style.width).toBe('60%')
+ })
+})
diff --git a/web/src/ui/Skeleton.tsx b/web/src/ui/Skeleton.tsx
new file mode 100644
index 0000000..75e2458
--- /dev/null
+++ b/web/src/ui/Skeleton.tsx
@@ -0,0 +1,12 @@
+import type { ComponentPropsWithRef } from 'react'
+import styles from './Skeleton.module.css'
+
+export type SkeletonProps = Omit, 'children'>
+
+// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse.
+// Full width and 16 px tall by default; size it with className or style.
+// Hidden from assistive technology — announce loading on the container.
+export function Skeleton({ className, ...rest }: SkeletonProps) {
+ const classes = [styles.skeleton, className].filter(Boolean).join(' ')
+ return
+}
diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts
index f0e6b63..85dc9a7 100644
--- a/web/src/ui/index.ts
+++ b/web/src/ui/index.ts
@@ -2,9 +2,13 @@ export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
export { Card, type CardProps, type CardTone } from './Card.tsx'
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 { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
export { ListRow, type ListRowProps } from './ListRow.tsx'
export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
+export { Sheet, type SheetProps } from './Sheet.tsx'
+export { Skeleton, type SkeletonProps } from './Skeleton.tsx'
export { Slider, type SliderProps } from './Slider.tsx'
export { Tag, type TagProps, type TagTone } from './Tag.tsx'
export { TextInput, type TextInputProps } from './TextInput.tsx'