# 10 · Patterns

> Recurring problems and the Since Labs answer to each. Use the pattern; do not
> re-solve it.

---

## 10.1 Page

Every product page is the same three things.

```tsx
<div className="mx-auto w-full max-w-[1280px] px-8 py-8">
  <PageHeader
    breadcrumbs={[{ label: "Apps", href: "/apps" }, { label: "Sora" }]}
    title="Review queue"
    description="Fee schedules the extractor was not confident about."
    actions={<Button size="md">Run ingestion</Button>}
  />
  {/* content */}
</div>
```

- **One `PageHeader` per page.** It owns the h1. Never hand-roll a title beside it.
- Breadcrumbs whenever the page is more than one level deep. Last crumb is the current page and is not a link.
- The description is one sentence, capped at 62ch, and says what the page is *for* — not that it exists.
- Primary action top-right. One only.

---

## 10.2 Navigation

**Platform sidebar:** four fixed items. **App sidebar:** takes over while you are
inside an app, with an "All apps" escape hatch at the top. **Operator sidebar:**
its own tree, and per-app surfaces hang off the Apps row.

Active state is computed from the live pathname on the client — never from a
request header. Active row: `accent-soft` background, `accent-ink` text, medium
weight. Ancestor of the active row: `ink` text, no background.

Children reveal only while their parent branch is active.

---

## 10.3 Command palette (⌘K)

The app switcher, and the model for any future palette.

- Opens on ⌘K / Ctrl-K from anywhere; `Esc` closes; the trigger is also clickable.
- `role="dialog"`, `aria-modal="true"`, focus moves to the input on open.
- `↑` `↓` wrap around; `↵` opens the highlighted row; hover moves the cursor.
- The highlighted row scrolls into view — `scrollIntoView({ block: "nearest" })`.
- Empty result says what was searched for: `No apps match "xyz".`
- Footer shows the shortcuts and one escape link: "Browse all apps".
- Unavailable rows render disabled at 45% opacity with a reason, not hidden.

---

## 10.4 Forms

**Layout.** One column. Labels above fields, always visible. Fields at `lg`
(48px). 16px between fields, 20px between groups. Primary action bottom-left of
the form or full-width in a card.

**Validation.** Validate on submit, not on every keystroke. Show the error under
the field with `aria-describedby`, set `aria-invalid`, and colour the border
`error-dot`. Summarise at the top only if the form is long enough to scroll.

**Errors say what to do:**
> ✅ "Enter a valid email address."
> ❌ "Invalid input."

**Pending.** Disable the submit and change the label — `Signing in…`,
`Submitting…`. Never leave a button clickable through a submit.

**Server actions.** A `<Button>` inside `<form action={…}>` **must** pass
`type="submit"`. The component defaults to `type="button"` and fails silently
otherwise — no console error, no visual difference, a dead control. This has
shipped to production here more than once.

**Consent and legal text** goes below the fields, 11.5px, with the checkbox
first and a real `<label>`.

---

## 10.5 Tables

- Header cells: 11px, `+0.08em`, uppercase, `ink-muted`, `scope="col"`.
- Rows: 13.5px, `border` bottom, none on the last row.
- Numbers right-aligned and `tabular-nums`.
- Identifiers in mono, `ink-muted`.
- Row actions right-aligned; destructive actions in a `ConfirmDialog`.
- A visually hidden `<caption>` naming the table.
- Wide tables scroll inside `overflow-x-auto` — never widen the page.

**Never put a popover inside a card with `overflow-hidden`.** CSS forces the
other axis to `auto` when one axis is not `visible`, so the card grows its own
scrollbar and the operator has to scroll *inside* the card to finish the action.
Use a real modal.

---

## 10.6 Cards and grids

App and entity cards: `xl` (14px) radius, `border-strong`, white, 20px padding.
Hover: border to `accent/40` plus the `lifted` shadow. The whole card is the
link; secondary controls inside it call `stopPropagation`.

Grid: `1 → 2 → 3` at `sm` / `lg`, 12–16px gap. Cards in a row are equal height —
`h-full` on the card, `line-clamp-2` on the description.

---

## 10.7 Feedback

| Situation | Pattern |
|---|---|
| Field-level problem | Inline error under the field |
| Page-level problem | `Alert` at the top of the content area |
| Result of an action | `Alert` in place, or an updated row |
| Background job finished | A status chip on the row that changed |
| Destructive action | `ConfirmDialog`, then the row disappears |

Since Labs does **not** use toasts. A toast that disappears is a message the
operator has to catch. State the outcome where the action happened, and leave it
there.

---

## 10.8 Empty, loading, error

**Empty** — `EmptyState`: what is not here, why or what to do, one action.

**Loading** — skeleton blocks in `surface-mist` at the shape of the content.
Nothing under ~300ms. A spinner only for an indeterminate action inside a button.

**Error** — degrade the region, not the page. If activity fails to load, the rest
of the dashboard still renders and the activity panel carries the message. Never
take a whole page down because one query failed.

---

## 10.9 Authentication surfaces

The split layout: a `46%` ink panel on the left with the wordmark, the eyebrow,
the serif hero and one paragraph; the form on the right on `paper`, in a white
card at `max-w-440px`.

Below `md`, the panel is hidden and the wordmark moves to the top of the form
column. The hero copy is *not* stacked above the form on mobile — it is dropped.
Marketing copy above a sign-in field on a phone is a wall between a person and
their work.

---

## 10.10 Permissions and absence

When someone cannot do something, the answer is almost never to hide the control
silently.

| Case | Pattern |
|---|---|
| Not entitled yet | Show it disabled, with "Not available yet" |
| Retired | Show it disabled, with "Retired" |
| Wrong role | Hide it — a customer should not learn the shape of the operator console |
| Coming soon | Show it at 60% opacity with a "Coming soon" label |

---

## 10.11 Anti-patterns

- ❌ A `<details>` popover for a destructive confirm inside a clipping ancestor
- ❌ `<div onClick>` instead of a button
- ❌ A placeholder used as a label
- ❌ Toasts for anything that matters
- ❌ Nav that grows with the data
- ❌ Two h1s, or a hand-rolled title next to a `PageHeader`
- ❌ A spinner covering a whole page
- ❌ Colour as the only signal
