@spark-web/design-system
Version:
--- title: Design System ---
710 lines (604 loc) • 29.3 kB
Markdown
# 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 `-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 `-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 `-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 `-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',
[' (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 `-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 `-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 `-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 `-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 |
| ` (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 `-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 `-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
`-web/*` or an explicit consumer-overlay substitute, and anything
missing (side panel, pagination, bulk-action toast) is flagged with a
`// COMPONENT GAP:` comment.