feat: formalize design system on existing shadcn/Tailwind foundation

✅ merged · #357 · open-legal-products/mike ← bmersereau/mike · opened 2mo ago by bmersereau · merged 2mo ago by willchen96 · +1,159-254 across 42 files · ↗ on GitHub

From the PR description

Summary

Formalizes the design system that already exists (shadcn/ui new-york + Tailwind v4 CSS-variable tokens) rather than introducing a new one: documents the current tokens and primitives, consolidates three duplicated feature-component patterns into shared primitives, and fixes the accessibility gaps in components/ui/*.

No visual redesign, no new dependency, no Storybook (that stays in #323).

Closes #322

Changes

1. docs/design-system.md (new)

Documents the current foundations, derived from frontend/src/app/globals.css and an audit of frontend/src/app/components:

  • Color - the --app-* surface family (Mike's own liquid-glass chrome) and the shadcn semantic set, with intent per token. Flags two real gotchas: @theme inline overrides part of Tailwind's blue scale to Mike azure (and leaves blue-300/400/500/800/900 untouched, so the scale is discontinuous), and the --app-* tokens have no .dark values, so dark mode is currently incomplete.
  • Typography - Inter (font-sans) / EB Garamond (font-serif), how they're loaded in both the web app and the add-in, and the de facto type scale.
  • Spacing and radius - the actual subset in use (px-3/py-2/gap-2 dominate; h-7 for compact chrome), plus the --radius token and which radii the app really uses.
  • Where components live - the components/ui/ vs shared/ui/ vs components/shared/ vs feature split, including the rule that shared/ui/ cannot import from app/ and that new files there must be registered in the add-in's @source list.
  • Choosing - a stop-at-the-first-match list for existing primitive vs. shadcn registry vs. one-off, with "promote on the second occurrence" as the consolidation trigger.
  • Accessibility baseline - the rules the primitives now follow.

2. Consolidation

Targets were re-derived from the current tree rather than reused from earlier analysis, so they differ from what the issue's "audit for consolidation candidates" bullet might have anticipated. Ranked by (number of duplicate sites) x (how identical the markup is):

  • EmptyState (components/ui/empty-state.tsx) - icon + serif display heading + muted copy + optional action. TableEmptyState already existed as a wrapper, but 10 call sites across 7 files each re-hand-rolled the interior; the heading string had already drifted into two class orderings (font-serif text-2xl font-medium vs text-2xl font-medium font-serif), and TRTable had additionally re-implemented the wrapper with a different max-width. Now one component, one class string.
  • GlassIconButton (shared/ui/GlassIconButtonUI.tsx + components/ui/glass-icon-button.tsx) - the circular glass icon button. ModalUI owned a copy inline but never exported it, so TREditColumnMenu, TRSidePanel and DocumentSidePanel each carried a byte-identical 400-character class string. It lives in shared/ui/ (following the PillButtonUI/GlassCardUI convention) so ModalUI can use it too - otherwise the most-used instance of the button would have kept its own copy and missed the focus-ring fix. aria-label is required by the type.
  • CheckSquare (components/ui/check-square.tsx) - the selection square in directory and picker rows, 4 sites across FileDirectory and ProjectPickerModal, including the indeterminate/disabled variants. Decorative (aria-hidden) by default since the row usually owns the semantics; a caller that passes a role keeps it exposed and supplies its own aria-checked. This also removes a visual drift where ProjectPickerModal drew a dot instead of the check mark used everywhere else.

AddProjectDocsModal no longer exists - the picker markup it used to hold now lives in FileDirectory, which is where the checkbox consolidation landed.

3. Accessibility pass on components/ui/*

Each was re-verified against current source; fixes applied where still broken.

  • cite-button - added type="button" (it would submit an enclosing form), an aria-label for the icon-only mode (previously only title, the weakest name source) that is deliberately not set when the visible "Cite" label is shown so the accessible name still contains the visible label (WCAG 2.5.3), a focus-visible ring, and role="status" so the Cite→Copied swap is announced.
  • pill-button - the base class in PillButtonUI gained a focus-visible ring. Fixing it there covers the plain button, the asChild/Slot path, and the Word add-in in one place.
  • tab-pill-button - focus-visible ring.
  • toggle-switch - the off-state track was bg-gray-100, roughly 1.07:1 against white and well under the 3:1 that WCAG 1.4.11 requires for a control boundary. Now bg-gray-300 with an inset ring-gray-400. The on state already passes.
  • search-bar - the input has outline-none and the only focus signal was a near-imperceptible wrapper tint; the wrapper now also gets a ring, and the clear button gets its own. Added a label prop (default "Search") because a placeholder is not an accessible name.
  • form-field - FORM_CONTROL_GLASS_CLASS and the minimal variant both suppressed the outline with nothing in its place, so these inputs had no keyboard focus state at all. Both now carry a ring. FieldLabel also silently dropped all forwarded props on its as="p" / as="span" branches, which meant an id passed for aria-labelledby never reached the DOM.
  • liquid-dropdown - the glass skin replaced the shadcn focus background with app-surface-hover, a ~1% luminance step that made the highlighted item indistinguishable from a hovered one. Now app-surface-active plus an inset ring.
  • button (shadcn) and dropdown-menu (Radix) were checked and left alone - their focus handling is already adequate and diverging from the registry costs more than it gains. button's missing type default is upstream shadcn behavior and is called out in the docs instead.

4. CONTRIBUTING.md

One additive "Frontend UI Work" section on checking components/ui/ and the shadcn registry before writing a new component, and on the promote-on-second-occurrence rule. Existing sections are untouched. Also linked the new doc from docs/README.md.

Test plan

  • Unit tests added - empty-state, check-square and glass-icon-button test files, plus new accessibility assertions in cite-button, pill-button, toggle-switch, and new search-bar, form-field and tab-pill-button test files. Written failing first (17 failures), then implemented.
  • npx vitest run (frontend) - 447 passing, up from 404. The 4 failures in src/app/lib/mikeApi.test.ts (blob.text is not a function) are pre-existing, present on unmodified main locally, unrelated to this change, and green in CI - a local jsdom/Node artifact, so no issue was filed for them.
  • npm run lint --prefix frontend with the ESLint cache cleared - 0 errors.
  • npx tsc --noEmit (frontend) - clean.
  • npm run build --prefix frontend - compiles, 25 static pages generated.
  • Word add-in npm run typecheck and webpack --mode production - both pass, since ModalUI now depends on the new shared/ui file and the add-in's Tailwind @source list had to learn about it.
  • No E2E added - this is a refactor plus a11y attributes with no new user-facing flow.

No changes to package.json or package-lock.json.

Our analysis

Formalize the frontend design system — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (5)

SHA Subject Author Date
526b4277 test: add failing design-system primitive and a11y tests for #322 Beau Mersereau 2026-08-19 ↗ GitHub
49f7dead feat: consolidate shared UI primitives and fix primitive accessibility #322 Beau Mersereau 2026-08-19 ↗ GitHub
32cb82fd docs: document the design system foundations and primitive reuse rule #322 Beau Mersereau 2026-08-19 ↗ GitHub
534fe2b7 refactor(ui): align document empty states willchen96 2026-08-21 ↗ GitHub
49182907 test(e2e): scope document upload action to modal willchen96 2026-08-21 ↗ GitHub

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-357.md from inside the repo you want the changes in.

⬇ Download capture-pull-357.md