# Vendor admin — list page pattern

## Before using this pattern

Read `node_modules/@spark-web/design-system/patterns/vendor-admin/CLAUDE.md`
fully before implementing this pattern. The layout shell / content region, the
`Heading level="2"` page-title rule, the side-panel-vs-full-page rule, the
infinite-scroll-vs-pagination rule, the bulk-action toast rule, and the badge
tone mapping defined there all apply to this pattern and take precedence over
anything below.

Working in vendor-portal? Apply the substitutions in
`node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.

## What this pattern is

A full page for displaying a searchable, filterable list of records on the
vendor-admin surface — leads, applications, referrals, and similar — rendered
inside the vendor-admin shell (fixed Header + left NavBar). It supports two
record-detail interactions (a slide-in side panel or, where the surface rules
permit, a full page) and two list-loading strategies (infinite scroll or
pagination). This is the most common page type on the vendor-admin surface.

## When to use this pattern

Use this pattern when the PRD describes any of the following:

- A list of records a vendor / installer / partner can search, filter, or sort
- A management interface where records can be viewed, assigned, exported, or
  bulk-acted upon
- The words "list", "manage", "view all", "leads", "applications", "referrals",
  "records", or "results"

---

## Component docs to read

Read these before implementing — they own the component-level rules:

- `node_modules/@spark-web/heading/CLAUDE.md` — Heading API, `level` values
- `node_modules/@spark-web/badge/CLAUDE.md` — status / count tone mapping
- `node_modules/@spark-web/button/CLAUDE.md` — action Button (Export) API,
  `loading`, prominence/tone
- `node_modules/@spark-web/icon/CLAUDE.md` — icon sizing and tones (SearchIcon,
  action icons)
- `node_modules/@spark-web/field/CLAUDE.md` — Field API, `labelVisibility`,
  `disabled` context (DatePicker reads it)
- `node_modules/@spark-web/date-picker/CLAUDE.md` — DatePicker API; **must be
  wrapped in a Field** (it reads `disabled` from Field context)
- `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect filter
  dropdowns; grouped `options` shape
- `node_modules/@spark-web/a11y/CLAUDE.md` — VisuallyHidden (accessible labels)
- `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput + InputAdornment
  search field
- `node_modules/@spark-web/data-table/CLAUDE.md` — DataTable API,
  `createColumnHelper`, `isLoading`, `emptyState`, row click
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking and gap
- `node_modules/@spark-web/box/CLAUDE.md` — flex layout utilities
- `node_modules/@spark-web/columns/CLAUDE.md` — responsive multi-column layout
- `node_modules/@spark-web/text/CLAUDE.md` — Text API
- `node_modules/@spark-web/alert/CLAUDE.md` — page-level feedback Alert
- `node_modules/@spark-web/checkbox/CLAUDE.md` — row-select Checkbox
  (multi-select lists only)
- `node_modules/@spark-web/modal-dialog/CLAUDE.md` — export / assign modals

---

## Page structure

```
Page (renders into the vendor-admin shell content region — no Header/NavBar here)
  Page Stack                     — gap="large", margin="xxlarge" marginBottom="none", height="full", overflow hidden
    Alert (conditional)          — page-level success/error feedback; omit if no page-level actions
    Title row (Box flex)         — Heading level="2" + optional count Badge (left); right-aligned action Button(s)
    Filter bar (Columns)         — date-range (Field+DatePicker ×2), multi-select filter, search TextInput — REQUIRED if filtering exists
    Scroll region (Stack)        — position relative, height full, overflow hidden — the side-panel/toast anchor
      Scroll wrapper (Stack)     — overflowY scroll — scrolls the table, not the page
        DataTable                — items/columns via createColumnHelper, isLoading, emptyState
      SidePanel (conditional)    — slide-in detail (COMPONENT GAP — primitives placeholder) — single selected record only
      BulkActionToast (cond.)    — bottom-anchored bulk actions (COMPONENT GAP — primitives placeholder) — multi-select, ≥1 row selected
    Pagination (conditional)     — only when paginated (COMPONENT GAP — primitives placeholder); omit when infinite-scroll
    Modals (export / assign)     — declared in JSX, open via state
