# Patterns

How the components combine into screens: the two shells, ranked actions, tables, forms, overlays, the status vocabulary, the four non-happy states, toasts, page help, the phone composition and the voice. Each pattern names the surfaces that use it.

*Generated from the kit by `phase-4/src/build_patterns_impl.py`; the HTML pages under `docs/patterns/` carry the live specimens and copy buttons.*

## Contents

- [App shells: HQ and brand](#app-shells)
- [Page header and action ranking](#page-header)
- [Data tables](#data-tables)
- [Galleries and pickers](#galleries-pickers)
- [Forms and validation](#forms-validation)
- [Wizard and guided flow](#wizard)
- [Side sheets, dialogs and confirms](#overlays)
- [Status vocabulary](#status-vocabulary)
- [Empty, loading, error and not wired](#states)
- [Toasts and undo](#toasts-undo)
- [Page help](#page-help)
- [Phone patterns](#phone)
- [Copy and voice](#copy-voice)

<a id="app-shells"></a>

## App shells: HQ and brand

One frame, two menus. RJ HQ (super admin) and a brand (for example RJ Clothing) share the sidebar, top bar and help; the place switcher decides which registry the menu shows.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [super-brands](../../../phase-3/pages/super-brands/index.html), [dashboard](../../../phase-3/pages/dashboard/index.html), [orders](../../../phase-3/pages/orders/index.html), [sign-in](../../../phase-3/pages/sign-in/index.html)

<a id="app-shells-anatomy"></a>

### Anatomy

*Live specimen on the docs page.* brand shell · `.frame .app .sidebar .topbar .page` · phone `.phone .p-bar .k-actbar .tabbar`

| Part | Spec | Build as |
|---|---|---|
| Sidebar | `--sidebar-w` wide, navy satin sheen `--sidebar-sheen` plus `--sidebar-sheen-light`; items 36px high, active item has a 2px `--sidebar-primary` rail. The sheen lives here and nowhere else. | shadcn `Sidebar` (`collapsible="none"`) |
| Place switcher | Monogram tile (HQ, RC) + place name + role line; opens a searchable list of places. | `Popover` + `Command` |
| Jump to | ⌘K, navigation only: pages and records, never actions. | `CommandDialog` |
| Menu groups | Archivo `--text-micro` group labels; counts right-aligned, tabular, muted. Counts must agree with the page they open. | `SidebarGroup`, `SidebarMenuBadge` |
| Top bar | `--topbar-h`, translucent white with blur; breadcrumbs, STAGING badge, "?" help, account avatar. | header + `Breadcrumb` |
| Page | Content stops growing at `--content-max`. | `max-w-content` |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--sidebar-w` | `232px` |  |  |
| `--topbar-h` | `56px` |  |  |
| `--content-max` | `1280px` |  | page content stops growing past this (raised by specimens; build lane) |
| `--sidebar` | `220 49% 11%` | `#0E1729` |  |
| `--sidebar-foreground` | `222 19% 82%` | `#C9CEDA` | --rj-mist |
| `--sidebar-primary` | `221 54% 75%` | `#9FB4E2` | active-item rail + icon tint |
| `--sidebar-accent` | `222 43% 19%` | `#1B2744` |  |
| `--sidebar-border` | `222 33% 22%` | `#253049` |  |
| `--sidebar-sheen` | `linear-gradient(180deg, #131D33 0%, #0E1729 52%, #0C1425 100%)` |  |  |
| `--sidebar-sheen-light` | `radial-gradient(closest-side, rgba(130,160,220,.11), transparent)` |  |  |

<a id="app-shells-hq-vs-brand"></a>

### HQ versus brand

|  | HQ shell (RJ HQ) | Brand shell (for example RJ Clothing) |
|---|---|---|
| Who | Super admins at RJ HQ | Brand admins and staff |
| Monogram | `HQ` | Brand initials, for example `RC` |
| Menu | Overview (Dashboard, Orders, Analytics) · Catalogue (Global Store, Cloth library) · Brands & access (Brands, Users & access, API access, Audit log) | Store (Dashboard, Orders, Customers, Support, Campaigns, Analytics) · Products (Global Store, Create Product, My Products, Pricing, Variants) · Website · Selling · Needs attention · Settings |
| Routes | `/super/*` plus HQ `/settings/users`, `/settings/audit-log` | Everything else |
| Phone | Place bar, crumb, Menu bottom sheet with the whole registry, tab bar | Place bar, tab bar Home · Orders · Products · Menu, Menu bottom sheet |

- `/settings/users` and `/settings/audit-log` exist in both places as two different surfaces (one per shell). Pick the surface from the place, not the URL.
- `/founder` sits in the brand shell (RJ Clothing in the switcher) while it reads all brands. Decision to check before build.
- Off-menu pages (`/fit-review`, `/tailors`, `/appointments`) get a "Not in the menu" sidebar group showing the current page (proposed).
- `/sign-in` is the one page with no shell: a navy side panel on the sidebar's satin ground, the only place the sheen leaves the menu.

<a id="app-shells-environment"></a>

### Environment badge

Staging shows an ochre `STAGING` badge in the top bar (`.staging`, `--warning`). Production and local use the slate badge (`.env-prod`, `.env-local`, `--neutral`). Production is never green: green means "done", not "live system".

*Live specimen on the docs page.* `.staging` `.env-prod` `.env-local`

> **Note.** The badge text is 10.5px, under the 11.5px floor. It is deliberate: board 3 approved it and the env badges match it.

<a id="app-shells-sticky"></a>

### Sticky behaviour

- Top bar sticks to the top of the content column; the sidebar is sticky and full height (`h-dvh`), and only its menu list scrolls.
- Every shell wrapper uses `overflow:clip`, never `hidden`, or nothing below it can stick. See [gotcha 1](../implementation/gotchas/).
- Every child of the sidebar is `shrink-0`, otherwise the Jump to button squashes on short windows.

<a id="app-shells-build"></a>

### Build it

The shell is a server component (`app/(brand)/layout.tsx`, `app/(hq)/layout.tsx`) that reads the place and the menu registry, with three client islands: the place switcher, Jump to, and the help trigger. The menu registry already exists in code (`nav-registry.ts`); the counts come from the same loaders as the pages so they agree.

`app/(brand)/layout.tsx`

```tsx
// app/(brand)/layout.tsx — server component
export default async function BrandLayout({ children }: { children: React.ReactNode }) {
  const place = await getPlace();            // RJ Clothing, from the session
  const menu = await getBrandMenu(place);    // nav-registry + counts
  return (
    <SidebarProvider>
      <AppSidebar place={place} menu={menu} />   {/* "use client" inside: switcher + ⌘K */}
      <div className="flex min-w-0 flex-1 flex-col overflow-clip">
        <TopBar place={place} />                 {/* crumbs, STAGING, ?, avatar */}
        <main className="mx-auto w-full max-w-content">{children}</main>
      </div>
    </SidebarProvider>
  );
}
```

<a id="page-header"></a>

## Page header and action ranking

Every page opens the same way: a title, one plain line saying what the page is for, and its actions ranked so there is exactly one obvious next step.

Seen on: [order-detail](../../../phase-3/pages/order-detail/index.html), [support](../../../phase-3/pages/support/index.html), [fit-review](../../../phase-3/pages/fit-review/index.html), [website](../../../phase-3/pages/website/index.html), [customer-detail](../../../phase-3/pages/customer-detail/index.html)

<a id="page-header-anatomy"></a>

### Anatomy

*Live specimen on the docs page.* `.ph .page-title .ph-r` · phone `.p-head` + `.k-actbar`

| Part | Spec |
|---|---|
| Title | Archivo `--title-w` at `--text-page`, line height `--lh-page`, tracking `--tr-page` (negative). Phone: 24px. |
| Purpose line | One sentence in Plex Sans, `--muted-foreground`. Plain words: no table names, routes or ticket codes (those go in page help). |
| Actions | Right-aligned, `--s-2` apart, ranked right to left: primary last (rightmost). |
| Record pages | `.k-rec`: 44px avatar, name with status chips, a meta line, then the ranked actions. |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--text-page` | `26px` |  |  |
| `--lh-page` | `1.15` |  |  |
| `--tr-page` | `-.015em` |  | page title in the app |
| `--title-w` | `600` |  |  |
| `--control-h` | `40px` |  | button, input, select (desktop) |
| `--control-h-sm` | `34px` |  | small button, row actions |

<a id="page-header-ranking"></a>

### Action ranking

1. **Primary** (`btn-primary`, navy): the one thing this page is for. One per screen, never two.
2. **Secondary** (`btn-secondary`, outlined white): useful, not the point.
3. **Tertiary** (`btn-tertiary`, text only): low-stakes links such as "Copy request id".
4. **"⋯" menu** (`DropdownMenu`): the rest, including destructive items in `--destructive` at the bottom after a separator.

> **Rule.** Destructive is never the primary on a list or detail page. Use `btn-danger-quiet` in the header; the solid `btn-danger` appears only inside the confirm that does the damage.

*Live specimen on the docs page.* `.btn-primary` `.btn-secondary` `.btn-tertiary` `.btn-ghost` `.btn-danger-quiet` `.btn-danger`

<a id="page-header-picks"></a>

### The primary on each page

The designers picked one primary per surface. Frank still has to confirm the picks (PROPOSALS, Decision 10); build them as listed and keep the choice in one place per page.

| Surface | Primary | Instead of (today) |
|---|---|---|
| [Order detail](../../../phase-3/pages/order-detail/index.html) | Open envelope | Open envelope and Refund both secondary |
| [Fit review](../../../phase-3/pages/fit-review/index.html) | Review oldest | No primary |
| [Support](../../../phase-3/pages/support/index.html) | Open next due ticket | No primary |
| [Website](../../../phase-3/pages/website/index.html) | Review releases | No action at all |
| [ERP retry queue](../../../phase-3/pages/orders-retry-erp/index.html) | Retry all N | A primary on every row |
| [Funnel](../../../phase-3/pages/analytics-funnel/index.html), [Revenue](../../../phase-3/pages/analytics-revenue/index.html) | Download CSV | No button |
| [System status](../../../phase-3/pages/analytics-system-status/index.html) | Ping all | Ping all as secondary |
| [My Products](../../../phase-3/pages/products/index.html) | Create product | A solid "Price" on every card |
| [Customer detail](../../../phase-3/pages/customer-detail/index.html) | New ticket | Both buttons secondary, no handler |
| [Founder](../../../phase-3/pages/founder/index.html) | Open refund gate | Levers not built |
| [Refund requests](../../../phase-3/pages/founder-refund-requests/index.html) | Review next request | No actions |
| [Pricing variant](../../../phase-3/pages/pricing-variant/index.html) | Change price in Create Product | A tertiary link |
| [Variant groups](../../../phase-3/pages/variant-groups/index.html) | Add variants to a group | No primary ("confirm or drop") |

<a id="page-header-phone"></a>

### On the phone

- The title drops to 24px in `.p-head`; the purpose line shortens.
- The primary moves into the sticky action bar above the tab bar (`.k-actbar`), full width, 48px high.
- Secondary actions go into a "⋯" action sheet; the header keeps at most one icon button.
- Read-only pages with no primary show no action bar at all.

<a id="data-tables"></a>

## Data tables

Lists of records are tables on the desktop and lists on the phone. One toolbar, typed columns, the whole row clickable, and counts that always add up.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [orders](../../../phase-3/pages/orders/index.html), [customers](../../../phase-3/pages/customers/index.html), [variants](../../../phase-3/pages/variants/index.html), [support](../../../phase-3/pages/support/index.html), [products](../../../phase-3/pages/products/index.html)

<a id="data-tables-anatomy"></a>

### Anatomy

*Live specimen on the docs page.* `.dt` row with `.cb`, `.thumb`, `.chip`, money `.num`, version `.code` · phone `.k-list` / `.k-li`

| Part | Spec |
|---|---|
| Toolbar | Search (240 to 320px; `/` focuses it), filter chips with counts, sort menu, view switch (Table / Kanban on orders), density. |
| Header | `--row-th` high, Archivo `--text-micro` semibold in `--muted-foreground` on `--muted`; sticks under the top bar and toolbar. |
| Row | `--row` (or `--row-compact` in compact density); the whole row opens the record, plus an explicit Open link for keyboard users. |
| Frozen first column | Edge shade `--sticky-edge` when the table scrolls sideways. |
| Row actions | Edit opens the quick-edit sheet; the "⋯" menu holds the rest. |
| Bulk | Checkbox column; selecting shows a pill fixed at the bottom of the content column: "3 selected · Set status · Clear". Changes offer Undo. |
| Pagination | 25 per page; on the phone "Show 25 more". |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--row` | `44px` |  | table row |
| `--row-th` | `36px` |  | table header |
| `--row-compact` | `36px` |  | compact table density (raised by data-table) |
| `--sticky-edge` | `hsl(var(--foreground) / .09)` |  | frozen-column edge shade (raised by data-table) |
| `--muted` | `225 22% 96%` | `#F4F5F8` | table header, wells, hover |
| `--z-sticky` | `3` |  |  |

<a id="data-tables-columns"></a>

### Column types

| Type | Rule | Example |
|---|---|---|
| Product | Thumbnail (30×40 on `--studio`), name in Plex Sans semibold, handle or cloth code underneath in Plex Mono. | Navy birdseye two-piece suit / `navy-birdseye-two-piece` |
| Status | A chip from the [status vocabulary](#status-vocabulary); never colour alone. | Available |
| Money | Right-aligned, tabular numbers, no pence in lists. GBP first; other currencies follow. | £1,150 · €1,320 · $1,450 |
| Date | Relative near today, short date after. | Today 14:20 · Yesterday · 2 Oct |
| Code / id | Plex Mono `--text-code`; long ids wrap, never overflow. | `HS 81045`, `req_8f3a21c4` |
| Version | Mono, lower-case v. | `v4` |

> **Rule.** Counts agree across the page: if the gallery says 214 products, the filter chips add up to 214 and the menu badge says 214.

<a id="data-tables-states"></a>

### Empty, loading, error

- **First use**: what the table is for, plus one action ("Browse Global Store").
- **Filtered empty**: "No products match “linen”." plus "Clear filters". Keep the header and toolbar.
- **Loading**: skeleton rows on the real header (pulse `--motion-skeleton`), never a spinner over the table.
- **Error**: the error block in place of the rows, naming the cause, with Try again and the request id. See [states](#states).

<a id="data-tables-phone"></a>

### On the phone

- Never a table. Each record is a list row: `.k-li` (72px minimum) without a picture, the product row with a thumbnail when there is one.
- Filters move behind one Filter button with a count, opening a bottom sheet.
- Selection swaps the action bar to "3 selected" with the bulk actions (`.k-actbar.sel`).
- "Show 25 more" replaces page numbers.

<a id="data-tables-build"></a>

### Build it

`DataTable` is a client component built on TanStack Table v8 and the shadcn `Table`. The page (server) loads the first page of rows and passes them in; filters, sort and page live in the URL (`searchParams`) so a view can be shared and the back button works. Saved views are named URL states.

`orders/page.tsx`

```tsx
// app/(brand)/orders/page.tsx — server
export default async function OrdersPage({ searchParams }: { searchParams: Promise<OrdersQuery> }) {
  const q = parseOrdersQuery(await searchParams);       // zod
  const { rows, total, counts } = await listOrders(q);  // one read, counts from the same query
  return <OrdersTable rows={rows} total={total} counts={counts} query={q} />;  // "use client"
}
```

<a id="galleries-pickers"></a>

## Galleries and pickers

Where the cloth and the garment do the visual work: product and cloth cards in a grid, the same cards in picker mode, and a table view that is the same table as everywhere else.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [brand-global-store](../../../phase-3/pages/brand-global-store/index.html), [products](../../../phase-3/pages/products/index.html), [super-cloths](../../../phase-3/pages/super-cloths/index.html), [create-product](../../../phase-3/pages/create-product/index.html)

<a id="galleries-pickers-cards"></a>

### Cards

*Live specimen on the docs page.* `.lift` · photo on `--studio` · `--r-card` · `--e-card-hover`

- Photo first, on the `--studio` ground; corners `--r-card-inner` inside the 1px border. A card without a photo says "No photo yet", never a grey box.
- Name in Archivo, code in Plex Mono, status chip and price on one line.
- Hover lifts the card `--lift-shift` with `--e-card-hover` (product, cloth and kanban cards all lift the same way).
- Card actions live in a "⋯" menu; a card has at most one visible button (Global Store brand side: "Add to my products").

| Token | Value | Hex | Note |
|---|---|---|---|
| `--studio` | `radial-gradient(120% 90% at 50% 18%, #f7f8fa 0%, #e3e7ee 80%)` |  | garment photo ground |
| `--r-card` | `8px` |  | cards, table wrap, popovers (= radius + 2) |
| `--r-card-inner` | `calc(var(--r-card) - 1px)` |  | photo corners inside a 1px-bordered card (raised by cards-gallery) |
| `--r-media` | `4px` |  | photos inside cards |
| `--e-card-hover` | `0 10px 24px -14px rgba(14,23,41,.28)` |  | lifted card / kanban card (raised by cards-gallery + data-display) |
| `--lift-shift` | `-2px` |  | card hover lift (raised by cards-gallery) |

<a id="galleries-pickers-picker"></a>

### Picker mode

- The same card with a radio tick (single) or checkbox (multi) in the corner; the selected card gets `--accent` and a `--ring` edge.
- Items you cannot pick stay visible and say why ("In My Products", disabled look).
- Search and filter chips sit above the grid; the confirm button names the count: "Add 3 cloths".
- A pick for a product is built locally today (`.cp-pick`); cards-gallery only has cloth pick cards. Build one `PickCard`.

> **Note.** Build as `Command` (search + keyboard) over a grid of `Card`s with `aria-selected`. On the phone the picker is a full-screen sheet.

<a id="galleries-pickers-views"></a>

### Gallery, table and phone

- The gallery's table view is the same `DataTable` as everywhere else (same product cell, money cell, version, row menu).
- The cloth library browses as a 6-up cloth-card gallery with state chips All / In sync / Drift / Tombstoned whose counts add up.
- Phone: two cards per row for browsing, or list rows with a thumbnail for long lists.

<a id="forms-validation"></a>

## Forms and validation

Labels above, one control height, help under the field, errors that say what is wrong and how to fix it, and a save bar that keeps Save in view.

Seen on: [settings-brand](../../../phase-3/pages/settings-brand/index.html), [global-store-editor](../../../phase-3/pages/global-store-editor/index.html), [campaign-detail](../../../phase-3/pages/campaign-detail/index.html), [product-prices](../../../phase-3/pages/product-prices/index.html), [variant-new](../../../phase-3/pages/variant-new/index.html)

<a id="forms-validation-field"></a>

### Field anatomy

*Live specimen on the docs page.* `.lbl` `.input` `.help` `.help.err` disabled

| Part | Desktop | Phone |
|---|---|---|
| Label | Archivo `--text-label` semibold, 7px above the control | Same |
| Control | `--control-h`, `--r-control`, edge `--input` (3.1:1 on white); hover `--input-hover`; focus `--ring` plus `--focus-ring` | `--control-h-m`, text `--text-input-m` (16px, so iOS does not zoom) |
| Help | `--text-micro` in `--muted-foreground`, 6px under | Same |
| Error | Replaces the help line, in `--destructive`; the edge turns `--destructive` | Same |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--control-h` | `40px` |  | button, input, select (desktop) |
| `--control-h-m` | `44px` |  | phone minimum tap target |
| `--text-input-m` | `16px` |  | phone text fields: iOS never zooms at >= 16px |
| `--input` | `219 14% 59%` | `#8892A5` | control borders: 3.1:1 on white (WCAG 1.4.11); was #C6CCD7 at 1.6:1 |
| `--input-hover` | `223 17% 45%` | `#5F6A86` | field / checkbox / select border on hover |
| `--ring` | `221 45% 45%` | `#3F5FA6` | focus |
| `--focus-ring` | `0 0 0 1px hsl(var(--ring)), 0 0 0 4px hsl(var(--ring) / .14)` |  |  |
| `--text-label` | `13px` |  | form label (Archivo 600) |

<a id="forms-validation-validation"></a>

### When to validate

- Format errors on blur, never on the first keystroke.
- Required fields on submit; then move focus to the first field in error and say how many there are in the save bar ("2 fields need attention").
- Server errors land on the field they belong to when there is one, otherwise in the save bar error slot with the cause.
- An error says what is wrong and what to do: "Use lower-case letters and dashes only", never "Invalid value".

<a id="forms-validation-money"></a>

### Money and currencies

- GBP is the base currency; EUR and USD follow. Tabular numbers everywhere.
- Edit shows pence (`£1,150.00`); lists do not (`£1,150`).
- Prices are one line per currency with the currency tag as an affix; a missing currency says "USD not set" instead of a blank.
- A save that publishes prices in a currency nobody has confirmed goes through the [unverified currency confirm](#unverified-currency).

<a id="forms-validation-readonly"></a>

### Read-only and locked

A field set elsewhere stays visible and says where: a lock chip "Set by RJ HQ" and one help line. Never hide it, never leave it looking editable. Brand slugs and HQ-owned avatars are the common cases.

<a id="forms-validation-save"></a>

### Saving: the sticky save bar

*Live specimen on the docs page.* `.k-savebar` with `.is-dirty` / `.is-error` · phone: glass bar above the tab bar

- Whole-page forms keep Save in a sticky bar: saved (muted, with when), dirty (count of changes), error (cause).
- Discard resets to the last saved values. Leaving with unsaved changes asks first (the unsaved guard).
- Short forms in a sheet put Save in the sheet footer instead.

Build with react-hook-form and zod (already a dependency): `formState.isDirty` drives the bar, `reset()` is Discard.

<a id="wizard"></a>

## Wizard and guided flow

For work that has to happen in order: a stepper that shows where you are, one step open at a time, and a single primary that names what comes next.

Seen on: [create-product](../../../phase-3/pages/create-product/index.html), [global-store-editor](../../../phase-3/pages/global-store-editor/index.html), [settings-brand-new](../../../phase-3/pages/settings-brand-new/index.html), [tailor-detail](../../../phase-3/pages/tailor-detail/index.html), [pricing-bulk](../../../phase-3/pages/pricing-bulk/index.html)

<a id="wizard-anatomy"></a>

### Anatomy

*Live specimen on the docs page.* stepper + `.k-savebar` · phone: compact title `--text-step-m` + progress + `.k-actbar`

- Stepper on the left rail (desktop) or across the top; done steps tick, the current step is ringed, later steps are muted.
- One step open at a time. Done steps stay editable; later steps unlock in order.
- The primary names the next step ("Next: Price") until the last one, which names the result ("Save product", "Start set-up").
- Long-running finishes (brand set-up) show progress over the step list and say "You can leave this page; the run carries on".

| Token | Value | Hex | Note |
|---|---|---|---|
| `--text-step-m` | `17px` |  | phone compact step title (raised by wizard-stepper) |
| `--control-h-m-lg` | `48px` |  | phone primary / sheet buttons |

<a id="wizard-order"></a>

### Order of steps

> **Open question.** The Global Store editor puts **Design before Price**, against the brief: the combos offered come from the chosen design, so Price would always say "pick a design first". Frank to confirm (PROPOSALS, Decision 3).

<a id="wizard-build"></a>

### Build it

One `Stepper` client component; each step is a form section with its own zod schema. Keep the step in the URL (`?step=design`) so the browser Back button and a refresh land on the same step. The kit never shipped a `[data-k-steps]` hook; both wizard pages wrote their own, so this is new code.

<a id="overlays"></a>

## Side sheets, dialogs and confirms

Stay on the list and edit in a side sheet; stop and decide in a dialog; confirm only what cannot be undone, and say exactly what will happen.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [settings-api-keys](../../../phase-3/pages/settings-api-keys/index.html), [fit-review-detail](../../../phase-3/pages/fit-review-detail/index.html), [campaigns-email](../../../phase-3/pages/campaigns-email/index.html), [product-prices](../../../phase-3/pages/product-prices/index.html)

<a id="overlays-which"></a>

### Which one

| Overlay | Use for | Size | Phone |
|---|---|---|---|
| Side sheet, small | Quick edit: one record, a few fields | `--sheet-w` | Bottom sheet |
| Side sheet, medium | Detail without leaving the list | `--sheet-w-md` | Bottom sheet |
| Side sheet, large | Create and long edits (API key, section editor) | `--sheet-w-lg` | Full-screen sheet |
| Dialog | A short, focused decision | `--dialog-w` | Centred, or bottom sheet when it holds a form |
| Confirm | Something that cannot be undone, costs money, or reaches customers | `--dialog-w` | Same |
| Help drawer | Page help, non-modal, below the top bar | `--help-w` | Bottom sheet |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--sheet-w` | `404px` |  |  |
| `--sheet-w-md` | `532px` |  | medium side sheet: detail views (raised by overlays) |
| `--sheet-w-lg` | `856px` |  | large side sheet: create key, long forms (raised by overlays) |
| `--dialog-w` | `480px` |  |  |
| `--help-w` | `380px` |  |  |
| `--e-sheet` | `-24px 0 60px -20px rgba(14,23,41,.35)` |  | side sheet, help drawer |
| `--e-dialog` | `0 24px 64px -20px rgba(14,23,41,.40)` |  |  |
| `--e-phone-sheet` | `0 -20px 50px -20px rgba(14,23,41,.50)` |  | phone bottom sheet |
| `--scrim` | `rgba(10,14,24,.38)` |  |  |
| `--motion-sheet-in` | `260ms var(--ease-in)` |  |  |
| `--motion-sheet-out` | `180ms var(--ease-out)` |  |  |

<a id="overlays-sheet"></a>

### Side sheet

*Live specimen on the docs page.* click Quick edit · `.k-stage .k-scrim .k-sheet` (phone `.k-bottom` + `.k-grab`)

- Header and footer stay put; only the body scrolls, and it never scrolls the page behind (`overscroll-behavior: contain`).
- Focus moves into the sheet (the `data-k-autofocus` field first), Tab stays inside, Escape or the scrim closes, focus returns to the opener.
- A sheet with unsaved changes asks before closing: the **unsaved guard** dialog sits above the sheet ("Discard your changes?" · Discard changes / Keep editing).
- "Open full editor" in the footer when the sheet is a shortcut to a bigger page.

<a id="overlays-confirm"></a>

### Confirms

*Live specimen on the docs page.* dialog surface `--r-frame` `--e-dialog` · buttons echo the verb

- The title is the question, naming the thing: "Revoke the key “Fabric vision worker”?"
- The body says the consequence in one or two sentences.
- Buttons echo the verb: "Keep key" / "Revoke key". Never Yes / No / OK. Never `window.confirm`.
- Destructive confirms use the solid `btn-danger`. Erasure waits for the typed name before the button enables.
- Sends and publishes that reach customers ("Send now") always confirm; there is no Undo for them.

<a id="overlays-unverified-currency"></a>

### Confirm unverified currency

When a save would publish prices in a currency nobody has confirmed, list every affected item and ask for one tick to own it. The figures shown are worked out from GBP.

*Live specimen on the docs page.* overlays family · "Confirm unverified currency" · Save stays disabled until the tick

> **Open question.** The screen map shows this confirm only on the brand side. Whether HQ recommended prices have an unverified state too is a product decision (PROPOSALS, global-store-editor 3). The brand side asks once per session, not on every add (brand-global-store 6, proposed).

<a id="overlays-build"></a>

### Build it

| Kit | React |
|---|---|
| `.k-sheet` (+ `.md`, `.lg`) | shadcn `Sheet` (Radix Dialog) with `w-sheet`, `w-sheet-md`, `w-sheet-lg` |
| `.k-sheet.k-bottom`, `.k-full` | vaul `Drawer` (shadcn `drawer`) |
| `.k-dialog` | `Dialog`; confirms are `AlertDialog` |
| `.k-guard` | A nested `AlertDialog` opened from the sheet's `onInteractOutside` / `onEscapeKeyDown` when the form is dirty |
| `[data-k-autofocus]` | `onOpenAutoFocus={(e) => { e.preventDefault(); ref.current?.focus(); }}` |

<a id="status-vocabulary"></a>

## Status vocabulary

One vocabulary, owned by the status-feedback family. Every chip and dot in the admin reads from this list: the words are exact, the colour comes from the role, never from taste.

Seen on: [orders](../../../phase-3/pages/orders/index.html), [orders-kanban](../../../phase-3/pages/orders-kanban/index.html), [orders-retry-erp](../../../phase-3/pages/orders-retry-erp/index.html), [support](../../../phase-3/pages/support/index.html), [releases](../../../phase-3/pages/releases/index.html), [campaigns](../../../phase-3/pages/campaigns/index.html)

<a id="status-vocabulary-rules"></a>

### Colour rules

| Role | Token | Means |
|---|---|---|
| Success | `--success` | Done, healthy, live, in sync. |
| Info | `--info` | In progress or informational: scheduled, preview, shipped, in production. |
| Warning | `--warning` | Needs attention soon, or partly working: retrying, waiting on us, out of stock, not wired. |
| Danger | `--destructive` | Failed and needs a person now. Never for a finished outcome (refunded, ended, retired). |
| Neutral | `--neutral` | Resting states: awaiting, paused, closed, refunded, ended. |
| Locked | `--locked` on `--muted` | Set elsewhere or not allowed. |
| Draft · Available · Retired | `--draft` / `--available` / `--retired` | Catalogue lifecycle only. Product UI uses these names, never the generic role. |

- A chip is a 6px dot plus a label on a soft fill: the status colour at `--status-soft-alpha` on white, text in the status colour. Every pairing is at least 4.5:1.
- Never colour alone: the label always says the state.
- `.plain` drops the dot for flags and severities; `.lg` is the header size; `.ico` swaps the dot for an icon; `.mono` for versions.
- Kanban column names (Awaiting, Cutting, Stitching, Finishing, Packed, Shipped) are production stages, not chips.

*Live specimen on the docs page.* `.chip` + role · `.lg` `.ico` `.mono` `.plain` `.cnt`

| Token | Value | Hex | Note |
|---|---|---|---|
| `--success` | `158 51% 26%` | `#21654C` |  |
| `--info` | `218 49% 39%` | `#335794` |  |
| `--warning` | `36 79% 29%` | `#83550F` | also the STAGING badge |
| `--destructive` | `353 53% 42%` | `#A2323F` |  |
| `--neutral` | `222 13% 39%` | `#575F72` |  |
| `--draft` | `var(--info)` | `#335794` | Draft     = info blue |
| `--available` | `var(--success)` | `#21654C` | Available = green |
| `--retired` | `var(--neutral)` | `#575F72` | Retired   = slate |
| `--locked` | `var(--neutral)` | `#575F72` | "locked" banner + lock chip |
| `--not-wired` | `var(--warning)` | `#83550F` | "not wired" banner (feature exists, backend not connected) |
| `--status-soft-alpha` | `.12` |  |  |
| `--chip-h` | `22px` |  |  |
| `--chip-h-lg` | `26px` |  | header-size chip (raised by status-feedback) |
| `--r-chip` | `4px` |  | square chips |

<a id="status-vocabulary-values"></a>

### Every status value

#### Catalogue lifecycle · Global Store, My Products, editor

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Draft | `.chip.draft` | `--draft` | Being prepared at HQ; brands cannot see it. |
| Available | `.chip.available` | `--available` | Brands can adopt it. With a version: "Available v4" (version in the chip, Plex Mono elsewhere). |
| Retired | `.chip.retired` | `--retired` | Kept for history; no new adoptions. Never red. |
| Not yet available to brands | `.chip.warning.ico` | `--warning` | Saved at HQ, not yet released to brands. |
| Update ready | `.chip.info` | `--info` | Brand side: HQ has a newer version than this copy. |

Source: status-feedback · Catalogue

#### Order payment · Orders, order detail, dashboard

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Awaiting payment | `.chip.warning` | `--warning` | Checkout started, money not taken. |
| Paid | `.chip.success` | `--success` | Money taken. |
| Payment failed | `.chip.danger` | `--destructive` | The card or bank refused; needs action. |
| Refunded | `.chip` | `--neutral` | Neutral, not red: a refund is a finished outcome, not a fault. Fulfilment then shows "—". |

Source: status-feedback · Order payment

#### Order fulfilment · Orders, kanban, order detail

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Awaiting | `.chip` | `--neutral` | Paid, not started. Replaces "Not started" (same word as the kanban's first column). |
| In production | `.chip.info` | `--info` | At the factory. |
| Shipped | `.chip.info` | `--info` | On its way. Info blue, not green: the job is not done until it arrives. |
| Delivered | `.chip.success` | `--success` | Arrived. |
| Special order | `.chip.plain` | `--neutral` | A flag, not a stage (no dot). |
| Not mapped | `.chip.unmapped` | `--not-wired` | The ERP does not send this stage yet (dashed, not-wired tint). |

Source: status-feedback · Order fulfilment; KIT v2-13

#### ERP sync · Orders, retry queue, dashboard

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| In sync | `.chip.success` | `--success` | The ERP has the order. Replaces "Synced". |
| Pending | `.chip` | `--neutral` | Queued for the ERP. |
| Retrying | `.chip.warning` | `--warning` | Failed once, being retried automatically. |
| ERP failed | `.chip.danger` | `--destructive` | Needs a person: open the retry queue. |
| Stuck | `.chip.danger.plain` | `--destructive` | 5 of 5 attempts used. As a count: "3 stuck". |
| Not read | `.chip.ico` | `--neutral` | The ERP status could not be read; not the same as failed. |

Source: status-feedback · ERP sync; orders-retry-erp NOTES

#### Release · Website, releases

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Preview | `.chip.info` | `--info` | The version being checked. |
| Live | `.chip.success` | `--success` | What customers see. |
| Up to date | `.chip.success` | `--success` | Preview and live are the same. |
| 2 behind preview | `.chip.warning` | `--warning` | Live is older than preview, with the count. |
| Not the same as preview | `.chip.warning` | `--warning` | Live differs from preview. |
| Hold 2 | `.chip.warning.plain` | `--warning` | Releases on hold, with the count. |
| V4 | `.chip.plain.mono` | `--neutral` | A version label. |

Source: status-feedback · Release

#### Website section and site · Website, menu and sections

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Draft | `.chip.draft` | `--draft` | Section not ready. |
| Ready | `.chip` | `--neutral` | Ready, not published. |
| Published | `.chip.success` | `--success` | In the live release. |
| Public: online | `.chip.info` | `--info` | The storefront is reachable. |
| Public: offline | `.chip` | `--neutral` | The storefront is switched off. |
| Suspended | `.chip.warning` | `--warning` | HQ suspended the site: "Since Today 09:10 by RJ HQ". |

Source: status-feedback · Website section; website NOTES

#### Campaign · Campaigns, campaign detail

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Draft | `.chip.draft` | `--draft` | Not scheduled. |
| Scheduled | `.chip.info` | `--info` | Starts later. |
| Live | `.chip.success` | `--success` | Running now. |
| Paused | `.chip` | `--neutral` | Stopped before its end date. |
| Ended | `.chip.ended` | `--retired` | Ran its course: neutral, not red. Use `.chip.ended` (pages still carry the `.chip.retired` stand-in). |

Source: status-feedback · Campaign; KIT v2-13

#### Email broadcast · Email broadcasts

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Draft | `.chip.draft` | `--draft` | Being written. |
| Scheduled | `.chip.info` | `--info` | Will send at the set time. |
| Sent | `.chip.success` | `--success` | Delivered to the list. |
| Sent with issues | `.chip.warning` | `--warning` | Sent, with bounces or failures to show. |

Source: campaigns-email page

#### Support ticket · Support inbox, thread, customer

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Open | `.chip.info` | `--info` | New, not yet answered. |
| Re-opened | `.chip.warning` | `--warning` | The customer wrote again after it was resolved. |
| Waiting on us | `.chip.warning` | `--warning` | Replaces "Pending operator". |
| Waiting on customer | `.chip` | `--neutral` | Ball is in the customer's court. |
| Resolved | `.chip.success` | `--success` | Answered and done. |
| Closed | `.chip` | `--neutral` | Archived. |

Source: status-feedback · Support ticket

#### Severity · Support case rail, inbox

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Low | `.chip.plain` | `--neutral` | No dot. |
| Medium | `.chip.warning.plain` | `--warning` |  |
| High | `.chip.danger.plain` | `--destructive` | The plain danger chip. |

Source: status-feedback · Severity

#### Reply templates and AI pairs · Support templates, training pairs

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Ready to send | `.chip.success` | `--success` | Template has copy. |
| No copy yet | `.chip.warning` | `--warning` | Template exists with no text. |
| Patrick may send | `.chip.info.plain` | `--info` | The assistant may send this one. |
| Opener allowed | `.chip.plain` | `--neutral` | A tone opener can be added. |
| Edited before sending | `.chip.warning.plain` | `--warning` | From `was_edited`. Pages shorten it to "Edited" in rows. |
| Sent as drafted | `.chip.success.plain` | `--success` | Sent without edits. |

Source: support pages; PROPOSALS support-training-pairs 1

#### Refund request · Founder refund gate

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Raised | `.chip.info` | `--info` | Support asked for it. |
| In review | `.chip.warning` | `--warning` | Someone is looking at it. |
| Cleared | `.chip.success` | `--success` | Cleared for refund. |
| Declined | `.chip.danger` | `--destructive` | Refused, with the reason. |

Source: status-feedback · Refund request (values assumed)

#### Fit review · Fit review queue and detail

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Review pending | `.chip.warning` | `--warning` | Waiting for a reviewer. |
| More info requested | `.chip.warning` | `--warning` | Sent back to the customer. |
| Fit accepted | `.chip.success` | `--success` | Approved. |
| Awaiting factory message | `.chip.info` | `--info` | After approval, until the factory confirms. |

Source: fit-review-detail page

#### Cloth mirror · HQ cloth library

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| In sync | `.chip.success` | `--success` | Mirror matches Laravel. |
| Drift | `.chip.warning` | `--warning` | Fields differ between the two. |
| Missing in mirror | `.chip.danger` | `--destructive` |  |
| Missing in Laravel | `.chip.danger` | `--destructive` |  |
| Tombstoned | `.chip.retired` | `--retired` | Removed upstream, kept for history. |
| Fresh | `.chip.success` | `--success` | Synced recently. |
| Stale > 6h | `.chip.danger` | `--destructive` | Not synced for more than six hours. |
| Never synced | `.chip.retired` | `--retired` |  |
| Syncing | `.chip.info` | `--info` | A run is in progress. |

Source: status-feedback · Cloth mirror; super-cloths page

#### Sync run · Cloth sync, variants sync

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Running | `.chip.info` | `--info` |  |
| Succeeded | `.chip.success` | `--success` |  |
| Partial | `.chip.warning` | `--warning` | With the count: "Partial · 3 not read". |
| Failed | `.chip.danger` | `--destructive` |  |

Source: status-feedback · Sync run

#### Brand provisioning · Settings › Brands, brand detail

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Provisioning | `.chip.info` | `--info` | The set-up run is going. |
| Created but not stocked | `.chip.warning` | `--warning` | Brand exists, catalogue not cloned. |
| Ready | `.chip.success` | `--success` |  |
| Failed | `.chip.danger` | `--destructive` | With the failed step named. |
| Unknown | `.chip` | `--neutral` | State could not be read (locked grey). |

Source: status-feedback · Brand provisioning; settings-brands NOTES

#### Stock and variants · Variants, editor, cloth library

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| In stock | `.chip.available` | `--available` |  |
| Out of stock | `.chip.warning` | `--warning` | Warning amber everywhere, never red. "OOS" only in a narrow phone cell. |
| Live | `.chip.available` | `--available` | A variant switched on for sale. |
| Price drift | `.chip.warning` | `--warning` | Price differs from HQ. |
| Override | `.chip.info.plain` | `--info` | A brand value replaces the derived one. |

Source: KIT cross-family; variants, product-prices pages

#### People, keys and access · Users, API access, tailors, profile

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Active | `.chip.success` | `--success` |  |
| Disabled | `.chip.retired` | `--retired` | Illustrative value. |
| Suspended | `.chip.warning` | `--warning` | Assumed value (HQ users). |
| Revoked | `.chip.retired` | `--retired` | An API key that no longer works. |
| Candidate | `.chip.plain` | `--neutral` | Tailor not yet vetted. |
| Vetted | `.chip.info` | `--info` |  |
| Bank verified | `.chip.success` | `--success` |  |
| Bank pending | `.chip.warning` | `--warning` | Only for onboarded tailors; candidates show "Not onboarded". |
| Admin only | `.chip.locked.ico` | `--locked on --muted` |  |
| Locked | `.chip.locked.ico` | `--locked on --muted` | Set elsewhere (for example "Set by RJ HQ"). |

Source: status-feedback · Access; people pages

#### System and connections · System status, connected apps, appointments

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Operational | `.chip.success` | `--success` |  |
| Degraded | `.chip.warning` | `--warning` | Slow: latency over the threshold (800 ms proposed). |
| Down | `.chip.danger` | `--destructive` |  |
| Not configured | `.chip` | `--neutral` |  |
| Ready | `.chip.success` | `--success` | Also Healthy, Issued. |
| Not ready | `.chip.warning` | `--warning` | Also Not issued. |
| Not checked | `.chip` | `--neutral` | Also Not requested, Not connected. |

Source: analytics-system-status, connected-apps, appointments pages

#### Customers · Customers, customer detail

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| ★ VIP | `.chip.info.plain` | `--info` |  |
| Active | `.chip.success` | `--success` |  |
| Refund | `.chip.warning` | `--warning` | Has a refund on record. |

Source: customers pages

#### Wiring and environment · Every page

| Shows as | Class | Colour token | Meaning and wording |
|---|---|---|---|
| Not wired | `.chip.warning.plain` | `--warning` | The feature exists, its backend is not connected on this environment. |
| Not built | `.chip.plain` | `--neutral` | Designed, not built yet (founder levers). |
| Staging | `.staging` | `--warning` | Top-bar badge: ochre, 10.5px uppercase (deliberately below the 11.5px floor, approved on board 3). |
| Production / Local | `.env-prod / .env-local` | `--neutral` | Slate. Production is never green. |

Source: status-feedback · Environment


<a id="status-vocabulary-open"></a>

### Open questions (from PROPOSALS)

These values are drawn but not settled. Build them behind one `status.ts` map so a changed word is a one-line edit.

| Question | What is open | Where it is tracked |
|---|---|---|
| ERP sync words | Are "In sync, Pending, Retrying, ERP failed, Stuck, Not read" the right words? The retry queue drops Retrying; the dashboard and founder pages draw "Not read" as `warning plain` while status-feedback draws it neutral with an icon. | PROPOSALS Decision 5; orders-retry-erp NOTES 4; status-feedback NOTES 6 |
| Refund request states | Raised / In review / Cleared / Declined are assumed: the screen map lists the column, not the values. Who clears a refund is also open. | PROPOSALS Decision 4 |
| Campaign status model | Status is worked out from the dates today, so Draft, Scheduled, Live and Paused cannot all be set by hand. A real Pause, or a display-only label? | PROPOSALS Decision 2 |
| "Pending" filter in Support | The inbox filter chip "Pending" is proposed as "Waiting", to match the ticket chips. | PROPOSALS support 3, support-thread D1 |
| People statuses | "Suspended" (HQ) and "Disabled" (brand) are assumed values; confirm the real vocabulary before build. | PROPOSALS hq-settings-users, settings-users 3 |
| Brand provisioning colours | Ready = success, Created but not stocked = warning, Provisioning = info, Failed = danger, Unknown = locked grey. | PROPOSALS settings-brands 4 |
| One state, two wordings | "Not yet available to brands" (status-feedback) and "Changes not available to brands yet" (editor) name the same state. "Done" (variants sync) and "Succeeded" (sync run) too. Pick one each; the kit suggests the status-feedback words. | global-store-editor and variants-sync pages |
| Case | Appointments writes its chips lower case ("ready", "not issued"); every other page uses sentence case. Build in sentence case. | appointments page |
| Thresholds | Stale fit review at 5 days, stale kanban card at 7 days paid and not started, urgent ticket at 12 h, slow at 800 ms, checkout healthy at 90%: all proposed, all need a yes. | PROPOSALS summary |

<a id="status-vocabulary-build"></a>

### Build it

`src/lib/status.ts`

```ts
// src/lib/status.ts — the one map every Badge and dot reads
export type Tone = "success" | "info" | "warning" | "danger" | "neutral" | "locked" | "draft" | "available" | "retired" | "ended" | "unmapped";

export const ORDER_PAYMENT = {
  awaiting_payment: { label: "Awaiting payment", tone: "warning" },
  paid:             { label: "Paid",             tone: "success" },
  payment_failed:   { label: "Payment failed",   tone: "danger" },
  refunded:         { label: "Refunded",         tone: "neutral" },   // never red
} as const satisfies Record<string, { label: string; tone: Tone }>;

// <StatusBadge tone="success">Paid</StatusBadge> -> bg-success/soft text-success, 6px dot
```

<a id="states"></a>

## Empty, loading, error and not wired

Every surface was designed in its unhappy states too. Each one says what happened and what to do next, in plain words, and never pretends an empty list means nothing is there.

Seen on: [orders](../../../phase-3/pages/orders/index.html), [products](../../../phase-3/pages/products/index.html), [order-envelope](../../../phase-3/pages/order-envelope/index.html), [founder-refund-requests](../../../phase-3/pages/founder-refund-requests/index.html), [settings-rules](../../../phase-3/pages/settings-rules/index.html)

<a id="states-empty"></a>

### Empty

*Live specimen on the docs page.* `.sf-empty` (status-feedback)

| Kind | Says | Action |
|---|---|---|
| First use | What will be here and where it comes from | The one way to fill it |
| No results | "No products match “linen”." | Clear filters |
| No permission | Who can do this ("RJ HQ adds and removes people") | None, or "Ask RJ HQ" |
| Compact (in a panel) | One line | Optional link |

<a id="states-loading"></a>

### Loading

*Live specimen on the docs page.* `.skel` pulse on `--motion-skeleton` · `.spin` inside a busy button

- Skeletons sit on the real layout (the real table header, the real card grid) and pulse; reduced motion stops the pulse.
- A spinner only lives inside the button that started the work (`aria-busy`), with the label in the -ing form.
- Work of known length shows progress against the batch: "58 of 142".

| Token | Value | Hex | Note |
|---|---|---|---|
| `--motion-skeleton` | `1.6s var(--ease-standard) infinite alternate` |  | skeleton pulse (raised by data-table + cards-gallery) |

<a id="states-error"></a>

### Error

*Live specimen on the docs page.* `.sf-errpage` (status-feedback)

- Name the real cause: "Cloth sync failed: Holland & Sherry did not answer. Try again." Never "Something went wrong".
- Say what did not happen ("Nothing was changed") and what was kept.
- Give the request id with a copy button; staff paste it into a ticket.
- **Partial failure** lists what worked and what did not, with a retry for the failed part only.

<a id="states-wiring"></a>

### Not wired, not built, locked

*Live specimen on the docs page.* `.sf-alert.wired` `.sf-alert.locked`

| Notice | Means | Looks |
|---|---|---|
| Not wired | The feature exists; its backend is not connected on this environment | Warning, dashed (`--not-wired`) |
| Not captured | The store was read and has no row (order envelope) | Neutral empty |
| Not built | Designed, not built yet (founder levers) | Plain chip, controls inert |
| Locked | Set elsewhere or not allowed for this role | `--locked` on `--muted` with a lock icon |

> **Rule.** Keep "not wired" and "not captured" apart: one is a broken connection, the other is a true empty. An empty list that could not be read must never look like an empty list.

<a id="toasts-undo"></a>

## Toasts and undo

A toast confirms that something happened, in the past tense, and offers Undo when the change can be taken back. There is no toast system in the admin today; every page proposed one.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [website-menu](../../../phase-3/pages/website-menu/index.html), [products](../../../phase-3/pages/products/index.html), [super-cloths](../../../phase-3/pages/super-cloths/index.html)

<a id="toasts-undo-anatomy"></a>

### Anatomy and kinds

*Live specimen on the docs page.* `.toast` `.success` `.undo` `.error` `.progress` (also `.warning`, `.info`)

| Property | Value |
|---|---|
| Position | Bottom right on the desktop, up to 380px wide, three visible, stacked `--toast-gap` apart |
| Phone | Full width above the action bar and tab bar; tap to dismiss; an action sits inline, never on its own row |
| Enter / leave | Rises `--toast-shift` on `--motion-toast-in`, fades on `--motion-toast-out` |
| Stays | `--toast-dwell`; with Undo `--toast-dwell-undo` (twice as long) |
| Errors | Stay until dismissed and use `role="alert"` |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--toast-dwell` | `2600ms` |  |  |
| `--toast-dwell-undo` | `5200ms` |  | an Undo toast stays twice as long (raised by status-feedback) |
| `--toast-gap` | `8px` |  | gap between stacked toasts (raised by status-feedback) |
| `--motion-toast-in` | `200ms var(--ease-in)` |  |  |
| `--motion-toast-out` | `140ms var(--ease-out)` |  |  |
| `--toast-shift` | `8px` |  | toast rises 8px |
| `--e-toast` | `0 12px 28px -14px rgba(14,23,41,.35)` |  |  |
| `--z-toast` | `50` |  |  |

<a id="toasts-undo-undo"></a>

### Undo or confirm

| Change | Feedback |
|---|---|
| Status change, bulk status, reorder, remove from a menu, section change | Do it now, toast with Undo |
| Save of a form | Toast without Undo (Discard was the chance) |
| Send, publish, revoke, erase, refund, anything a customer sees | Confirm first, then a plain toast |
| Long-running job | Progress toast, then the result toast |

> **Rule.** The toast is never the only record. The result also shows in place (the row recovers, the release notice updates), so a missed toast loses nothing.

<a id="toasts-undo-copy"></a>

### Wording

- Past tense, the thing that changed, with numbers: "3 products set to Retired".
- The small line says the effect: "They no longer show to brands."
- Errors follow the error rule: the cause, then what did not happen.
- No exclamation marks, no "Success!".

<a id="toasts-undo-build"></a>

### Build it

`toasts`

```tsx
import { toast } from "sonner";
import { TOAST_DWELL_MS, TOAST_DWELL_UNDO_MS } from "@/lib/kit-motion";

toast.success("Changes saved", { description: "Navy birdseye two-piece, version 5", duration: TOAST_DWELL_MS });
toast("3 products set to Retired", {
  description: "They no longer show to brands.",
  duration: TOAST_DWELL_UNDO_MS,
  action: { label: "Undo", onClick: () => restoreStatus(ids) },
});

// layout: <Toaster position="bottom-right" gap={TOAST_GAP_PX} visibleToasts={3} theme="light" />
```

<a id="page-help"></a>

## Page help

Every page explains itself in the same place: a "?" in the top bar opens a drawer with what the page is for, what you can do, what it is connected to, and where the data comes from.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [orders](../../../phase-3/pages/orders/index.html), [support](../../../phase-3/pages/support/index.html), [settings-brand](../../../phase-3/pages/settings-brand/index.html)

<a id="page-help-trigger"></a>

### The trigger

*Live specimen on the docs page.* `.help-q` closed and open

- Top bar, right, just before the account avatar, on every page (phone: in the place bar).
- 32px circle (36px on the phone) with a 44px hit area; pressed state while the drawer is open (`aria-expanded`).
- The "?" is also the close button: the drawer opens below the top bar, so the trigger stays visible.

<a id="page-help-drawer"></a>

### The drawer

| Part | Holds |
|---|---|
| Summary | One or two sentences: what this page is for. |
| What you can do here | The actions, in the page's own words. |
| Connected to | Pages and systems this one feeds or reads (Laravel, the ERP, Stripe…), as links. |
| Where the data comes from | Tables, routes and sources, for example `supabase.dtc_campaigns`. This is where engineering subtitles move to. |

- Width `--help-w`, non-modal: the page stays usable behind it.
- Phone: a bottom sheet with the same four parts.
- Every page got real help copy in the designs; someone has to own it in the build (PROPOSALS, recurring item).

| Token | Value | Hex | Note |
|---|---|---|---|
| `--help-w` | `380px` |  |  |
| `--tooltip-delay` | `400ms` |  | hover delay before a tooltip; keyboard focus shows it at once (raised by overlays + page-help) |
| `--hovercard-delay` | `500ms` |  | (raised by overlays) |

<a id="page-help-vs-tooltip"></a>

### Help, tooltip or label

- **Labels never explain the UI.** A label names the thing ("Recommended price"), nothing more.
- **Tooltips** name icon-only buttons and show shortcuts; they wait `--tooltip-delay` on hover and appear at once on keyboard focus.
- **Help lines** under a field say what the value is for or where it comes from.
- **Page help** explains the page; it is where table names and routes belong.

<a id="page-help-build"></a>

### Build it

`PageHelp` is a client component: a non-modal `Sheet` positioned under the top bar (`top: var(--topbar-h)`, `left: var(--sidebar-w)`), opened by the `HelpTrigger` in the top bar. Each page exports its help content next to the page (`help.ts`), so copy changes stay out of the layout.

<a id="phone"></a>

## Phone patterns

The phone is its own composition, not the desktop squeezed. Same data and tokens, a different layout: lists instead of tables, bottom sheets instead of side sheets, and the one primary in thumb reach.

Seen on: [orders](../../../phase-3/pages/orders/index.html), [global-store](../../../phase-3/pages/global-store/index.html), [support-thread](../../../phase-3/pages/support-thread/index.html), [customer-detail](../../../phase-3/pages/customer-detail/index.html), [settings-audit-log](../../../phase-3/pages/settings-audit-log/index.html)

<a id="phone-rule"></a>

### Two compositions, one URL

> **Rule.** Every surface ships a desktop (1440) and a phone (390) composition. Never a responsive collapse: no width media queries to rearrange a layout. The server picks the composition from the request and serves it at the same URL; there is no redirect to `/m/`.

`one URL, two compositions`

```tsx
// app/(brand)/orders/page.tsx — server: pick the composition, share the data
import { headers } from "next/headers";
import { userAgent } from "next/server";

export default async function OrdersPage(props: PageProps) {
  const data = await loadOrders(props);                          // one loader, one schema
  const { device } = userAgent({ headers: await headers() });
  return device.type === "mobile"
    ? <OrdersPhone data={data} />                                  // data-composition="phone"
    : <OrdersDesktop data={data} />;
}
```

<a id="phone-anatomy"></a>

### Anatomy

*Live specimen on the docs page.* `.p-bar` · `.tabs` (scrolling) · `.k-list` · `.k-actbar` · `.tabbar`

| Part | Spec |
|---|---|
| Place bar | `--pbar-h`: place monogram, page title, "?" and at most one more icon button |
| Tab bar | `--tabbar-h` including padding; brand: Home · Orders · Products · Menu. Menu opens a bottom sheet with the whole registry. |
| Action bar | The one primary, full width at `--control-h-m-lg`, sticky above the tab bar on glass (`--actbar-blur`). Selecting rows swaps it to "3 selected" plus bulk actions. |
| Lists | `.k-li` rows at least `--li-min-h`; the product row adds a thumbnail. |
| Tabs | `--tab-h-m`, scroll sideways, never wrap. |
| Targets | `--control-h-m` minimum for anything tappable; text fields at `--text-input-m`. |

| Token | Value | Hex | Note |
|---|---|---|---|
| `--pbar-h` | `52px` |  | phone top bar (raised by specimens) |
| `--tabbar-h` | `62px` |  | phone tab bar incl. padding (raised by specimens) |
| `--control-h-m` | `44px` |  | phone minimum tap target |
| `--control-h-m-lg` | `48px` |  | phone primary / sheet buttons |
| `--li-min-h` | `72px` |  | phone list row without a thumbnail (.k-li) |
| `--tab-h-m` | `44px` |  | phone tab strip, thumb height |
| `--actbar-blur` | `8px` |  | phone action bar / phone save bar glass |
| `--text-input-m` | `16px` |  | phone text fields: iOS never zooms at >= 16px |
| `--vp-phone` | `390px` |  |  |
| `--vp-desktop` | `1440px` |  |  |

<a id="phone-swaps"></a>

### What changes on the phone

| Desktop | Phone |
|---|---|
| Table | List rows; filters behind one Filter button with a count |
| Side sheet | Bottom sheet (`.k-bottom`, grab handle, 88% max height); long forms full screen |
| Header buttons | One primary in the action bar; the rest in a "⋯" action sheet |
| Two-column detail | One column; the aside becomes a "Case details" or "More" sheet |
| Pagination | "Show 25 more" |
| Five KPI tiles | Three per row, 20px value |
| Select with a long list | Bottom sheet with search |
| Hover states | None: touch has no hover; pressed state instead |
| Kanban | Stage tabs with counts |

<a id="copy-voice"></a>

## Copy and voice

Write so that anyone on the team, or Frank's mum, understands it on first read. Plain British English, no exclamation marks, buttons that say what they do.

Seen on: [settings-brand](../../../phase-3/pages/settings-brand/index.html), [settings-brand-detail](../../../phase-3/pages/settings-brand-detail/index.html), [settings-api-keys](../../../phase-3/pages/settings-api-keys/index.html), [sign-in](../../../phase-3/pages/sign-in/index.html), [selling](../../../phase-3/pages/selling/index.html)

<a id="copy-voice-rules"></a>

### The rules

- **The mum test.** If it needs explaining, rewrite it. One idea per line.
- **Buttons say what they do**: verb plus thing. "Publish to brands", "Save draft", "Sync cloths". Never "Submit", "OK", "Yes".
- **Confirms echo the verb**: "Keep key" / "Revoke key".
- **Labels never explain the UI.** They name the thing. Explanations go in a help line or page help.
- **Errors name the cause** and say what did not happen. Never "Something went wrong".
- **No engineering in headers**: table names, routes and ticket codes move to page help.
- **No exclamation marks**, no "Oops", no "Success!".

<a id="copy-voice-rewrites"></a>

### Rewrites from the build

| Before | After | Where |
|---|---|---|
| Payload Tenants passthrough · DP-05 | Identity, storefront theme, contact and features for RJ Clothing | [Brand settings](../../../phase-3/pages/settings-brand/index.html) |
| One mirrored switch; no silent rollback. | One switch sets both sides. If either side refuses, nothing changes. | [Brand detail](../../../phase-3/pages/settings-brand-detail/index.html) |
| Dismiss token reveal | I have stored it | [API keys](../../../phase-3/pages/settings-api-keys/index.html) |
| `window.confirm` "Are you sure?" | Keep key / Revoke key | [API keys](../../../phase-3/pages/settings-api-keys/index.html) |
| Continue to RJ DTC ADMIN - STAGING | Sign in to RJ DTC Admin | [Sign in](../../../phase-3/pages/sign-in/index.html) |
| Broadcasts | Email broadcasts | [Email broadcasts](../../../phase-3/pages/campaigns-email/index.html) |
| Selling | How customers buy (the menu label) | [How customers buy](../../../phase-3/pages/selling/index.html) |
| Pending operator | Waiting on us | Support |
| Synced | In sync | ERP sync chips |
| Not started | Awaiting | Order fulfilment |
| user_brand_access · canonical in Laravel | RJ HQ adds and removes people | [Users](../../../phase-3/pages/settings-users/index.html) |

<a id="copy-voice-numbers"></a>

### Numbers, dates and codes

| Thing | Write | Not |
|---|---|---|
| Money in a list | £1,150 | £1150.00, 1150 GBP |
| Money being edited | £1,150.00 | 1150 |
| Other currencies | €1,320 · $1,450 (after GBP) | USD first |
| Dates | Today 14:20 · Yesterday · 2 Oct | 2026-10-02T14:20:00Z |
| Codes, handles, versions, ids | Plex Mono: `HS 81045`, `navy-birdseye-two-piece`, `v4` | Plex Sans |
| Counts | Add up across the page | A chip that says 12 next to a list of 11 |

<a id="copy-voice-content"></a>

### Real content only

- Real menswear made-to-measure content: the kit's product set (Navy birdseye two-piece suit, HS 81045, £1,150 …), real cloth codes and mills.
- Staff by first name and initials. No real customer names, emails or phone numbers.
- Never "TEST", "Lorem", "foo", "Sample product", "Untitled", grey boxes where a garment belongs, or staging junk data.

> **Note.** Illustrative values in the designs are not data: combo prices, floor amounts, staff emails, MCP client names, QR images, option lists on New brand, API key names and the revoked count (PROPOSALS summary). Read them from the real source.
