---
name: unitpost-design
description: "Build or change any Unitpost UI — customer dashboard, staff admin, marketing site, docs, guides, auth, onboarding. Use for new pages, components, dialogs, tables, forms, empty states, settings, and public pages. Encodes the token vocabulary, the shared component kit (packages/ui), page patterns, and the failure modes we reject."
---

# Design Unitpost interfaces

Act as a Unitpost design engineer. Unitpost is email and messaging infrastructure for developers and their teams: neutral, precise, calm, evidence-led. The interface should feel like a well-kept console (xAI Console, Vercel dashboard, Linear), never like a marketing template or a generic admin kit.

Everything visual comes from two places and nowhere else:

1. **Tokens** — `packages/ui/src/theme.css`. Color, surface, border, radius, shadow, type roles. Light and dark are both defined there; a site-wide change is a one-line token edit.
2. **The kit** — `packages/ui/src/components/*` (shadcn `base-nova` on Base UI, re-themed). Imported in apps as `@/components/ui/<name>`. Pattern components built on the kit live in `apps/web/src/components/`.

If you need something that isn't in either place, add it there (see "Extending the system"), then use it. Do not build a one-off.

A machine check (`npm run ui:check`, run by CI) enforces the mechanical parts of this document. The judgment parts are on you.

---

## 1. Priority order

When rules compete, protect them in this order:

1. **Behavior and data.** Preserve every `data-testid`, aria attribute, keyboard path, and handler. Never change copy, routes, or flows while restyling.
2. **The token vocabulary.** No literal colors, no palette utilities, no arbitrary sizes in app chrome.
3. **The kit.** A control that exists in the kit is used from the kit. A pattern that exists in `components/` is used from there.
4. **The page pattern.** List pages, detail pages, settings pages, dialogs, and empty states each have one shape (§6). Reuse it.
5. **Restraint.** Fewer surfaces, fewer borders, fewer colors. Hierarchy comes from type and spacing first.
6. **Polish.** Alignment, rhythm, hover/focus states, both themes.

Ask before proceeding only when a change would alter what a screen *does*. Otherwise make the smallest change that satisfies the rule.

---

## 2. Tokens

### 2.1 Neutral surfaces — the elevation ladder

| Utility | Light | Dark | Use |
|---|---|---|---|
| `bg-background` | white | near-black | Page canvas. Outline-card interior. |
| `bg-surface-subtle` | #fafafa | #0b0b0b | Quiet bands, **field fills** (every input/select/combobox), skeleton rows. |
| `bg-surface` | #f6f6f6 | #111 | **Filled cards**, grouped sections, table footers, code chips. |
| `bg-surface-hover` | #eee | #191919 | Hover on any neutral interactive row/button/item. |
| `bg-surface-active` | #e5e5e5 | #202020 | Selected tab, pressed toggle, selected row, active nav item, progress tracks. |
| `bg-surface-raised` | white | #141414 | Cards that sit *on* a filled region; consent banners. |
| `bg-surface-overlay` | white | #161616 | Popovers, menus, dialogs, sheets (via `bg-popover`). |

`bg-primary` / `text-primary-foreground` is the one strong monochrome fill (default Button, checked Checkbox/Radio, filled Badge). `bg-secondary` is a quiet fill for secondary buttons only.

**Never** `bg-muted`, `bg-accent`, `bg-foreground/N`, `bg-black/5`, `bg-white`, or any `zinc/gray/neutral-*` in app chrome. The ladder above covers every neutral case.

### 2.2 Borders

| Utility | Use |
|---|---|
| `border-border-subtle` | Between adjacent filled surfaces; card rings on filled cards; menu separators. |
| `border-border` | Default: outline cards, fields, table rules, structural dividers. |
| `border-border-strong` | Hover on fields, selected outlines, deliberate emphasis. |
| `border-input` | Alias of `border` — used by field controls. |
| `ring-ring` | Focus ring color. Focus is `ring-3 ring-ring/30` on fields, `ring-ring/50` on buttons. |

### 2.3 Text

| Utility | Use |
|---|---|
| `text-foreground` | Body copy, titles, values. |
| `text-muted-foreground` | Secondary copy, labels, captions, disabled-looking meta. **The only muted grey.** |
| `text-placeholder` | Placeholder text, tertiary meta (timestamps in dense rows), inactive icons. |
| `text-link` | Inline links in prose. Buttons use `variant="link"` instead. |

Never `text-foreground/70`, `text-muted-foreground/60`, `text-white`, `text-black`. On a colored fill use that fill's `-foreground` token (`text-primary-foreground`, `text-success-foreground`, `text-brand-foreground`, `text-editor-accent-foreground`).

### 2.4 Semantic roles

Each of `brand`, `success`, `warning`, `destructive`, `info` has four tokens:

- `bg-{role}` + `text-{role}-foreground` — strong fill (rare: destructive button, status dots).
- `text-{role}` — the role's text/icon color on a neutral or `-surface` background.
- `bg-{role}-surface` + `border-{role}-border` — the soft callout recipe (Alert, Badge, Card `tone`).

