Web UI patterns
Use the shipped React components, state boundaries, accessibility behavior, and tests.
Web UI patterns
The web application uses CSS Modules and semantic tokens rather than a utility framework. Reuse the
existing primitives when they fit; add product-specific composition in the owning module instead of
turning components/ui into a second product domain.
Ownership
| Path | Responsibility |
|---|---|
src/components/ui | reusable interaction and presentation primitives |
src/components/layout | application shell, navigation, responsive layout, and error surfaces |
src/modules/<domain> | route pages and product-specific composition |
src/services | typed Hono calls and TanStack Query lifecycle |
src/store | deliberate client-only Zustand state |
src/styles/_variables.css | semantic color, spacing, typography, elevation, and motion tokens |
src/configs/rpc-client.ts | credentialed transport and stable application errors |
TanStack Query owns remote data. Do not copy API results into Zustand. Components do not call raw
fetch; service modules own query keys, mutations, invalidation, and error conversion.
Included primitives
The current inventory includes buttons, inputs, forms, checkboxes, switches, selects, selectors,
tabs, menus, breadcrumbs, tooltips, modals, drawers, bottom sheets, cards, stat cards, tags, date
and date-range pickers, tables with pagination, filters, search, infinite-scroll triggers,
skeletons, spinners, empty states, toasts, avatars, identity cells, file icons, file cells, date
cells, area charts, uploads, async images, keyboard hints, truncated text, the command palette, and
a floating action button. The users module is the reference composition for a paginated data table
(Table + createTableStore + useTableQueryParams against a typed list endpoint); the files
module is the reference for table cell primitives (FileCell / FileIcon / DateCell) over an
owner-scoped list; the dashboard module is the reference for stat cards and charts. Zag-backed
popovers are composed inside the date controls and notification bell rather than exported as a
standalone primitive.
FileIcon resolves its glyph and category tint through resolveFileType, which maps a MIME type
onto a fixed category set and falls back to the filename extension. Extend that map when a new
upload type ships; do not branch on MIME strings inside a page. Its category tints are the one place
in the primitive library that sets colour outside the token palette, and each has a dark-mode value.
Zag.js-backed controls must retain their keyboard, focus, and ARIA behavior. A similar visual built
from generic div elements is not an equivalent replacement. Keep semantic labels and error
associations when composing form controls.
Responsive and application states
Every material screen should deliberately handle:
- initial loading and mutation-pending states;
- empty data and disabled-provider states;
- recoverable and terminal errors;
- unauthenticated and unauthorized behavior;
- keyboard navigation, visible focus, and screen-reader labels;
- narrow viewport layout and touch targets;
- reduced motion and sufficient contrast;
- long names, translated-like expansion, and unbroken user content.
The settings shell has distinct desktop and mobile layouts. Tables include mobile card rendering; test both representations when columns or filtering change.
Localization
The buyer SPA ships English, Spanish, and German through i18next and react-i18next. Locale
resources live in apps/web/src/i18n/locales, and the language switcher is available on both
authentication and authenticated application surfaces, with the same preference also exposed as a
language field in /settings/profile. The selected locale is stored in app.locale; otherwise the
browser language is used, with English as the fallback. The runtime updates the document lang
attribute when the locale changes.
Use useTranslation from @/i18n in React components and the exported i18n instance in service
callbacks that cannot use hooks. Use interpolation and plural keys for variable copy, and pass the
resolved locale into Intl or date-fns formatting. Do not assemble translated sentences from
fragments when word order can vary by language.
Add every new key to every locale file. The i18n test enforces key parity so a missing buyer-facing translation fails the web test suite. API-provided notification bodies and provider error messages remain server-owned content and are displayed verbatim.
The service worker fallback at apps/web/public/offline.html cannot import the React locale bundle,
so it carries the minimal offline copy for each supported locale and reads the same app.locale
preference. Update that static copy when adding a locale.
Testing
Vitest runs in a Node environment. Existing component contract tests use react-dom/server to prove
important static zero-key and disabled-provider states. They do not prove focus, pointer, portal,
measurement, animation, or browser navigation behavior.
Use the smallest useful layer:
vp run --filter @app/web test
pnpm test:e2e
vp run --filter @app/web buildAdd a browser test when behavior depends on the DOM or user interaction. Keep static rendering tests for deterministic copy and availability contracts, and preserve the production bundle budget.
Branding
Change identity and tokens through Customize branding and design tokens. Do not introduce Tailwind into the buyer SPA, embed the product name into every component, or manually edit generated PWA assets.