10.1 Page
Every product page is the same three things.
<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
PageHeaderper 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;
Esccloses; 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,
borderbottom, 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