Skip to content

Spec 019 — Design-system components ​

Status: implemented Branch: 019-design-system-components

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

Spec 010 built the token layer (panda.config.ts) and wrote docs/architecture/design-system.md, but stopped short of components: the rule there is "colocated cva until a second feature needs it," and no component in apps/web has crossed that line. So shared/ui holds one thing (ErrorBoundary) and every feature that needs a control invents its own. features/account/ApiKeyInput.tsx is exactly that — a hand-rolled input, styled from a feature-local styles.ts, because the design system offers no Input to reach for. The next control any feature needs will be hand-rolled the same way, each one re-deciding focus rings, disabled states, and label wiring that a shared component would settle once.

@base-ui/react is installed (spec 004) and imported nowhere. The accessible behaviour it exists to provide — focus traps, label association, tooltip-on-focus — is therefore absent from every control the app renders today. This spec starts the component layer with four controls and, more importantly, fixes the shape every future one follows: a closed component that owns its Base UI primitive and its Panda styling internally and exposes only a props API.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable. P2–P4 each add one component on top of the pattern P1 establishes.

P1 — The closed-component pattern exists, is documented, and Button proves it ​

As the developer, I want one written, demonstrated answer to "how does a design-system component wrap a headless primitive," so that every control after the first follows the same closed shape instead of re-deciding it, and a feature reaches for shared/ui rather than hand-rolling.

Independent test: with only this story implemented, shared/ui exports a Button used purely through props; design-system.md states the closed-component pattern and the amended promotion rule; and a /ui route renders Button in its states.

Acceptance scenarios

  1. Given a consumer that needs a button, when it renders one, then it imports a single component from shared/ui and drives it entirely through props — importing nothing from @base-ui/react and nothing from styled-system to do so.
  2. Given Button with variant, size and disabled props, when it renders, then each variant/size resolves through a Panda recipe (no literal colours), disabled blocks onClick, and the control has a visible focus ring and an accessible name.
  3. Given design-system.md, when it is read, then it documents the closed-component pattern, the "only the finished component escapes shared/ui" boundary as a convention (not a guard test), the Storybook-later direction, and the amended promotion rule (foundation components are promoted deliberately, ahead of a second consumer; feature components still colocate until one appears).
  4. Given the /ui gallery route, when it is opened by URL, then it renders Button in every variant × size × disabled state, and no navigation item links to it.

P2 — Input (closed, over Base UI Field) ​

As the developer, I want a closed Input that owns its label/error accessibility, so a feature stops hand-rolling one (as account did) and gets association and error wiring for free.

Independent test: with only this story added, shared/ui exports an Input whose label is associated with the field and whose error is exposed to assistive tech, used purely through props.

Acceptance scenarios

  1. Given Input with a label, when it renders, then the label is programmatically associated with the field (clicking the label focuses the input).
  2. Given Input with error set, when it renders, then the error text is linked to the field for assistive tech (aria-describedby) and the field is marked invalid.
  3. Given Input, when the user types, then onChange fires with the value, and the component exposes no Base UI part and no Panda class to the caller.
  4. Given /ui, when opened, then Input appears both with and without an error.

P3 — Dialog (closed, over Base UI Dialog) ​

As the developer, I want a closed Dialog that owns focus trapping and dismissal, so a feature gets a modal that is accessible by construction from a small props API.

Independent test: with only this story added, shared/ui exports a Dialog controlled by open/onOpenChange that traps focus while open and is labelled by its title.

Acceptance scenarios

  1. Given open={false}, when rendered, then nothing shows; given open={true}, then the dialog and its backdrop show and focus moves into the dialog.
  2. Given an open dialog, when Escape is pressed or the close affordance is used, thenonOpenChange(false) fires; focus stays trapped inside while it is open.
  3. Given a dialog with a title (and optional description), when it renders, then title is its accessible name and description its accessible description.
  4. Given /ui, when opened, then a trigger opens the Dialog.

P4 — Tooltip (closed, over Base UI Tooltip) ​

As the developer, I want a closed Tooltip that opens on hover and keyboard focus, so hover help is reachable without a mouse from a two-prop API.

Independent test: with only this story added, shared/ui exports a Tooltip taking content and a trigger children, whose content appears on both hover and focus, and whose trigger carries an accessible name matching the content.

