From 3f5dea37d83395039fed266147ef401beeee3320 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:25:22 -0400 Subject: [PATCH] Add Photo and Icon components Icon wraps a lucide-react icon component at stroke width 2.75, sized 22 px (nav) or 16 px (inline) by its CSS module, aria-hidden unless a label names it. Photo renders the 400 and 1200 renditions as a lazy srcset through the washed filter, with configurable radius and inset, and falls back to the striped "dish photo" placeholder when there is no photo or it fails to load. Adds lucide-react. Maestro task: a9214566-6076-49e5-98b8-89ba98d6d838 Co-Authored-By: Claude Opus 5.5 --- web/package-lock.json | 20 +++++++++-- web/package.json | 1 + web/src/ui/Icon.module.css | 15 +++++++++ web/src/ui/Icon.test.tsx | 45 +++++++++++++++++++++++++ web/src/ui/Icon.tsx | 21 ++++++++++++ web/src/ui/Photo.module.css | 26 +++++++++++++++ web/src/ui/Photo.test.tsx | 66 +++++++++++++++++++++++++++++++++++++ web/src/ui/Photo.tsx | 49 +++++++++++++++++++++++++++ web/src/ui/index.ts | 2 ++ 9 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 web/src/ui/Icon.module.css create mode 100644 web/src/ui/Icon.test.tsx create mode 100644 web/src/ui/Icon.tsx create mode 100644 web/src/ui/Photo.module.css create mode 100644 web/src/ui/Photo.test.tsx create mode 100644 web/src/ui/Photo.tsx 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 ( +
+ +
+ ) + } + + const base = `/media/photos/${encodeURIComponent(id)}` + const classes = [styles.frame, styles.photo, className].filter(Boolean).join(' ') + return ( + {name} 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' -- 2.55.0