Categorical colors for data/labels: `teal`, `violet`, `link`, `pink`, `brown` (each with `-foreground`). Charts: `--chart-1…5`. Editor canvas chrome: `editor-accent`, `editor-component` (+ `-foreground`, `-strong`).

Rules: color carries meaning or it isn't there. Green means succeeded, amber means needs attention, red means failed/destructive, blue means informational or Unitpost identity. A recommendation is not green; a total is not blue. Pair color with an icon or word — never color alone.

### 2.5 Shape

| Utility | Value | Use |
|---|---|---|
| `rounded-control` | pill (9999px) | Every control: Button, Input, Select trigger, Toggle, Tabs pill, Badge, Combobox, chips. |
| `rounded-panel` | 14px | Cards, popovers, menus, floating panels (Card/Popover use `rounded-xl` which resolves the same). |
| `rounded-dialog` | 16px | Dialog, AlertDialog, Sheet. |
| `rounded-md` / `rounded-lg` | 8 / 10px | Menu items, small inner boxes, code blocks, dashed drop zones, checkbox (`rounded-[4px]`). |

Controls are pills. Panels are soft rectangles. Don't put a pill radius on a card or a square radius on a button.

### 2.6 Elevation

`shadow-xs` / `shadow-sm` / `shadow-md` / `shadow-lg` are the whole ladder and are dark-mode aware. `xl`/`2xl` collapse onto `lg`. Filled and outline cards have **no** shadow; `raised` has `sm`; popovers/menus `md`; dialogs/sheets `lg`. Never write `shadow-[…]` or inline `boxShadow`. Hairlines are `ring-1 ring-border-subtle`, not shadows.

### 2.7 Spacing and sizing

Tailwind's 4px scale only. Controls: `h-7` (xs), `h-8` (sm / default field height), `h-9` (sm button), `h-10` (default button), `h-11` (lg). Icons: `size-3.5` in dense chrome, `size-4` default, `size-4.5` (18px) next to `text-base`, `size-5` in empty-state tiles. Never `size-[18px]`, `h-[42px]`, `w-[560px]`; if a fixed width is essential, use `max-w-*` steps or a CSS variable.

### 2.8 Typography

Fonts (set in the app layout via `next/font`): `font-sans` Inter for UI, `font-heading` DM Sans for headings (h1–h4 get it automatically), `font-mono` Source Code Pro for **machine values only** — code, commands, IDs, addresses, DNS records, timestamps. Never mono for prose or labels.

Scale: `text-3xs` 10px · `text-2xs` 11px · `text-xs` 12 · `text-sm` 14 · `text-base` 16 · `text-lg` 18 · `text-xl` 20 · `text-2xl` 24 … Nothing below 10px. No `text-[Npx]`.