Acceptance scenarios

  1. Given a Tooltip trigger, when it is hovered, then content appears; when it is focused by keyboard, then content also appears.
  2. Given the Tooltip, when it renders, then the trigger carries an accessible name matching the content (the popup itself is visual-only, per Base UI — research.md V3), so the control is not unlabelled to assistive tech.
  3. Given /ui, when opened, then hovering or focusing the trigger shows the Tooltip.

Requirements ​

The closed-component pattern

  • R1 — Four components live in apps/web/src/shared/ui/, each a single named export driven by a props API: Button, Input, Dialog, Tooltip. A component uses its Base UI primitive and its Panda styling inside its own file; neither the Base UI parts nor the Panda classes are re-exported.
  • R2 — shared/ui exports only finished components. A consumer imports neither @base-ui/react nor styled-system to use any of the four. This boundary is a documented convention in design-system.md, deliberately not a guard test (chosen this session).
  • R3 — Styling is a Panda config recipe, each one authored in its own *Recipe.ts file beside its component and registered in panda.config.ts: a cva recipe for Button, a slot recipe (sva) for each multi-part component (Input = field + label + error; Dialog = backdrop + panel + title; Tooltip = trigger + popup). Config recipes — not colocated cva — because these components are deliberately promoted (R4) and are JIT (only used variants are emitted), the documented Panda approach for design-system components; the per-file split keeps panda.config.ts a thin manifest. Confirmed — research.md V4: every Base UI part accepts className as a plain string (or (state) => string) and merges it onto the DOM element; Panda's generated classes are not stripped. Base UI ships no stylesheet (F1), so the slot recipes are the only visual styling.

The promotion-rule amendment

  • R4 — design-system.md's "Colocated until promoted" rule gains a carve-out: foundation / design-system components are promoted to shared/ui deliberately, ahead of a second consumer; feature components still colocate as a cva until a second feature needs them. The four components are the first application of the carve-out, and the rule text names them as the example.

The components

  • R5 — Button (pure Panda, no Base UI). Props: variant (solid | outline | ghost), size (sm | md), and pass-through native <button> props (onClick, disabled, type, children). solid references the primary token. The component owns its focus-visible ring and disabled styling.
  • R6 — Input (Base UI Field). Props: label, error?, and pass-through native <input> props (value, onChange, placeholder, type, disabled, name, required). Label↔field association and the aria-describedby error link are owned via Base UI Field. Confirmed — research.md V1: Field.{Root,Label,Control,Error} (or the standalone Input) give automatic label association, aria-describedby on the error, and aria-invalid. A plain error: string maps to Field.Root invalid={!!error} + Field.Error match.
  • R7 — Dialog (Base UI Dialog). Props: open, onOpenChange, title, description?, children (body). Focus trap, Escape-to-close, backdrop, and title/description a11y come from Base UI. No footer/actions slot in v1 — actions go in children. Confirmed — research.md V2: Dialog.Root takes controlled open/onOpenChange; modal (default true) traps focus, locks scroll and enables Escape/outside-press dismissal; Title/Description auto-label the popup. Construction detail (F3): a Dialog.Close renders inside Dialog.Popup, mounted through Dialog.Portal.
  • R8 — Tooltip (Base UI Tooltip). Props: content, children (trigger), label?, side?. Opens on hover and on keyboard focus; positioning and delay come from Base UI. Base UI does not announce tooltip content to assistive tech, so the closed component requires an accessible name on the trigger — label applied as aria-label, defaulted from content when it is a string — and the content is visual-only. Confirmed with a refutation folded in — research.md V3: opens on focus and hover, but the auto-association the spec first assumed does not exist in Base UI 1.6.0, so the trigger carries the accessible name instead. F4: delay/closeDelay live on the trigger, side/sideOffset on the positioner; Tooltip.Provider is optional.

The accent

  • R9 — primary stays provisional. Button's solid variant references the token; finalising the accent is a one-token edit and is out of scope here.

