---
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.
- **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).

### 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. `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).
- **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. `sm:max-w-sm` default; widen with `className="sm:max-w-lg"` for forms. Footer is sticky. Always `DialogTitle` (visually hidden if needed) and `DialogDescription`.
- **AlertDialog** — confirmations that must be acknowledged. Use **ConfirmDialog** (`components/confirm-dialog`) for destructive actions: simple or typed-to-confirm, ⌘↵/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.
- **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/`)

`PageHeader` (title + subtitle; drives the collapsing toolbar title), `ListControlBar`, `BulkActionsBar`, `RowActions`, `SelectAllHead`/`SelectRowCell`, `TableRowsSkeleton`, `EmptyState`, `ErrorState`, `ConfirmDialog`, `DetailSection`/`Meta`/`LinkRow`/`RelatedEmailList`, `SegmentedTabs`/`SegmentedTabLinks`, `Hint`, `Shortcut`, `FloatingSidebar`, `selectableCardClasses()` (the monochrome selected-tile recipe: `border-primary bg-surface-active ring-1 ring-primary`).

---

## 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

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

```
<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.

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

```
<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>
```

### 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. `sm:max-w-md` for one column, `sm:max-w-2xl` for two. Focus the first field on open. Confirmation of a destructive result is a separate `ConfirmDialog`, 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.

---

## 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 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. 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.
