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
- Given a consumer that needs a button, when it renders one, then it imports a single component from
shared/uiand drives it entirely through props — importing nothing from@base-ui/reactand nothing fromstyled-systemto do so. - Given
Buttonwithvariant,sizeanddisabledprops, when it renders, then each variant/size resolves through a Panda recipe (no literal colours),disabledblocksonClick, and the control has a visible focus ring and an accessible name. - Given
design-system.md, when it is read, then it documents the closed-component pattern, the "only the finished component escapesshared/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). - Given the
/uigallery route, when it is opened by URL, then it rendersButtonin 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
- Given
Inputwith alabel, when it renders, then the label is programmatically associated with the field (clicking the label focuses the input). - Given
Inputwitherrorset, when it renders, then the error text is linked to the field for assistive tech (aria-describedby) and the field is marked invalid. - Given
Input, when the user types, thenonChangefires with the value, and the component exposes no Base UI part and no Panda class to the caller. - Given
/ui, when opened, thenInputappears 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
- Given
open={false}, when rendered, then nothing shows; givenopen={true}, then the dialog and its backdrop show and focus moves into the dialog. - Given an open dialog, when Escape is pressed or the close affordance is used, then
onOpenChange(false)fires; focus stays trapped inside while it is open. - Given a dialog with a
title(and optionaldescription), when it renders, thentitleis its accessible name anddescriptionits accessible description. - Given
/ui, when opened, then a trigger opens theDialog.
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
- Given a
Tooltiptrigger, when it is hovered, thencontentappears; when it is focused by keyboard, thencontentalso appears. - Given the
Tooltip, when it renders, then the trigger carries an accessible name matching thecontent(the popup itself is visual-only, per Base UI — research.md V3), so the control is not unlabelled to assistive tech. - Given
/ui, when opened, then hovering or focusing the trigger shows theTooltip.
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/uiexports only finished components. A consumer imports neither@base-ui/reactnorstyled-systemto use any of the four. This boundary is a documented convention indesign-system.md, deliberately not a guard test (chosen this session). - R3 — Styling is a Panda config recipe, each one authored in its own
*Recipe.tsfile beside its component and registered inpanda.config.ts: acvarecipe forButton, a slot recipe (sva) for each multi-part component (Input= field + label + error;Dialog= backdrop + panel + title;Tooltip= trigger + popup). Config recipes — not colocatedcva— 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 keepspanda.config.tsa thin manifest. Confirmed — research.md V4: every Base UI part acceptsclassNameas 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 toshared/uideliberately, ahead of a second consumer; feature components still colocate as acvauntil 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).solidreferences theprimarytoken. The component owns itsfocus-visiblering anddisabledstyling. - R6 —
Input(Base UIField). Props:label,error?, and pass-through native<input>props (value,onChange,placeholder,type,disabled,name,required). Label↔field association and thearia-describedbyerror link are owned via Base UI Field. Confirmed — research.md V1:Field.{Root,Label,Control,Error}(or the standaloneInput) give automatic label association,aria-describedbyon the error, andaria-invalid. A plainerror: stringmaps toField.Root invalid={!!error}+Field.Error match. - R7 —
Dialog(Base UIDialog). Props:open,onOpenChange,title,description?,children(body). Focus trap, Escape-to-close, backdrop, and title/description a11y come from Base UI. Nofooter/actions slot in v1 — actions go inchildren. Confirmed — research.md V2:Dialog.Roottakes controlledopen/onOpenChange;modal(default true) traps focus, locks scroll and enables Escape/outside-press dismissal;Title/Descriptionauto-label the popup. Construction detail (F3): aDialog.Closerenders insideDialog.Popup, mounted throughDialog.Portal. - R8 —
Tooltip(Base UITooltip). 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 —labelapplied asaria-label, defaulted fromcontentwhen 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/closeDelaylive on the trigger,side/sideOffseton the positioner;Tooltip.Provideris optional.
The accent
- R9 —
primarystays provisional.Button'ssolidvariant references the token; finalising the accent is a one-token edit and is out of scope here.
The gallery
- R10 — A
/uiroute 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.tsxconvention and is mounted inmain.tsx. Confirmed — research.md V5: a child route appended inmain.tsxrenders inside the App shell'sQueryBoundary+Suspensewith no special handling (the gallery fetches no server data); the page carries the*Pagesuffix (e.g.UiGalleryPage) and "no nav item" means noNavLinkinApp.tsx. - R11 — Storybook is out of scope; the
/uigallery is the interim surface it will later replace.
Documentation
- R12 —
design-system.mdgains 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 insideshared/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,disabledblocksonClick, accessible name);Input(label association, error viaaria-describedby,onChangefires);Dialog(openshows it, Escape/close firesonOpenChange, focus trapped,titleis the accessible name);Tooltip(contenton hover and focus, associated with the trigger). - R15 — The four components compile under the React Compiler with
panicThreshold: 'all_errors'without bail-out, sopnpm buildstays green. Confirmed — research.md V6: a throwaway wrapper rendering Base UI Field/Input/Dialog/Tooltip built clean underpanicThreshold: 'all_errors';@rolldown/plugin-babelexcludesnode_modules, so only oursrc/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,DialogandTooltipare exported fromshared/ui, and the/uigallery renders all four importing nothing from@base-ui/reactorstyled-system. - SC2 — Each component is operable by keyboard and carries the accessibility wiring it owns:
Buttonfocusable with a visible focus ring and an accessible name;Inputlabel-associated with its error linked viaaria-describedby;Dialogfocus-trapped, Escape-dismissable, labelled by itstitle;Tooltipopenable by focus with its trigger carrying an accessible name matching the content. - SC3 —
/uirenders every component in its documented states and is reachable by URL with no navigation item pointing at it. - SC4 —
design-system.mddocuments the closed-component pattern, the boundary convention, and the amended promotion rule;react.mdnames the boundary. Each statement is true of the repository at merge, not an intention. - SC5 —
pnpm typecheck,pnpm test,pnpm lint,pnpm buildandpnpm verify:contractare all green, and the app runs with/uishowing 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
/uigallery is the interim surface. - Finalising the
primaryaccent 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'sApiKeyInput). 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
Tooltipis visual-only (research.md V3); a futurePopover(not in this spec) is the accessible route for content that must be announced. - Dark-mode work beyond the existing
_osDarktoken layer.
Assumptions
- The stack is as pinned:
@base-ui/react1.6.0, Panda 1.11.x, React 19.2 with the React Compiler, React Router 8, Vitest 4 with Testing Library. shared/uialready exists as the home for promoted presentational components (ErrorBoundarylives there); no recipe component has been promoted yet, exactly asdesign-system.mdrecords.- 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
/uigallery is their only render surface for now.
Traceability
Each acceptance scenario and success criterion must map to a named test. Filled in during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | Human 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 #2 | apps/web/src/shared/ui/__tests__/Button.test.tsx — "P1 #2: renders each variant with an accessible name", "P1 #2: disabled blocks onClick" |
| P1 #3 | Human 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 #4 | apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P1 #4 / P2 #4: renders Button variants and an Input in both states" |
| P2 #1 | apps/web/src/shared/ui/__tests__/Input.test.tsx — "P2 #1: the label is associated with the control" |
| P2 #2 | apps/web/src/shared/ui/__tests__/Input.test.tsx — "P2 #2: error is exposed via aria-describedby and marks the field invalid" |
| P2 #3 | apps/web/src/shared/ui/__tests__/Input.test.tsx — "P2 #3: onChange fires with the typed value" |
| P2 #4 | apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P1 #4 / P2 #4: renders Button variants and an Input in both states" |
| P3 #1 | apps/web/src/shared/ui/__tests__/Dialog.test.tsx — "P3 #1: hidden when closed, shown with focus inside when open" |
| P3 #2 | apps/web/src/shared/ui/__tests__/Dialog.test.tsx — "P3 #2: Escape requests close via onOpenChange(false)" |
| P3 #3 | apps/web/src/shared/ui/__tests__/Dialog.test.tsx — "P3 #3: title is the accessible name" |
| P3 #4 | apps/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 #1 | apps/web/src/shared/ui/__tests__/Tooltip.test.tsx — "P4 #1: content appears on hover and on keyboard focus" |
| P4 #2 | apps/web/src/shared/ui/__tests__/Tooltip.test.tsx — "P4 #2: the trigger carries an accessible name matching the content" |
| P4 #3 | apps/web/src/features/ui-gallery/__tests__/UiGalleryPage.test.tsx — "P4 #3: a tooltip trigger is present with an accessible name" |
| SC1 | Same 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) |
| SC2 | The 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" |
| SC3 | apps/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) |
| SC4 | Human 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 |
| SC5 | The 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) |
| SC6 | apps/web/src/__tests__/conventions.test.ts — "P4 #2/SC4: no literal colour values" (the pre-existing literal-colour guard; extends spec 010 SC4) |