feat: formalize design system on existing shadcn/Tailwind foundation

⛔ closed · #321 · open-legal-products/mike ← revoirsoftware/mike · opened 23d ago by revoirsoftware · closed 22d ago · +1,162-178 across 32 files · ↗ on GitHub

From the PR description

Summary

Formalizes the design system that already exists rather than changing it: documents the current tokens and primitives, promotes three duplicated patterns into the shared layer, and fixes the accessibility gaps in the primitives. No visual redesign - the only pixel-level changes are the accessibility fixes listed below.

Closes #316

Scope note: catalog tool deferred

The component catalog (acceptance-criteria item 2, Storybook or Ladle) was intentionally deferred to a follow-up issue and is not in this PR. As a result this PR adds no new dependencies - frontend/package.json and package-lock.json are untouched.

1. docs/design-system.md

Documents the existing foundations, measured off globals.css and all ~120 component files rather than assumed:

  • Colour - the three vocabularies actually in use, and when each applies: the Tailwind grey ramp (930 usages, the real working palette), the app-* surface tokens, and the shadcn semantic tokens (45 usages, effectively primitives-only). Plus the accent blue, and the five files that legitimately use raw hex.
  • Dark mode is documented as unshipped - a full .dark token set exists but nothing ever adds the class, and dark: variants appear only in the three vendored shadcn primitives. Worth writing down so nobody assumes it works.
  • Typography - Inter/EB Garamond split, when to reach for font-serif, and the de facto type scale (text-xs at ~200 uses is the workhorse; this is a dense, table-heavy app).
  • Spacing/radius - the de facto scale by measured frequency, plus the "bigger surface, bigger radius" convention.
  • The glass/"liquid" idiom - and the rule that layered shadow-[...] recipes are never retyped, since that is this codebase's main source of copy-paste drift.
  • Which layer to reach for - a 5-step ladder from existing primitive → shared constant → shadcn registry → feature-local one-off → new primitive, with an explicit promotion bar (same markup in 3+ places).

2. Consolidated primitives

Chosen from a grep-and-read pass over the feature components, not invented. Each was verified as genuinely the same markup before promoting it:

New primitive Replaces Call sites migrated
ui/glass-icon-button.tsx A character-for-character identical 300-char glass close button 4 - Modal, DocumentSidePanel, TRSidePanel, TREditColumnMenu
ui/empty-state.tsx The icon + serif title + description + action body repeated inside TableEmptyState 8 across 5 files - ProjectAssistantTable, ProjectReviewsTable, ProjectsOverview (×2), WorkflowList (×3), WorkflowDetailPage
ui/check-square.tsx The selection square, including its mixed and muted states 5 - AddProjectDocsModal, QuickActionsModal, FileDirectory (×3)

Deliberately not consolidated, to keep this honest:

  • Inline Loader2 spinners - 43 sites with real size/colour variation at each; a wrapper would trade one line for one line.
  • Tag pills - already shared via tabular/pillUtils.ts.
  • The hand-rolled "Actions" dropdown (5 copies of an outside-click effect + panel). Real duplication and the biggest win, but migrating it to Radix changes focus/Escape/portal behaviour, so it belongs in its own PR rather than riding along with a docs change.
  • ProjectReviewsTable's load-error state and the short "no results found" variants - superficially similar but a different heading size and a deliberately smaller state.

Two design decisions worth flagging: EmptyState renders no wrapper, so it drops straight into the existing TableEmptyState container; and CheckSquare is deliberately role-free and ARIA-free, because call sites disagree about whether the checkbox semantics belong on the square or on the row button wrapping it, and adding a role would double up on the three that already declare one.

3. Accessibility fixes

All applied, not just reported, and each covered by a test:

  • cite-button - was missing type="button" (so it submitted any surrounding form) and had no accessible name at all when rendered icon-only. Now labelled only when there is no visible text, so the visible "Cite" stays the accessible name (WCAG 2.5.3).
  • search-bar - the input had no accessible name (a placeholder is not a name, WCAG 4.1.2), and its only focus indicator was a white/70white/90 shift, which is imperceptible (WCAG 2.4.7).
  • tab-pill-button - the inactive state used text-gray-400 on a translucent white pill, ~2.5:1, failing WCAG 1.4.3. Now text-gray-500 at ~4.8:1.
  • toggle-switch - the off state was a white thumb on a gray-100 track, ~1.07:1, effectively invisible (WCAG 1.4.11). Fixed with ring-1 ring-gray-300 rather than a border, so the absolutely-positioned thumb doesn't shift by 1px.
  • dropdown-menu - menu items set outline-hidden and relied on focus:bg-accent, but the liquid-dropdown skin overrides that with a near-white hover colour (1.02:1), leaving keyboard users no focus indicator. Added a background-independent ring. Also, a selected radio item was marked by bg-gray-100 on white (1.03:1) and by colour alone - now also carries font-medium as a second, non-colour channel (WCAG 1.4.1).
  • pill-button - had no focus-visible ring.
  • Verified as already passing: the oklch token palette (--muted-foreground is ~4.6:1 on the light background) and button/input, which already ship upstream shadcn focus and aria-invalid handling.

4. CONTRIBUTING.md

One additive ## Frontend UI section on checking components/ui/ and the shadcn registry before hand-rolling a component. Placed in its own section and does not restructure the file, so it should not conflict with #315's separate addition.

Test plan

  • 39 new tests across 9 files in frontend/src/app/components/ui/ (new primitives + the ARIA/focus/contrast assertions), matching the existing house style: queried by role and accessible name, asserting only the classes that carry meaning.
  • Written first and confirmed failing for the right reasons before implementing.
  • Full frontend suite: 315 passed. The 4 failures in src/app/lib/mikeApi.test.ts (blob.text is not a function) are pre-existing and reproduce on an unmodified tree - a local Node 24 Response.blob() difference. CI and Code Quality are green on main at this branch's base commit (1af9231), and CI runs Node 22, so no issue was filed.
  • npm run test:coverage (the CI gate) - the src/app/lib/** ratchet floor is untouched by this change.
  • npm run lint with the cache cleared - 0 errors, and no warnings in any file this PR touches.
  • npm run build - passes with the same placeholder env vars CI uses.
  • tsc --noEmit clean.

Our analysis

Formalize the frontend design system — 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-321.md from inside the repo you want the changes in.

⬇ Download capture-pull-321.md