Plan 019 — Design-system components
Status: approved Written in plan mode from spec.md (approved) and research.md (complete). Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Context
Spec 010 built the token layer and wrote design-system.md, but stopped at "colocated cva until a second feature needs it," so shared/ui holds only ErrorBoundary and every control is hand-rolled (features/account/ApiKeyInput.tsx is the proof). @base-ui/react 1.6.0 is installed and imported nowhere, so the accessible behaviour it exists to provide is absent from every control. This plan starts the component layer with four closed components — one export with a props API each, owning its Base UI primitive and Panda styling internally — and fixes the shape every future control follows. Discovery verified the whole seam against the installed package (research V1–V6): the styling attaches, the a11y is free where Base UI provides it, and the compiler does not bail.
Goal
shared/ui gains Button, Input, Dialog and Tooltip, each a single props-driven export whose Base UI parts and Panda classes never escape the file; a /ui gallery shows them; and design-system.md/ react.md document the closed-component pattern, the boundary convention, and the amended promotion rule. A feature reaches for shared/ui instead of hand-rolling, and gets accessibility by construction.
Approach
Six tasks, sequenced so each lands on a green tree. Button first because it is pure Panda and proves the recipe → component → export → test loop with no Base UI variables; the three Base UI wrappers next, each self-contained; the gallery to make them visible and satisfy "run the app"; the docs last, describing what the first five tasks made true (so SC4 is checkable by reading, not an intention).
1 · Button (pure Panda). Add a button config recipe (defineRecipe) to panda.config.ts (variant: solid/outline/ghost referencing the existing primary/border/text tokens; size: sm/md), create shared/ui/Button.tsx consuming styled-system/recipes' generated button and spreading native <button> props, create the public surface shared/ui/index.ts, and test variants + disabled blocking onClick + accessible name + focus-visible. No token change — primary stays provisional (R9).
2 · Input (Base UI Field). Add an input slot recipe (defineSlotRecipe, slots root/label/control/error), create shared/ui/Input.tsx wrapping Field.Root invalid={!!error} > Field.Label + Field.Control (used instead of Base UI's standalone Input, to keep the native onChange/value surface — research V1) + a conditional Field.Error match={true}. Label association, aria-describedby and aria-invalid come free (V1). Test them + onChange.
3 · Dialog (Base UI Dialog). Add a dialog slot recipe (slots backdrop/popup/title/description), create shared/ui/Dialog.tsx wrapping Dialog.Root open onOpenChange (forwarding only the boolean from the 2-arg callback) > Dialog.Portal > Dialog.Backdrop + Dialog.Popup > Dialog.Title + Dialog.Description? + children + a Dialog.Close inside the popup (F3). modal (default true) gives focus trap, scroll lock, Esc and outside-press. Test open/close via onOpenChange, focus trap, and title as accessible name.
4 · Tooltip (Base UI Tooltip). Add a tooltip slot recipe (slots popup, and trigger if styled), create shared/ui/Tooltip.tsx wrapping Tooltip.Root > Tooltip.Trigger > Tooltip.Portal > Tooltip.Positioner side={side} sideOffset > Tooltip.Popup. Enforce the a11y contract discovery forced (V3): the trigger carries an accessible name — label applied as aria-label, defaulted from content when it is a string; the popup content is visual-only. Delay props live on the trigger (F4). Test open on hover and keyboard focus, and the trigger's accessible name.
5 · Gallery. Create features/ui-gallery/{UiGalleryPage.tsx, routes.tsx, styles.ts} rendering all four in their states, mount { path: '/ui', element: <UiGalleryPage/> } in main.tsx with noNavLink in App.tsx, and header-comment the page as a Storybook stopgap (R11). Test that each component renders (covers P1 #4 / P2 #4 / P3 #4 / P4 #3).
6 · Documentation. Update design-system.md (closed-component pattern; the "only the finished component escapes shared/ui" boundary convention; the promotion-rule amendment — foundation components promoted deliberately; the "wrap Base UI only where a native element falls short" rule, research F2; the Tooltip visual-only a11y contract; the Panda-only/headless note with the CSPProvider escape hatch, F1; Storybook-later) and react.md (enforcement/ownership table row: Base UI imported only inside shared/ui — a documented convention, not machine-enforced). Written last (SC4).
Architecture
apps/web/
panda.config.ts + recipes.button (recipe); slotRecipes.input/dialog/tooltip
src/
shared/ui/
Button.tsx closed: `button` recipe + native <button> props
Input.tsx closed: Base UI Field + `input` slot recipe
Dialog.tsx closed: Base UI Dialog + `dialog` slot recipe (Close inside Popup)
Tooltip.tsx closed: Base UI Tooltip + `tooltip` slot recipe; enforces trigger aria-label
index.ts public surface — exports the four (ErrorBoundary keeps its direct path)
__tests__/ Button/Input/Dialog/Tooltip .test.tsx
features/ui-gallery/
UiGalleryPage.tsx renders all four in their states; Storybook-stopgap header comment
routes.tsx { path: '/ui', element: <UiGalleryPage/> }
styles.ts gallery layout (css())
__tests__/UiGalleryPage.test.tsx
main.tsx + mount ui-gallery routes (no NavLink)
../../docs/architecture/design-system.md + closed-component section & amendments
../../docs/architecture/react.md + enforcement-table rowThe closed boundary (documented convention, not a guard test — spec R2). @base-ui/react/* and styled-system/recipes for these components are imported only inside shared/ui/; consumers import the finished component from shared/ui. Base UI is wrapped only where a native element falls short: Dialog (focus trap), Tooltip (positioning + hover/focus), Input (Field label/error association). Button is a native <button>, so it is pure Panda (research F2).
Styling. Promoted foundation components use Panda config recipes (panda.config.ts), not colocated cva (spec R3/R4). Recipes reference tokens only; the existing literal-colour guard (conventions.test.ts) already covers src/, so SC6 needs no new test.
The gallery composes with the existing shell unchanged (research V5): App.tsx wraps <Outlet/> in QueryBoundary + Suspense; the /ui child renders inside them with no special handling, and its page carries the *Page suffix per spec 010 P1 #4.
Tech stack
React 19.2.8, React Router 8.3.0, TanStack Query 5.101.4, @base-ui/react 1.6.0 (already installed), Panda 1.11.5, Vite 8.1.5 with @vitejs/plugin-react 6.0.4 and the React Compiler via @rolldown/plugin-babel (panicThreshold: 'all_errors'), Biome 2.5.5, Vitest 4.1.10 + Testing Library, TypeScript 7. Node ≥ 22.18, pnpm 11.15.1.
No new dependencies — runtime or dev. Base UI and Panda are already present (spec 004). The compiler was verified against Base UI usage in research V6.
Global Constraints
Copied verbatim from the architecture docs and the approved spec. Every task inherits these.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.
From docs/architecture/react.md and design-system.md (the conventions this spec extends):
- Tokens, never literals. A style references a token by name; a literal hex/
rgb/hslanywhere inapps/web/srcis rejected byconventions.test.ts. - No hand-written memoization. No
useMemo/useCallback/React.memo; the React Compiler owns it. - Styling lives beside the component, never in the
.tsx. Promoted components use config recipes inpanda.config.ts; the component file references the generated recipe. - Suspense-only data flow, one error boundary per route — the shell (
App.tsx) owns both; the gallery adds neither (it fetches no server data).
From the approved spec.md:
- Closed components (R1/R2). Each of the four is one export with a props API; Base UI parts and Panda classes never escape the file;
@base-ui/reactandstyled-systemare imported only insideshared/ui/(documented convention, not a guard test). - Accent stays provisional (R9).
primaryis unchanged; no palette work. - No API endpoint (HTTP contract).
apps/api/openapi.jsonuntouched;pnpm verify:contractstays green.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
| Path | Change | Responsibility |
|---|---|---|
apps/web/panda.config.ts | modified | recipes.button + slotRecipes.{input,dialog,tooltip}, referencing tokens only |
apps/web/src/shared/ui/Button.tsx | new | Closed Button: button recipe + native <button> props; solid uses primary |
apps/web/src/shared/ui/Input.tsx | new | Closed Input: Base UI Field + input slot recipe; label/error props |
apps/web/src/shared/ui/Dialog.tsx | new | Closed Dialog: Base UI Dialog + dialog slot recipe; Close inside Popup |
apps/web/src/shared/ui/Tooltip.tsx | new | Closed Tooltip: Base UI Tooltip + tooltip slot recipe; enforces trigger accessible name |
apps/web/src/shared/ui/index.ts | new | Public surface — exports Button, Input, Dialog, Tooltip |
apps/web/src/shared/ui/__tests__/Button.test.tsx | new | Variants, disabled blocks onClick, accessible name, focus ring |
apps/web/src/shared/ui/__tests__/Input.test.tsx | new | Label association, aria-describedby error, aria-invalid, onChange |
apps/web/src/shared/ui/__tests__/Dialog.test.tsx | new | Open/close via onOpenChange, focus trap, title accessible name |
apps/web/src/shared/ui/__tests__/Tooltip.test.tsx | new | Opens on hover and focus; trigger accessible name matches content |
apps/web/src/features/ui-gallery/UiGalleryPage.tsx | new | Renders the four in their states; Storybook-stopgap comment |
apps/web/src/features/ui-gallery/routes.tsx | new | { path: '/ui', element: <UiGalleryPage/> } |
apps/web/src/features/ui-gallery/styles.ts | new | Gallery layout via css() |
apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx | new | Each component renders (P1 #4 / P2 #4 / P3 #4 / P4 #3) |
apps/web/src/main.tsx | modified | Mount ui-gallery routes; no NavLink added to App.tsx |
docs/architecture/design-system.md | modified | Closed-component pattern, boundary convention, promotion amendment, F1/F2, Tooltip a11y, Storybook note |
docs/architecture/react.md | modified | Enforcement/ownership row for the Base UI boundary convention |
shared/ui/ErrorBoundary.tsx keeps its current direct-path importers untouched; index.ts is the new surface for the four components, not a forced re-home of the existing one.
Data & contracts
No HTTP contract change; the only new type surface is the four components' props (the interfaces later tasks and consumers rely on):
Button(props: { variant?: 'solid' | 'outline' | 'ghost'; size?: 'sm' | 'md' } & ButtonHTMLAttributes<HTMLButtonElement>)— defaultsvariant: 'solid',size: 'md'.Input(props: { label: string; error?: string } & InputHTMLAttributes<HTMLInputElement>)—errordrivesField.Root invalidand a renderedField.Error.Dialog(props: { open: boolean; onOpenChange: (open: boolean) => void; title: string; description?: string; children: ReactNode }).Tooltip(props: { content: ReactNode; label?: string; side?: 'top' | 'right' | 'bottom' | 'left'; children: ReactElement })—labeldefaults tocontentwhen it is a string and is applied as the trigger'saria-label.
Test strategy
| Spec criterion | How it becomes a test |
|---|---|
| P1 #2 | Button.test.tsx — each variant renders; disabled blocks onClick; button has an accessible name; focus-visible class present |
| P1 #4 · P2 #4 · P3 #4 · P4 #3 | UiGalleryPage.test.tsx renders /ui and asserts each component (and its documented states) is present |
| P2 #1, #2, #3 | Input.test.tsx — clicking the label focuses the control (association); error sets aria-describedby + aria-invalid; onChange fires with the value |
| P3 #1, #2, #3 | Dialog.test.tsx — open shows dialog + backdrop and moves focus in; Esc/close calls onOpenChange(false); focus stays trapped; title is the accessible name |
| P4 #1, #2 | Tooltip.test.tsx — content appears on hover and on keyboard focus; the trigger carries an accessible name matching content |
| SC2 | The four component tests collectively assert the a11y wiring each owns |
| SC6 | Existing conventions.test.ts literal-colour guard already covers src/; the new components add no literal colour — no new test |
| SC5 | The full command set at step 5: pnpm typecheck, test, lint, build, verify:contract, and the app run with /ui showing the four |
Not directly testable, and what stands in (honest gaps, per the spec's own DoD style):
- P1 #1 · SC1 ("consumers import only from
shared/ui, nothing from@base-ui/react/styled-system") — the boundary is a convention, not a guard (R2). The gallery is the consumer that proves it works through the barrel (UiGalleryPage.test.tsx); the "imports nothing from Base UI" claim is verified by human review of the gallery's imports. - P1 #3 · SC4 ("the docs document the pattern / are true of the repo") — human review; no test asserts prose.
react.md's enforcement table names each rule's owner, and the boundary row states plainly that it is convention-only. - P3 #4 "no nav item" · SC3 —
App.tsxgains noNavLink; verified by review that the nav is unchanged. The route mount is exercised by the gallery test.
Alternatives considered
- Base UI's standalone
Input(onValueChange) — rejected forField.Control, which keeps the nativeonChange/valuesurface spec R6 asks for (research V1). - A guard test for the closed boundary — rejected: spec R2 makes it a documented convention (the human's Q3 call this session).
- Colocated
cva— rejected: these are promoted foundation components, so config recipes are the correct form (spec R3/R4; the amendment todesign-system.md). - Base UI
Button— rejected: a native<button>already has a button's behaviour; wrapping adds a layer for no gain (research F2). - Compound/open components (
<Dialog.Popup>exposed) — rejected in brainstorming; the closed props API is the chosen model. - A gallery slice per component task — rejected for one gallery task after the four; each component's own test covers behaviour, the gallery test covers presence, and one
/uipage is simpler to mount.
Risks
- Base UI floating components need jsdom shims. Tooltip positioning (Floating UI) may reference
ResizeObserver/matchMedia/DOMRectthat jsdom lacks. Mitigation: add the shims toapps/web/src/testSetup.tsonly if a test surfaces the gap — do not pre-add speculatively. - Tooltip's 600 ms open delay (F4) makes a naive hover test flaky. Mitigation: tests drive it with a
delay={0}(or fake timers); the gallery may set a short delay for usability. - Portals render to
document.body. Dialog/Tooltip popups mount outside the test container. Mitigation: query via Testing Library'sscreen/document, notcontainer. primaryis provisional teal. Button'ssoliduses it; the accent may change later. Mitigation: it is a single token — R9 accepts this deliberately; no component hard-codes a colour.- Compiler / build — already de-risked: research V6 built a wrapper rendering all three Base UI primitives clean under
panicThreshold: 'all_errors'.
Open questions
- Gallery folder placement.
features/ui-gallery/follows the feature-folder +routes.tsxconvention, though the gallery is a developer surface rather than a product feature. It is the least surprising home given the routing convention; flagged in case you'd rather it live elsewhere. (Human.)Resolved (human, 2026-08-17):features/ui-gallery/confirmed. - Everything
research.mdraised is closed: statuscomplete, six verdicts, the one refutation folded into the spec.