Type roles (classes in theme.css; they set family, size, weight, tracking, leading — don't stack conflicting `text-*`/`font-*` on them):

| Role | Where |
|---|---|
| **App scale** | |
| `type-title` | Page title (24/600). `PageHeader` uses it. One per page. |
| `type-heading` | Section heading inside a page (18/600). |
| `type-subheading` | Card / subsection title (16/600). `DetailSection` uses it. |
| `type-label` | Form labels, table headers that need weight (14/600). |
| `type-nav` | Sidebar and docs navigation lists (13px). |
| `type-caption` | Muted 12px helper text. |
| `type-overline` | 12px uppercase tracked label above a group (`dt` in meta grids, section eyebrows in settings). Use sparingly — never as decoration on marketing pages. |
| `type-code` | Mono 13px inline/blocks. |
| **Public scale** (fluid; marketing, docs) | |
| `type-display` | One hero statement per site, if any. |
| `type-page-title` | Public page h1. |
| `type-section-title` | Public section h2. |
| `type-body` / `type-body-muted` | Long-form reading copy. |

Dashboard body copy is `text-sm`; secondary lines `text-sm text-muted-foreground`; dense meta `text-xs text-muted-foreground`. Headings are sentence case. Tabular numbers (`tabular-nums`) whenever numbers align in a column.

---

## 3. The kit — what to reach for

All from `@/components/ui/<file>`. Variants are the API; don't override their look with className (spacing/width/margin overrides are fine).

### Actions

- **Button** — `variant`: `default` (one per view: the primary action) · `outline` (secondary) · `secondary` (quiet filled) · `ghost` (toolbar / icon-only / tertiary) · `destructive` (soft red; pair with a confirmation) · `link` (inline text action). `size`: `default` h-10 · `sm` h-9 · `xs` h-7 · `lg` h-11 · `icon` / `icon-sm` / `icon-xs` / `icon-lg`. Polymorphic: `render={<Link href="…" />}`. Icon-only buttons **must** have `aria-label` and usually a `Hint`. Never style a raw `<button>` or `<a>` to look like one; use `buttonVariants()` if you truly need only classes.
- **SplitButton** — the split button: ONE primary action + a chevron that opens the secondary ones. `<SplitButton menuLabel="More …" menu={<DropdownMenuItem…/>}><Button>Primary</Button></SplitButton>`; `variant`/`size` are shared by both segments so the pill reads as one control. `Add contact ▾ Import CSV / Export CSV`, `Publish ▾ Pause / Unpublish`, `Copy .MD ▾`. Reach for this, not a hand-built `ButtonGroup` + `DropdownMenu` — three hand-rolled copies existed before it.
- **ButtonGroup** — the lower-level primitive under `SplitButton`: several kit controls fused into one pill (shared edges go square, outer silhouette stays `rounded-full`). Use directly only for non-menu fusions: `ButtonGroupText` (a unit or prefix) + a `Button`, an `Input` + `Button`. Never more than one **primary** action in a group.
- **Toggle** / **ToggleGroup** — pressed state controls (formatting, filter chips, view switches). `ToggleGroup` is for one-of-N or many-of-N in a row; `spacing={0}` joins them.
- **HoverArrow** — the chevron→arrow morph inside CTA buttons. The only allowed button decoration.
- **DropdownMenu** — actions on an object (row menus via `RowActions`, account menus, context menus). Items: `DropdownMenuItem` (`variant="destructive"` for deletes), `CheckboxItem`, `RadioGroup/RadioItem`, `Group/Label`, `Separator`, `Shortcut`, `Sub`.
- **Command** / **CommandDialog** — searchable pickers and the ⌘K palette.

### Fields

Every field is `h-8`, pill, `bg-surface-subtle`, `border-input`, hover `border-border-strong`, focus `ring-3 ring-ring/30`. Label above with **Label** (or `Field`/`FieldLabel`/`FieldDescription`/`FieldError` for full form semantics). Helper text `text-xs text-muted-foreground` below. Errors set `aria-invalid` (turns the ring red) and show `FieldError`.

- **Input** — all text-like `type`s incl. `file`, `number`, `tel`, `datetime-local`. `type="hidden"` and `type="color"` stay native (comment it).
- **InputGroup** — input with addon (prefix text, trailing icon button, unit suffix): `InputGroup` › `InputGroupInput` + `InputGroupAddon align="inline-start|inline-end"` › `InputGroupText` / `InputGroupButton`. Use for search boxes (icon left), URL fields (`https://` prefix), copyable values (copy button right).
- **Textarea** — multi-line; `rounded-lg`, not pill.
- **Select** — closed list of ≤ ~30 known options. `Select` › `SelectTrigger` › `SelectValue`, `SelectContent` › `SelectItem` (+ `SelectGroup`/`SelectLabel`/`SelectSeparator`). Pass `items={{value: label}}` when the trigger should show a label. **NativeSelect** only when a native control is genuinely right (inside a natively-posting `<form>`, 40+ options, mobile-first).
- **Combobox** — searchable list, optionally multi (`ComboboxChips`). For free text + suggestions (a From address), use `Popover` + `Input` and document why.
- **Checkbox** (with `Label`), **RadioGroup**/**RadioGroupItem**, **Switch** (`size="sm"` in dense rows). A Switch is an immediate on/off; a Checkbox is a choice submitted later.
- **Slider**, **DateTimePicker**, **Calendar**, **InputOTP**, **SlideToConfirm** (irreversible actions on mobile-ish surfaces — the slide *is* the action).

**Slide to confirm** (`ConfirmDialog slide`) — a kit `Slider` the user must drag to 100% before the destructive button enables; releasing early snaps it back to 0 and re-disables the button. The labeled button stays the single point of action (⌘↵, pending copy, `confirmTestid`). Use it **only** for irreversible, protected, or bulk-destructive actions — the staff override on the protected `unitpost-system` workspace (`data-testid="protected-override-slider"`, SECURITY.md §4.13) is the reference. **Never for ordinary deletes** — those are the simple or typed `ConfirmDialog`. Difference from `SlideToConfirm`: that component fires the action at the end of the drag; the gate arms a button. Pick one gate per dialog, never typed + slide.

### Containers

- **Card** — `appearance`: `filled` (default; information cards, settings sections, dashboard tiles) · `outline` (selectable objects, tables, previews, quiet groupings) · `raised` (above a filled region, consent banners) · `ghost` (layout only). `tone`: `success|warning|destructive|info` for status cards. `size="sm"` for dense tiles. `render={<Link/>}` for link tiles. Structure: `CardHeader` › `CardTitle` (+ `CardDescription`, `CardAction` on the right), `CardContent`, `CardFooter`. **Never nest a card in a card.** Never `rounded-xl border p-6` by hand.
- **DetailSection** (`components/detail-section`) — titled Card with optional description and action; the unit of every detail page. `collapsible` (+ `defaultOpen`, `id` as the anchor a `#hash` deep link opens) turns the header into a disclosure built on `Collapse` — for secondary, optional sections that must not compete with required content (e.g. the SMS brand's **Company documents**). `Meta` (dt/dd pairs) and `LinkRow` live beside it.
- **Tabs** — `TabsList variant="default"` (pill segmented control: view switches, in-page sections) or `variant="line"` (underline: filters over a list). `wrap` for multi-row chips. Route tabs: `SegmentedTabLinks`; state tabs: `SegmentedTabs` (both in `components/segmented-tabs`).
- **Accordion**, **Collapse** (animated height), **Separator**, **ScrollFade** (edge fades on horizontal scrollers).
- **Pagination** — `Pagination` › `PaginationContent` › `PaginationItem` with `PaginationFirst`/`PaginationPrevious`/`PaginationLink`/`PaginationNext`/`PaginationLast`/`PaginationEllipsis`. Links are ghost pill Buttons (`icon-sm`), the current page is `outline`, disabled edges keep their slot. `pageRange(current, total)` returns the numbers + `null` gaps for the standard `1 … 4 [5] 6 … 20` strip. Lives in a table toolbar next to the "1–50 of 1,234" range text, never as a page-footer hero; a single page shows the range text alone. Admin wraps it as `TablePagination` (range + strip in one line).
- **Table** — `Table` (`density="compact"` for lookup/reference tables and docs), `TableHeader`/`TableHead`, `TableBody`/`TableRow`/`TableCell`, `TableFooter`, `TableCaption`. Rows hover `surface-hover`, selected `surface-active`. Cells are `whitespace-nowrap` — add `whitespace-normal` on the one long-text column. Right-align numeric columns and their headers. Wrap in `Card appearance="outline"` or a `rounded-xl border` frame (a table frame is not a card). Selection: `SelectAllHead` + `SelectRowCell`; actions: `RowActions`; bulk: `BulkActionsBar`; loading: `TableRowsSkeleton`.

### Overlays

- **Dialog** — forms and multi-step tasks. Width comes from `size` and **only** from `size` (`ui:check` fails a `DialogContent` whose `className` carries a `max-w-*`): `sm` (384px) — confirmations and single-field prompts **only** · `md` (512px) — the **default**; a one-column form of 2–6 fields · `lg` (768px) — a form with a table/list in it, or two columns (recipient review, campaign create, the Sender ID country picker, contact import mapping) · `xl` (1024px) — editors, previews, and multi-column pickers only (template picker, plan comparison). A bare `<DialogContent>` is therefore a sane form width; set `size="sm"` explicitly on confirmations. Footer is sticky. Always `DialogTitle` (visually hidden if needed) and `DialogDescription`. Live specimens of all four sizes: `/brand#overlays`.
- **AlertDialog** — confirmations that must be acknowledged. Its default width is the 384px confirmation width (the same as Dialog `sm`). Use **ConfirmDialog** (`components/confirm-dialog`) for destructive actions: simple, typed-to-confirm, or **slide-to-confirm** (`slide` — see "Slide to confirm" below), ⌘↵/Esc hints built in.
- **Sheet** — side panels (inspectors, long forms on mobile). `FloatingSidebar` is the dashboard's right-edge inspector.
- **Popover** — small anchored panels with controls (color pickers, date pickers). **Tooltip** — text-only, via **Hint** (label + optional `keys` shortcut). Every icon-only button gets a Hint.
- **Toaster** (sonner) — transient confirmations. Never toast an error the user must act on; use `Alert` in place.

### Status and feedback

- **Badge** — `default` · `secondary` · `outline` · `ghost` · `success` · `warning` · `destructive` · `info` · `brand` · `teal` · `violet` · `link`. Status chips only; not for metadata that isn't a status. One sanctioned non-status use: a **live total beside a page title or list heading** (`PageHeader badge={<Badge variant="secondary" className="tabular-nums" aria-label="1,284 contacts">1,284</Badge>}`) — always `secondary`, always `tabular-nums`, always an `aria-label` naming what's counted. Don't wrap it in a new component.
- **Alert** — `variant`: `neutral|info|success|warning|destructive`; `size`: `sm` (inline one-liner) · `default` (page notice with `title`, body, `action`). Inline, in-context, persistent. Not for empty regions (EmptyState) or transient confirmations (toast).
- **EmptyState** / **ErrorState** — whole-region states: icon tile + title + one sentence + ≤2 actions. Every list has an empty state; every fetch has an error state with Retry.
- **Skeleton** (`bg-skeleton`, never with opacity) — loading placeholders shaped like the content. **Spinner** — inline pending (in a button: `<Spinner /> Saving…`). **Progress** (`tone`) — quota and job bars.
- **Kbd** / **Shortcut** — keyboard hints, platform-aware.
- **Steps**, **Breadcrumb**, **Avatar** (+ `AvatarGroup`), **Sidebar** (+ `SidebarProvider cookieName`), **TruncateTooltip**.

### Pattern components (`apps/web/src/components/`)

`PageContainer` (the one outer wrapper: `width="default" | "reading" | "full"` → 4xl / 3xl / none, same padding; §6 "Page width"), `PageHeader` (title + subtitle; drives the collapsing toolbar title), `StatusPill`/`StatusPillGroup` (dot + label lifecycle state), `BackLink` (up-one-level on detail pages), `ListControlBar`, `BulkActionsBar`, `RowActions`, `SelectAllHead`/`SelectRowCell`, `TableRowsSkeleton`, `EmptyState`, `ErrorState`, `ConfirmDialog`, `DetailSection`/`Meta`/`LinkRow`/`RelatedEmailList`, `SegmentedTabs`/`SegmentedTabLinks`, `Hint`, `Shortcut`, `FloatingSidebar`, `CountryFlag` (emoji flag for a phone number or sending identity, `Hint` carries the country/reach in words — never the only signal; `hint={false}` inside another focusable), `selectableCardClasses()` (the monochrome selected-tile recipe: `border-primary bg-surface-active ring-1 ring-primary`).

**Messaging (SMS) patterns** — live specimens on `/brand/patterns#messaging`:

- **`PhoneNumber`** `{ number, country?, masked?, testid? }` — the one way a phone number renders: `CountryFlag` + the number in `font-mono tabular-nums` (an E.164 address is a machine value). Absent → the `type-caption` "Not set", never a dash (§7). `masked` for the activity-log form (`+1•••••4567`). Use it in every list cell, detail meta, dialog description, and activity row that shows a number; never compose flag + text by hand.
- **`ReachList`** `{ kind, country?, countries?, variant?: "inline" | "list", max? }` — where a sending identity delivers: flags + `countryName`s from the reach table (`reachFor`), in table order, "+N" overflow with a `Hint` listing the rest. `inline` for table cells and menu captions, `list` for detail pages. Pass `countries` when you already hold the union (a campaign's senders, a brand's Sender ID across markets). No reach → "Reach not set", visible.
- **`RegistrationStatusBlock`** `{ status, labels, variants, help?, feedback?, failureReason?, source?, onResubmit?, resubmitLabel?, extraFields?, testid? }` — one registration's state for brands, numbers, and Sender IDs (they share one state machine): `Badge` + one `type-caption` line of help + optional `Meta` pairs + `CarrierFeedback` + at most ONE `Button` (resubmit/fix). Use for every "Registration" section; never two actions inside it.
- **`CarrierFeedback`** `{ items, extra?, title?, source?: "carrier" | "regulator" | "unitpost" | "provider", testId? }` — the reviewer's words on kit `Alert size="sm"`: carrier/regulator → `warning`, Unitpost pre-check → `info`, provider issue → `neutral`. Scrolls inside five lines so a registry dump never widens its parent.
- **`SkipReasonLine`** `{ reason, ctx?, variant?: "badge" | "line" | "alert", showRemedy? }` — one skipped recipient in the one vocabulary (ADR-0051 §5): words from `skipReasonCopy`, tone from the reason (deferral `info`, dead sender `destructive`, else `warning`), remedy with its `fixHref` as `Button variant="link"`. `badge` in recipient rows, `line` in preflight groups and campaign breakdowns, `alert` on a single message's page. Never write a skip sentence by hand.
- **`StatusCaption`** `{ badge, caption? }` — a status `Badge`/`StatusPill` with its one-line explanation beneath, fixed rhythm. One caption, max (§6).
- **`SmsRecipientReview`** `{ state, target, excludedIds, onExcludedChange?, defaultExpanded?, testid? }` (`app/dashboard/sms/_components/sms-recipient-review.tsx`) — every recipient of a campaign's segment BEFORE Send, one row each: include checkbox (`SelectRowCell`; omit `onExcludedChange` for read-only), `PhoneNumber` + name (linked to the contact), a consent `Badge` (`Marketing` / `Transactional` / `Unscoped` / `None`), and either the sender that carries them (`PhoneNumber` or the Sender ID name + kind) or the `SkipReasonLine` badge with its fix one click away. Search (name / number / country), sort (`Select`: status / name / country / consent), "Select all reachable" / "Select none", and a counts line ("12 of 15 recipients reachable · 2 skipped · 1 excluded") that stays visible when the table is collapsed; expanded by default when anything is skipped. Pages of 200 load once expanded (`recipients.nextCursor` on the preflight route). Use it in the create dialog and on the DRAFT / SCHEDULED campaign page; never render an aggregate + capped "See who" list for a recipient set the author can act on.
- **`SenderIdCountryTable`** `{ rows, variant?: "nested" | "table", colSpan?, mapActions?, onResubmit?, onRelease?, testid? }` — the per-country rows of ONE Sender ID identity (ADR-0051: one string per brand, one row per country). `nested` renders `TableRow`s that branch under the identity row in the senders table with the §6 nested-rows connector; `table` is the standalone country · status · actions table on the identity page. Each row: `CountryFlag` + `countryName`, a status `Badge` with `StatusCaption`, the regulator's `CarrierFeedback` when sent back, `RowActions` (Fix & resubmit, Release). Dialogs stay with the page; the component only emits callbacks. Never render a Sender ID's countries as separate top-level rows.

---

## 4. Interaction states

Every interactive element has all five, from the kit — never hand-roll them:

| State | Neutral controls | Fields |
|---|---|---|
| Hover | `bg-surface-hover` (ghost/outline), `bg-primary/90` (default fill) | `border-border-strong` |
| Active/pressed | `translate-y-px` + `bg-surface-active` | — |
| Selected | `bg-surface-active text-foreground` (tabs, toggles, rows, nav) | — |
| Focus-visible | `ring-3 ring-ring/50` | `border-ring ring-3 ring-ring/30` |
| Disabled | `opacity-50 pointer-events-none` | `bg-surface opacity-50 cursor-not-allowed` |

Invalid fields: `aria-invalid` → `border-destructive ring-destructive/20`. Loading buttons: keep width, swap leading icon for `Spinner`, disable. Destructive actions: `Button variant="destructive"` → `ConfirmDialog`.

Motion: `transition-colors` on hover states, `duration-100` on popovers/menus (enter only; Select has no exit animation on purpose — see its comment). No motion for decoration; respect `prefers-reduced-motion`.

---

## 5. Both themes, always

Every surface is reviewed in light and dark before handoff. Tokens make this free — unless you wrote a literal. The known fixed-light surfaces are: recipient email previews (`force-light` + `bg-white` "paper"), the template editor canvas (mirrors the sent HTML), the unsubscribe page shell (renders the workspace's brand). Mark each with a comment containing `recipient email paper` or `canvas mirror` so the guardrail's ratchet skips it.

Dark mode is near-black (`#080808`) with restrained lifts; borders do the separating, not shadows. Semantic colors are re-tuned for dark (softer fills, dim surfaces) — never darken a light hex by hand.

---

## 6. Page patterns

### Page width

Every dashboard page renders inside **`PageContainer`** (`components/page-container`). It owns the outer wrapper, the padding, and the width, so a page never sets its own `max-w`. There are three sizes and no fourth:

| `width` | Class | Use |
|---|---|---|
| `default` | `max-w-4xl` | **Everything** — list, index, detail, settings, dashboard home, the add-domain form. Email and SMS siblings (Campaigns, Messages/Emails, Templates, Topics, Activity, Dashboard) must land on the same size. |
| `reading` | `max-w-3xl` | One item you read top to bottom: a single email, received email, batch, SMS message, or the activity feed. |
| `full` | no max width | A tool page whose canvas needs the viewport but still wants standard page padding. Rare. Full-bleed editors (template editor, general editor, automation builder) size themselves to the viewport with `data-fullbleed` instead and do not use `PageContainer` at all. |

```tsx
<PageContainer className="space-y-6">                 ← default; no width prop
  <BackLink … />                                      ← detail pages only
  <PageHeader title="Campaigns" subtitle="…" />
  …
</PageContainer>

<PageContainer width="reading">  …one message… </PageContainer>
```

Padding is identical across sizes (`px-4 py-8 sm:px-6 sm:py-10`), so the title's left edge never moves between siblings. The wrapper carries `data-testid="page-container"` and `data-width` for specs.

**A table never widens the page.** If a table needs more than 4xl, drop or truncate a column (`max-w-[20rem] truncate`, a `Hint` for the full value); the `Table` container scrolls horizontally as a last resort. The specific failure this rule exists to stop: five different widths across the dashboard, with SMS Campaigns at 6xl one click away from Email Campaigns at 4xl (2026-09-18).

**Vertical rhythm** between `PageHeader` and the first block, and between top-level blocks, is one value: `space-y-6` on the page's root (or `mb-6` on the header row when the root can't carry `space-y`). Not `space-y-8`, not `mt-8`.

`ui:check` fails any `page.tsx` / `layout.tsx` under `app/dashboard` that contains `max-w-3xl` … `max-w-7xl`, and any dashboard file that reproduces the hand-rolled `mx-auto w-full … max-w-Nxl` wrapper.

### List page (contacts, campaigns, keys, domains…)

```
<PageContainer className="space-y-6">                          ← one wrapper, default width (§6 "Page width")
<PageHeader title="Contacts" subtitle="People you can send to." />   ← one type-title
[Tabs / SegmentedTabLinks if the resource has sibling views]
<ListControlBar>  search (InputGroup w/ icon)  filters (Select / Tabs line)  ⟶  primary Button
<Card appearance="outline"> <Table> … </Table> </Card>   or   <EmptyState …/>
<BulkActionsBar> appears above the table when rows are selected
```

The primary action (`Button` default) sits top-right of the control bar. Row actions live in `RowActions` at the row's end. Status is a `Badge`. Timestamps are `text-muted-foreground tabular-nums`, right-aligned. Loading: `TableRowsSkeleton`. Never a second primary button on the page.

**Row actions are never bare buttons in a cell.** They go in `RowActions` (kebab
for 2+, single icon button for 1), which keeps one row height and forwards each
action's `testid`. A `Button` with `h-auto whitespace-normal` in a table cell is
the specific failure this rule exists to stop: the buttons wrap, lose their
shared height, and the column turns into a ragged stack.

**One primary action per surface, with variants inside it.** When a resource has
2–3 creation flavours (template category, number type), use ONE `Button` +
`DropdownMenu` with a titled item per flavour and a `type-caption` line of the
consequence (cost, wait). Two rival top-level buttons read as two unrelated
features and stack badly at narrow widths. Reference: `NewTemplateMenu`
(email templates), `NewNumberMenu` (SMS numbers).

**`StatusPill` vs `Badge`.** `StatusPillGroup` for progress or lifecycle read as
a sequence ("Brand created · Brand ready · Number approved") — the dot carries
the state. `Badge` for a single classification on a row or title ("active",
"transactional"). Don't hand-roll a dot + label span; don't use `Alert` for a
step list.

**Captions earn their place.** One `type-caption` line per section, max. If the
explanation needs a paragraph it belongs in a guide, a `Hint`, or the tab that
owns the topic — not stacked under every heading. CTAs are 1–2 words
("Manage", "Verify", "New number"), never a sentence ("Get a toll-free number",
"View submitted data").

**"Get set up" is per-channel.** One `ActivationChecklist` + one
`SidebarActivation`, scoped by the active channel through `ActivationProvider`.
Adding a channel means adding its step ids, defs, `CHANNEL_STEPS` entry and
completion derivation — never a second card. Steps are DERIVED from live data,
never a "mark done" flag, and never include a paid action (a checklist that nags
you toward a charge is a dark pattern; offers belong on the resource page). A
gated channel resolves to email so a dark product cannot advertise itself.

**Every level-down page starts with `BackLink`.** Any page below a list
(`/domains/[id]`, `/contacts/[id]`, `/sms/brands/[id]`, `/sms/numbers/[id]`) opens
with `<BackLink href label />` as its FIRST child, above `PageHeader` — so it
reads as leaving the record, not acting on it. Never inside the header block,
never repeated at the bottom, and never a second one on the page.

It is a `Link` to the parent LIST, not `router.back()`: the list is stable and
shareable, whereas `back()` sends someone who deep linked into the record
somewhere unrelated (or off-site). Label is the plural resource in sentence case
("Back to domains"); the component supplies the "Back to" prefix and the
`HoverArrow`.

**A resource row links to a real page.** If a row has an Edit/Manage action, the
destination must exist and show full info with per-field CRUD. Locking is
per-FIELD: disable the frozen inputs with a `Hint` explaining why, and leave the
rest editable. Disabling the whole action because *some* fields are frozen
creates a dead end where the UI reports a problem it won't let you fix.

**Every refusal lets the customer save their work.** A refusal must (a) let the
customer save what they have, (b) name the exact thing that is wrong, (c) offer
a skip or override where compliance allows it, and (d) link to the fix where it
does not. **Hard stops are for Send (or Publish, or Verify), never for Draft.**
So a create dialog's primary button is never disabled by a readiness problem:
the draft saves, the problem renders as an `Alert variant="warning"` list with
one fix link per row, and the Send action is what refuses, with the one-click
alternatives beside it ("Make transactional", "Switch sender"). Recipients a
send would skip are rows the author can see and untick (`SmsRecipientReview`),
not a number and a shrug. The specific failure this rule exists to stop: a
sender "registered for Account notifications, not marketing" that blocked
*Create draft* and made the customer guess whether the contact or the sender
was at fault (SMS campaign flow, 2026-09-18; CHANNELS.md §8.5).

### Detail page (one contact, one campaign…)

```
<PageContainer className="space-y-6">                       ← default width; `reading` only for a single message/event
<BackLink href="/dashboard/<parent>" label="<plural>" />   ← FIRST child, above the header
<PageHeader title={name} subtitle={meta} />  + header actions (outline / ghost / RowActions)
<dl grid> <Meta label="Status">… </Meta> … </dl>            ← type-overline labels, tabular values
<DetailSection title="…" action={…}> … </DetailSection>     ← one per topic, filled cards
<DetailSection title="Danger zone"> <Card tone="destructive"> … </Card> </DetailSection>
```

### Nested rows (a child that belongs to one parent row)

When a resource is **owned by exactly one row** and is not independently
manageable, nest it under that row instead of giving it its own section, page, or
nav entry. Email domains nest subdomains this way; SMS numbers nest the use case
they're registered under; a Sender ID nests the countries it is enabled for
(`SenderIdCountryTable variant="nested"`).

Indent the child `pl-3`, then draw the connector: an `aria-hidden`
`relative flex-none self-stretch` span containing an absolutely-positioned
`w-px bg-border` vertical guide (`h-full` for middle children, a short stub such
as `h-[11px]` for the last one, so the line ends at the elbow) plus a
`CornerDownRight` glyph at `size-3.5 text-muted-foreground opacity-70`.

Use it when the child has no meaning apart from its parent. If the child is
genuinely shared across parents, it is a real resource — give it a page. And if
it's only a saved set of form answers, it belongs **inside the form**, not in the
tree and not in nav: a standalone page implies an ownership that doesn't exist.

### Settings page

Sidebar nav (`type-nav`) on the left, one topic per route. Each topic is a stack of `Card appearance="filled"` sections: `CardHeader` (title `type-subheading`, description), `CardContent` with a form (`Field`s, `h-8` controls, labels above), `CardFooter` with the Save button right-aligned. Destructive settings go last in a `tone="destructive"` card.

### Dialog form

`DialogHeader` (title + one-line description) → fields in a `grid gap-4` → sticky `DialogFooter` with `Cancel` (outline) left of the primary. A bare `DialogContent` (`md`) for one column; `size="lg"` when a table/list sits in the form or it has two columns; `size="xl"` for editors and previews only; never a `max-w-*` className. Focus the first field on open. Confirmation of a destructive result is a separate `ConfirmDialog` (`size` `sm`), not a second step in the same dialog.

### Empty / error / loading

Empty: `EmptyState` with the resource's icon, a title that names what's missing ("No domains yet"), one sentence of why it matters, and the same primary action as the page header. Error: `ErrorState` with Retry. Loading: skeletons in the shape of the final layout — never a centered spinner for a whole page.

### Marketing / docs page

Public scale type roles. One `type-page-title` h1, `type-section-title` h2s. Content width `max-w-6xl`; prose `max-w-prose`. CTAs are kit `Button`s (`size="lg"` in heroes, with `HoverArrow`). Cards are `Card appearance="filled"` or `outline`; link tiles use `Card render={<Link/>}`. Docs tables are `Table density="compact"`. No all-caps eyebrows, no gradient text, no icon tiles in colored squares.

---

## 7. Copy

Sentence case everywhere (titles, buttons, labels, tabs). Buttons are verbs ("Add domain", "Save changes", not "Submit"). Empty states say what's missing and what to do. Errors say what happened and how to recover; never "Something went wrong" alone. Numbers use `tabular-nums` and locale separators. Dates are relative in lists ("2 h ago") with the absolute in a Hint. No exclamation marks. No em dashes in UI copy.

---

## 8. Reject these (named so you can see them)

- **The recipe card** — `rounded-xl border bg-surface p-6` written by hand instead of `Card`.
- **The disguised button** — a `<button>`/`<a>` styled to look like `Button`. Also its cousin: a `Button` with `className` overriding its height, radius, or colors.
- **Palette leakage** — `zinc-500`, `#71717a`, `bg-white`, `text-white`, `bg-black/5`, `border-foreground/10`, `text-muted-foreground/70`. Every one has a token.
- **The pixel font** — `text-[11px]`, `text-[13px]`, `leading-[1.2]`. The scale has a step; the roles have a name.
- **Nested cards** — a card inside a card to "group". Use spacing, a `Separator`, or a `type-subheading`.
- **Native controls** — `<select>`, `type="radio"`, `type="checkbox"`, `role="tablist"` by hand.
- **The bare spinner page** — a centered spinner where skeletons belong.
- **Status by color alone** — a green dot with no label; a red border with no text.
- **The decorative eyebrow** — uppercase tracked micro-labels on marketing pages, section numbers, gradient text, glows, colored icon tiles.
- **Two primaries** — more than one default-filled Button in a view.
- **The mono label** — monospace on prose, names, or labels. Mono is for machine values.
- **Shadow as border** — `shadow-[0_0_0_1px…]`. Use `ring-1 ring-border-subtle`.
- **Square controls / pill cards** — radius roles swapped.
- **The silent literal** — a fixed-light or third-party color with no comment saying why.
- **The private width** — a page that sets its own `max-w-5xl` because its table "needs the room". Width belongs to `PageContainer` (§6); fix the table.

---

## 9. Extending the system

**Add a kit component:** `npm run ui:add -- <shadcn-name>` (writes to `packages/ui/src/components`, fixes imports). Then re-theme it to §2/§4: pill controls, `bg-surface-subtle` fields, `surface-hover/active` states, `ring-ring/30` focus, panel radius + `ring-border` + `shadow-md` on popups, no `bg-muted`/`bg-accent`. Add it to `/brand/components` and to §3 of this file in the same PR.

**Add a variant:** extend the component's `cva` config in `packages/ui`; never fork the component into an app.

**Add a token:** define it in `theme.css` for **both** `:root` and `.dark`, register it in `@theme inline`, document it in §2 and on `/brand/colors`. Shadows go through `--elevation-*`, radii through `--shape-*`.

**Add a pattern component** (a composition used on 3+ pages): put it in `apps/web/src/components/`, build it only from the kit, document it in §3 "Pattern components".

**Change something site-wide** (a color, a radius, the field height): edit the token or the kit component. If you find yourself doing a search-and-replace across pages, stop — the thing you're changing should have been a token.

---

## 10. Guardrails

`npm run ui:check` (also `apps/web` lint → CI) fails on:

- **Hard:** palette utilities, opacity recipes on semantic roles, native checkbox/radio/select, hand-rolled tablists, a `DialogContent` sized via a `max-w-*` className instead of `size=`, a dashboard `page.tsx`/`layout.tsx` that sets its own `max-w-3xl…7xl` or any dashboard file with the hand-rolled `mx-auto w-full … max-w-Nxl` page wrapper (use `PageContainer`, §6), a missing `@source "../../../../packages/ui/src"` or theme import in either app's `globals.css`, a broken `@/components/ui` alias, or an app-local `components/ui/` folder.
- **Ratchet** (per-file counts in `tools/ui-baseline.json` may only go down): raw `<button>`/`<input>`/`<table>`, `text-[Npx]`, white/black literals, hex colors, `foreground/N` recipes, and the brand casing `UnitPost` (the product is `Unitpost`, one capital — §7). A deliberate exception carries a comment on the same or previous line containing `recipient email paper`, `fixed light`, `third-party`, `native color input`, `canvas mirror`, or `design-system: allow`. After removing exceptions, run `node tools/check-ui-system.mjs --update`.

Before handoff: `npm run ui:check`, typecheck, and look at the surface in both themes.

---

## 11. Reference

- Tokens: `packages/ui/src/theme.css`
- Kit: `packages/ui/src/components/` — live catalogue with every variant and state at `/brand/components`; tokens at `/brand/colors`, `/brand/typography`, `/brand/materials`.
- Pattern components: `apps/web/src/components/`
- This file is served publicly at `/design.md` for agents working outside the repo.
