# Pixelize Design Guide — how to design a page with `pixelize-design-library`

For any developer or LLM building UI in a consumer app. `COMPONENT-GOTCHAS.md` (next to this file)
says **which component does what**; this file says **how to compose them into a page that looks
designed**. Read both before building a screen. Everything here is copyable as-is.

**App conventions win.** If the app has its own density/size file (hrms `core/layout/density.ts`,
tickets `layoutDensity.ts`), use it — it encodes these same rules. Otherwise the library's
`PageHeader`, `StatCard` and `QueryStateBoundary` already do.

## 1. The look

Calm, dense, professional SaaS admin — Linear/Stripe-dashboard, not a landing page.

- **Hierarchy comes from type and whitespace, not boxes.** One surface per level. Never nest a
  card inside a card; inside a card, separate with spacing or a `Divider`.
- **One primary action per view.** One `variant="solid"` button in the page header; everything
  else `outline` or `ghost`. The only exception is a composer's Send button.
- **Left-aligned, scannable.** Titles and forms left-aligned; numbers right-aligned in tables.
- **Color means something.** Neutral by default; brand color for the primary action and selection;
  semantic color only for status. No decorative gradients, no colored section backgrounds.
- **Content cards float, data surfaces are ruled.** `Card` is borderless by default
  (`variant="elevated"`: `background[50]` lifted by the soft `shadows.card`). Tables, filter bars,
  list panes and fields keep their 1px `boxborder[200]` rule; a `Card` that frames a table or a
  list of rows takes `variant="outline"`. Heavier shadows only on floating things (menus,
  popovers, modals) and on hover of a card that is itself clickable.
- **Paint the page with `backgroundColor.base`.** In light mode it sits one step (1.09:1) below
  the card, which is what gives a borderless card its top edge; `backgroundColor.main` is a
  component surface and can be as white as the card.

## 2. Spacing (Chakra units, 1 = 4px)

| Where | Value |
| --- | --- |
| Page padding | `px={{ base: 4, md: 6 }} py={{ base: 4, md: 5 }}` |
| Between page sections | `gap={5}` (a `Flex direction="column"`) |
| Between cards in a grid | `gap={4}` |
| Inside a card | `Card`'s own padding — don't add more; `padding="none"` (or `"2xs"`/`"xs"` for dense) instead of `sx={{ p }}`, `bodyProps={{ p: 0 }}` for edge-to-edge content |
| Between form fields | `gap={4}` vertical, `gap={3}` side by side |
| Inline clusters (tags, meta, icon + text) | `gap={1.5}`–`gap={2}` |
| Page header → content | the section gap (`gap={5}`) — no extra margin |

Never raw `px` strings (`"13px"`, `"18px"`) for spacing or size; never `margin` hacks to align.
Borders are the one px value: `borderWidth="1px"`. Fixed widths (side panel, list pane) in `rem`: `templateColumns={{ base: "1fr", lg: "minmax(0, 1fr) 20rem" }}`.

## 3. Type

| Role | Props |
| --- | --- |
| Page title + subtitle | `PageHeader` (18px/600 h1 by default, `sm` muted subtitle; `titleSize="xl"` for a top-level page) — don't hand-build it |
| Section / card title | `Heading as="h2" fontSize="md" fontWeight={600} color={colors.header[900]}`, in `Card`'s `header` prop |
| Body | `Text fontSize="sm" color={colors.text[800]}` |
| Label / meta / helper | `Text fontSize="xs" color={colors.textMuted}` |
| KPI tile | `StatCard` — pass `trend` and `goodDirection`; the delta is green when the change is *good* (a shorter sales cycle is green); `iconTone` tints the icon chip (unset = brand primary) |
| Numbers in tables/columns | `sx={NUMERIC_TEXT}` (root export, tabular figures) so digits align; `Table` columns take `align: "end"` |
| Code, ids, shortcuts | `fontFamily="mono"` (a real monospace stack); shortcut hints are `KbdShortcut`, never a hand-styled `<kbd>` |
| Overline (rare) | `textStyle="overline"` |

**`textStyle` sets size, line height, weight and tracking in one prop.** Prefer it to hand-picking
the four: `<Text textStyle="label">`. The ramp (`TEXT_STYLES`, also a root export):

