diff --git a/web/package-lock.json b/web/package-lock.json
index 6414da2..28508c6 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,6 +9,7 @@
"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"
@@ -736,7 +737,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1261,7 +1262,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -1760,6 +1761,21 @@
"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",
diff --git a/web/package.json b/web/package.json
index e5bf6d4..0055edc 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,6 +14,7 @@
},
"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"
diff --git a/web/src/ui/Icon.module.css b/web/src/ui/Icon.module.css
new file mode 100644
index 0000000..1c297c1
--- /dev/null
+++ b/web/src/ui/Icon.module.css
@@ -0,0 +1,15 @@
+.icon {
+ display: inline-block;
+ flex: none;
+ vertical-align: middle;
+}
+
+.nav {
+ width: 22px;
+ height: 22px;
+}
+
+.inline {
+ width: 16px;
+ height: 16px;
+}
diff --git a/web/src/ui/Icon.test.tsx b/web/src/ui/Icon.test.tsx
new file mode 100644
index 0000000..195b11a
--- /dev/null
+++ b/web/src/ui/Icon.test.tsx
@@ -0,0 +1,45 @@
+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()
+ 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()
+ 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()
+ expect(svg(container)).toHaveAttribute('stroke-width', '2.75')
+ })
+
+ it('is 22 px at the nav size', () => {
+ const { container } = render()
+ const style = getComputedStyle(svg(container))
+ expect([style.width, style.height]).toEqual(['22px', '22px'])
+ })
+
+ it('is 16 px inline by default', () => {
+ const { container } = render()
+ const style = getComputedStyle(svg(container))
+ expect([style.width, style.height]).toEqual(['16px', '16px'])
+ })
+
+ it('merges a caller className', () => {
+ const { container } = render()
+ expect(svg(container)).toHaveClass('extra')
+ })
+})
diff --git a/web/src/ui/Icon.tsx b/web/src/ui/Icon.tsx
new file mode 100644
index 0000000..c3b5919
--- /dev/null
+++ b/web/src/ui/Icon.tsx
@@ -0,0 +1,21 @@
+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
+}
diff --git a/web/src/ui/Photo.module.css b/web/src/ui/Photo.module.css
new file mode 100644
index 0000000..8dc45d9
--- /dev/null
+++ b/web/src/ui/Photo.module.css
@@ -0,0 +1,26 @@
+/* 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);
+}
diff --git a/web/src/ui/Photo.test.tsx b/web/src/ui/Photo.test.tsx
new file mode 100644
index 0000000..b4ed27a
--- /dev/null
+++ b/web/src/ui/Photo.test.tsx
@@ -0,0 +1,66 @@
+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()
+ 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()
+ const img = screen.getByRole('img')
+ expect(img).toHaveAttribute('loading', 'lazy')
+ expect(img).toHaveAttribute('alt', 'Pizza night')
+ })
+
+ it('applies the washed filter', () => {
+ render()
+ 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()
+ 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()
+ 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()
+ 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()
+ fireEvent.error(container.querySelector('img')!)
+ rerender()
+ expect(container.querySelector('img')).toHaveAttribute('src', '/media/photos/01JZZZZZZZZZZZZZZZZZZZZZZZ-400.jpg')
+ })
+})
diff --git a/web/src/ui/Photo.tsx b/web/src/ui/Photo.tsx
new file mode 100644
index 0000000..476fc4a
--- /dev/null
+++ b/web/src/ui/Photo.tsx
@@ -0,0 +1,49 @@
+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()
+ 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 (
+
+ dish photo
+
+ )
+ }
+
+ const base = `/media/photos/${encodeURIComponent(id)}`
+ const classes = [styles.frame, styles.photo, className].filter(Boolean).join(' ')
+ return (
+
setFailedId(id)}
+ />
+ )
+}
diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts
index 85dc9a7..bab54f9 100644
--- a/web/src/ui/index.ts
+++ b/web/src/ui/index.ts
@@ -4,8 +4,10 @@ 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'