UNPKG

@spark-web/design-system

Version:

--- title: Design System ---

710 lines (604 loc) 29.3 kB
# 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 TextInputREQUIRED 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 1Page 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 2Page-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 3Title 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 4Filter 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 5Scroll 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 6Side-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 7Bulk-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 8Pagination (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 9Modals (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.