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 3ea1eed..9ce06bc 100644 --- a/web/package.json +++ b/web/package.json @@ -5,13 +5,16 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "npm run lint:colors && vitest run", + "lint:colors": "node scripts/lint-colors.mjs", + "check:dist-fonts": "node scripts/check-dist-fonts.mjs" }, "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/scripts/check-dist-fonts.mjs b/web/scripts/check-dist-fonts.mjs new file mode 100644 index 0000000..fa9d2cd --- /dev/null +++ b/web/scripts/check-dist-fonts.mjs @@ -0,0 +1,32 @@ +// Fails when the production build references Google Fonts: fonts are +// self-hosted, so dist must never point at fonts.googleapis.com or +// fonts.gstatic.com. +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)) +const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com'] + +function* files(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) yield* files(path) + else yield path + } +} + +const violations = [] +for (const path of files(distDir)) { + const content = readFileSync(path, 'latin1') + for (const host of hosts) { + if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`) + } +} + +if (violations.length > 0) { + console.error('Production build references Google Fonts; fonts must be self-hosted:') + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} +console.log('check:dist-fonts: dist makes no Google Fonts requests') diff --git a/web/scripts/lint-colors.mjs b/web/scripts/lint-colors.mjs new file mode 100644 index 0000000..e6ed7e5 --- /dev/null +++ b/web/scripts/lint-colors.mjs @@ -0,0 +1,42 @@ +// Fails when a hex colour literal appears in any CSS or TypeScript file under +// web/ outside the design tokens: every colour must come from a custom +// property in tokens.css. Dependencies, build output and hidden directories +// (tool caches) are not source and are skipped. +import { readFileSync, readdirSync } from 'node:fs' +import { extname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const webRoot = fileURLToPath(new URL('..', import.meta.url)) +const allowed = new Set(['src/styles/tokens.css']) +const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')]) +const extensions = new Set(['.css', '.ts', '.tsx']) +const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g + +function* sourceFiles(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path) + } else if (extensions.has(extname(entry.name))) yield path + } +} + +const violations = [] +for (const path of sourceFiles(webRoot)) { + const name = relative(webRoot, path).split('\\').join('/') + if (allowed.has(name)) continue + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, i) => { + for (const match of line.matchAll(hexColour)) { + violations.push(`${name}:${i + 1}: ${match[0]}`) + } + }) +} + +if (violations.length > 0) { + console.error('Hex colour literals found; use a token from src/styles/tokens.css:') + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} +console.log('lint:colors: no hex colour literals outside src/styles/tokens.css') diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f50d2b2..ed77cf6 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,15 +1,101 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import { describe, expect, it } from 'vitest' -import App from './App.tsx' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApiClient } from './api/index.ts' +import { mockViewport } from './test/matchMedia.ts' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts' +import App, { routes } from './App.tsx' -describe('App', () => { - it('renders the MealQuest shell', () => { - render( - - - , - ) - expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument() +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +function renderAt(path: string, location = fakeLocation(path)) { + render( + + + , + ) + return location +} + +describe('App routes', () => { + it('registers every handoff route plus the extra settings pages', () => { + expect(routes.map((route) => route.path)).toEqual([ + '/', + '/onboarding', + '/plan', + '/plan/shopping', + '/plan/shopping/archive', + '/discover', + '/quick', + '/cook-from-pantry', + '/dinner/:id', + '/settings', + '/settings/notifications', + '/settings/ai', + '/settings/household', + '/settings/regulars', + '/settings/calendar', + ]) + }) + + it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))( + '%s renders its placeholder page inside the shell', + async (path, title) => { + mockViewport(390) + stubFetch(jsonResponse(200, ME)) + renderAt(path) + const heading = await screen.findByRole('heading', { level: 1, name: title }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() + }, + ) +}) + +describe('App bootstrap', () => { + it('shows the skeleton until /me loads, then the routes', async () => { + mockViewport(390) + const fetchMock = stubFetch(jsonResponse(200, ME)) + renderAt('/plan') + + expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + + expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' }) + }) + + it('sends a signed-out visitor to sign-in and never renders the routes', async () => { + const fetchMock = stubFetch(errorResponse(401, 'unauthenticated')) + const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1')) + + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1')) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + }) + + it('shows the not-in-this-household screen on a 403', async () => { + const fetchMock = stubFetch(errorResponse(403, 'forbidden')) + const location = renderAt('/') + + expect(await screen.findByText(/not in this household/)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument() + expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(location.assign).not.toHaveBeenCalled() + }) + + it('reports a /me failure with a retry instead of rendering the routes', async () => { + mockViewport(390) + stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME)) + renderAt('/') + + expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') + screen.getByRole('button', { name: 'Retry' }).click() + expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 9415bc4..13cdf10 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,25 +1,58 @@ import { useState } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' +import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' +import { AuthBootstrap } from './AuthBootstrap.tsx' +import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { AppShell } from './shell/AppShell.tsx' -// Placeholder shell only: no product UI, auth or API calls yet. -function Shell() { +// Every route the app serves, with the heading its placeholder page shows. +export const routes = [ + { path: '/', title: 'Home' }, + { path: '/onboarding', title: 'Onboarding' }, + { path: '/plan', title: 'Plan' }, + { path: '/plan/shopping', title: 'Supply run' }, + { path: '/plan/shopping/archive', title: 'Supply archive' }, + { path: '/discover', title: 'Discover' }, + { path: '/quick', title: 'Quick dinners' }, + { path: '/cook-from-pantry', title: 'Cook from what I have' }, + { path: '/dinner/:id', title: 'Dinner' }, + { path: '/settings', title: 'Settings' }, + { path: '/settings/notifications', title: 'Notifications' }, + { path: '/settings/ai', title: 'AI assistant' }, + { path: '/settings/household', title: 'Household' }, + { path: '/settings/regulars', title: 'Regulars' }, + { path: '/settings/calendar', title: 'Calendar' }, +] as const + +// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded. +export function AppRoutes() { return ( -
-

MealQuest

