Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 3f5dea37d8 Add Photo and Icon components
CI / ci (pull_request) Successful in 1m30s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m31s
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 <noreply@anthropic.com>
2026-10-03 18:25:22 -04:00
9 changed files with 243 additions and 2 deletions
+18 -2
View File
@@ -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",
+1
View File
@@ -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"
+15
View File
@@ -0,0 +1,15 @@
.icon {
display: inline-block;
flex: none;
vertical-align: middle;
}
.nav {
width: 22px;
height: 22px;
}
.inline {
width: 16px;
height: 16px;
}
+45
View File
@@ -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(<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
@@ -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 <LucideGlyph strokeWidth={2.75} className={classes} {...a11y} />
}
+26
View File
@@ -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);
}
+66
View File
@@ -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(<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
@@ -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<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,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'