Skip to content

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 row

The 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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.

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/hsl anywhere in apps/web/src is rejected by conventions.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 in panda.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/react and styled-system are imported only inside shared/ui/ (documented convention, not a guard test).
  • Accent stays provisional (R9). primary is unchanged; no palette work.
  • No API endpoint (HTTP contract). apps/api/openapi.json untouched; pnpm verify:contract stays 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 ​

PathChangeResponsibility
apps/web/panda.config.tsmodifiedrecipes.button + slotRecipes.{input,dialog,tooltip}, referencing tokens only
apps/web/src/shared/ui/Button.tsxnewClosed Button: button recipe + native <button> props; solid uses primary
apps/web/src/shared/ui/Input.tsxnewClosed Input: Base UI Field + input slot recipe; label/error props
apps/web/src/shared/ui/Dialog.tsxnewClosed Dialog: Base UI Dialog + dialog slot recipe; Close inside Popup
apps/web/src/shared/ui/Tooltip.tsxnewClosed Tooltip: Base UI Tooltip + tooltip slot recipe; enforces trigger accessible name
apps/web/src/shared/ui/index.tsnewPublic surface — exports Button, Input, Dialog, Tooltip
apps/web/src/shared/ui/__tests__/Button.test.tsxnewVariants, disabled blocks onClick, accessible name, focus ring
apps/web/src/shared/ui/__tests__/Input.test.tsxnewLabel association, aria-describedby error, aria-invalid, onChange
apps/web/src/shared/ui/__tests__/Dialog.test.tsxnewOpen/close via onOpenChange, focus trap, title accessible name
apps/web/src/shared/ui/__tests__/Tooltip.test.tsxnewOpens on hover and focus; trigger accessible name matches content
apps/web/src/features/ui-gallery/UiGalleryPage.tsxnewRenders the four in their states; Storybook-stopgap comment
apps/web/src/features/ui-gallery/routes.tsxnew{ path: '/ui', element: <UiGalleryPage/> }
apps/web/src/features/ui-gallery/styles.tsnewGallery layout via css()
apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsxnewEach component renders (P1 #4 / P2 #4 / P3 #4 / P4 #3)
apps/web/src/main.tsxmodifiedMount ui-gallery routes; no NavLink added to App.tsx
docs/architecture/design-system.mdmodifiedClosed-component pattern, boundary convention, promotion amendment, F1/F2, Tooltip a11y, Storybook note
docs/architecture/react.mdmodifiedEnforcement/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>) — defaults variant: 'solid', size: 'md'.
  • Input(props: { label: string; error?: string } & InputHTMLAttributes<HTMLInputElement>) — error drives Field.Root invalid and a rendered Field.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 }) — label defaults to content when it is a string and is applied as the trigger's aria-label.

Test strategy ​

Spec criterionHow it becomes a test
P1 #2Button.test.tsx — each variant renders; disabled blocks onClick; button has an accessible name; focus-visible class present
P1 #4 · P2 #4 · P3 #4 · P4 #3UiGalleryPage.test.tsx renders /ui and asserts each component (and its documented states) is present
P2 #1, #2, #3Input.test.tsx — clicking the label focuses the control (association); error sets aria-describedby + aria-invalid; onChange fires with the value
P3 #1, #2, #3Dialog.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, #2Tooltip.test.tsx — content appears on hover and on keyboard focus; the trigger carries an accessible name matching content
SC2The four component tests collectively assert the a11y wiring each owns
SC6Existing conventions.test.ts literal-colour guard already covers src/; the new components add no literal colour — no new test
SC5The 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.tsx gains no NavLink; 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 for Field.Control, which keeps the native onChange/value surface 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 to design-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 /ui page is simpler to mount.

Risks ​

  • Base UI floating components need jsdom shims. Tooltip positioning (Floating UI) may reference ResizeObserver/matchMedia/DOMRect that jsdom lacks. Mitigation: add the shims to apps/web/src/testSetup.ts only 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's screen/document, not container.
  • primary is provisional teal. Button's solid uses 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.tsx convention, 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.md raised is closed: status complete, six verdicts, the one refutation folded into the spec.