| `textStyle` | Use |
| --- | --- |
| `title` · `heading` · `subheading` | page / section / card titles (24 · 20 · 16px) |
| `body` | running text (14px) |
| `ui` · `label` | control and form text (13px, weight 500 · 600) |
| `caption` | helper and meta text (12px) |
| `overline` | small-caps eyebrow (11px, uppercase) |

Chakra size keys only (`xs-minus xs xs-plus sm md lg xl 2xl`) — never `fontSize="14px"`.
`xs-plus` (13px) is the control size; `xs-minus` (11px) is for chips, counts, keyboard hints and
overlines only; nothing is smaller. Weights: 400 body, 500 labels, 600 titles, 700 KPI numbers and the `title` step only.

**Radius and elevation come from the role, not the scale step.** `RADIUS_ROLE` (root export) names
the radius a surface plays: `control`, `field`, `tooltip` and `media` 8px · `option` 6px · `card`,
`float`, `dialog` and `sheet` 12px · `chip` full. Anchored popups (`Menu`, `Popover`, `Select`,
`Dropdown`) all take the 12px radius, and `Menu` and `Popover` the `md` (float) shadow, so a panel
you build yourself takes `borderRadius={RADIUS_ROLE.float}` and `boxShadow="md"`. A resting card is
`shadows.card`; heavier shadows belong to floating things only.

## 4. Color — pick by role

`const { colors } = useCustomTheme();` Never a hex, never `gray.500`/`blue.600` literals — those
stay light in dark mode.

| Need | Token |
| --- | --- |
| Page canvas | `colors.backgroundColor.base` |
| Card / panel surface | `colors.background[50]` |
| Your own overlay (popover, panel, `ModalContent bg=`) | `colors.surfaceRaised` — divider inside it `raisedRule(colors)`; `Popover*` and `Menu*` come from the package root (Primitives barrel) |
| Subtle inset (toolbar strip, hovered row, code) | `colors.backgroundColor.secondary` |
| Border / divider | `colors.boxborder[200]` |
| Heading ink | `colors.header[900]` |
| Body ink | `colors.text[800]` |
| Secondary text, icons | `colors.textMuted` |
| Brand-colored text/link | `colors.accentText` — never `primary[500]` |
| Status text/icon | `colors.semanticText.success \| error \| warning \| info` |
| Status chip | `<Tag label="Won" colorScheme="green" />` (subtle, `sm` by default) — never a painted `Box`. Tone → `colorScheme`: success `green`, warning `orange`, error `red`, info `blue`, neutral `gray`. Quiet rows: `<Tag tone="success" display="dot" label=… />`; map an app's status vocabulary once with `<StatusTag status map label display />` |
| Selectable list row (inbox, picker) | `listRowBackgrounds(colors)` for hover/selected + `sx={focusVisibleStyles(colors)}`; pad the list `p={2}` so the ring isn't clipped |
| Option row in a dropdown/menu/select | `optionRowFill` / `optionRowCursorSx` (fill = cursor only) + `optionRowInk` + a trailing `Check` for the selected row — never a selected fill |
| Any filled surface you paint | `resolveFilledSurface(colors.<scale>, colors)` → `{ fill, color }` |
| Icon tint chip, avatar tint | `bg={colors.primary.opacity[8]}` + `color={colors.accentText}` (`opacity` exists on `primary`/`secondary`/`tertiary` only) |

**`Tag` — pick `variant` and `colorScheme`/`tone` deliberately, or two chips read as the same thing:**

| Need | Use |
| --- | --- |
| Status (draft, active, error, in review, …) | `tone="neutral\|info\|success\|warning\|error"` — wins over `colorScheme` when both are set |
| Category, label or filter (not a state) | `colorScheme` alone — brand (`primary`/`secondary`/`tertiary`) or a utility scale; never `tone` for a non-status label |
| Default, everyday chip | `variant="subtle"` (the default) — low-emphasis, sits on any surface |
| Must stand out (a status that needs attention) | `variant="solid"` |
| Dense list, table cell, or an already-colored surface | `variant="outline"` — no fill to compete with the surface |