-
+ + }> + {routes.map(({ path, title }) => ( + } /> + ))} + + ) } +export type AppProps = { + api: ApiClient +} + // App expects to be rendered inside a router (BrowserRouter in main.tsx, // MemoryRouter in tests). -export default function App() { - const [queryClient] = useState(() => new QueryClient()) +export default function App({ api }: AppProps) { + const [queryClient] = useState(createQueryClient) return ( - - - } /> - - + + + + + + + ) } diff --git a/web/src/AuthBootstrap.tsx b/web/src/AuthBootstrap.tsx new file mode 100644 index 0000000..53f8661 --- /dev/null +++ b/web/src/AuthBootstrap.tsx @@ -0,0 +1,38 @@ +import { useSyncExternalStore, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ApiError, ME_PATH, useApi } from './api/index.ts' +import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx' +import { ErrorBanner } from './ui/ErrorBanner.tsx' +import { Skeleton } from './ui/Skeleton.tsx' +import styles from './pages/FullScreen.module.css' + +export const meQueryKey = [ME_PATH] as const + +// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is +// already navigating to sign-in, so the skeleton stays up; an account outside +// the household sees only the "not in this household" screen. +export function AuthBootstrap({ children }: { children: ReactNode }) { + const api = useApi() + const forbidden = useSyncExternalStore(api.subscribe, api.forbidden) + const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity }) + + if (forbidden) return + if (me.isSuccess) return children + if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) { + return ( +
+ void me.refetch()}>MealQuest could not load: {me.error.message} +
+ ) + } + return ( +
+
+ + + + +
+
+ ) +} diff --git a/web/src/api/ApiContext.tsx b/web/src/api/ApiContext.tsx new file mode 100644 index 0000000..b12ae8a --- /dev/null +++ b/web/src/api/ApiContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext, type ReactNode } from 'react' +import type { ApiClient } from './client.ts' + +const ApiContext = createContext(null) + +export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) { + return {children} +} + +// useApi returns the app's API client. It must be rendered inside ApiProvider. +export function useApi(): ApiClient { + const client = useContext(ApiContext) + if (client === null) throw new Error('useApi: no ApiProvider above this component') + return client +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..f6fa5be --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts' +import { ApiError, createApiClient, CSRF_HEADER } from './index.ts' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('apiFetch', () => { + it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => { + const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 })) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 }) + await expect( + api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }), + ).resolves.toEqual({ ok: 2 }) + + const get = request(fetchMock, 1) + expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' }) + expect(get.headers.has(CSRF_HEADER)).toBe(false) + + const patch = request(fetchMock, 2) + expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' }) + expect(patch.headers.get(CSRF_HEADER)).toBe('token-1') + expect(patch.headers.get('Content-Type')).toBe('application/json') + }) + + it('refuses a non-GET request before /me has loaded', async () => { + const fetchMock = stubFetch() + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([ + 'https://evil.example/api/v1/me', + '//evil.example/api/v1/me', + // The placeholder origin an earlier guard compared against. + '//same-origin.invalid/api/v1/me', + 'https://same-origin.invalid/api/v1/me', + '/\\evil.example/api/v1/me', + '/auth/logout', + '/api/v1/../auth/logout', + '/api/v1/%2e%2e/auth/logout', + 'api/v1/me', + ])('refuses %s without sending it or the CSRF token', async (path) => { + const fetchMock = stubFetch(jsonResponse(200, ME)) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow( + `POST ${path}: not a same-origin /api/v1/ path`, + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' }) + expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false) + }) + + it('rejects with a typed ApiError from the error body', async () => { + stubFetch(errorResponse(503, 'unavailable', true)) + const api = createApiClient(fakeLocation()) + const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e) + expect(error).toBeInstanceOf(ApiError) + expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true }) + }) + + it('rejects a response without the API error body', async () => { + stubFetch(new Response('bad gateway', { status: 502 })) + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON') + }) + + it('on 401 navigates to sign-in with the current path and search to return to', async () => { + stubFetch(errorResponse(401, 'unauthenticated')) + const location = fakeLocation('/plan/shopping', '?week=2026-10-05') + const api = createApiClient(location) + + await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 }) + expect(location.assign).toHaveBeenCalledExactlyOnceWith( + '/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05', + ) + }) + + it('on a csrf 403 refreshes /me once and retries with the new token', async () => { + const fetchMock = stubFetch( + jsonResponse(200, ME), + errorResponse(403, 'csrf'), + jsonResponse(200, { ...ME, csrfToken: 'token-2' }), + jsonResponse(201, { id: 'j1' }), + ) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({ + id: 'j1', + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1') + expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' }) + expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' }) + expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2') + expect(api.forbidden()).toBe(false) + }) + + it('does not loop when the retry gets a csrf 403 too', async () => { + const fetchMock = stubFetch( + jsonResponse(200, ME), + errorResponse(403, 'csrf'), + jsonResponse(200, { ...ME, csrfToken: 'token-2' }), + errorResponse(403, 'csrf'), + ) + const api = createApiClient(fakeLocation()) + await api.loadMe() + + await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({ + status: 403, + code: 'csrf', + }) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(api.forbidden()).toBe(false) + }) + + it('marks any other 403 as not in this household and notifies subscribers', async () => { + const fetchMock = stubFetch(errorResponse(403, 'forbidden')) + const api = createApiClient(fakeLocation()) + const listener = vi.fn() + api.subscribe(listener) + + await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(api.forbidden()).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('does not take a 403 without the API error body as not in this household', async () => { + stubFetch(new Response('denied by proxy', { status: 403 })) + const api = createApiClient(fakeLocation()) + await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON') + expect(api.forbidden()).toBe(false) + }) + + it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => { + const fetchMock = stubFetch(errorResponse(403, 'csrf')) + const api = createApiClient(fakeLocation()) + await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('loadMe', () => { + it('rejects a /me response without a CSRF token', async () => { + stubFetch(jsonResponse(200, { ...ME, csrfToken: '' })) + const api = createApiClient(fakeLocation()) + await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token') + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..a4f840e --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,155 @@ +import { ApiError, isRecord, parseApiError } from './errors.ts' + +// CSRF_HEADER carries the session's CSRF token on every non-GET request. +export const CSRF_HEADER = 'X-MealQuest-CSRF' + +export const ME_PATH = '/api/v1/me' + +// API_PREFIX is the only path apiFetch sends to. +const API_PREFIX = '/api/v1/' + +// apiURL resolves path against the page origin and returns it as the +// path-absolute URL to fetch. It refuses any path that would not resolve to a +// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere +// else: absolute and protocol-relative URLs on another origin, and dot +// segments that climb out of /api/v1/. +function apiURL(origin: string, method: string, path: string): string { + const url = new URL(path, origin) + if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) { + throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`) + } + return url.pathname + url.search +} + +// Me is the GET /api/v1/me response: the signed-in member, the household and +// the session's CSRF token. +export type Me = { + name: string + email: string + household: { label: string } + csrfToken: string +} + +// ApiLocation is the part of window.location the client reads and navigates. +// origin is the page origin every request must stay on. +export type ApiLocation = Pick + +export type ApiRequest = { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + // Sent as JSON. + body?: unknown +} + +export type ApiClient = { + // apiFetch sends a same-origin JSON request to path, which must be under + // /api/v1/, and resolves with the decoded response body, or rejects with an + // ApiError. + apiFetch(path: string, request?: ApiRequest): Promise + // loadMe fetches GET /api/v1/me and caches its CSRF token. + loadMe(): Promise + // forbidden reports whether a request was refused because the signed-in + // account is not in this household; subscribe is notified when it changes. + forbidden(): boolean + subscribe(listener: () => void): () => void +} + +// createApiClient builds the app's API client. location is window.location in +// the app: a 401 sends it to /auth/login with the current path to return to. +export function createApiClient(location: ApiLocation): ApiClient { + let csrfToken: string | null = null + let refreshing: Promise | null = null + let forbidden = false + const listeners = new Set<() => void>() + + async function send(method: string, path: string, body: unknown): Promise { + const headers: Record = { Accept: 'application/json' } + if (body !== undefined) headers['Content-Type'] = 'application/json' + if (method !== 'GET') { + if (csrfToken === null) { + throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`) + } + headers[CSRF_HEADER] = csrfToken + } + return fetch(path, { + method, + headers, + credentials: 'same-origin', + body: body === undefined ? undefined : JSON.stringify(body), + }) + } + + // refreshMe shares one in-flight /me request between every request that + // got a csrf 403 at the same time. + function refreshMe(): Promise { + refreshing ??= loadMe().finally(() => { + refreshing = null + }) + return refreshing + } + + async function apiFetch(path: string, request: ApiRequest = {}): Promise { + const method = request.method ?? 'GET' + const url = apiURL(location.origin, method, path) + let response = await send(method, url, request.body) + if (response.status === 403) { + const error = await parseForbidden(method, path, response) + // Only a non-GET carries the token, so only it can be refused for it. + if (error.code !== 'csrf' || method === 'GET') throw error + // The session's token changed: refresh it once and retry once. + await refreshMe() + response = await send(method, url, request.body) + if (response.status === 403) throw await parseForbidden(method, path, response) + } + return decode(method, path, response) + } + + // parseForbidden reads a 403. Any API error other than csrf means the + // account is not in this household. + async function parseForbidden(method: string, path: string, response: Response): Promise { + const error = await parseApiError(method, path, response) + if (error.code !== 'csrf') markForbidden() + return error + } + + function markForbidden() { + if (forbidden) return + forbidden = true + listeners.forEach((listener) => listener()) + } + + async function decode(method: string, path: string, response: Response): Promise { + if (response.status === 401) { + location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`) + } + if (!response.ok) throw await parseApiError(method, path, response) + if (response.status === 204) return undefined as T + return (await response.json()) as T + } + + async function loadMe(): Promise { + const me = await apiFetch(ME_PATH) + if ( + !isRecord(me) || + typeof me.name !== 'string' || + typeof me.email !== 'string' || + !isRecord(me.household) || + typeof me.household.label !== 'string' || + typeof me.csrfToken !== 'string' || + me.csrfToken === '' + ) { + throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`) + } + csrfToken = me.csrfToken + return me as Me + } + + return { + apiFetch, + loadMe, + forbidden: () => forbidden, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..94b81e2 --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,41 @@ +// ApiError is a failed API response carrying the server's +// {"error":{"code","message","retryable"}} body. +export class ApiError extends Error { + readonly status: number + readonly code: string + readonly retryable: boolean + + constructor(status: number, code: string, message: string, retryable: boolean) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + this.retryable = retryable + } +} + +// parseApiError reads an error response body. A body that is not the API +// error shape is itself an error: the server always sends one. +export async function parseApiError(method: string, path: string, response: Response): Promise { + const text = await response.text() + let body: unknown + try { + body = JSON.parse(text) + } catch { + throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`) + } + const error = isRecord(body) ? body.error : undefined + if ( + !isRecord(error) || + typeof error.code !== 'string' || + typeof error.message !== 'string' || + typeof error.retryable !== 'boolean' + ) { + throw new Error(`${method} ${path}: ${response.status} response has no API error body`) + } + return new ApiError(response.status, error.code, error.message, error.retryable) +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..ea7a989 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,5 @@ +export { ApiProvider, useApi } from './ApiContext.tsx' +export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts' +export { ApiError } from './errors.ts' +export { createQueryClient, shouldRetry } from './queryClient.ts' +export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts' diff --git a/web/src/api/queryClient.test.ts b/web/src/api/queryClient.test.ts new file mode 100644 index 0000000..81a1378 --- /dev/null +++ b/web/src/api/queryClient.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { ApiError, createQueryClient, shouldRetry } from './index.ts' + +describe('query retries', () => { + it('retry only an ApiError the server marked retryable, a bounded number of times', () => { + expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true) + expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false) + expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false) + expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false) + expect(shouldRetry(0, new Error('network down'))).toBe(false) + }) + + it('are the QueryClient default for queries and mutations', () => { + const options = createQueryClient().getDefaultOptions() + expect(options.queries?.retry).toBe(shouldRetry) + expect(options.mutations?.retry).toBe(shouldRetry) + }) +}) diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts new file mode 100644 index 0000000..b703055 --- /dev/null +++ b/web/src/api/queryClient.ts @@ -0,0 +1,20 @@ +import { QueryClient } from '@tanstack/react-query' +import { ApiError } from './errors.ts' + +// MAX_RETRIES bounds retries of a failure the server marked retryable. +export const MAX_RETRIES = 3 + +// shouldRetry retries only an ApiError the server marked retryable — never a +// 401, a 403 or a malformed response. +export function shouldRetry(failureCount: number, error: unknown): boolean { + return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES +} + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: shouldRetry, staleTime: 30_000 }, + mutations: { retry: shouldRetry }, + }, + }) +} diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx new file mode 100644 index 0000000..f93ce20 --- /dev/null +++ b/web/src/api/useJob.test.tsx @@ -0,0 +1,169 @@ +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts' +import { ApiProvider, createApiClient, useJob } from './index.ts' + +type Draft = { dinners: string[] } + +function job(status: string, result: Draft | null, error: string | null = null) { + return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error }) +} + +function renderJob(jobId: string | null) { + const api = createApiClient(fakeLocation()) + const wrapper = ({ children }: { children: ReactNode }) => {children} + return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) +} + +// advance runs the timers due in ms and lets the polls they start answer. +async function advance(ms: number) { + await act(() => vi.advanceTimersByTimeAsync(ms)) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('useJob', () => { + it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => { + const fetchMock = stubFetch( + job('queued', null), + job('running', { dinners: ['Tacos'] }), + job('running', { dinners: ['Tacos', 'Chili'] }), + job('running', { dinners: ['Tacos', 'Chili'] }), + job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }), + ) + const { result } = renderJob('j1') + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + + await advance(0) + expect(result.current.status).toBe('queued') + expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' }) + + await advance(499) + expect(fetchMock).toHaveBeenCalledTimes(1) + await advance(1) + expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] }) + + await advance(2000) + expect(fetchMock).toHaveBeenCalledTimes(4) + await advance(2000) + expect(fetchMock).toHaveBeenCalledTimes(5) + expect(result.current).toEqual({ + status: 'succeeded', + result: { dinners: ['Tacos', 'Chili', 'Soup'] }, + error: null, + }) + + // Terminal: no timer left, no further polls. + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(5) + }) + + it('exposes a failed job as its error and stops', async () => { + const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused')) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(result.current.status).toBe('failed') + expect(result.current.error?.message).toBe('model refused') + expect(vi.getTimerCount()).toBe(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('exposes a failed poll as the error and keeps polling to the terminal status', async () => { + const fetchMock = stubFetch( + job('running', { dinners: ['Tacos'] }), + errorResponse(503, 'unavailable', true), + job('succeeded', { dinners: ['Tacos', 'Chili'] }), + ) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) + expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' }) + expect(vi.getTimerCount()).toBe(1) + + // The backoff carries on from the failed poll: the next one is 1 s later. + await advance(999) + expect(fetchMock).toHaveBeenCalledTimes(2) + await advance(1) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('stops polling on unmount after a failed poll', async () => { + const fetchMock = stubFetch(errorResponse(503, 'unavailable', true)) + const { result, unmount } = renderJob('j1') + + await advance(0) + expect(result.current).toMatchObject({ status: undefined, result: null }) + expect(result.current.error).toMatchObject({ status: 503 }) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('stops polling the old job when the job id changes', async () => { + const fetchMock = stubFetch(job('running', null), job('running', null)) + const { result, rerender } = renderJob('j1') + + await advance(0) + rerender({ id: 'j2' }) + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + await advance(0) + expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2') + expect(vi.getTimerCount()).toBe(1) + }) + + it('stops polling on unmount', async () => { + const fetchMock = stubFetch(job('running', null)) + const { unmount } = renderJob('j1') + + await advance(0) + expect(vi.getTimerCount()).toBe(1) + unmount() + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('ignores a poll that answers after unmount', async () => { + let answer!: (response: Response) => void + const fetchMock = stubFetch() + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))) + const { result, unmount } = renderJob('j1') + + unmount() + await act(async () => answer(await Promise.resolve(job('running', null)))) + expect(vi.getTimerCount()).toBe(0) + expect(result.current.status).toBeUndefined() + }) + + it('polls nothing without a job id', async () => { + const fetchMock = stubFetch() + const { result } = renderJob(null) + await advance(5000) + expect(fetchMock).not.toHaveBeenCalled() + expect(result.current).toEqual({ status: undefined, result: null, error: null }) + }) +}) diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts new file mode 100644 index 0000000..2a26c2c --- /dev/null +++ b/web/src/api/useJob.ts @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react' +import { useApi } from './ApiContext.tsx' + +export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' + +// Job is the GET /api/v1/jobs/{id} response. result may be set while the job +// is running (a partial result); error is set only when it failed. +export type Job = { + id: string + kind: string + status: JobStatus + result: R | null + error: string | null +} + +export type JobState = { + // undefined until the first poll answers. + status: JobStatus | undefined + // The latest result, partial while the job runs. + result: R | null + // The job's failure, or the latest polling request's own failure (cleared + // by the next poll that answers). + error: Error | null +} + +// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS. +export const FIRST_DELAY_MS = 500 +export const MAX_DELAY_MS = 2000 + +const idle = { status: undefined, result: null, error: null } + +function isTerminal(status: JobStatus): boolean { + return status === 'succeeded' || status === 'failed' +} + +// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails, +// backing off from 500 ms to 2 s. A failed poll is exposed as the error and +// polling continues. Polling stops only at a terminal status, when jobId +// changes and on unmount. A null jobId polls nothing. +export function useJob(jobId: string | null): JobState { + const { apiFetch } = useApi() + const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle }) + + useEffect(() => { + if (jobId === null) return + let active = true + let timer: ReturnType | undefined + let delay = FIRST_DELAY_MS + + function next() { + timer = setTimeout(poll, delay) + delay = Math.min(delay * 2, MAX_DELAY_MS) + } + + async function poll() { + try { + const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`) + if (!active) return + const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null + setState({ jobId, job: { status: job.status, result: job.result, error } }) + if (isTerminal(job.status)) return + } catch (error) { + if (!active) return + // A failed poll is not the job's end: keep the last known status and + // result, expose the request's error, and poll again. + setState((prev) => ({ + jobId, + job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) }, + })) + } + next() + } + + void poll() + return () => { + active = false + clearTimeout(timer) + } + }, [apiFetch, jobId]) + + // State left from a previous jobId is not this job's. + return state.jobId === jobId ? state.job : idle +} diff --git a/web/src/assets/fonts/BagelFatOne-Latin.woff2 b/web/src/assets/fonts/BagelFatOne-Latin.woff2 new file mode 100644 index 0000000..bd96304 Binary files /dev/null and b/web/src/assets/fonts/BagelFatOne-Latin.woff2 differ diff --git a/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 b/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 new file mode 100644 index 0000000..f3bd500 Binary files /dev/null and b/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 differ diff --git a/web/src/assets/fonts/BagelFatOne-OFL.txt b/web/src/assets/fonts/BagelFatOne-OFL.txt new file mode 100644 index 0000000..21c7b2d --- /dev/null +++ b/web/src/assets/fonts/BagelFatOne-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/Figtree-Bold.woff2 b/web/src/assets/fonts/Figtree-Bold.woff2 new file mode 100644 index 0000000..81bcca4 Binary files /dev/null and b/web/src/assets/fonts/Figtree-Bold.woff2 differ diff --git a/web/src/assets/fonts/Figtree-OFL.txt b/web/src/assets/fonts/Figtree-OFL.txt new file mode 100644 index 0000000..80b12bb --- /dev/null +++ b/web/src/assets/fonts/Figtree-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/Figtree-Regular.woff2 b/web/src/assets/fonts/Figtree-Regular.woff2 new file mode 100644 index 0000000..2c1165f Binary files /dev/null and b/web/src/assets/fonts/Figtree-Regular.woff2 differ diff --git a/web/src/assets/fonts/Figtree-SemiBold.woff2 b/web/src/assets/fonts/Figtree-SemiBold.woff2 new file mode 100644 index 0000000..8855ccb Binary files /dev/null and b/web/src/assets/fonts/Figtree-SemiBold.woff2 differ diff --git a/web/src/main.tsx b/web/src/main.tsx index 1a842db..fe00a99 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,17 +1,22 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' +import './styles/tokens.css' +import './styles/base.css' import App from './App.tsx' +import { createApiClient } from './api/index.ts' const root = document.getElementById('root') if (!root) { throw new Error('MealQuest: #root element missing from index.html') } +const api = createApiClient(window.location) + createRoot(root).render( - + , ) diff --git a/web/src/pages/FullScreen.module.css b/web/src/pages/FullScreen.module.css new file mode 100644 index 0000000..12c55ec --- /dev/null +++ b/web/src/pages/FullScreen.module.css @@ -0,0 +1,23 @@ +/* A full-viewport screen shown instead of the app shell. */ +.screen { + min-height: 100dvh; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + background: var(--color-bg); +} + +.loading { + width: min(100%, 480px); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.title { + height: 32px; + width: 60%; +} diff --git a/web/src/pages/NotInHouseholdPage.tsx b/web/src/pages/NotInHouseholdPage.tsx new file mode 100644 index 0000000..f1bb3db --- /dev/null +++ b/web/src/pages/NotInHouseholdPage.tsx @@ -0,0 +1,14 @@ +import { EmptyState } from '../ui/EmptyState.tsx' +import styles from './FullScreen.module.css' + +// Shown when the server refuses a signed-in account that is not in this +// household. Nothing else renders: the account can do nothing here. +export function NotInHouseholdPage() { + return ( +
+ + You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it. + +
+ ) +} diff --git a/web/src/pages/PlaceholderPage.tsx b/web/src/pages/PlaceholderPage.tsx new file mode 100644 index 0000000..a8d7124 --- /dev/null +++ b/web/src/pages/PlaceholderPage.tsx @@ -0,0 +1,9 @@ +export type PlaceholderPageProps = { + // The route's screen name, shown as the page heading. + title: string +} + +// Stands in for a screen until its feature is built. +export function PlaceholderPage({ title }: PlaceholderPageProps) { + return

{title}

+} diff --git a/web/src/shell/AppShell.module.css b/web/src/shell/AppShell.module.css new file mode 100644 index 0000000..e1eff44 --- /dev/null +++ b/web/src/shell/AppShell.module.css @@ -0,0 +1,24 @@ +.shell { + position: relative; + height: 100dvh; + display: flex; + background: var(--color-bg); +} + +.main { + flex: 1; + min-width: 0; + overflow-y: auto; +} + +/* The bottom bar overlays the last 94 px of the screen (plus the safe area), + so the scroll area reserves at least 100 px beneath its content. */ +.mobile .main { + padding: 0 20px; + padding-bottom: calc(100px + env(safe-area-inset-bottom)); +} + +.tablet .main, +.desktop .main { + padding: 32px 36px; +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx new file mode 100644 index 0000000..03d7bfd --- /dev/null +++ b/web/src/shell/AppShell.test.tsx @@ -0,0 +1,211 @@ +import { render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it } from 'vitest' +import { AppRoutes } from '../App.tsx' +import { declaredStyle, outerSize } from '../test/cssRules.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { mockViewport } from '../test/matchMedia.ts' +import bottomBarStyles from './BottomBar.module.css' +import railStyles from './Rail.module.css' +import sidebarStyles from './Sidebar.module.css' + +const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings'] +const MOBILE = 390 +const TABLET = 1180 +const DESKTOP = 1440 + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, width: number) { + const resize = mockViewport(width) + render( + + + , + ) + return resize +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function navLinks() { + return within(nav()).getAllByRole('link') +} + +function current() { + return navLinks() + .filter((link) => link.getAttribute('aria-current') === 'page') + .map((link) => link.textContent) +} + +describe('AppShell', () => { + it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => { + renderAt('/', width) + expect(navLinks().map((link) => link.textContent)).toEqual(ORDER) + expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([ + '/', + '/plan', + '/discover', + '/quick', + '/settings', + ]) + }) + + it('shows only the mobile bottom bar below 768 px', () => { + renderAt('/', 767) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.queryByText('M')).not.toBeInTheDocument() + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + const bar = declaredStyle(nav()) + expect(bar.position).toBe('absolute') + expect(bar.left).toBe('12px') + expect(bar.right).toBe('12px') + expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))') + expect(bar.height).toBe('62px') + expect(outerSize(nav(), 'height')).toBe(62) + expect(bar.background).toBe('var(--color-text)') + expect(bar.borderRadius).toBe('var(--radius-pill)') + }) + + it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => { + renderAt('/discover', MOBILE) + const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`) + expect(pills).toHaveLength(1) + const pill = declaredStyle(pills[0]!) + expect(pill.position).toBe('absolute') + expect(pill.top).toBe('10px') + expect(pill.height).toBe('42px') + expect(pill.background).toBe('var(--color-accent)') + expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex)) + + const active = screen.getByRole('link', { name: 'Discover' }) + expect(declaredStyle(active).color).toBe('var(--on-accent)') + expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!) + expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px') + + const inactive = screen.getByRole('link', { name: 'Plan' }) + expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)') + expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!) + }) + + it('makes the full 62 px bar height the mobile tap target', () => { + renderAt('/', MOBILE) + for (const link of navLinks()) { + expect(declaredStyle(link).height).toBe('62px') + expectMinHitArea(link) + } + }) + + it('reserves at least 100 px below the mobile scroll area', () => { + renderAt('/', MOBILE) + const padding = declaredStyle(screen.getByRole('main')).paddingBottom + const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding) + expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull() + expect(Number(reserved![1])).toBeGreaterThanOrEqual(100) + }) + + it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => { + renderAt('/plan', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(nav()).toHaveClass(railStyles.rail!) + expect(declaredStyle(nav()).width).toBe('96px') + expect(outerSize(nav(), 'width')).toBe(96) + expect(declaredStyle(nav()).background).toBe('var(--color-surface)') + const mark = within(nav()).getByText('M') + expect(declaredStyle(mark).width).toBe('48px') + expect(outerSize(mark, 'width')).toBe(48) + expect(outerSize(mark, 'height')).toBe(48) + expect(declaredStyle(mark).background).toBe('var(--color-accent)') + expect(screen.queryByText('MealQuest')).not.toBeInTheDocument() + expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument() + + for (const link of navLinks()) { + const pill = link.firstElementChild! + expect(declaredStyle(pill).width).toBe('56px') + expect(declaredStyle(pill).height).toBe('36px') + expect(outerSize(pill, 'width')).toBe(56) + expect(outerSize(pill, 'height')).toBe(36) + expect(declaredStyle(link).fontSize).toBe('11px') + expect(declaredStyle(link).fontWeight).toBe('700') + expectMinHitArea(link) + } + expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!) + expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!) + }) + + it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => { + renderAt('/quick', width) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + const sidebar = nav().closest('aside')! + expect(sidebar).toHaveClass(sidebarStyles.sidebar!) + expect(declaredStyle(sidebar).width).toBe('208px') + expect(declaredStyle(sidebar).background).toBe('var(--color-surface)') + expect(declaredStyle(sidebar).padding).toBe('28px 16px') + // The 16 px side padding sits inside the 208 px, not outside it. + expect(outerSize(sidebar, 'width')).toBe(208) + + const wordmark = within(sidebar).getByText('MealQuest') + expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(wordmark).fontSize).toBe('28px') + expect(declaredStyle(wordmark).color).toBe('var(--color-accent)') + + expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument() + expect(within(sidebar).getByText('Shared household')).toBeInTheDocument() + expect(screen.queryByText('M')).not.toBeInTheDocument() + + const active = screen.getByRole('link', { name: 'Quick' }) + expect(active).toHaveClass(sidebarStyles.active!) + expect(declaredStyle(active).background).toBe('var(--color-accent)') + expect(declaredStyle(active).color).toBe('var(--on-accent)') + expect(declaredStyle(active).fontSize).toBe('15px') + expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!) + // Rows stretch across the sidebar, so only their height needs declaring. + for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px') + }) + + it('switches layout when the viewport crosses a breakpoint', () => { + const resize = renderAt('/', MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + resize(TABLET) + expect(nav()).toHaveClass(railStyles.rail!) + resize(DESKTOP) + expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!) + resize(MOBILE) + expect(nav()).toHaveClass(bottomBarStyles.bar!) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + }) + + describe.each([ + ['mobile', MOBILE], + ['tablet', TABLET], + ['desktop', DESKTOP], + ])('aria-current (%s)', (_layout, width) => { + it.each([ + ['/', ['Home']], + ['/plan', ['Plan']], + ['/plan/shopping', ['Plan']], + ['/plan/shopping/archive', ['Plan']], + ['/discover', ['Discover']], + ['/quick', ['Quick']], + ['/settings', ['Settings']], + ['/settings/notifications', ['Settings']], + ['/settings/ai', ['Settings']], + ['/settings/household', ['Settings']], + ['/settings/regulars', ['Settings']], + ['/settings/calendar', ['Settings']], + ['/onboarding', []], + ['/cook-from-pantry', []], + ['/dinner/abc123', []], + ])('%s marks %j current', (path, expected) => { + renderAt(path, width) + expect(current()).toEqual(expected) + }) + }) +}) diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx new file mode 100644 index 0000000..7baf72b --- /dev/null +++ b/web/src/shell/AppShell.tsx @@ -0,0 +1,22 @@ +import { Outlet } from 'react-router' +import { BottomBar } from './BottomBar.tsx' +import { Rail } from './Rail.tsx' +import { Sidebar } from './Sidebar.tsx' +import { useBreakpoint } from './useBreakpoint.ts' +import styles from './AppShell.module.css' + +// The responsive frame around every page: one nav layout per breakpoint and a +// scrolling main area for the routed page. +export function AppShell() { + const breakpoint = useBreakpoint() + return ( +
+ {breakpoint === 'mobile' && } + {breakpoint === 'tablet' && } + {breakpoint === 'desktop' && } +
+ +
+
+ ) +} diff --git a/web/src/shell/BottomBar.module.css b/web/src/shell/BottomBar.module.css new file mode 100644 index 0000000..97effa3 --- /dev/null +++ b/web/src/shell/BottomBar.module.css @@ -0,0 +1,65 @@ +.bar { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(16px + env(safe-area-inset-bottom)); + height: 62px; + padding: 0 6px; + box-sizing: border-box; + display: flex; + justify-content: space-around; + align-items: center; + border-radius: var(--radius-pill); + background: var(--color-text); +} + +/* The one grape pill, shared by every item: BottomBar sets its left and width + to the active item's and slides it between items. */ +.pill { + position: absolute; + top: 10px; + height: 42px; + z-index: 0; + border-radius: var(--radius-pill); + background: var(--color-accent); + pointer-events: none; +} + +/* The whole bar height is the tap target; the 42 px pill sits behind it. + Items stay above the pill. */ +.item { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 62px; + min-width: 44px; + padding: 0 13px; + box-sizing: border-box; + color: var(--color-neutral-400); + text-decoration: none; + white-space: nowrap; + transition: color .2s; +} + +.item[aria-current='page'] { + color: var(--on-accent); +} + +.label { + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +/* Inactive items are icon-only on screen but keep their name for assistive tech. */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx new file mode 100644 index 0000000..3ff7db6 --- /dev/null +++ b/web/src/shell/BottomBar.test.tsx @@ -0,0 +1,282 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AppRoutes } from '../App.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { mockViewport } from '../test/matchMedia.ts' +import styles from './BottomBar.module.css' + +const MOBILE = 390 +const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)' + +// jsdom does no layout, so lay the bar out here: items sit side by side from +// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px. +function layOut(element: HTMLElement): { left: number; width: number } { + const bar = element.parentElement! + const items = Array.from(bar.querySelectorAll(`.${styles.item}`)) + let left = 6 + for (const item of items) { + const width = item.getAttribute('aria-current') === 'page' ? 110 : 48 + if (item === element) return { left, width } + left += width + } + return { left: 0, width: 0 } +} + +// jsdom paints nothing either, so bounding boxes come from the laid-out +// offsets with the bar at the viewport's left edge. A test mid-slide sets +// where the pill is drawn and how far each item is still translated. +let drawnPill: { left: number; width: number } | null +let translated: Map + +function box(left: number, width: number): DOMRect { + return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect +} + +let animate: Mock + +beforeEach(() => { + drawnPill = null + translated = new Map() + // jsdom has no Web Animations API. + animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation) + Element.prototype.animate = animate + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (!(this instanceof HTMLElement)) return box(0, 0) + if (this.classList.contains(styles.pill!)) { + return drawnPill + ? box(drawnPill.left, drawnPill.width) + : box(parseFloat(this.style.left), parseFloat(this.style.width)) + } + if (this.classList.contains(styles.item!)) { + return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth) + } + return box(0, 0) + }) + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) { + return layOut(this).left + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return layOut(this).width + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(Element.prototype, 'animate') + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, reducedMotion = false) { + mockViewport(MOBILE, { reducedMotion }) + render( + + + , + ) +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function pill(): HTMLElement { + return nav().querySelector(`.${styles.pill}`)! +} + +function link(name: string): HTMLElement { + return within(nav()).getByRole('link', { name }) +} + +function runningOn(element: Element): Animation[] { + return animate.mock.contexts + .map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation })) + .filter(({ target }) => target === element) + .map(({ animation }) => animation) +} + +function animationsOf(element: Element) { + return animate.mock.contexts + .map((target, i) => ({ target, args: animate.mock.calls[i]! })) + .filter(({ target }) => target === element) + .map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions })) +} + +describe('BottomBar', () => { + it('places the pill on the active item without animating on first render', () => { + renderAt('/plan') + expect(pill().style.left).toBe('54px') + expect(pill().style.width).toBe('110px') + expect(pill()).toBeVisible() + expect(animate).not.toHaveBeenCalled() + }) + + it('hides the pill when no item is active', () => { + renderAt('/onboarding') + expect(pill()).not.toBeVisible() + }) + + it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => { + renderAt('/') + fireEvent.click(link('Quick')) + + expect(animationsOf(pill())).toEqual([ + { + keyframes: [ + { left: '6px', width: '110px' }, + { left: '150px', width: '110px' }, + ], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(pill().style.left).toBe('150px') + expect(pill().style.width).toBe('110px') + }) + + it('FLIPs every item that moved from its old left to its new one with the same timing', () => { + renderAt('/') + fireEvent.click(link('Quick')) + + // Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left + // and Quick grows in place; Settings ends where it started. + for (const name of ['Plan', 'Discover']) { + expect(animationsOf(link(name))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + } + expect(animationsOf(link('Quick'))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(animationsOf(link('Home'))).toEqual([]) + expect(animationsOf(link('Settings'))).toEqual([]) + }) + + it('fades the new label in over 220 ms ease-out after 120 ms', () => { + renderAt('/') + fireEvent.click(link('Discover')) + + const label = within(link('Discover')).getByText('Discover') + expect(label).toHaveClass(styles.label!) + expect(animationsOf(label)).toEqual([ + { + keyframes: [ + { opacity: 0, transform: 'translateX(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' }, + }, + ]) + }) + + it('does not animate when the active item stays the same', () => { + renderAt('/plan') + fireEvent.click(link('Plan')) + expect(animate).not.toHaveBeenCalled() + }) + + it('jumps the pill to the new item with no animation under reduced motion', () => { + renderAt('/', true) + fireEvent.click(link('Settings')) + + expect(link('Settings')).toHaveAttribute('aria-current', 'page') + expect(animate).not.toHaveBeenCalled() + expect(pill().style.left).toBe('198px') + expect(pill().style.width).toBe('110px') + }) + + it('re-places the pill when the viewport resizes', () => { + renderAt('/discover') + expect(pill().style.left).toBe('102px') + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) + fireEvent(window, new Event('resize')) + expect(pill().style.left).toBe('120px') + expect(pill().style.width).toBe('96px') + expect(animate).not.toHaveBeenCalled() + }) + + it('starts a second slide from where the first one is on screen, not from its destination', () => { + renderAt('/') + fireEvent.click(link('Quick')) + const [firstPill] = runningOn(pill()) + const firstPlan = runningOn(link('Plan'))[0]! + + // Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan + // still carries 31 of its 62 px FLIP translation. + drawnPill = { left: 78, width: 110 } + translated.set(link('Plan'), 31) + animate.mockClear() + fireEvent.click(link('Settings')) + + expect(firstPill!.cancel).toHaveBeenCalled() + expect(firstPlan.cancel).toHaveBeenCalled() + expect(animationsOf(pill())).toEqual([ + { + keyframes: [ + { left: '78px', width: '110px' }, + { left: '198px', width: '110px' }, + ], + options: { duration: 420, easing: SPRING }, + }, + ]) + // Plan's layout does not move between Quick and Settings, so it only + // finishes the 31 px it had left. Settings moves 62 px left. + expect(animationsOf(link('Plan'))).toEqual([ + { + keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(animationsOf(link('Settings'))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + }) + + it('stops a running slide and places the pill on the active item when the viewport resizes', () => { + renderAt('/') + fireEvent.click(link('Quick')) + const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn) + expect(slide).toHaveLength(4) + + animate.mockClear() + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) + fireEvent(window, new Event('resize')) + + for (const animation of slide) expect(animation.cancel).toHaveBeenCalled() + expect(animate).not.toHaveBeenCalled() + expect(pill().style.left).toBe('140px') + expect(pill().style.width).toBe('96px') + }) + + it('re-places the pill once the web fonts are ready', async () => { + let fontsReady!: () => void + const ready = new Promise((resolve) => (fontsReady = resolve)) + Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } }) + try { + renderAt('/discover') + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124) + fontsReady() + await ready + expect(pill().style.width).toBe('124px') + } finally { + Reflect.deleteProperty(document, 'fonts') + } + }) + + it('crossfades the icon colour with a .2s colour transition', () => { + renderAt('/') + for (const item of within(nav()).getAllByRole('link')) { + expect(declaredStyle(item).transition).toBe('color .2s') + } + }) +}) diff --git a/web/src/shell/BottomBar.tsx b/web/src/shell/BottomBar.tsx new file mode 100644 index 0000000..f6af58b --- /dev/null +++ b/web/src/shell/BottomBar.tsx @@ -0,0 +1,152 @@ +import { useLayoutEffect, useRef } from 'react' +import { NavLink, useLocation } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './BottomBar.module.css' + +// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value. +const SLIDE_MS = 420 +const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)' +const LABEL_MS = 220 +const LABEL_DELAY_MS = 120 +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' + +// The active item and every item's layout offset within the bar when the pill +// was last placed (offsets ignore transforms, so a running FLIP does not skew +// them). +type Geometry = { + active: HTMLElement | null + itemLefts: number[] +} + +// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive +// items show only their icon; the active item adds its label. One shared +// grape pill sits behind the items and slides to the active one. +export function BottomBar() { + const barRef = useRef(null) + const pillRef = useRef(null) + const placed = useRef(null) + // The pill slide and item FLIPs currently running. + const sliding = useRef([]) + const stopSlide = () => { + sliding.current.forEach((animation) => animation.cancel()) + sliding.current = [] + } + const { pathname } = useLocation() + + // After each navigation: if the active item changed, move the pill to it and, + // motion allowed, slide the pill, FLIP the items and fade the label in. A + // slide still running is measured where it is on screen, then stopped, so + // the next one starts from there instead of from its destination. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + const active = activeItem(bar) + const before = placed.current + if (before && before.active === active) return + const from = before && onScreen(bar, pill, before) + stopSlide() + placed.current = place(bar, pill, active) + if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return + + const after = placed.current + if (before.active) { + sliding.current.push( + pill.animate( + [ + { left: `${from.pill.left}px`, width: `${from.pill.width}px` }, + { left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` }, + ], + { duration: SLIDE_MS, easing: SPRING }, + ), + ) + } + items(bar).forEach((item, i) => { + const dx = from.itemLefts[i]! - after.itemLefts[i]! + if (dx === 0) return + sliding.current.push( + item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { + duration: SLIDE_MS, + easing: SPRING, + }), + ) + }) + active.querySelector(`.${styles.label}`)!.animate( + [ + { opacity: 0, transform: 'translateX(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' }, + ) + }, [pathname]) + + // Item widths change with the viewport and once the web fonts arrive, so + // stop any running slide and re-place the pill (without animating) on both. A failed font load + // rejects fonts.ready and is left to surface as an unhandled rejection. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + let mounted = true + const replace = () => { + if (!mounted) return + stopSlide() + placed.current = place(bar, pill, activeItem(bar)) + } + window.addEventListener('resize', replace) + // jsdom has no document.fonts; every browser the app targets does. + if (document.fonts) void document.fonts.ready.then(replace) + return () => { + mounted = false + window.removeEventListener('resize', replace) + } + }, []) + + return ( + + ) +} + +function items(bar: HTMLElement): HTMLElement[] { + return Array.from(bar.querySelectorAll(`.${styles.item}`)) +} + +// NavLink's aria-current is the one record of which item is active. +function activeItem(bar: HTMLElement): HTMLElement | null { + return bar.querySelector(`.${styles.item}[aria-current="page"]`) +} + +// Where the pill and items are on screen right now, as offsets within the +// bar. Bounding boxes include a running slide: the pill's box is its animated +// left and width, and an item's box is its current layout plus whatever FLIP +// translation remains, which applies to the layout it had when last placed. +function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) { + const origin = bar.getBoundingClientRect().left + bar.clientLeft + const pillBox = pill.getBoundingClientRect() + return { + pill: { left: pillBox.left - origin, width: pillBox.width }, + itemLefts: items(bar).map( + (item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft, + ), + } +} + +// Puts the pill on the active item (hidden when no item is active) and +// returns the resulting geometry. +function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry { + pill.hidden = !active + pill.style.left = `${active?.offsetLeft ?? 0}px` + pill.style.width = `${active?.offsetWidth ?? 0}px` + return { active, itemLefts: items(bar).map((item) => item.offsetLeft) } +} diff --git a/web/src/shell/Rail.module.css b/web/src/shell/Rail.module.css new file mode 100644 index 0000000..f9da188 --- /dev/null +++ b/web/src/shell/Rail.module.css @@ -0,0 +1,51 @@ +.rail { + width: 96px; + padding: 24px 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + background: var(--color-surface); +} + +.mark { + width: 48px; + height: 48px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 22px; +} + +.item { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + min-width: 56px; + min-height: 44px; + color: var(--color-neutral-700); + font-size: 11px; + font-weight: 700; + text-decoration: none; +} + +.pill { + width: 56px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} diff --git a/web/src/shell/Rail.tsx b/web/src/shell/Rail.tsx new file mode 100644 index 0000000..221bef4 --- /dev/null +++ b/web/src/shell/Rail.tsx @@ -0,0 +1,27 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Rail.module.css' + +// Tablet nav: a narrow icon rail with the "M" mark on top. +export function Rail() { + return ( + + ) +} diff --git a/web/src/shell/Sidebar.module.css b/web/src/shell/Sidebar.module.css new file mode 100644 index 0000000..4aa11ce --- /dev/null +++ b/web/src/shell/Sidebar.module.css @@ -0,0 +1,78 @@ +.sidebar { + width: 208px; + padding: 28px 16px; + box-sizing: border-box; + display: flex; + flex-direction: column; + background: var(--color-surface); +} + +.wordmark { + margin: 0; + padding: 0 12px 22px; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + color: var(--color-accent); +} + +.nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.item { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 12px 16px; + box-sizing: border-box; + border-radius: var(--radius-pill); + color: var(--color-neutral-800); + font-size: 15px; + font-weight: 700; + text-decoration: none; +} + +.active { + background: var(--color-accent); + color: var(--on-accent); +} + +.household { + margin-top: auto; + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 22px; + background: var(--color-bg); +} + +.avatar { + width: 36px; + height: 36px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); + font-family: var(--font-heading); + font-size: 15px; +} + +.householdText { + margin: 0; + display: flex; + flex-direction: column; + font-size: 13px; + line-height: 1.3; +} + +.householdNote { + color: var(--color-neutral-700); +} diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx new file mode 100644 index 0000000..9374af2 --- /dev/null +++ b/web/src/shell/Sidebar.tsx @@ -0,0 +1,36 @@ +import { NavLink } from 'react-router' +import { Icon } from '../ui/Icon.tsx' +import { navItems } from './navItems.ts' +import styles from './Sidebar.module.css' + +// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared +// household card at the foot. +export function Sidebar() { + return ( + + ) +} diff --git a/web/src/shell/navItems.ts b/web/src/shell/navItems.ts new file mode 100644 index 0000000..8b22f63 --- /dev/null +++ b/web/src/shell/navItems.ts @@ -0,0 +1,20 @@ +import type { LucideIcon } from 'lucide-react' +import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react' + +export type NavItem = { + to: string + label: string + icon: LucideIcon + // Match the path exactly; otherwise nested paths (/plan/shopping, + // /settings/ai) also mark the item current. + end: boolean +} + +// The primary nav, in the same order in every layout. +export const navItems: readonly NavItem[] = [ + { to: '/', label: 'Home', icon: House, end: true }, + { to: '/plan', label: 'Plan', icon: Calendar, end: false }, + { to: '/discover', label: 'Discover', icon: Compass, end: false }, + { to: '/quick', label: 'Quick', icon: Zap, end: false }, + { to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false }, +] diff --git a/web/src/shell/useBreakpoint.ts b/web/src/shell/useBreakpoint.ts new file mode 100644 index 0000000..28b9f48 --- /dev/null +++ b/web/src/shell/useBreakpoint.ts @@ -0,0 +1,31 @@ +import { useCallback, useState, useSyncExternalStore } from 'react' + +export type Breakpoint = 'mobile' | 'tablet' | 'desktop' + +// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px, +// desktop ≥ 1200 px. +export const TABLET_QUERY = '(min-width: 768px)' +export const DESKTOP_QUERY = '(min-width: 1200px)' + +// The layout the current viewport calls for, updated as the viewport crosses +// a breakpoint. +export function useBreakpoint(): Breakpoint { + const [lists] = useState(() => ({ + tablet: window.matchMedia(TABLET_QUERY), + desktop: window.matchMedia(DESKTOP_QUERY), + })) + const subscribe = useCallback( + (onChange: () => void) => { + lists.tablet.addEventListener('change', onChange) + lists.desktop.addEventListener('change', onChange) + return () => { + lists.tablet.removeEventListener('change', onChange) + lists.desktop.removeEventListener('change', onChange) + } + }, + [lists], + ) + return useSyncExternalStore(subscribe, () => + lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile', + ) +} diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..dc83c17 --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,12 @@ +/* App-wide base styles. Values come only from tokens.css. */ +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..167806b --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,116 @@ +/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white". + Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */ +/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside) + in place of the handoff's Google Fonts @import, so the app never calls Google. + Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets. + Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */ +@font-face { + font-family: "Bagel Fat One"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Bagel Fat One"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2"); +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2"); +} + +:root { + /* Ground + ink */ + --color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */ + --color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */ + --color-text: #201e1d; + --color-divider: color-mix(in srgb, #201e1d 16%, transparent); + --on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */ + + /* Primary accent — Grape (hue 300) */ + --color-accent: #5a358c; /* oklch(0.42 0.14 300) */ + --color-accent-100: #f8f1ff; + --color-accent-200: #eedfff; + --color-accent-300: #dac3ff; + --color-accent-400: #c1a1f9; + --color-accent-500: #a47ee1; + --color-accent-600: #865fc0; + --color-accent-700: #68449b; /* accent-colored small text on light grounds */ + --color-accent-800: #4a2d73; + --color-accent-900: #2d1a49; + + /* Second accent — Teal blue (hue 215) */ + --color-accent-2: #29889c; /* oklch(0.58 0.09 215) */ + --color-accent-2-100: #e0fbff; + --color-accent-2-200: #bbf3ff; + --color-accent-2-300: #88e0f6; + --color-accent-2-400: #3fc7e4; + --color-accent-2-500: #00a9ca; + --color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */ + --color-accent-2-700: #006b88; + --color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */ + --color-accent-2-900: #002f3e; + + /* Neutrals (unchanged from Organic) */ + --color-neutral-100: #f9f4ed; + --color-neutral-200: #eee7db; + --color-neutral-300: #dcd3c4; + --color-neutral-400: #c0b6a5; + --color-neutral-500: #a19786; + --color-neutral-600: #82796a; + --color-neutral-700: #645c50; + --color-neutral-800: #474238; + --color-neutral-900: #2e2b25; + + /* Type */ + --font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */ + --font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */ + --font-mono: ui-monospace, Menlo, monospace; + + /* Radii */ + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 28px; + --radius-card: 32px; /* mobile hero cards */ + --radius-pill: 999px; /* buttons, inputs, tags, nav */ + + /* Spacing (Organic, density 1.1) */ + --space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px; + + /* Elevation */ + --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); + --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); + + /* Motion */ + --ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */ + --ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */ + --ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */ +} + +@media (prefers-reduced-motion: reduce) { + :root { --motion-scale: 0; } +} diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts new file mode 100644 index 0000000..3260596 --- /dev/null +++ b/web/src/test/cssRules.ts @@ -0,0 +1,75 @@ +// 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 +} + +function px(value: string, what: string): number { + if (value === '') return 0 + const match = /^(\d+(?:\.\d+)?)px$/.exec(value) + if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`) + return Number(match[1]) +} + +// The border-box size the element's declared rules give it on one axis: +// width or height, plus padding and border unless box-sizing is border-box. +// jsdom does no layout (getBoundingClientRect is all zeros), so this is the +// way to catch padding that would push a fixed box past its specified size. +export function outerSize(element: Element, axis: 'width' | 'height'): number { + const style = declaredStyle(element) + const size = px(style.getPropertyValue(axis), axis) + if (style.boxSizing === 'border-box') return size + const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom'] + return sides.reduce( + (total, side) => + total + + px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) + + px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`), + size, + ) +} diff --git a/web/src/test/fetchStub.ts b/web/src/test/fetchStub.ts new file mode 100644 index 0000000..9a7a932 --- /dev/null +++ b/web/src/test/fetchStub.ts @@ -0,0 +1,49 @@ +import { vi, type Mock } from 'vitest' +import type { ApiLocation, Me } from '../api/index.ts' + +export const ME: Me = { + name: 'Sam', + email: 'sam@example.com', + household: { label: 'Our kitchen' }, + csrfToken: 'token-1', +} + +export function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +} + +export function errorResponse(status: number, code: string, retryable = false): Response { + return jsonResponse(status, { error: { code, message: `${code} message`, retryable } }) +} + +// stubFetch replaces the global fetch with a mock answered from responses in +// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals. +export function stubFetch(...responses: Response[]): Mock { + const fetchMock = vi.fn() + for (const response of responses) fetchMock.mockResolvedValueOnce(response) + fetchMock.mockImplementation((input, init) => { + throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// request returns the method, path and headers of the fetch mock's nth call. +export function request(fetchMock: Mock, n: number) { + const call = fetchMock.mock.calls[n] + if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`) + const [input, init] = call + return { + path: String(input), + method: init?.method, + credentials: init?.credentials, + headers: new Headers(init?.headers), + body: init?.body, + } +} + +// fakeLocation stands in for window.location on https://mealquest.test, +// recording navigations. +export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } { + return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() } +} diff --git a/web/src/test/hitArea.ts b/web/src/test/hitArea.ts new file mode 100644 index 0000000..fa0f903 --- /dev/null +++ b/web/src/test/hitArea.ts @@ -0,0 +1,26 @@ +import { expect } from 'vitest' + +// Minimum tap target from the handoff's accessibility rules. +export const MIN_HIT_AREA = 44 + +function px(value: string): number { + return value.endsWith('px') ? Number.parseFloat(value) : 0 +} + +// The tap target an element's stylesheet guarantees: the larger of its +// declared size and declared minimum size, per axis. jsdom does no layout, so +// this reads the CSS Modules' computed values (Vitest runs with css: true). +// A dimension the CSS leaves unsized counts as 0 and fails the check. +export function hitArea(element: Element): { width: number; height: number } { + const style = getComputedStyle(element) + return { + width: Math.max(px(style.width), px(style.minWidth)), + height: Math.max(px(style.height), px(style.minHeight)), + } +} + +export function expectMinHitArea(element: Element) { + const { width, height } = hitArea(element) + expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA) + expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA) +} diff --git a/web/src/test/matchMedia.ts b/web/src/test/matchMedia.ts new file mode 100644 index 0000000..a4312c3 --- /dev/null +++ b/web/src/test/matchMedia.ts @@ -0,0 +1,51 @@ +import { act } from '@testing-library/react' + +type Listener = (event: MediaQueryListEvent) => void + +// Replaces window.matchMedia with one that answers min-width / max-width +// queries for a pretend viewport width, and prefers-reduced-motion: reduce +// with reducedMotion (jsdom has no matchMedia and no layout). Returns a +// function that resizes the pretend viewport and notifies every query whose +// answer changed. +export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void { + let width = initialWidth + const lists: { list: MediaQueryList; listeners: Set }[] = [] + + function evaluate(query: string): boolean { + if (query === '(prefers-reduced-motion: reduce)') return reducedMotion + const min = /\(min-width:\s*(\d+)px\)/.exec(query) + const max = /\(max-width:\s*(\d+)px\)/.exec(query) + if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`) + return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1])) + } + + window.matchMedia = (query: string) => { + const listeners = new Set() + const list = { + media: query, + get matches() { + return evaluate(query) + }, + onchange: null, + addEventListener: (_type: string, listener: Listener) => listeners.add(listener), + removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener), + addListener: (listener: Listener) => listeners.add(listener), + removeListener: (listener: Listener) => listeners.delete(listener), + dispatchEvent: () => true, + } as unknown as MediaQueryList + lists.push({ list, listeners }) + return list + } + + return (next: number) => { + const before = lists.map(({ list }) => list.matches) + width = next + act(() => { + lists.forEach(({ list, listeners }, i) => { + if (list.matches === before[i]) return + const event = { matches: list.matches, media: list.media } as MediaQueryListEvent + listeners.forEach((listener) => listener(event)) + }) + }) + } +} diff --git a/web/src/ui/Button.module.css b/web/src/ui/Button.module.css new file mode 100644 index 0000000..8b9de26 --- /dev/null +++ b/web/src/ui/Button.module.css @@ -0,0 +1,63 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-width: 44px; + min-height: 48px; + padding: 0 22px; + border: 1.5px solid transparent; + border-radius: var(--radius-pill); + font-family: var(--font-heading); + font-size: 15px; + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft); +} + +.button:active:not(:disabled) { + transform: scale(0.97); +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.primary { + background: var(--color-accent); + color: var(--on-accent); +} + +.primary:hover:not(:disabled), +.primary:active:not(:disabled) { + background: var(--color-accent-800); +} + +.outline { + background: transparent; + border-color: var(--color-neutral-300); + color: var(--color-text); +} + +.outline:hover:not(:disabled), +.outline:active:not(:disabled) { + background: var(--color-neutral-200); +} + +.dark { + background: var(--color-text); + color: var(--color-bg); +} + +.dark:hover:not(:disabled), +.dark:active:not(:disabled) { + background: var(--color-neutral-800); +} + +@media (prefers-reduced-motion: reduce) { + .button { + transition: none; + } +} diff --git a/web/src/ui/Button.test.tsx b/web/src/ui/Button.test.tsx new file mode 100644 index 0000000..4797e88 --- /dev/null +++ b/web/src/ui/Button.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { Button } from './Button.tsx' +import styles from './Button.module.css' + +describe('Button', () => { + it('is a button that defaults to type="button" and the primary variant', () => { + render() + const button = screen.getByRole('button', { name: 'Save changes' }) + expect(button).toHaveAttribute('type', 'button') + expect(button).toHaveClass(styles.button!, styles.primary!) + }) + + it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => { + render() + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toHaveClass(styles[variant]!) + expectMinHitArea(button) + }) + + it('declares the 100 ms pressed scale transition', () => { + render() + expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms') + }) + + it('does not fire onClick when disabled', () => { + const onClick = vi.fn() + render( + , + ) + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) + + it('forwards the ref, rest props and an explicit type', () => { + const ref = createRef() + render( + , + ) + const button = screen.getByRole('button', { name: 'Send' }) + expect(ref.current).toBe(button) + expect(button).toHaveAttribute('type', 'submit') + expect(button).toHaveAttribute('aria-describedby', 'hint') + }) +}) diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx new file mode 100644 index 0000000..4a58825 --- /dev/null +++ b/web/src/ui/Button.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Button.module.css' + +export type ButtonVariant = 'primary' | 'outline' | 'dark' + +export type ButtonProps = ComponentPropsWithRef<'button'> & { + variant?: ButtonVariant +} + +// Pill button. Primary is grape with --on-accent text; outline and dark are the +// secondary treatments from the handoff. `type` defaults to "button" so a +// Button inside a form never submits by accident. +export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) { + const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ') + return + )} + + ) +} 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/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/IconButton.module.css b/web/src/ui/IconButton.module.css new file mode 100644 index 0000000..7dd0c66 --- /dev/null +++ b/web/src/ui/IconButton.module.css @@ -0,0 +1,74 @@ +/* The button is the hit box; .face is the visible circle. A 40 px face sits in + a 44 px hit box whose negative margin keeps the 40 px layout footprint. */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: inherit; + cursor: pointer; +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.size40 { + width: 44px; + height: 44px; + margin: -2px; +} + +.size62 { + width: 62px; + height: 62px; +} + +.face { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); + transition: transform 100ms var(--ease-out-soft); +} + +.size40 .face { + width: 40px; + height: 40px; +} + +.size62 .face { + width: 62px; + height: 62px; + box-shadow: var(--shadow-md); +} + +.button:active:not(:disabled) .face { + transform: scale(0.97); +} + +.surface { + background: var(--color-surface); + color: var(--color-text); +} + +.accent { + background: var(--color-accent); + color: var(--on-accent); +} + +.teal { + background: var(--color-accent-2-600); + color: var(--on-accent); +} + +@media (prefers-reduced-motion: reduce) { + .face { + transition: none; + } +} diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx new file mode 100644 index 0000000..0341c1a --- /dev/null +++ b/web/src/ui/IconButton.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { hitArea, expectMinHitArea } from '../test/hitArea.ts' +import { IconButton } from './IconButton.tsx' + +describe('IconButton', () => { + it('is named by its label, not its icon', () => { + const onClick = vi.fn() + render( + + + , + ) + const button = screen.getByRole('button', { name: 'Notifications' }) + expect(button).toHaveAttribute('type', 'button') + fireEvent.click(button) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('keeps a 44 px hit area around the 40 px circle', () => { + render() + const button = screen.getByRole('button', { name: 'Back' }) + expectMinHitArea(button) + const face = button.firstElementChild! + expect(hitArea(face)).toEqual({ width: 40, height: 40 }) + }) + + it('is 62 px at the large size', () => { + render() + const button = screen.getByRole('button', { name: 'Try it' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 62, height: 62 }) + }) +}) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx new file mode 100644 index 0000000..1a5ab73 --- /dev/null +++ b/web/src/ui/IconButton.tsx @@ -0,0 +1,33 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './IconButton.module.css' + +export type IconButtonSize = 40 | 62 +export type IconButtonTone = 'surface' | 'accent' | 'teal' + +export type IconButtonProps = Omit, 'aria-label'> & { + // Accessible name; an icon alone never names a button. + label: string + size?: IconButtonSize + tone?: IconButtonTone +} + +// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS); +// the 62 px size is the round action button with --shadow-md. +export function IconButton({ + label, + size = 40, + tone = 'surface', + type = 'button', + className, + children, + ...rest +}: IconButtonProps) { + const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ') + return ( + + ) +} diff --git a/web/src/ui/ListRow.module.css b/web/src/ui/ListRow.module.css new file mode 100644 index 0000000..f182b5b --- /dev/null +++ b/web/src/ui/ListRow.module.css @@ -0,0 +1,69 @@ +.row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + min-height: 44px; + padding: 14px 16px; + color: var(--color-text); + text-align: left; +} + +.interactive { + padding: 0; + border-radius: var(--radius-md); +} + +.interactive:hover { + background: var(--color-neutral-200); +} + +.main { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1 1 auto; + min-width: 44px; + min-height: 44px; + padding: 14px 16px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.interactive > .trailing { + padding-right: 16px; +} + +.leading, +.trailing { + display: inline-flex; + align-items: center; + flex: none; +} + +.body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.title { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: var(--color-neutral-700); +} diff --git a/web/src/ui/ListRow.test.tsx b/web/src/ui/ListRow.test.tsx new file mode 100644 index 0000000..f1e9108 --- /dev/null +++ b/web/src/ui/ListRow.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { ListRow } from './ListRow.tsx' +import { Toggle } from './Toggle.tsx' + +describe('ListRow', () => { + it('renders leading, title, meta and trailing slots without being interactive', () => { + render( + icon} + title="Notifications" + meta="Push and Google Chat" + trailing={chevron} + />, + ) + for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) { + expect(screen.getByText(text)).toBeInTheDocument() + } + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('is a single button with a ≥ 44 px hit area when it has onClick', () => { + const onClick = vi.fn() + render() + const row = screen.getByRole('button', { name: '10 new side quests to swipe' }) + expect(row).toHaveAttribute('type', 'button') + expectMinHitArea(row) + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('keeps a trailing toggle outside the row button so each control fires only its own action', () => { + const onClick = vi.fn() + const onCheckedChange = vi.fn() + render( + } + onClick={onClick} + />, + ) + const row = screen.getByRole('button', { name: /^Dinner reminders/ }) + const toggle = screen.getByRole('switch', { name: 'Dinner reminders' }) + expect(row).not.toContainElement(toggle) + expect(row.querySelector('button')).toBeNull() + + fireEvent.click(toggle) + expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true) + expect(onClick).not.toHaveBeenCalled() + + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + expect(onCheckedChange).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/ui/ListRow.tsx b/web/src/ui/ListRow.tsx new file mode 100644 index 0000000..8004bf2 --- /dev/null +++ b/web/src/ui/ListRow.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' +import styles from './ListRow.module.css' + +export type ListRowProps = { + title: ReactNode + meta?: ReactNode + // Leading slot: icon, photo or avatar. + leading?: ReactNode + // Trailing slot: chevron, toggle, count or action. + trailing?: ReactNode + // When set, the leading slot, title and meta form one button. The trailing + // slot stays a sibling outside it, so a trailing toggle or action is never + // nested in the row's button and never triggers the row's action. + onClick?: () => void + className?: string +} + +export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) { + const main = ( + <> + {leading && {leading}} + + {title} + {meta && {meta}} + + + ) + const trailingSlot = trailing && {trailing} + if (onClick) { + return ( +
+ + {trailingSlot} +
+ ) + } + return ( +
+ {main} + {trailingSlot} +
+ ) +} 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/Segmented.module.css b/web/src/ui/Segmented.module.css new file mode 100644 index 0000000..3d94baa --- /dev/null +++ b/web/src/ui/Segmented.module.css @@ -0,0 +1,35 @@ +.track { + display: flex; + gap: 4px; + padding: 4px; + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.option { + flex: 1 1 0; + min-width: 44px; + min-height: 44px; + padding: 0 var(--space-4); + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft); +} + +.selected { + background: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-sm); +} + +@media (prefers-reduced-motion: reduce) { + .option { + transition: none; + } +} diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx new file mode 100644 index 0000000..c107a1e --- /dev/null +++ b/web/src/ui/Segmented.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { Segmented } from './Segmented.tsx' +import styles from './Segmented.module.css' + +const options = [ + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, + { value: 'year', label: 'Year' }, +] as const + +type Period = (typeof options)[number]['value'] + +function Harness({ initial = 'week' }: { initial?: Period }) { + const [value, setValue] = useState(initial) + return +} + +function radio(name: string) { + return screen.getByRole('radio', { name }) +} + +describe('Segmented', () => { + it('is a named radiogroup of radios with one checked', () => { + render() + expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument() + expect(screen.getAllByRole('radio')).toHaveLength(3) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveAttribute('aria-checked', 'false') + expect(radio('Week')).toHaveClass(styles.selected!) + }) + + it('has a single tab stop on the selected option', () => { + render() + expect(radio('Month')).toHaveAttribute('tabindex', '0') + expect(radio('Week')).toHaveAttribute('tabindex', '-1') + expect(radio('Year')).toHaveAttribute('tabindex', '-1') + }) + + it('moves selection and focus with the arrow keys, wrapping at the ends', () => { + render() + radio('Week').focus() + + fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' }) + expect(radio('Month')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveFocus() + + fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' }) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + expect(radio('Week')).toHaveFocus() + + fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' }) + expect(radio('Month')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveFocus() + expect(radio('Year')).toHaveAttribute('aria-checked', 'false') + }) + + it('jumps to the first and last option with Home and End', () => { + render() + fireEvent.keyDown(radio('Month'), { key: 'End' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(radio('Year'), { key: 'Home' }) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + }) + + it('selects an option on click', () => { + render() + fireEvent.click(radio('Year')) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + expect(radio('Week')).toHaveAttribute('aria-checked', 'false') + }) + + it('gives every option a ≥ 44 px hit area', () => { + render() + for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) + }) + + it('fails fast when the value is not one of the options', () => { + expect(() => + render( {}} />), + ).toThrow('value "decade" is not one of its options') + }) +}) diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx new file mode 100644 index 0000000..bd53b27 --- /dev/null +++ b/web/src/ui/Segmented.tsx @@ -0,0 +1,76 @@ +import { useRef, type KeyboardEvent } from 'react' +import styles from './Segmented.module.css' + +export type SegmentedOption = { + value: T + label: string +} + +export type SegmentedProps = { + // Accessible name of the radiogroup. + label: string + options: readonly SegmentedOption[] + value: T + onValueChange: (value: T) => void + className?: string +} + +const step: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } + +// Segmented control with radiogroup semantics: one tab stop (the selected +// option); arrow keys move the selection and focus, wrapping at the ends; +// Home and End jump to the first and last option. +export function Segmented({ label, options, value, onValueChange, className }: SegmentedProps) { + const refs = useRef<(HTMLButtonElement | null)[]>([]) + const selected = options.findIndex((option) => option.value === value) + if (selected === -1) { + throw new Error(`Segmented "${label}": value "${value}" is not one of its options`) + } + + function select(index: number) { + const option = options[index] + if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`) + onValueChange(option.value) + refs.current[index]?.focus() + } + + function onKeyDown(event: KeyboardEvent) { + const delta = step[event.key] + let next: number + if (delta !== undefined) next = (selected + delta + options.length) % options.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = options.length - 1 + else return + event.preventDefault() + select(next) + } + + return ( +
+ {options.map((option, index) => { + const checked = index === selected + return ( + + ) + })} +
+ ) +} 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..8e8cc27 --- /dev/null +++ b/web/src/ui/Sheet.test.tsx @@ -0,0 +1,267 @@ +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('skips controls that cannot take keyboard focus when moving focus in', () => { + render( + {}}> + + + + + + + , + ) + expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus() + }) + + it('focuses the dialog itself when every control is hidden', () => { + render( + {}}> + + , + ) + expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus() + }) + + it('does not stop the Tab trap on a hidden control', () => { + render( + {}}> + + + + + , + ) + 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('does not stop the Tab trap on a control disabled by its fieldset', () => { + render( + {}}> +
+ +
+ + +
+ +
+
, + ) + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + expect(first).toHaveFocus() + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).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..4686f3a --- /dev/null +++ b/web/src/ui/Sheet.tsx @@ -0,0 +1,123 @@ +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', + 'input', + 'select', + 'textarea', + '[tabindex]', +].join(',') + +// Whether an element can take keyboard focus: being disabled (by its own +// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden +// or inert ancestor (or self), display: none on the way up to the panel, or +// visibility: hidden all rule it out. +function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean { + if (element.matches(':disabled')) return false + if (element.tabIndex < 0) return false + if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false + if (getComputedStyle(element).visibility === 'hidden') return false + for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) { + if (getComputedStyle(node).display === 'none') return false + } + return true +} + +function focusables(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll(focusableSelector)).filter((element) => + isKeyboardFocusable(element, panel), + ) +} + +// Focuses the first control that actually takes focus, or the panel if none does. +function focusFirst(panel: HTMLElement) { + for (const element of focusables(panel)) { + element.focus() + if (document.activeElement === element) return + } + panel.focus() +} + +// 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', '') + focusFirst(panel) + + 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