```

---

## Section 1 — Page Stack (outer wrapper)

The page body is a single `Stack` that fills the shell content region and owns
the page rhythm. Per the surface rules' spacing rule, list pages use
`gap="large"` with `margin="xxlarge" marginBottom="none"` so the scroll region
reaches the viewport edge, `height="full"`, and clip overflow so the inner
scroll region — not the page — scrolls.

```tsx
<Stack
  gap="large"
  margin="xxlarge"
  marginBottom="none"
  height="full"
  overflow="hidden"
>
```

Rules:

- This is a plain `Stack` — **never** `PageHeader` and **never** `Container`.
  Container is reserved for centered form / settings pages (see form-page.md).
- All spacing comes from tokens (`gap`, `margin`) — no raw pixel margins.

---

## Section 2 — Page-level feedback (conditional)

When a page-level action (CSV export, bulk assign / close) can produce success
or error feedback, render an `<Alert>` as the first child of the page Stack,
above the title row. Only rendered when feedback state exists.

```tsx
import { Alert } from '@spark-web/alert';

{
  successMessage && (
    <Alert tone="positive" closeLabel="Close" onClose={clearSuccess}>
      {successMessage}
    </Alert>
  );
}
```

Rules:

- `tone="positive"` for success, `tone="critical"` for failure.
- An `Alert` with `onClose` requires `closeLabel` (discriminated union — see the
  Alert doc).
- Feedback state is local component state, reset on filter change or navigation.
- If the page has no page-level actions, omit this section entirely.

---

## Section 3 — Title row

A flex row: the page title `Heading level="2"` (with an optional count / limit
`Badge` beside it) on the left, and right-aligned page-level action Button(s).
Per the surface rules, vendor-admin uses `Heading level="2"` for the page title
— **never** `PageHeader` and **never** `level="1"`.

```tsx
<Box
  display="flex"
  justifyContent="spaceBetween"
  alignItems="center"
  flexDirection={{ mobile: 'column', tablet: 'row' }}
>
  <Box display="flex" alignItems="center" gap="medium">
    <Heading level="2">{pageTitle}</Heading>
    {countShown && <Badge tone="info">{`${count} results`}</Badge>}
  </Box>

  <Box
    gap="medium"
    display="flex"
    marginTop={{ mobile: 'medium', tablet: 'none' }}
  >
    <Button
      onClick={onExport}
      loading={isExporting}
      prominence="none"
      tone="neutral"
    >
      Export all as CSV
      <ArrowCircleRightIcon />
    </Button>
  </Box>
</Box>
```

Rules:

- The title is always `Heading level="2"`. Sub-section titles step to
  `level="3"`.
- Page-level actions (Export, etc.) are right-aligned in their own `Box` — never
  in the content/scroll area. Use the Button `loading` prop for in-flight state;
  do not hand-roll a "Exporting… %" label unless the consumer's export hook
  exposes progress (overlay concern).
- An optional count / limit indicator is a `Badge` on the left, beside the
  title. Use a tone from the surface badge tone mapping (typically `info` for a
  neutral count; `caution` when approaching a limit).
- If the page has no page-level actions, render the title row with the heading
  only.

---

## Section 4 — Filter bar

Renders the date-range pickers, the multi-select filter, and the search input in
a horizontal row that collapses on small screens. Per the surface rules, filter
rows use `gap="medium"`.

```tsx
<Columns gap="medium" collapseBelow="tablet">
  {/* Date range — always first: From then To */}
  <Field label="From" labelVisibility="hidden">
    <DatePicker value={startDate} onChange={setStartDate} />
  </Field>
  <Field label="To" labelVisibility="hidden">
    <DatePicker value={endDate} onChange={setEndDate} />
  </Field>

  {/* Multi-select filter */}
  <Box>
    <VisuallyHidden as="span" id="list-filter-label">
      Filters
    </VisuallyHidden>
    <MultiSelect
      aria-labelledby="list-filter-label"
      options={filterOptions}
      placeholder="Filter by..."
      onChange={setFilters}
    />
  </Box>

  {/* Search — always last */}
  <Field label="Search" labelVisibility="hidden">
    <TextInput
      type="search"
      inputMode="search"
      placeholder="Search"
      value={search}
      onChange={e => setSearch(e.target.value)}
    >
      <InputAdornment placement="start">
        <SearchIcon size="xxsmall" tone="muted" />
      </InputAdornment>
    </TextInput>
  </Field>