The gallery

  • R10 — A /ui route renders every component in its states, reachable by URL with no navigation item. Its page carries a header comment marking it a stopgap for Storybook. Its route follows the existing feature-folder + routes.tsx convention and is mounted in main.tsx. Confirmed — research.md V5: a child route appended in main.tsx renders inside the App shell's QueryBoundary + Suspense with no special handling (the gallery fetches no server data); the page carries the *Page suffix (e.g. UiGalleryPage) and "no nav item" means no NavLink in App.tsx.
  • R11 — Storybook is out of scope; the /ui gallery is the interim surface it will later replace.

Documentation

  • R12 — design-system.md gains a section defining the closed-component pattern, the R2 boundary convention, the Storybook-later note, and the R4 promotion-rule amendment.
  • R13 — react.md's enforcement/ownership table gains a row naming the "Base UI imported only inside shared/ui" rule as a convention that is documented, not machine-enforced (R19-style honesty).

Testing / compatibility

  • R14 — Each component has a test asserting behaviour and accessibility: Button (variants render, disabled blocks onClick, accessible name); Input (label association, error via aria-describedby, onChange fires); Dialog (open shows it, Escape/close fires onOpenChange, focus trapped, title is the accessible name); Tooltip (content on hover and focus, associated with the trigger).
  • R15 — The four components compile under the React Compiler with panicThreshold: 'all_errors' without bail-out, so pnpm build stays green. Confirmed — research.md V6: a throwaway wrapper rendering Base UI Field/Input/Dialog/Tooltip built clean under panicThreshold: 'all_errors'; @rolldown/plugin-babel excludes node_modules, so only our src/ wrappers are compiled.
  • R16 — Every acceptance scenario and success criterion maps to a named test (project Definition of Done).

HTTP contract ​

No endpoint is added, removed or changed by this spec, and apps/api/openapi.json is untouched. The work is confined to apps/web and docs/. pnpm verify:contract must stay green throughout.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — Button, Input, Dialog and Tooltip are exported from shared/ui, and the /ui gallery renders all four importing nothing from @base-ui/react or styled-system.
  • SC2 — Each component is operable by keyboard and carries the accessibility wiring it owns: Button focusable with a visible focus ring and an accessible name; Input label-associated with its error linked via aria-describedby; Dialog focus-trapped, Escape-dismissable, labelled by its title; Tooltip openable by focus with its trigger carrying an accessible name matching the content.
  • SC3 — /ui renders every component in its documented states and is reachable by URL with no navigation item pointing at it.
  • SC4 — design-system.md documents the closed-component pattern, the boundary convention, and the amended promotion rule; react.md names the boundary. Each statement is true of the repository at merge, not an intention.
  • SC5 — pnpm typecheck, pnpm test, pnpm lint, pnpm build and pnpm verify:contract are all green, and the app runs with /ui showing the four components.
  • SC6 — No literal colour appears in the four components; every colour resolves through a token (extends spec 010 SC4).

