11.1 Button
The one action control.
Variants
| Variant | Fill | Text | Use |
|---|---|---|---|
primary | accent-ink #A34A1F | white | The one action the page exists for. One per view |
secondary | white, border-strong | ink-body | Everything alongside the primary |
ghost | none | ink-muted | Tertiary controls in dense chrome |
destructive | error-bg, error-dot border | error-text | Only where something is actually destroyed |
Sizes — sm 32px (table rows, toolbars), md 38px (inline chrome),
lg 48px (forms, auth, marketing — the default).
Rules
- The label is a verb phrase completing "I want to…":
Save changes,Send invite,Delete organization. NeverOK,Submit,Yes,Learn more. - Primary fill is
accent-ink, notaccent. White on#C4602Ais 4.16:1 and fails AA for a 14px label; on#A34A1Fit is 5.90:1. type="submit"is required inside a form. The component defaults totype="button"and dies silently otherwise.- Disabled: 50% opacity,
cursor-not-allowed. Keep it in the DOM and focusable-adjacent — do not remove it. - A link that looks like a button stays an
<a>. UsebuttonVariants(). - Icon-only buttons need
aria-label.
11.2 Card and Panel
Card is a bare container: white, border, 2xl radius, raised shadow.
Panel adds a titled header rail — a heading, an optional description, and
right-aligned actions. Prefer Panel. Content that is worth boxing is
almost always worth naming.
headingLevel is caller-set (h2 by default) so the page's heading outline
stays correct.
Never nest a Panel inside a Panel. Two borders one inside the other is a
signal that the page needs splitting.
11.3 Input
- The label is always rendered and tied by
id. A placeholder is not a label. - The placeholder shows a format —
[email protected]— not a repeat of the label. hintfor standing guidance;errorreplaces it when set.aria-invalidandaria-describedbyare wired automatically.- 48px,
10pxradius,border-strong, white. - Focus: the 2px
accentring at 2px offset. Neveroutline: none.
The same rules govern textareas and selects. A textarea is border-strong,
9px radius, 3 rows minimum, and resizes vertically only.
11.4 StatusChip
State as a pill: background, text colour, and a dot.
Six tones — success, attention, warning, error, info, neutral. Pass a
raw status string and toneForStatus() maps it, or pass tone directly.
The mapping lives in one place so pending cannot render as attention in one
table and as neutral in another. Add new statuses to the map, not to the call
site.
The dot is the redundant non-colour channel. Do not remove it.
11.5 Alert
An inline message about the surface it sits on. Same six tones, 10px radius, a
2px left border in the tone's dot colour.
error and warning carry role="alert"; quieter tones do not, so a page of
informational notes does not shout over a screen reader on load.
An Alert sits at the top of the content region it concerns — not floating, not
fixed, not dismissible-by-timeout.
11.6 PageHeader
Breadcrumb → eyebrow → serif h1 → description → actions.
- Owns the page's single h1 at 28px Lora semibold.
- Description capped at 62ch,
ink-muted. - Breadcrumbs: last crumb is
aria-current="page"and not a link. - Server-safe — no
use client— so only the controls passed asactionsship JavaScript.
Never render a heading next to it. Several admin pages currently hand-roll
font-lora text-[32px] instead of using this; that is the bug, not the
component.
11.7 EmptyState
Icon in a mist circle → title → description → action.
The title says what is not here: "No access requests yet". Not "Nothing to see here", not an emoji shrug. The description says why, or what to do about it.
11.8 StatTile and StatGrid
dt/dd pairs inside a <dl>, so a screen reader reads the label together with
its value.
- Label: 12px, uppercase,
+0.06em,ink-muted. - Value: 26px semibold,
tabular-nums,ink. accentvariant for the single most important figure — the sanctioned terracotta gradient wash. At most one accent tile per grid.StatGridtakes 2, 3 or 4 columns and collapses to 1 on mobile.
11.9 DataTable
DataTable / Th / Td / Tr.
- Wrapper is
overflow-x-auto— the table scrolls, the page does not. - A visually hidden
<caption>is required. Thisscope="col", 11px, uppercase,+0.08em,ink-muted.numericonTh/Tdright-aligns and appliestabular-nums.- Last row drops its bottom border.
11.10 Avatar
Initials from a name, falling back to an email, falling back to U.
terra tone fills with accent-ink so white initials clear AA. mist tone for
quieter contexts — a table row, a secondary list.
aria-hidden — the person's name is always adjacent in real markup, and an
avatar reading out "YJ" is noise.
11.11 Eyebrow
The ALL-CAPS section label — the one place the brand uses uppercase.
12px, medium, +0.08em, ink-muted. Optional slash prop prefixes the //
device (one per surface). Always sits above a heading, never alone, never as a
control.
11.12 ConfirmDialog
Destructive confirmation in a real <dialog> opened with showModal().
Why it must be a real dialog: showModal() puts the element in the browser's
top layer, outside the normal flow, so no ancestor's overflow can clip it.
The <details> popovers this replaced were being cut off at the card edge on
the last row of a table, with no way to reach the confirm button.
Requirements:
m-autoon the dialog — the UA stylesheet centres a modal withmargin: auto, and Tailwind's preflight resets it to 0, parking the dialog in the top-left corner.- Backdrop click closes: a click whose
targetis the dialog element itself is a backdrop click. Esccloses; focus moves to the first field and returns to the trigger on close.- The form stays server-rendered and is passed through as
children, so its server-action binding never crosses the client boundary. - The confirm button repeats the verb —
Delete organization, notConfirm.
11.13 Component checklist
Before a new component is merged:
- Uses only tokens — no hard-coded hex, no Tailwind default palette
- Text colours clear 4.5:1; UI boundaries clear 3:1
- Keyboard reachable; visible focus ring; correct tab order
- Labelled for assistive tech (
aria-label,aria-describedby,scope,caption) - Has designed empty, loading and error states where it can have them
- Nothing communicated by colour alone
- Works at 375px wide
- Hit area ≥ 44px
- Honours
prefers-reduced-motion - Sentence case, verb-phrase labels
- Server-safe unless it genuinely needs
use client