</Columns>
```

Ordering (fixed): **date-range (From, then To) first → multi-select filter →
search last.** This is the observed vendor-admin order (date range leads, search
trails) and differs from internal-admin (where search is leftmost). Hold this
order.

Rules:

- **Date range** is two `Field` + `DatePicker` pairs, labels `"From"` / `"To"`
  with `labelVisibility="hidden"`. `DatePicker` **must** be wrapped in a `Field`
  — it reads `disabled` from Field context and has no standalone label. See the
  date-picker doc.
- **Multi-select filter** uses `MultiSelect` from `@spark-web/multi-select`. Its
  `options` are grouped — `Array<{ label, options }>` — which suits the
  vendor-admin multi-facet filter (e.g. Assigned To / Intent / Status /
  Financial Product groups in one control). For a single flat facet pass
  `[{ label: '', options }]`. It renders no visible label, so label it for
  assistive tech via `aria-labelledby` pointing at a `VisuallyHidden` element
  from `@spark-web/a11y`. (vendor-portal substitutes a local
  `MultiselectCheckbox` with the same grouped `options` / `onChange` shape —
  COMPONENT GAP is not implied; the Spark component exists. See the overlay.)
  Filter state is typed `SelectedOptions` — an object keyed by group label →
  `string[]` — **not** `string[]`; do not declare `useState<string[]>` (see
  `node_modules/@spark-web/multi-select/CLAUDE.md`).
- **Search** is a `TextInput` (`type="search"`) wrapped in a `Field`
  (`label="Search"`, hidden), with a leading `InputAdornment` holding a
  `SearchIcon size="xxsmall" tone="muted"`.
- Filter labels are never visible — wrap inputs in a `Field` with
  `labelVisibility="hidden"`; the `label` is still required for accessibility,
  and `aria-labelledby` + `VisuallyHidden` for `MultiSelect`.
- If no filtering or searching is needed, omit this section entirely.

---

## Section 5 — Scroll region and table

The scroll region is the **anchor** for the side panel and the bulk-action toast
(both are `position: absolute` within it — see Sections 6 and 7). It is a
`Stack` that is `position="relative"`, `height="full"`, and clips overflow; an
inner `Stack` scrolls the table vertically.

```tsx
<Stack position="relative" height="full" overflow="hidden">
  <Stack width="full" css={{ overflowY: 'scroll' }}>
    <DataTable
      items={rows}
      columns={columns}
      isLoading={isLoading}
      emptyState={
        <Text tone="muted" size="small">
          No records found. Try adjusting your filters.
        </Text>
      }
    />
  </Stack>

  {/* Section 6 — SidePanel (conditional) */}
  {/* Section 7 — BulkActionToast (conditional) */}
</Stack>
```

`position` / `height` / `overflow` here are **Spark Stack props** (Box props
forwarded through Stack) — not raw CSS. Only `overflowY: 'scroll'` on the inner
Stack is a documented exception (no Spark prop for axis-specific overflow).

### Table (DataTable)

Always uses `@spark-web/data-table`. The **current** API is a single `DataTable`
that takes `items` + `columns` (built with `createColumnHelper`), `isLoading`,
and `emptyState`. (vendor-portal pins an older `data-table` whose columns are a
raw `ColumnDef[]` array; **document and build the current API**, not the pinned
one.)

```tsx
import { createColumnHelper, DataTable } from '@spark-web/data-table';

const columnHelper = createColumnHelper<RecordRow>();

