Skip to content

ADR-0019: packages/web-ui β€” Component Architecture and Definition of Done ​

Last Updated: 2026-06-08 Status: Active Context: Decksmith


Context ​

Before scaffolding packages/web-ui, Session C (Phase 4.0.5) aligned on what a "complete component" means and how the package should be structured. Without this alignment, components are judged inconsistently and the boundary between shared UI and app-specific code drifts.


Current Decision ​

1. Package Structure ​

packages/web-ui/src/
  ui/           β†’ shadcn/ui + Base UI generated components (Button, Input, Dialog…)
  components/   β†’ Decksmith custom prop-driven components (DeckCard, ManaSymbol…)
  typography/   β†’ Semantic type components (Heading, Body, Label, Overline)
  icons/        β†’ Custom animated SVG icon components (BellIcon, TrashIcon…)
  index.ts      β†’ Re-exports everything

Each component is colocated with its files:

ui/Button/
  Button.tsx
  Button.stories.tsx
  Button.mdx
  index.ts

apps/web structure (app-specific components):

apps/web/src/
  components/   β†’ Functional components coupled to the app (Navbar, Sidebar…)
  routes/       β†’ Pages and loaders
  store/        β†’ Zustand UI state

2. The packages/web-ui vs apps/web Boundary ​

Rule: A component belongs in packages/web-ui if and only if it is prop-driven and app-agnostic β€” it could be used in another React app without modification.

Mental test: "Could this component be used in another React app without changing anything?" Yes β†’ packages/web-ui. No β†’ apps/web/src/components/.

Components in packages/web-ui never import from:

  • TanStack Router (no useNavigate, <Link> with app routes)
  • TanStack Query hooks (no useUser, useDecks)
  • Zustand store
  • packages/api-client
  • apps/api
ComponentLocationReason
<Button>packages/web-ui/ui/Pure UI, props only
<ManaSymbol symbol="W">packages/web-ui/components/Pure UI, props only
<DeckCard name format colors>packages/web-ui/components/Prop-driven, reusable
<Navbar>apps/web/src/components/Uses router + Zustand
<Sidebar>apps/web/src/components/Uses global UI state
<CardSearchSlideOver>apps/web/src/components/Uses TanStack Query

3. When to Create a Component ​

Rule of three: Don't create a component preemptively. When a pattern repeats in 3 different contexts, extract it. Building abstractions before seeing 3 real uses produces components that don't fit actual usage and must be refactored immediately.

4. Definition of Done β€” Two Levels ​

v1 β€” Utilisable ​

A component reaches v1 when it is safe to use in the app. It is not yet considered stable.

  • [ ] Functional β€” all main use cases work
  • [ ] TypeScript strict β€” explicit props, no any
  • [ ] JSDoc on the component export and non-obvious props
  • [ ] Semantic tokens only β€” no hardcoded values
  • [ ] Every variant and state has a Story (default, hover, focus, disabled, error, loading…)
  • [ ] Keyboard navigable (usable without mouse)
  • [ ] Storybook @storybook/addon-a11y β€” 0 violations

Complet β€” Stable ​

All v1 criteria, plus:

  • [ ] Play functions on key interactions
  • [ ] Dark + light mode verified in Storybook
  • [ ] ComponentName.mdx β€” anatomy diagram, API table, dos/don'ts, accessibility notes
  • [ ] a11y-reviewer agent run β€” 0 violations
  • [ ] Motion tokens used for all transitions β€” never arbitrary durations
  • [ ] Error and loading states covered where applicable

A component is promoted to packages/web-ui when it reaches v1. It is considered stable when it is complete. Development on the app is never blocked waiting for component perfection.

5. Storybook MDX Documentation Structure ​

Each component's .mdx file follows this structure:

mdx
# ComponentName

Brief description β€” what it is and when to use it.

## Anatomy

<Anatomy>
  <ComponentName ...props />
  <Anatomy.Label target="root">Root β€” semantic token usage</Anatomy.Label>
  <Anatomy.Label target="label">Label β€” typography token</Anatomy.Label>
</Anatomy>

## API

<ArgTypes />

## Dos and Don'ts

| Do                                                   | Don't                                      |
| ---------------------------------------------------- | ------------------------------------------ |
| Use `<Button variant="primary">` for the main action | Don't put two primary buttons side by side |
| Use `on-accent` text on amber buttons                | Don't use white text on amber β€” fails WCAG |

## Accessibility

Notes on keyboard interaction, ARIA roles, focus management.

## Related

Links to related components.

The <Anatomy> component is a Storybook-only utility (.storybook/components/Anatomy.tsx) that renders the real component with floating annotation labels. It is never imported in apps/web.

6. Code Comments Policy ​

Two distinct levels β€” not to be confused:

  • JSDoc (required on exports): describes the component's purpose, props, and any non-obvious behaviour. This is the API documentation.
  • Inline comments (only when the WHY is non-obvious): a hidden constraint, a workaround for a specific bug, behaviour that would surprise a reader. If removing the comment wouldn't confuse a future reader, don't write it.

Do not comment what code does β€” well-named identifiers already do that.


Rationale ​

Why separate ui/ from components/: ui/ files are regenerable via npx shadcn add β€” they are scaffolded first and customized after. components/ files are written from scratch and represent Decksmith's visual identity. The distinction signals intent and prevents accidentally overwriting custom code with a shadcn regeneration.

Why typography/ is separate: <Heading>, <Body>, and <Label> are not UI components in the traditional sense β€” they are contracts on the type scale. A <Heading level={2}> encodes "text-2xl, font-700, tracking-tight" in a single token. Separating them signals that raw Tailwind type classes are never used directly in feature JSX.

Why the prop-driven boundary matters: It mirrors the architectural rule already in place for packages (ADR-0005): packages don't reach into each other's concerns. A UI component that imports a routing hook is no longer a UI component β€” it is a feature. Keeping this boundary makes packages/web-ui independently testable and reusable.

Why two levels of done: A checklist with 12 required items before a component can be used creates a bottleneck β€” nothing ships while components are being perfected. v1 sets a safe minimum; "complete" sets the quality bar for stability. Components earn stability through use, not before.


Trade-offs ​

Benefits:

  • Clear ownership boundary β€” no ambiguity about where a component lives
  • packages/web-ui is independently testable (no app coupling)
  • Two-level done prevents both under-engineering and over-engineering
  • MDX anatomy pattern produces living, always-accurate documentation

Costs:

  • The <Anatomy> component must be built as a Storybook utility before docs can use it
  • The prop-driven rule sometimes requires extracting data-fetching to a parent β€” slightly more wiring per feature

Risks:

  • Boundary erosion over time (adding a routing import "just this once")
    • Mitigation: domain-reviewer agent pattern applied to packages/web-ui β€” any import from router/query/store in the package is a red flag to catch in review

Evolution History ​

2026-06-08: Initial decision ​

  • Validated during Session C (Phase 4.0.5)
  • Key decisions: prop-driven boundary rule, two-level done, rule of three for component creation, MDX anatomy pattern, typography/ separation, JSDoc vs inline comment policy

References ​

Built with VitePress