# 11 · Components

> The library, component by component. Live examples for all of these are in the
> [component gallery](/components/); the reference implementations are in
> [`components/ui/`](https://github.com/sincelabs/brand/tree/main/components/ui)
> and can be copied into `sincelabs/app` as-is.

---

## 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`. Never `OK`, `Submit`, `Yes`, `Learn more`.
- Primary fill is `accent-ink`, **not** `accent`. White on `#C4602A` is 4.16:1 and fails AA for a 14px label; on `#A34A1F` it is 5.90:1.
- **`type="submit"` is required inside a form.** The component defaults to `type="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>`. Use `buttonVariants()`.
- 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* — `name@company.com` — not a repeat of the label.
- `hint` for standing guidance; `error` replaces it when set.
- `aria-invalid` and `aria-describedby` are wired automatically.
- 48px, `10px` radius, `border-strong`, white.
- Focus: the 2px `accent` ring at 2px offset. Never `outline: 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 as `actions` ship 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`.
- `accent` variant for the single most important figure — the sanctioned
  terracotta gradient wash. **At most one accent tile per grid.**
- `StatGrid` takes 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**.
- `Th` is `scope="col"`, 11px, uppercase, `+0.08em`, `ink-muted`.
- `numeric` on `Th`/`Td` right-aligns and applies `tabular-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-auto` on the dialog — the UA stylesheet centres a modal with `margin: auto`, and Tailwind's preflight resets it to 0, parking the dialog in the top-left corner.
- Backdrop click closes: a click whose `target` is the dialog element itself is a backdrop click.
- `Esc` closes; 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`, not `Confirm`.

---

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