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'