Skip to content

Design system: the lookup table

The app's look is a handful of knobs, each living in exactly one file. This page is the lookup: which knob, where, and what class to type. The why behind each decision lives in Frontend surfaces (the incident diary); the rules for editing are in frontend/CLAUDE.md. Nothing here is a value to copy — every entry is a token to reference.

Tokens (the knobs)

DimensionKnobWhereUse as
Corner radius--radiussrc/index.cssrounded-lg / md / sm (all derived)
Control heightspacing.control*tailwind.config.tsh-control (36px, THE default), h-control-sm (32), h-control-lg (40), h-control-xs (28, list toolbars)
Type below text-xsfontSize.{label,hint,fine}tailwind.config.tstext-label (11px, dense row text), text-hint (10px, uppercase field labels, helper lines), text-fine (9px, fine print)
Header band--chassis-header-hsrc/index.cssh-header in TSX, var(--chassis-header-h) in CSS
Frame margin--chassis-guttersrc/index.cssp-gutter, pr-gutter, …
Motionbase transition + ease-out-quart / ease-out-exposrc/index.css, tailwind.config.tsnothing to type for hovers; animate-fade-in / fade-up / scale-in for entrances
ColoursCSS variablessrc/index.cssbg-background, text-muted-foreground, border-border/60, … never a hex

The type scale above text-xs is Tailwind's own (text-xs 12, text-sm 14, text-base 16). Page titles: text-2xl font-semibold tracking-tight (the agency and account greeting). Card titles: text-sm font-medium. Section labels inside a card: text-xs uppercase tracking-wide text-muted-foreground.

cn() (lib/utils.ts) knows every token above, so cn("h-control", "h-7") resolves to the last one like any other Tailwind pair. Before that config, twMerge classified text-label as a colour and kept both h-control and h-7, leaving CSS order to decide.

Recipes (the canonical class strings)

ThingHelperFile
List surface / row / hover / selected / header / pilllistSurfaceClass, listRowClass, listRowHoverClass, listRowSelectedClass, listHeaderClass, listPillClass, listRowsClasslib/list-styles.ts
Controls above a listfilterControlClass, filterControlActiveClass, filterSearchClass; the FilterSelect componentlib/filter-styles.ts, components/FilterSelect.tsx
Chassis header controlstopbarControlClass, topbarIconButtonClass, topbarIconClasslib/topbar-styles.ts
Menu row (ONE size: gap-2 px-2.5 py-2 text-sm)SelectItem, DropdownMenuItem, MenuItem + menuContentClass, SearchSelect rowsui/select.tsx, ui/dropdown-menu.tsx, ui/menu-item.tsx, components/SearchSelect.tsx
Tile inside a card (a person, a billing source)rounded-md bg-muted/40 px-3 py-2, label + text-xs text-muted-foreground sub-lineagency pages (AgencyBilling Source, AgencyMembers Tile)
Card, always open (account + agency pages)SettingsCard, SettingsRow, SettingsFact, StatusTextcomponents/settings/SettingsCard.tsx
Card, collapsible (workspace settings pages)SettingsSection (icon, title, badge, missing, slug, defaultOpen)components/settings/SettingsSection.tsx

The two settings cards share a border and their parts, but not their scale, on purpose. A SettingsCard is one of a handful of panels on a short page you read, so it carries the page rhythm: px-4 py-3 header, text-sm title. A SettingsSection is one row of a long closed stack you skim to find the row you want, so its header is a line of an index rather than a heading: px-3 py-2.5, text-xs title, h-3 w-3 chevron, h-4 px-1.5 text-fine badge. They were briefly unified on the card rhythm; at that size the stack read as a page of headings and the badges pulled the eye off the titles. | Report block (performance pages) | Section (title, note, action) | components/Section.tsx | | Page-level message (outcome, warning) | InlineBanner above the card, dismissable | components/InlineBanner.tsx | | Every button / input / select / tab | the primitive in components/ui/; never a height on the call site | components/ui/* |

Copy

  • No em dashes in user-facing strings. Period, colon, or comma.
  • Value-dense: every sentence carries new information; cut before adding.
  • Labels are nouns, buttons are verbs, states are lower-case after a dot (Subscription · active).
  • Greeting title + invariant header on hub pages; tab controls live inside the tab, no subtitles, counts or chips in headers (agency hub direction, 2026-08-27).

Enforcement

  • cd frontend && npm run build runs, in order: scripts/check-ui-tokens.mjs (the ratchet), tsc -b (the type gate), vite build.
  • The ratchet counts raw text-[Npx] and raw h-6…h-10 in src/pages, src/components, src/shell against scripts/ui-tokens.baseline.json and fails when a count rises. Retire some, then node scripts/check-ui-tokens.mjs --update and commit the lower baseline. Counts only go down.
  • Focus rings are off globally in index.css (deliberate product call); never add focus: ring utilities.

Adding a knob

A new dimension gets a token in tailwind.config.ts or index.css first, a line in the table above, and a cn() class group in lib/utils.ts if it shares a Tailwind prefix (h-, text-, p-). Then use it. Never the other way round.