feat(ui): in-tree component primitives + /dev/components showcase (#139) #148

Merged
james merged 1 commit from 139-ui-primitives into main 2026-06-19 18:30:23 +00:00
Owner

Closes #139. Composes with #138's token foundation (now on main).

What lands

The nine day-one DS primitives under app/components/ui/:

Primitive Surface
Button 3 variants (primary/secondary/ghost) × 3 sizes (sm/md/lg), leadingIcon/trailingIcon slots, fullWidth
IconButton same variants/sizes, required aria-label, square heights ride --control-*
Card --surface + --border + --radius-lg, elevation="raised"|"hover", optional padding scale
Badge 6 tones (neutral/accent/success/warning/danger/info) × 2 sizes (sm/md)
Avatar initials or image (TypeScript narrows: src requires alt), 4 sizes (sm/md/lg/xl), circle/square shapes
Field label + hint + error wrapper. Auto-wires id + aria-describedby + aria-invalid onto a single control child via cloneElement
Input / Textarea / Select controlled, --ring focus, 3 sizes that ride --control-* so they sit flush with same-size Buttons

Each primitive ships its .tsx next to a colocated .module.css. CSS modules are the only path to :hover, :focus-visible, :disabled pseudo-states — inline styles (the existing surface idiom) can't express them. Library alternatives (Radix, headless-ui, shadcn-copy) considered and rejected in ADR-0024; the gist is that at nine focused primitives the library overhead — install-script review per-package, additional dependency surface, the "another way to read" cost for future contributors — outweighs the per-component savings. The decision flips when heavier primitives (Dialog, Combobox, Menu, Tooltip) land and the accessibility complexity grows past what hand-rolled handles cleanly.

Adjacent changes worth flagging

  • lucide-react added as a runtime dep. Pure data + JSX, no install scripts (verified — npx allow-scripts reports no new missing config; the lavamoat allowlist is unchanged per ADR-0010). Ticket said "devDep" but Next.js standalone bundles need runtime React-component deps in dependencies or the build artifact fails to resolve them.
  • Onest + JetBrains Mono via next/font/google — self-hosted at build time, weights matching the DS (display 600/700, body 400/500, mono 400/500/600). layout.tsx attaches the next/font CSS variables on <html>; theme CSS aliases --font-sans / --font-display / --font-mono to them with the system fallback chain preserved.
  • Shape/font tokens added to light.css / dark.css that #138's scope didn't cover: --text-* size scale (xs through 3xl), --radius-* (sm/md/lg/xl/full), --control-* heights, --space-* 4px grid, --shadow-xs (theme-tuned for dark), --font-display / --font-mono. Theme-invariant tokens (sizes / radii / spacing) live on :root so they cascade under both themes; color/shadow tokens stay in the per-theme blocks. The primitives literally couldn't ship without these, and they're an obvious oversight in #138's color-only scope.

/dev/components showcase

Lives at app/(app)/dev/components/page.tsx — auth-gated by the (app) group, notFound()s itself when NODE_ENV === "production". Static-rendered (no per-request work). Two side-by-side panels with data-theme="light" and data-theme="dark" so a reviewer scans both surfaces in one tab without flipping the app-wide theme switch.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean (0 warnings, 0 errors).
  • npm run build — clean. /dev/components appears in the route table as a static () prerender.
  • npm test — 421 passed, 91 skipped (no regression — existing screens use inline styles and were not touched).
  • Visual review: hit /dev/components locally (npm run dev), confirm both themes render correctly, hover/focus/disabled states behave on every interactive primitive.

What does NOT change

  • No existing screen rewritten. Per-screen rebuild tickets (Account, Profile, Skills, etc.) flip surfaces over to the primitives one at a time.
  • Bridge tokens from ADR-0023 (--color-*) still in place; nothing removed.
  • The heavier primitives the DS calls for later (Dialog, Toast, Tooltip, Progress, Tabs) are explicit follow-ups when the first screen that needs one lands.
Closes #139. Composes with #138's token foundation (now on `main`). ## What lands The nine day-one DS primitives under `app/components/ui/`: | Primitive | Surface | |---|---| | `Button` | 3 variants (primary/secondary/ghost) × 3 sizes (sm/md/lg), `leadingIcon`/`trailingIcon` slots, `fullWidth` | | `IconButton` | same variants/sizes, **required** `aria-label`, square heights ride `--control-*` | | `Card` | `--surface` + `--border` + `--radius-lg`, `elevation="raised"\|"hover"`, optional padding scale | | `Badge` | 6 tones (neutral/accent/success/warning/danger/info) × 2 sizes (sm/md) | | `Avatar` | initials *or* image (TypeScript narrows: `src` requires `alt`), 4 sizes (sm/md/lg/xl), `circle`/`square` shapes | | `Field` | label + hint + error wrapper. Auto-wires `id` + `aria-describedby` + `aria-invalid` onto a single control child via `cloneElement` | | `Input` / `Textarea` / `Select` | controlled, `--ring` focus, 3 sizes that ride `--control-*` so they sit flush with same-size Buttons | Each primitive ships its `.tsx` next to a colocated `.module.css`. **CSS modules** are the only path to `:hover`, `:focus-visible`, `:disabled` pseudo-states — inline styles (the existing surface idiom) can't express them. Library alternatives (Radix, headless-ui, shadcn-copy) considered and rejected in **ADR-0024**; the gist is that at nine focused primitives the library overhead — install-script review per-package, additional dependency surface, the "another way to read" cost for future contributors — outweighs the per-component savings. The decision flips when heavier primitives (`Dialog`, `Combobox`, `Menu`, `Tooltip`) land and the accessibility complexity grows past what hand-rolled handles cleanly. ## Adjacent changes worth flagging - **`lucide-react`** added as a runtime dep. Pure data + JSX, no install scripts (verified — `npx allow-scripts` reports no new missing config; the lavamoat allowlist is unchanged per ADR-0010). Ticket said "devDep" but Next.js standalone bundles need runtime React-component deps in `dependencies` or the build artifact fails to resolve them. - **Onest + JetBrains Mono via `next/font/google`** — self-hosted at build time, weights matching the DS (display 600/700, body 400/500, mono 400/500/600). `layout.tsx` attaches the next/font CSS variables on `<html>`; theme CSS aliases `--font-sans` / `--font-display` / `--font-mono` to them with the system fallback chain preserved. - **Shape/font tokens** added to `light.css` / `dark.css` that #138's scope didn't cover: `--text-*` size scale (xs through 3xl), `--radius-*` (sm/md/lg/xl/full), `--control-*` heights, `--space-*` 4px grid, `--shadow-xs` (theme-tuned for dark), `--font-display` / `--font-mono`. Theme-invariant tokens (sizes / radii / spacing) live on `:root` so they cascade under both themes; color/shadow tokens stay in the per-theme blocks. The primitives literally couldn't ship without these, and they're an obvious oversight in #138's color-only scope. ## /dev/components showcase Lives at `app/(app)/dev/components/page.tsx` — auth-gated by the `(app)` group, `notFound()`s itself when `NODE_ENV === "production"`. Static-rendered (no per-request work). Two side-by-side panels with `data-theme="light"` and `data-theme="dark"` so a reviewer scans both surfaces in one tab without flipping the app-wide theme switch. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean (0 warnings, 0 errors). - [x] `npm run build` — clean. `/dev/components` appears in the route table as a static (`○`) prerender. - [x] `npm test` — 421 passed, 91 skipped (no regression — existing screens use inline styles and were not touched). - [ ] **Visual review**: hit `/dev/components` locally (`npm run dev`), confirm both themes render correctly, hover/focus/disabled states behave on every interactive primitive. ## What does NOT change - No existing screen rewritten. Per-screen rebuild tickets (Account, Profile, Skills, etc.) flip surfaces over to the primitives one at a time. - Bridge tokens from ADR-0023 (`--color-*`) still in place; nothing removed. - The heavier primitives the DS calls for later (`Dialog`, `Toast`, `Tooltip`, `Progress`, `Tabs`) are explicit follow-ups when the first screen that needs one lands.
feat(ui): in-tree component primitives + /dev/components showcase (#139)
All checks were successful
PR / OSV-Scanner (pull_request) Successful in 18s
PR / Static analysis (pull_request) Successful in 54s
PR / Typecheck (pull_request) Successful in 1m1s
PR / npm audit (pull_request) Successful in 1m5s
PR / Package age policy (soft) (pull_request) Successful in 46s
PR / Lint (pull_request) Successful in 1m6s
Secrets / gitleaks (pull_request) Successful in 24s
PR / Test (postgres) (pull_request) Successful in 1m32s
PR / Coverage (soft) (pull_request) Successful in 1m33s
PR / Test (sqlite) (pull_request) Successful in 1m42s
PR / Build (pull_request) Successful in 1m53s
PR / Trivy (image) (pull_request) Successful in 2m5s
Commits / Conventional Commits (pull_request) Successful in 9s
e729a7dc30
Adds the nine day-one DS primitives under app/components/ui/:

  - Button (3 variants x 3 sizes + leadingIcon/trailingIcon/fullWidth)
  - IconButton (same variants/sizes, required aria-label)
  - Card (surface + border + radius, elevation raised/hover)
  - Badge (6 tones x 2 sizes)
  - Avatar (initials or image, 4 sizes, circle/square)
  - Field (label + hint + error, auto-wires id/aria-describedby
    onto a single control child)
  - Input + Textarea + Select (controlled, --ring focus, 3 sizes
    that ride --control-*)

Each primitive ships its TSX next to a colocated .module.css. CSS
modules are the only path to :hover / :focus-visible / :disabled
pseudo-states, which inline styles can't express; the rationale +
alternatives rejected (Radix, headless-ui, shadcn-style copy) live
in ADR-0024.

Lucide-react added as a runtime dep — pure data + JSX, no install
scripts (verified, no new lavamoat.allowScripts entry needed per
ADR-0010). Onest + JetBrains Mono load via next/font/google (self-
hosted at build, weights matching the DS: display 600/700, body
400/500, mono 400/500/600). Layout.tsx attaches the next/font CSS
variables on <html>; theme CSS aliases --font-sans / --font-display
/ --font-mono to them with a system fallback chain.

Shape/font tokens added to themes that #138 didn't cover: --text-*
size scale (xs through 3xl), --radius-* (sm/md/lg/xl/full),
--control-* heights (sm 30 / md 36 / lg 44), --space-* 4px grid,
--shadow-xs (theme-tuned for dark), --font-display / --font-mono.
Theme-invariant tokens (sizes, radii, spacing) live on :root;
color/shadow tokens live in the per-theme blocks.

/dev/components is a static-rendered showcase route under (app)
(auth-gated by virtue of the group) that notFound()'s itself when
NODE_ENV === "production". Renders every primitive variant in two
side-by-side panels — one data-theme="light", one data-theme="dark"
— so a reviewer scans both surfaces without flipping the app-wide
theme switch.

No existing screen is rewritten; existing inline-styled surfaces
keep working unchanged. Per-screen rebuild tickets adopt the
primitives.

Closes #139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 86.6% ≥ 50%
Branches 80.9% ≥ 75%
Functions 90.8% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 86.6% ✅ | ≥ 50% | | Branches | 80.9% ✅ | ≥ 75% | | Functions | 90.8% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit 17437cd42c into main 2026-06-19 18:30:23 +00:00
james deleted branch 139-ui-primitives 2026-06-19 18:30:24 +00:00
Sign in to join this conversation.
No description provided.