const columns = [
  columnHelper.accessor('name', { header: 'Name' }),
  columnHelper.accessor('received', { header: 'Received' }),
  columnHelper.accessor('status', {
    header: 'Status',
    cell: ({ getValue }) => (
      <Badge tone={toneFor(getValue())}>{labelFor(getValue())}</Badge>
    ),
  }),
];
```

Column rules:

- The **status column** is always a `<Badge>` (dot + label), tone mapped via the
  surface badge tone mapping (read it in the vendor-admin / internal-admin rules
  — do not re-derive; never `tone="pending"`, use `info`). Never plain text,
  never `StatusBadge` in a table.
- Default column width: equal flex distribution (`size` unset). Only hardcode a
  width for a fixed-width utility column (e.g. a row-select checkbox or icon).
- **Row-select checkbox column** (multi-select lists only): a leading column
  whose cell renders `@spark-web/checkbox` `Checkbox` wired to the row's
  selection handlers; stop click propagation so selecting a row does not also
  open its detail panel.
- **Loading / empty:** pass `isLoading` and `emptyState` to `DataTable` — never
  render an external spinner or "no results" text outside the table.
- **Row click → detail:** when a record has a detail view, open it via the side
  panel (Section 6), wiring `onRowClick` (and `enableClickableRow`) per the
  data-table doc and the surface row-interaction rules.

---

## Section 6 — Side-panel detail variant (conditional)

Per the surface "Detail interaction rule", vendor-admin opens a record's detail
in a **slide-in side panel** anchored to the scroll region — not a navigate-to
detail page. Render the panel as a sibling of the scroll wrapper, inside the
`position="relative"` scroll region.

```tsx
{
  /* COMPONENT GAP: SidePanel/Drawer needed — not yet in Spark — see Section 6
     rules for the protocol and prop contract. */
}
{
  selectedRecordId && selectedRows.length <= 1 && (
    <Box
      position="absolute"
      top={0}
      bottom={0}
      right={0}
      background="surface"
      shadow="large"
      css={{
        width: '90vw',
        overflowY: 'auto',
        ['@media (min-width: 768px)']: { maxWidth: '500px' },
      }}
    >
      <Stack gap="large" padding="large">
        <Box display="flex" justifyContent="spaceBetween" alignItems="center">
          <Heading level="3">{panelHeading}</Heading>
          <Button prominence="none" tone="neutral" onClick={closePanel}>
            Close
          </Button>
        </Box>
        {/* record detail body */}
      </Stack>
    </Box>
  );
}
```

Rules:

- **Side panel is a COMPONENT GAP** — there is no `@spark-web` drawer / side
  panel (verification note: see the overlay). **COMPONENT GAP protocol** (this
  applies to every gap in this file): build the placeholder from primitives,
  mark the first use with a `// COMPONENT GAP: <Name> needed — not yet in Spark`
  comment, and flag it for **product design review before production — the
  placeholder is a stop-gap; do not ship as-is**. Prop contract: `heading`
  (string), `onHide` (`() => void`, wired to the close Button), record body as
  children. (In vendor-portal, apply the overlay's `SidePanel` — see the overlay
  for its props.)
- **When to use a panel vs a full page** (surface rule): use the panel for
  inspecting / lightly acting on a single record while staying in the list;
  reserve a full page for heavyweight standalone flows (multi-step forms,
  settings). Settings is a full page (`Container`-centered), not a panel.
- Open the panel by setting the selected record — typically via a URL query
  param so the panel is deep-linkable — and render it **only when a single
  record is selected** (`selectedRows.length <= 1`). It overlays the list rather
  than reflowing it.

---

## Section 7 — Bulk-action toast (conditional)

Per the surface "Bulk actions rule", when the list is multi-select and at least
one row is selected, a **bottom-anchored bulk-action toast** appears, pinned to
the scroll region. It exposes the actions for the current selection (e.g.
assign, close) and a way to clear the selection.

