Skip to content
Brand guide

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

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

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

SituationPattern
Field-level problemInline error under the field
Page-level problemAlert at the top of the content area
Result of an actionAlert in place, or an updated row
Background job finishedA status chip on the row that changed
Destructive actionConfirmDialog, 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

EmptyEmptyState: 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.

CasePattern
Not entitled yetShow it disabled, with "Not available yet"
RetiredShow it disabled, with "Retired"
Wrong roleHide it — a customer should not learn the shape of the operator console
Coming soonShow 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

This chapter as plain markdown: /docs/patterns.md