`secondary` and `tertiary` are two distinct accents on every brand except `zinc`, which is
near-grey and has almost no hue to turn (see `COMPONENT-GOTCHAS.md`) — on `zinc`, reach for a
utility scheme to tell two categories apart.

## 5. Controls — two scales, `sm` by default

Fields (`TextInput`, `InputTextArea`, `NumberInput`, `Select`, `Search`, the
`DatePicker` family) sit on `CONTROL_SIZES` — `xs` 32px · `sm` 38px · `md` 40px · `lg` 48px.
`Button` and every button-like trigger (`SegmentedControl`, `Tabs`, `TableToggle`, `Dropdown`)
sit on the separate `BUTTON_SIZES` scale — `xs` 24px · `sm` 32px · `md` 40px · `lg` 48px. Both
**default to `sm`**, so on a desktop page **don't pass `size`**. `RangeSlider` takes the same
size names but keeps its own track and thumb values. They line up at `md`/`lg`; at
`sm` a button reads 6px shorter than the field beside it.

| Button role | Props |
| --- | --- |
| Primary (one per view) | `variant="solid" colorScheme="primary"` |
| Secondary | `variant="outline" colorScheme="gray"` |
| Tertiary / toolbar / row action | `variant="ghost" colorScheme="gray"` |
| Destructive (confirm step only) | `<AlertDialog tone="danger" confirmButtonText="Delete" … />` — red Confirm, neutral Cancel |
| Busy | `isLoading` — spinner only, width kept; add `loadingText` only if words must show |

