Appearance
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)
| Dimension | Knob | Where | Use as |
|---|---|---|---|
| Corner radius | --radius | src/index.css | rounded-lg / md / sm (all derived) |
| Control height | spacing.control* | tailwind.config.ts | h-control (36px, THE default), h-control-sm (32), h-control-lg (40), h-control-xs (28, list toolbars) |
Type below text-xs | fontSize.{label,hint,fine} | tailwind.config.ts | text-label (11px, dense row text), text-hint (10px, uppercase field labels, helper lines), text-fine (9px, fine print) |
| Header band | --chassis-header-h | src/index.css | h-header in TSX, var(--chassis-header-h) in CSS |
| Frame margin | --chassis-gutter | src/index.css | p-gutter, pr-gutter, … |
| Motion | base transition + ease-out-quart / ease-out-expo | src/index.css, tailwind.config.ts | nothing to type for hovers; animate-fade-in / fade-up / scale-in for entrances |
| Colours | CSS variables | src/index.css | bg-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)
| Thing | Helper | File |
|---|---|---|
| List surface / row / hover / selected / header / pill | listSurfaceClass, listRowClass, listRowHoverClass, listRowSelectedClass, listHeaderClass, listPillClass, listRowsClass | lib/list-styles.ts |
| Controls above a list | filterControlClass, filterControlActiveClass, filterSearchClass; the FilterSelect component | lib/filter-styles.ts, components/FilterSelect.tsx |
| Chassis header controls | topbarControlClass, topbarIconButtonClass, topbarIconClass | lib/topbar-styles.ts |
Menu row (ONE size: gap-2 px-2.5 py-2 text-sm) | SelectItem, DropdownMenuItem, MenuItem + menuContentClass, SearchSelect rows | ui/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-line | agency pages (AgencyBilling Source, AgencyMembers Tile) |
| Card, always open (account + agency pages) | SettingsCard, SettingsRow, SettingsFact, StatusText | components/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 buildruns, in order:scripts/check-ui-tokens.mjs(the ratchet),tsc -b(the type gate),vite build.- The ratchet counts raw
text-[Npx]and rawh-6…h-10insrc/pages,src/components,src/shellagainstscripts/ui-tokens.baseline.jsonand fails when a count rises. Retire some, thennode scripts/check-ui-tokens.mjs --updateand commit the lower baseline. Counts only go down. - Focus rings are off globally in
index.css(deliberate product call); never addfocus: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.