Out of scope ​

  • Storybook itself (R11) — the /ui gallery is the interim surface.
  • Finalising the primary accent or any palette change (R9).
  • Any component beyond the four — Card, Badge, Select, a rarity-coloured item label (deliberately not a component: it is a <span> with a rarity token), etc.
  • A guard test enforcing the closed boundary — it stays a documented convention (R2).
  • Wiring the four components into existing features (e.g. replacing account's ApiKeyInput). The components ship proven by tests and the gallery; adopting them is later work.
  • Dialog footer/actions slot (R7).
  • Announced/essential tooltip information — Base UI's Tooltip is visual-only (research.md V3); a future Popover (not in this spec) is the accessible route for content that must be announced.
  • Dark-mode work beyond the existing _osDark token layer.

Assumptions ​

  • The stack is as pinned: @base-ui/react 1.6.0, Panda 1.11.x, React 19.2 with the React Compiler, React Router 8, Vitest 4 with Testing Library.
  • shared/ui already exists as the home for promoted presentational components (ErrorBoundary lives there); no recipe component has been promoted yet, exactly as design-system.md records.
  • Base UI 1.6.0 provides the Field/Input, Dialog and Tooltip primitives used here. Every component-API and a11y assumption was verified against the installed package in research.md (V1–V6); the one refutation (Tooltip has no auto aria association) is folded into R8/P4 #2/SC2.
  • No second consumer of the four components exists yet; the /ui gallery is their only render surface for now.

Traceability ​

Each acceptance scenario and success criterion must map to a named test. Filled in during implementation.

CriterionTest
P1 #1Human review, not a guard test (R2 makes the boundary a documented convention) — apps/web/src/features/ui-gallery/UiGalleryPage.tsx imports { Button, Dialog, Input, Tooltip } only from ../../shared/ui, nothing from @base-ui/react or styled-system; that the barrel actually works is exercised by apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P1 #4 / P2 #4: renders Button variants and an Input in both states", "P3 #4: a trigger opens the Dialog", "P4 #3: a tooltip trigger is present with an accessible name"
P1 #2apps/web/src/shared/ui/__tests__/Button.test.tsx — "P1 #2: renders each variant with an accessible name", "P1 #2: disabled blocks onClick"
P1 #3Human review, not a guard test (prose is not test-asserted) — docs/architecture/design-system.md §Closed components documents the pattern, the R2 boundary as a convention, the Storybook-later direction; §Colocated until promoted's amendment documents the promotion-rule carve-out; docs/architecture/react.md's Five conventions with no enforcement layer names the boundary. pnpm docs:build confirms both compile
P1 #4apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P1 #4 / P2 #4: renders Button variants and an Input in both states"
P2 #1apps/web/src/shared/ui/__tests__/Input.test.tsx — "P2 #1: the label is associated with the control"
P2 #2apps/web/src/shared/ui/__tests__/Input.test.tsx — "P2 #2: error is exposed via aria-describedby and marks the field invalid"
P2 #3apps/web/src/shared/ui/__tests__/Input.test.tsx — "P2 #3: onChange fires with the typed value"
P2 #4apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P1 #4 / P2 #4: renders Button variants and an Input in both states"
P3 #1apps/web/src/shared/ui/__tests__/Dialog.test.tsx — "P3 #1: hidden when closed, shown with focus inside when open"
P3 #2apps/web/src/shared/ui/__tests__/Dialog.test.tsx — "P3 #2: Escape requests close via onOpenChange(false)"
P3 #3apps/web/src/shared/ui/__tests__/Dialog.test.tsx — "P3 #3: title is the accessible name"
P3 #4apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P3 #4: a trigger opens the Dialog"; "no navigation item" is human review — apps/web/src/App.tsx gains no NavLink, confirmed by the manual /ui run at T5
P4 #1apps/web/src/shared/ui/__tests__/Tooltip.test.tsx — "P4 #1: content appears on hover and on keyboard focus"
P4 #2apps/web/src/shared/ui/__tests__/Tooltip.test.tsx — "P4 #2: the trigger carries an accessible name matching the content"
P4 #3apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P4 #3: a tooltip trigger is present with an accessible name"
SC1Same evidence as P1 #1: the /ui gallery is the one consumer, proven via UiGalleryPage.test.tsx; "imports nothing from @base-ui/react/styled-system" is human review of the import line, not a guard (R2 is a documented convention, deliberately not machine-checked)
SC2The four component tests, collectively: Button.test.tsx — "P1 #2: renders each variant with an accessible name", "P1 #2: disabled blocks onClick"; Input.test.tsx — "P2 #1: the label is associated with the control", "P2 #2: error is exposed via aria-describedby and marks the field invalid"; Dialog.test.tsx — "P3 #1: hidden when closed, shown with focus inside when open", "P3 #2: Escape requests close via onOpenChange(false)", "P3 #3: title is the accessible name"; Tooltip.test.tsx — "P4 #1: content appears on hover and on keyboard focus", "P4 #2: the trigger carries an accessible name matching the content"
SC3apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx (all three its) exercises /ui rendering every component's documented states; "no navigation item" is human review that App.tsx is unchanged, confirmed by the manual /ui run (T5: pnpm --filter @gw2priory/web dev, open /ui)
SC4Human review, not a guard test — docs/architecture/design-system.md §Closed components and its §Colocated until promoted amendment; docs/architecture/react.md's boundary entry under Five conventions with no enforcement layer. pnpm docs:build (VitePress) confirms both render without error
SC5The full command set, all green at Step 5: pnpm typecheck, pnpm test, pnpm lint, pnpm build, pnpm verify:contract; plus the manual app run (pnpm --filter @gw2priory/web dev, /ui showing all four components, dialog opening/closing, tooltip on hover)
SC6apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values" (the pre-existing literal-colour guard; extends spec 010 SC4)