Icons: `lucide-react` only, `size={16}` in controls, `size={14}` inline with `xs` text. Every
icon-only button gets an `aria-label` and a `ToolTip`. Inline or panel busy indicator: `Spinner`
(themed; never Chakra's raw one).

**Labels:** use each field's own `label` / `helperText` / `errorMessage` — never wrap a library
input in `FormControl` + `FormLabel`. In a settings row (title + description left, control right)
the row title is the label: `<Text as="label" htmlFor={id}>` pointing at the field's `id`.

## 6. Page anatomy

```
┌ Page header ─ title + subtitle ─────────────── secondary · secondary · PRIMARY ┐
├ Toolbar ── search · filters ───────────────────────────── view toggle · count ─┤
│ Content ── table / card grid / form sections                                    │
└─────────────────────────────────────────────────────────────────────────────────┘
```

```tsx
<Flex direction="column" gap={5} px={{ base: 4, md: 6 }} py={{ base: 4, md: 5 }}>
  <PageHeader
    title="Deals"
    subtitle="248 open · $1.2M pipeline"
    actions={<>
      <Button variant="outline" colorScheme="gray" label="Export" />
      <Button variant="solid" colorScheme="primary" label="New deal" />
    </>}
  />
  <QueryStateBoundary isLoading={isLoading} error={error} onRetry={refetch}
    isEmpty={deals.length === 0} empty={{ icon: Handshake, title: "No deals yet" }}>
    {() => <DealsTable deals={deals} />}
  </QueryStateBoundary>
</Flex>
```

`PageHeader` also takes `breadcrumbs`, `leading` (avatar), `titleAddon` (status `Tag`), `meta` (icon
row) and `tabs` — a record page's whole header. Actions wrap under the title on their own when they do
not fit (`actionsLayout="auto"`), so don't hand-build a stacked variant. KPI rows are a `SimpleGrid` of `StatCard`s; card
sections are `Card` with the title in `header`.

| Pattern | Component — don't hand-build it |
| --- | --- |
| Multi-step flow (send, import, onboard) with validated Continue and a review step | `WizardDialog` (`Wizard` inline; `chrome="inline"` inside a `WorkspaceWindow` or other host that is already the dialog) — don't hand-roll Back / Continue / Finish around `StageProgress`; each step's `validate` returns its error messages, future steps stay locked until valid, each step's `summary` drives the review |
| Rich-text template with `${field}` placeholders | `Editor mergeFields={…}` — not a `Select` + caret tracking + dialog beside it; use its `label` / `helperText` / `errorMessage` |
| A PDF behind auth, shown inline | `PdfViewer getBlob={…}` — the toolbar's Open / Download then work with the token, so don't add your own buttons |
| Data grid | `Table` — defaults to `variant="studio"` (flat small-caps header, one row rule, the select slot resting on the row number, status as a pill). Pass `variant="classic"` for the banded header and per-column rules; don't restyle the header or rows by hand |
| Toolbar: search · filters · active-filter chips · count · actions | `FilterBar` (`search`, children, `activeFilters` + `onRemoveFilter`/`onClearAll`, `summary`, `actions`) |
| Read-only label/value grid (record details, key facts) | `DescriptionList` (`items`, `columns`, `layout="inline"` for a side panel, `copyable`, `isLoading`) |
| Settings row (title + description left, control right) | `SettingRow` inside a `SettingsSection` (`isDanger` for the danger zone, `action` for a header-right button) |
| A person: avatar + name (+ role/email, presence, remove) | `PersonChip` (`orientation="vertical"` for a stacked profile block) |
| An avatar on its own, or a stack of them | `Avatar` / `AvatarGroup` — never pick the fill, it is derived from `name` |
| Unsaved-changes bar | `SaveBar isOpen={isDirty} onSave onDiscard isSaving` |
| Create / edit a record in a side panel | `FormDrawer` (`sections`, `errors`, `isDirty`, `isSubmitting`) — don't hand-assemble Drawer + footer + discard confirm. A nested confirm dialog in any `Drawer` / `Modal` goes in `portalChildren` |
| Detail or sub-table under a table row | `Table renderRowDetail` (+ optional `expandedRows`) — don't put an Accordion outside the Table |
| Tabs that navigate between routes | `Tabs items=[{ id, label, href }] linkAs={RouterLink} value={currentId}` — never hand-build `Box as={Link}` tabs |
| Completion / capacity ring | `CircularProgress` (`ProgressBar` stays for linear) |
| Per-day activity (leave, tasks, shifts) | `CalendarGrid` with `events` mapped from your data — don't hand-roll a month grid |
| Voice note capture | `AudioRecorder`; `MediaAudioPlayer` for playback |
| Previewing an attachment (image, video, audio, PDF, Office, text, archive) | `MediaViewer` (`items`, `isOpen`, `onClose`) — omit `index` and it owns navigation itself |
| Notification tray | `NotificationCenter` — bell plus a two-tab panel (Needs you, Activity) with inline approvals and undo; `NotificationBell` alone is just the badge. `NotificationPanel`, `NotificationList` and `NotificationItem` are deprecated |
| Notification inbox page | `NotificationInbox` (tabs, search, type filter, bulk actions, reading pane, `variant="phone"`) — share one `NotificationCenterProvider` with the bell |
| Notification settings page | `NotificationPreferences` (`channels`, `groups`, `values`, `onChange`; locked cells, autosave with rollback) |
| Confirm that needs the name typed to arm it | `ConfirmDialog confirmationText="delete-me"` — `onConfirm` does **not** close; call `onClose` yourself |
| Scrim over the page or one panel while something runs | `Backdrop open loader` — `variant="dim"` by default. Add `container` to scope it to the nearest positioned ancestor, `allowBackgroundInteraction` to let clicks through, `onClick` to dismiss |
| A short "saved" / "copied" / "failed" notice | `useToaster().showToast({ title, status })` (`PixelizeThemeProvider` and `withTheme` mount the provider) — not a `ToolTip`, not an `AlertDialog` |
| Page through a list that is not a `Table` | `Pagination` (controlled: numbered pages, previous / next, optional rows-per-page picker) |
| Faceted filter column (checkboxes, radios, ranges) | `FilterSidebar` (`sidebarOptions`; controlled only when both `selected` and `onChange` are set) |
| Pick one of a few options as cards | `ChoiceCardGroup` of `ChoiceCard`; payment methods are `PaymentOptionGroup` of `PaymentOption` |
| On / off setting | `ToggleSwitch` — `Switch` is deprecated, a two-option pill is `SegmentedControl` |
| One-time code | `PinInput` · free-text chips: `TagInput` · email chips: `MultiSelect` (it has no options list) |
| Copy a value to the clipboard | `CopyButton` (icon, button or menu-item layout) — not a hand-built `navigator.clipboard` button |
| Plan state and upsell | `PlanBadge` (resolves org plan data into a `PlanPill`), `UpgradeButton` for the CTA, `LimitReachedModal` when a limit is hit |
| Charts | `ApexBarChart` · `ApexLineChart` · `ApexAreaChart` · `ApexPieChart` · `ApexRadialChart` · `ApexPolarChart`, inside a `Card`; default series colours follow the theme, so don't pass hexes |
| Whole admin screens (roles matrix, user directory, org list, sign-in history) | `RolesPermission` · `UserDetails` · `OrganizationDetails` · `SignInActivityTable` — don't rebuild them |
| "+N more" on an overflowed list | `MoreItems` |

**Reach for `Backdrop` in new work** — the `dim` scrim (black at 0.5, no blur, 225ms in /
195ms out) replaces every hand-rolled `position: fixed` overlay. Never build your own
scrim `Box`. `Loading` is the older API and stays on the heavier house scrim by default; pass
`variant="dim"` to route it through `Backdrop`, or just use `Backdrop` directly.

### Parts you reach for through their parent

These are root exports, but each belongs to a parent component; start from the parent unless a row
here says otherwise.

| Part | Belongs to | Use it for |
| --- | --- | --- |
| `DrawerHeader` · `DrawerBody` · `DrawerFooter` | `Drawer` | the only children a `Drawer` renders; style props go straight on each slot. For a create / edit form use `FormDrawer` instead |
| `ProfileCardHeader` · `ProfileCardBody` · `ProfileCardFooter` | `ProfileCard` | the only children a `ProfileCard` renders; any other sibling is dropped |
| `StageItem` | `StageProgress` | one chevron, bar or pill stage; `StageProgress` renders it, so don't lay stages out by hand |
| `FilePreviewTrigger` | `FilePreview` | a thumbnail button that opens one file in a modal; for several attachments use `MediaViewer` |
| `MediaScrubber` · `MediaStatusPanel` · `MediaDocumentStage` | `MediaViewer` | building a custom media UI: a `role="slider"` seek bar (`onSeek`), the no-preview / error panel (`tone="warning"` for errors), and the document stage driven by a `resolveDocument` you supply |
| `LazyWrapper` | `React.lazy` | a `Suspense` boundary with the themed spinner; pass `fallback` to replace it |
| `ScrollbarBox` | any scrolling region | a themed scroll container; give it an `aria-label`, because it is a keyboard stop while it scrolls |
| `AppearanceSettings` | a settings page | the theme, colour-mode and font-size controls in one panel; brand and font size are controlled, the colour-mode switch reads the theme context |
| `ApexPolarChart` | `ApexPieChart` | independent magnitudes as wedges of equal angle and varying radius; values that add to a whole belong in `ApexPieChart` |

### Navigation shell

`SideBar` is the rail, `NavigationBar` the top bar, `NavLauncher` the full-height "go anywhere" panel.
**The app owns the registry, permissions and persistence**; the library owns the look and the keyboard.

- Keep one registry (modules → screens) and derive the rail, the launcher and route guards from it.
  Filter by permission *before* passing `sections` to `NavLauncher`; use `isLoading` while access is
  unknown, `linkAs` for the router link, and `extraResults` + `isSearching` for remote (entity) results.
- Open the launcher with `<NavigationBar leading={<LauncherTrigger onClick isOpen />} />` — never inside
  `navigationBarText.text`. Neither component binds the chord: the app owns the `mod+k` listener
  (capture phase) and passes `shortcut={false}` to any page `Search`. Bind it to *open* the launcher only:
  once open, the launcher refocuses its own field on Cmd/Ctrl+K and `/`, and shows no open-chord hint
  (`openShortcutHint` is deprecated).
- Pins live in the app (server or local). Pass them to `NavLauncher` (`pinnedIds`, `onTogglePin`) *and*
  to `SideBar pinnedItems` + `onUnpin` so the rail shows what the launcher pinned. Persist collapse with
  `useSidebarPrefs`, and pass `autoCollapse={false}` if the app, not the rail, decides.
- Replacing a 9-dot app switcher in a nav bar: `<LauncherTrigger display="icon" />` (36px) in `moreIcon`;
  keep the chip variant (`leading`) where there is room.
- In a phone-width app bar use `<OrgSwitcher display="auto" />` (avatar below `md`) next to the bell,
  `LauncherTrigger display="icon"` and the avatar menu; keep every icon control 36px or less and
  `flexShrink={0}`. The title truncates first.
- Below `lg` the launcher is a Menu / Quick access / Apps tab bar over one panel (search stays a single
  stacked list), so design for phone and tablet with that structure, not a long scroll. Pass
  `headerActions` for the app's own icon button (help, customise) with an `aria-label`.
- Show the suite's apps with `appsVisibility="always"` and drive plan gating by editing `apps` /
  `locked`.
- For Spotlight-style search pass `searchMode="spotlight"` plus `synonyms` for the app's jargon
  (`pf`, `wfh`), `scopes` for its entity groups, up to 5 `quickActions`, copyable `facts` on reference
  items and `answers` for one-line lookups. Reuse `LauncherSearchEngine` for any other in-app search
  instead of writing a matcher.
- Show shortcut hints with `KbdShortcut`, never a hand-styled `<kbd>`.

## 7. Every page has four states

Design all four, not just the happy path.

`QueryStateBoundary` picks one per content region (loading > no access > error > empty > data). Pass
`children` as a function when it reads data that may still be undefined. `isForbidden` shows the
no-access panel between loading and error; `hasData` keeps content on screen during a refetch.

| State | Render |
| --- | --- |
| Loading | `loadingFallback` of `Skeletons` shaped like the content (rows, cards, `StatCard isLoading`) — never a lone centered `Spinner` for a page |
| Empty (first run) | `empty={{ icon, title, description, action }}` — the primary action as `action` |
| Empty (filtered) | `FilteredEmptyState` (`filterCount`, `onClearFilters`, `onRemoveLastFilter`; `scale="card"` inside a panel) — in the boundary pass `isEmpty` + `isFiltered` |
| Error | `ErrorState` (the boundary's `errorTitle`/`errorDescription`/`onRetry`): what failed + an outline "Retry" (+ `action` "Back to …" on a detail page) |

Page header stays visible in every state; only the content region swaps. Keep **one solid button
on screen**: when the whole page is empty, the `EmptyState` action is the solid one and the header
hides its primary; when only one section is empty on a populated page, that section's action is
`outline`. Header actions that need data (Export, bulk actions) are disabled outside the data state.

## 8. Never

- Hex colors, Chakra color literals, `px` font sizes, inline `style={{}}` for theme concerns.
- Raw `@chakra-ui/react` imports — layout primitives (`Box`, `Flex`, `Grid`, `Heading`, `Text`,
  `Menu`…) come from `pixelize-design-library` too. Anything the library owns a component for
  (`Tabs`, `Accordion`, `Modal`, `Card`, `Select`, `TextInput`, `Skeletons`…) has no Chakra
  passthrough at all — the owned component is the only one.
- Reaching for `SelectV2` (`@deprecated`), `SearchSelect` or `SelectSearch` (not deprecated, but not the
  preferred picker) in new work.
  One picker now: `Select` (`native={false}` for the themed, searchable, multi popup).
  `Dropdown` is the exception — it is an action menu, not a picker, and is not replaced.
- A card inside a card; a border on a content `Card` (it is borderless by default), or any resting
  shadow heavier than `shadows.card`.
- Painting a whole table cell with a status color — use a `Tag` in the cell.
- Centered page titles, hero-sized headings, `size="lg"` buttons, or `size` props restating the default.
- More than one solid button in a view; a destructive action without `AlertDialog`.
- A page with only the data state.
- **Work around a library gap silently.** If a component is wrong or can't do what the screen
  needs, report it (see "Found a gap or a bug?" in [COMPONENT-GOTCHAS.md](COMPONENT-GOTCHAS.md)) so
  it is fixed once, here, and remove your workaround when the issue closes.


## 9. Reference pages

Full screens built only from library exports, each with all four states, live in the library
playground's **Page examples** section (source: `src/Pages/examples/` in the Micro-Components repo —
not shipped in this package). Start from the closest one:

| Screen you're building | Reference | Shows |
| --- | --- | --- |
| Dashboard / overview | `dashboard.tsx` | `StatCard` row, chart `Card`, stage list with `ProgressBar`, task list, `QueryStateBoundary` |
| List of records | `records.tsx` | saved views, `FilterBar`, `Table` (`isGlobalSearch={false}`, `Tag` cells, row selection), `BulkSelectionBar` (count, facts, ranked actions, clear), `AlertDialog tone="danger"`, create `Drawer` |
| One record | `recordDetail.tsx` | `PageHeader` with breadcrumbs/avatar/meta, `Tabs` (declarative `items` with `count` badges), `DescriptionList` (grid + inline side panel), `Timeline` (per-event `icon`/`description`), `ErrorState` + back |
| Settings / long form | `settings.tsx` | `SectionRail` (rail from `lg`, `Select` below, dirty dots), `SettingsSection` + `SettingRow`, `SaveBar`, danger zone |
| Notification inbox and settings | `demo.notificationInbox.tsx` (`/notificationinbox`), `demo.notificationPreferences.tsx` (`/notificationpreferences`) in the **Feedback & overlays** group | `NotificationInbox` desktop, phone, shared provider with the bell and all load states; `NotificationPreferences` with locked cells, autosave rollback and read-only |
| Board | `pipeline.tsx` | `KanbanBoard` with custom cards and column totals (`height`/`maxH` to size it) |
| Inbox / master–detail | `inbox.tsx` | selectable list rows, conversation pane, composer, mobile list↔detail |
| App launcher (browse, pins, recents, search with actions) | `launcher.tsx` (`/examples/launcher`) | `NavLauncher` with an app-owned registry and role-based permission filter, pinned / frequent / recent ids, `linkAs`, remote results via `extraResults`, `isLoading`, Classic / Spotlight toggle, synonyms, scopes, quick actions, company facts with Copy, answers |
| Wizard / multi-step dialog | `wizard.tsx` (`/examples/wizard`) | `WizardDialog`, inline `Wizard` and `WizardDialog chrome="inline"` in a `WorkspaceWindow`: per-step `validate` messages, locked future steps, error summary, `summary`-driven review, `isFinishing` |
| Template editor with merge fields | `templateEditor.tsx` (`/examples/template-editor`) | `Editor mergeFields`: grouped Insert field, chips, unknown-field error chips and `onUnknownTokens`, Make dynamic, side preview, label / helper / error |
| Shop front (home → listing → product → bag → checkout → order) | `storefront/` | `ProductCard` grid / rail / quick-add, `ProductDetails` + `StickyBuyBar`, `CartItem`, `OrderSummary`, `CouponField`, address/payment groups, `OrderStatusTimeline`, `BagBar`; mobile frame + desktop |

## 10. Shop screens (e-commerce)

For catalogue, product, bag and checkout screens use the commerce kit, not `Card` + `Table`. Detail
and rules: [COMPONENT-GOTCHAS.md](COMPONENT-GOTCHAS.md) "Commerce kit".

- **Model the product once** as `axes` + `variants` (`OptionAxis[]`, `ProductVariant[]`) and hand the
  same objects to `ProductCard`, `QuickAddSheet`, `VariantSelector` and `ProductDetails`. Any vertical
  works — colour/size, pack size, storage, finish, shade — because the components only see axis ids.
- **Mobile first.** `ProductCard density="compact"` in a 2-up `ProductCardGrid`, a swipe `MediaGallery`,
  `StickyBuyBar` and `BagBar` pinned at the bottom, sheets (`QuickAddSheet`, filters) instead of popovers.
- **One loud action per view:** the single brand-filled button is Add to bag / Place order. Wishlist,
  Buy now, Remove and Move-to-wishlist are soft or ghost.
- **Be honest:** "N left" only from real stock, delivery as a date (`PincodeChecker`), MRP + discount
  from `PriceBlock`, the tax note and legal-metrology block in `DetailSections`.
- **Four states per screen** as usual: `ProductCardSkeleton` / `CartItem isLoading`, first-run and
  filtered-empty `EmptyState`, `ErrorState` with Retry.