```tsx
{
  /* COMPONENT GAP: BulkActionToast needed — not yet in Spark — protocol: see
     Section 6. Prop contract: count (selected rows), disabled (in-flight),
     onClose (clear selection), onActionSubmit (run the bulk action). */
}
{
  isMultiSelect && selectedRows.length > 0 && (
    <Box
      position="absolute"
      bottom={0}
      left={0}
      right={0}
      background="surface"
      shadow="large"
      padding="medium"
      display="flex"
      alignItems="center"
      justifyContent="spaceBetween"
    >
      <Text>{`${selectedRows.length} selected`}</Text>
      <Box display="flex" gap="medium" alignItems="center">
        <Button
          prominence="high"
          tone="primary"
          disabled={isBulkMutationRunning}
          onClick={runBulkAction}
        >
          Assign
        </Button>
        <Button prominence="none" tone="neutral" onClick={clearSelection}>
          Clear
        </Button>
      </Box>
    </Box>
  );
}
```

Rules:

- Rendered **only when** the list is multi-select **and**
  `selectedRows.length > 0`; hidden when the selection is empty.
- Mutually exclusive with the side panel (Section 6) — show the panel only for a
  single selected record, the toast only for a multi-row bulk selection.
- Disable the toast's actions while a bulk mutation is in flight (`disabled`).
- The toast container is a COMPONENT GAP — build the placeholder above from
  primitives (`Box` anchored absolutely to the scroll region per the surface
  rule, with a count `Text` + action/clear `Button`s) and mark the first use
  with `// COMPONENT GAP: BulkActionToast needed — not yet in Spark` (protocol:
  see Section 6). (In vendor-portal, apply the overlay's component.)

---

## Section 8 — Pagination (conditional, paginated lists only)

Per the surface "List loading rule", choose **infinite scroll** for large /
streamed / open-ended lists (fetch the next page when the scroll bottom is
reached and append rows — no pagination control) and **pagination** for bounded,
countable lists.

For paginated lists, render `TablePagination` **outside and below the scroll
region** — never nested inside DataTable.

```tsx
{
  /* COMPONENT GAP: TablePagination needed — not yet in Spark — protocol: see
     Section 6. Prop contract: total, pageSize, current (page), dataShowing
     (rows on this page), onChange (next page). */
}
{
  total > PAGE_SIZE && (
    <Box
      display="flex"
      alignItems="center"
      justifyContent="spaceBetween"
      paddingY="medium"
    >
      <Text tone="muted" size="small">
        {`Showing ${dataShowing} of ${total}`}
      </Text>
      <Box display="flex" gap="medium" alignItems="center">
        <Button
          prominence="low"
          tone="neutral"
          disabled={current <= 1}
          onClick={() => onChange(current - 1)}
        >
          Previous
        </Button>
        <Button
          prominence="low"
          tone="neutral"
          disabled={current * pageSize >= total}
          onClick={() => onChange(current + 1)}
        >
          Next
        </Button>
      </Box>
    </Box>
  );
}
```

Rules:

- **Pagination is a COMPONENT GAP** — there is no `@spark-web` pagination
  component. Build the placeholder above from primitives (Box/Text/Button) and
  mark the first use with
  `// COMPONENT GAP: TablePagination needed — not yet in Spark` (protocol: see
  Section 6). (In vendor-portal, apply the overlay's pagination control — see
  the overlay for its props.)
- Render pagination **only when** `total > pageSize` and use the real total from
  a dedicated count query — never derive total from `rows.length`.
- **Omit pagination entirely for infinite-scroll lists.** Infinite scroll and
  pagination are mutually exclusive — pick one per the surface rule.

---

## Section 9 — Modals (export / assign)

Page-level modals (an export modal, a bulk-assign modal) are declared in the JSX
and opened via local state. Build them on `@spark-web/modal-dialog`.

```tsx
<ExportModal isOpen={isExportOpen} onClose={closeExport} onExport={onExport} />
<AssignModal isOpen={isAssignOpen} onClose={closeAssign} onAssign={onAssign} />
```

Rules:

- Modals are always rendered in the tree (their open state is a prop), not
  conditionally mounted, so close transitions work.
- The export modal pairs with the title-row Export button; the assign modal
  pairs with the bulk-action toast's assign action.
- Use `@spark-web/modal-dialog` for the dialog chrome — never a custom overlay.

---

## Structural skeleton

Use this skeleton as the starting point for new builds and uplifts. Do not use
existing page implementations as a structural reference.

```tsx
<Stack
  gap="large"
  margin="xxlarge"
  marginBottom="none"
  height="full"
  overflow="hidden"
>
  {/* Section 2 — page-level feedback Alert (conditional) — see above */}

  {/* Section 3 — title row: Heading level="2" + count Badge (left),
      right-aligned action Button(s) — see above */}

  {/* Section 4 — filter bar Columns: date-range → multi-select → search — see above */}

  <Stack position="relative" height="full" overflow="hidden">
    <Stack width="full" css={{ overflowY: 'scroll' }}>
      {/* Section 5 — DataTable (items/columns via createColumnHelper,
          isLoading, emptyState) — see above */}
    </Stack>

    {/* Section 6 — SidePanel placeholder (conditional: single selected record;
        COMPONENT GAP) — see above */}

    {/* Section 7 — BulkActionToast placeholder (conditional: multi-select,
        ≥1 row selected; COMPONENT GAP) — see above */}
  </Stack>

  {/* Section 8 — TablePagination placeholder (paginated lists only — omit
      entirely for infinite scroll; COMPONENT GAP) — see above */}

  {/* Section 9 — export / assign modals (declared in JSX, open via state) — see above */}
</Stack>
```

---

## Documented exceptions summary

These raw CSS values are required and have no Spark token / prop equivalent. Use
them exactly as written. Do not substitute alternatives. Everything else uses
Spark props / tokens (the scroll region's `position`/`height`/`overflow` are
Stack props, not raw CSS, and so are not listed here).

| Value                        | Property             | Reason                                                      |
| ---------------------------- | -------------------- | ----------------------------------------------------------- |
| `overflowY: 'scroll'`        | inner scroll Stack   | No Spark prop for axis-specific overflow; scrolls the table |
| `overflowY: 'auto'`          | side-panel `Box` css | No Spark prop for axis-specific overflow; scrolls the panel |
| `width: '90vw'`              | side-panel `Box` css | Mobile panel width — no Spark token equivalent              |
| `maxWidth: '500px'` (≥768px) | side-panel `Box` css | Tablet+ panel width cap per surface rule — no Spark token   |
| `@media (min-width: 768px)`  | side-panel `Box` css | Breakpoint for panel width; placeholder is raw-CSS sized    |

The `position="absolute"`, `top={0}`, `bottom={0}`, `right={0}`, `left={0}` on
the side-panel / bulk-toast / pagination placeholder `Box`es are **Box props**,
not raw CSS, so they are not exceptions.

---

## Do NOTs

- NEVER use `PageHeader` for the page title — vendor-admin uses
  `Heading level="2"`. Do not promote it to `level="1"`.
- NEVER use `<Container>` as the list-page wrapper — the page body is a `Stack`
  with `margin="xxlarge" marginBottom="none"`. Container is for centered
  form/settings pages only.
- NEVER reproduce the Header or NavBar inside the page — the shell wraps the
  page (surface rule); the page starts at the title row.
- NEVER place the search input first in the filter bar — vendor-admin order is
  date-range → multi-select → search (search last). (This is the opposite of
  internal-admin; do not "correct" it.)
- NEVER use a bare `DatePicker` — it must be wrapped in a `Field` (it reads
  `disabled` from Field context and carries the hidden label).
- NEVER use plain text for status values — always `<Badge>` (dot + label) with a
  tone from the surface tone mapping; never `tone="pending"` (use `info`), never
  `StatusBadge` in a table.
- NEVER render an external spinner / loading text outside DataTable — use the
  `isLoading` prop; never omit `emptyState`.
- NEVER render both the side panel and the bulk-action toast at once — they are
  mutually exclusive (panel = single record; toast = multi-row selection).
- NEVER render pagination for an infinite-scroll list — pick one loading
  strategy per the surface rule; omit the pagination control entirely for
  infinite scroll.
- NEVER render pagination when all records fit on one page — only when
  `total > pageSize`; never derive `total` from `rows.length`.
- NEVER put pagination, the side panel, or the toast inside DataTable.
- NEVER substitute the documented exception values with alternatives.
- NEVER use the old `data-table` `ColumnDef[]` array API from vendor-portal's
  pinned version — use the current `createColumnHelper` + `items`/`columns` API.
- NEVER import a side panel, pagination, or bulk-action toast from `@spark-web`
  — they are COMPONENT GAPs. Build each as a primitives placeholder
  (Box/Stack/Heading/Text/Button), mark it with a `// COMPONENT GAP:` comment,
  and flag it for product design review before production — do not ship the
  placeholder as-is. (In vendor-portal, the overlay's local components remain
  the canonical substitution.)
- NEVER reach for vendor-portal's local `MultiselectCheckbox` in new code — the
  canonical multi-select filter is `@spark-web/multi-select` `MultiSelect`; the
  local wrapper is a retained overlay substitution only.

---

## Validation checklist

Run this checklist before marking any vendor-admin list-page task complete. Fix
every violation first. The uplift protocol in
`node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
against existing pages and reports PASS/FAIL per item.

1. Page title is `Heading level="2"` (not `PageHeader`, not `level="1"`), with
   any count indicator as a `Badge` beside it; page-level actions (Export) are
   right-aligned Buttons in the title row, not in the content area.
2. Outer wrapper is a `Stack` with
   `gap="large" margin="xxlarge" marginBottom="none" height="full" overflow="hidden"`
   — not `Container`, not `PageHeader`; the page does not reproduce the shell
   Header / NavBar.
3. If search/filtering exists: filter bar order is date-range (`Field` +
   `DatePicker`: From then To) → multi-select filter → search (search LAST),
   with `gap="medium"`; all labels hidden but accessible
   (`labelVisibility="hidden"` on each `Field`; `aria-labelledby` +
   `VisuallyHidden` for `MultiSelect`).
4. Every `DatePicker` is wrapped in a `Field`; the search `TextInput` has a
   leading `InputAdornment` + `SearchIcon size="xxsmall" tone="muted"`.
5. DataTable uses the current API — `items` + `columns` via
   `createColumnHelper`, with `isLoading` and `emptyState` — never the old
   `ColumnDef[]` array API, never an external spinner/empty text.
6. Status columns use `<Badge>` (dot + label) with a tone from the surface tone
   mapping — never plain text, never `StatusBadge`, never `tone="pending"`.
7. DataTable sits inside the scroll region: an outer
   `Stack position="relative" height="full" overflow="hidden"` wrapping an inner
   `Stack css={{ overflowY: 'scroll' }}` — never directly in the page Stack.
8. Side panel (if the record has detail) is rendered inside the scroll region,
   only when a single record is selected (`selectedRows.length <= 1`), and is
   flagged `// COMPONENT GAP: SidePanel/Drawer needed — not yet in Spark`.
9. Bulk-action toast (multi-select lists) renders only when `selectedRows.length
   > 0`, is mutually exclusive with the side panel, disables its actions during
   > an in-flight mutation, and is flagged as a COMPONENT GAP where no Spark
   > equivalent exists.
10. List loading is exactly one of infinite scroll OR pagination (surface rule).
    Pagination renders outside the scroll region, only when `total > pageSize`,
    with `total` from a dedicated count query, flagged
    `// COMPONENT GAP: TablePagination needed — not yet in Spark`; for
    infinite-scroll lists no pagination control is rendered.
11. Export / assign modals are built on `@spark-web/modal-dialog`, declared in
    the JSX with open state — not custom overlays.
12. No raw CSS values beyond the Documented exceptions table; every component is
    `@spark-web/*` or an explicit consumer-overlay substitute, and anything
    missing (side panel, pagination, bulk-action toast) is flagged with a
    `// COMPONENT GAP:` comment.
