feat: formalize design system on existing shadcn/Tailwind foundation
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 inlineoverrides part of Tailwind's blue scale to Mike azure (and leavesblue-300/400/500/800/900untouched, so the scale is discontinuous), and the--app-*tokens have no.darkvalues, 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-2dominate;h-7for compact chrome), plus the--radiustoken and which radii the app really uses. - Where components live - the
components/ui/vsshared/ui/vscomponents/shared/vs feature split, including the rule thatshared/ui/cannot import fromapp/and that new files there must be registered in the add-in's@sourcelist. - 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.TableEmptyStatealready 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-mediumvstext-2xl font-medium font-serif), andTRTablehad 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.ModalUIowned a copy inline but never exported it, soTREditColumnMenu,TRSidePanelandDocumentSidePaneleach carried a byte-identical 400-character class string. It lives inshared/ui/(following thePillButtonUI/GlassCardUIconvention) soModalUIcan use it too - otherwise the most-used instance of the button would have kept its own copy and missed the focus-ring fix.aria-labelis required by the type.CheckSquare(components/ui/check-square.tsx) - the selection square in directory and picker rows, 4 sites acrossFileDirectoryandProjectPickerModal, including the indeterminate/disabled variants. Decorative (aria-hidden) by default since the row usually owns the semantics; a caller that passes arolekeeps it exposed and supplies its ownaria-checked. This also removes a visual drift whereProjectPickerModaldrew 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- addedtype="button"(it would submit an enclosing form), anaria-labelfor the icon-only mode (previously onlytitle, 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, androle="status"so the Cite→Copied swap is announced.pill-button- the base class inPillButtonUIgained a focus-visible ring. Fixing it there covers the plain button, theasChild/Slotpath, and the Word add-in in one place.tab-pill-button- focus-visible ring.toggle-switch- the off-state track wasbg-gray-100, roughly 1.07:1 against white and well under the 3:1 that WCAG 1.4.11 requires for a control boundary. Nowbg-gray-300with an insetring-gray-400. The on state already passes.search-bar- the input hasoutline-noneand 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 alabelprop (default "Search") because a placeholder is not an accessible name.form-field-FORM_CONTROL_GLASS_CLASSand theminimalvariant both suppressed the outline with nothing in its place, so these inputs had no keyboard focus state at all. Both now carry a ring.FieldLabelalso silently dropped all forwarded props on itsas="p"/as="span"branches, which meant anidpassed foraria-labelledbynever reached the DOM.liquid-dropdown- the glass skin replaced the shadcn focus background withapp-surface-hover, a ~1% luminance step that made the highlighted item indistinguishable from a hovered one. Nowapp-surface-activeplus an inset ring.button(shadcn) anddropdown-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 missingtypedefault 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-squareandglass-icon-buttontest files, plus new accessibility assertions incite-button,pill-button,toggle-switch, and newsearch-bar,form-fieldandtab-pill-buttontest files. Written failing first (17 failures), then implemented. -
npx vitest run(frontend) - 447 passing, up from 404. The 4 failures insrc/app/lib/mikeApi.test.ts(blob.text is not a function) are pre-existing, present on unmodifiedmainlocally, unrelated to this change, and green in CI - a local jsdom/Node artifact, so no issue was filed for them. -
npm run lint --prefix frontendwith 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 typecheckandwebpack --mode production- both pass, sinceModalUInow depends on the newshared/uifile and the add-in's Tailwind@sourcelist 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.