feat: add component catalog for components/ui/*

🟢 open · #407 · open-legal-products/mike ← bmersereau/mike · opened 8d ago by bmersereau · +3,769-213 across 24 files · ↗ on GitHub

From the PR description

Summary

Adds a browsable component catalog for the primitives in frontend/src/app/components/ui/, using Ladle. 48 stories cover all 17 primitives currently in that directory. The catalog is linked from docs/design-system.md and smoke-built in CI.

Closes #323 Closes #408

Why Ladle and not Storybook

The issue asked for the choice to be made on actual setup and maintenance cost for this repo, not on which tool is more popular. Four things decided it:

  1. Nothing in components/ui/ imports from next/*. I grepped both components/ui/ and shared/ui/ and there are zero Next.js imports - the primitives are plain React plus lucide-react, clsx and tailwind-merge. That removes the single biggest reason to reach for Storybook here, since @storybook/nextjs exists mainly to shim next/image, next/font and the router. We would be paying for a compatibility layer with nothing to compatibility-layer.
  2. One devDependency instead of a package set. Ladle is a single @ladle/react entry. A Storybook install for this stack is the storybook core plus a framework package plus the addons you actually want, each on its own release cadence, plus periodic storybook upgrade migrations. For a small OSS frontend that is recurring maintenance nobody is assigned to.
  3. The repo already runs Vite. Vitest and @vitejs/plugin-react are installed, so Ladle reuses the transform pipeline and @/ alias resolution the tests already use. One build system, one mental model.
  4. The exit is cheap. Stories are CSF-compatible - export default { title } plus named exports - so if the project outgrows Ladle the stories port to Storybook without a rewrite. Choosing the light tool first is reversible; choosing the heavy one is not.

Trade-off accepted: Ladle has a smaller addon ecosystem and no Chromatic-style visual-regression story. Neither is in scope for #323, and #322 established the design system as documentation-first.

What changed

  • frontend/.ladle/config.mjs - story glob scoped to src/app/components/ui/*.stories.tsx, per the issue's "don't catalog all 144 components" constraint. a11y addon on, rtl and msw off.
  • frontend/.ladle/components.tsx - global Provider. Mike's dark mode is class-based (@custom-variant dark (&:is(.dark *))), so the Provider drives the same .dark class on the document root that Settings > Appearance sets. Without this the theme toggle would flip Ladle's own chrome and leave every app token on its light value.
  • frontend/.ladle/ladle.css - imports the app's real globals.css so the catalog renders on the actual tokens and materials rather than a copy, and supplies the two font variables that next/font normally injects on <body>.
  • 17 *.stories.tsx files colocated with their primitives, matching the existing *.test.tsx convention.
  • docs/design-system.md - new "Component catalog" section; removed the "tracked in issue #323, not set up yet" line.
  • .github/workflows/ci.yml - npm run catalog:build --if-present after the frontend build.
  • frontend/package.json - @ladle/react devDependency plus catalog and catalog:build scripts.

Coverage

All 17 primitives in components/ui/: button, check-square, cite-button, dropdown-menu, empty-state, form-field, glass-card, glass-icon-button, input, liquid-dropdown, liquid-surface, option-pill, pill-button, popover, search-bar, tab-pill-button, toggle-switch.

The issue listed the primitives as of filing; the directory has grown since, so I enumerated it rather than working from that list. liquid-surface.ts is class-name constants rather than a component, but it gets stories anyway - the elevation ladder is the part of the design system hardest to hold in your head from prose, and swatches of each material and interactive state are the clearest way to show it.

Stories lean on documenting the sharp edges the design system already calls out: Button has no type default so it submits inside a <form>; CiteButton only sets aria-label when its text is hidden (WCAG 2.5.3); CheckSquare is decorative and the row owns the ARIA state.

One non-obvious thing worth flagging in review

Tailwind v4's automatic source detection skips dot-directories, so nothing in frontend/.ladle/ is scanned for class names, and an @source override does not lift that exclusion. I found this by probing with a class used nowhere else and confirming it was absent from the built CSS.

The first version of the Provider happened to work only because every utility it used also appears somewhere in src/. That is a trap: the next person to add a novel Tailwind class to the Provider would get silently unstyled output. Catalog chrome is therefore styled with plain CSS in .ladle/ladle.css, and both that file and docs/design-system.md say why. Story files live under src/ and are scanned normally, so Tailwind works as expected in a *.stories.tsx.

Testing performed

Config/tooling change, so the regression check is a build gate rather than component unit tests - the primitives already have their own *.test.tsx suites.

  • npm run catalog:build --prefix frontend - passes, 48 stories across 17 primitives in meta.json.

  • Verified the built CSS actually resolves the design system rather than just compiling: liquid-glass-flat, liquid-glass-modal-row-selected, theme-dropdown-item, and both the light (#f9fafb) and dark (#0b0f14) --app-background values are present.

  • Ran the built catalog and loaded it in a browser: stories render, dark mode toggles the real dark tokens, the Radix dropdown opens and portals onto the float glass surface, no console errors.

  • npm run build --prefix frontend - passes. Confirmed stories are inside the TS program by injecting a deliberate bad prop (tone="not-a-real-tone") and checking tsc caught it, then reverting; this is what backs the claim that CI catches story/prop drift.

  • npm run lint --prefix frontend with the ESLint cache cleared - 0 errors, 34 warnings, which is exactly the pre-existing baseline. My first pass added 17 no-anonymous-default-export warnings, so the default exports are now named consts.

  • npm run test:coverage --prefix frontend - 637 passed, 4 failed, all 4 in src/app/lib/mikeApi.test.ts (blob.text is not a function).

    Those 4 failures are pre-existing and not caused by this PR. I verified rather than assumed: they reproduce identically after checking out origin/main's package.json and package-lock.json and reinstalling, with none of this branch's dependencies present. A lockfile diff also shows zero version changes to any pre-existing package - the 166 lockfile additions are new Ladle dependencies and the 27 removals are all deduped nested esbuild platform binaries. The failures are a local Node 24 vs CI Node 22 jsdom/Blob difference; CI on main at 6c77dda is green, so per the repo's guidance on toolchain mismatches I have not filed an issue for them.

Lockfile fix for #408

CI's npm ci was failing in both the frontend and playwright jobs with Missing: @swc/helpers@0.5.23 and Missing: esbuild@0.28.2 (plus platform binaries). Root cause: the lockfile above was generated locally with npm 11.16.0 (Node 24), and that npm silently accepted a shape that CI's npm 10.9.8 (bundled with the pinned Node 22) rejects as out of sync with package.json.

Fixed by installing an actual Node 22 (v22.23.2 / npm 10.9.8, matching what CI's actions/setup-node@v4 with node-version: 22 provisions) and running npm install against the existing lockfile in place, rather than deleting and regenerating it from scratch - a full wipe re-resolves every caret-ranged dependency to today's registry latest and would have dragged in hundreds of unrelated version bumps. The in-place repair only touches what was actually inconsistent:

  • 167 additions, all @ladle/react transitive deps (including @vitejs/plugin-react-swc's nested @swc/helpers@0.5.23, one of the two packages CI's error named).
  • 0 removals.
  • 27 version changes, all of them the esbuild instance nested under vitest moving from 0.28.1 to 0.28.2 - the second package CI's error named, and the version the rest of the tree already needed.

Verified: npm ci (fresh node_modules wipe) exits 0 with no missing/invalid/extraneous warnings, npm run catalog:build still produces 48 stories, npx vitest run / npm run test:coverage pass all 641 tests (also confirming the 4 mikeApi.test.ts failures noted above are Node 24-only - they pass under Node 22), npx tsc --noEmit is clean, npm run lint is unchanged at 0 errors / 34 warnings, and npm run build (next build) succeeds. All run under the same Node 22 toolchain CI uses.

Our analysis

Add Ladle component catalog — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-407.md from inside the repo you want the changes in.

⬇ Download capture-pull-407.md