# Vendor admin — dashboard / overview 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 rule, the page-title
rule (and its **documented dashboard exception** — see "Page titling" below),
the spacing/density rule, the role/feature-flag gating rule, and the badge tone
mapping defined there all apply to this pattern and take precedence over
component-level rules.

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

## What this pattern is

The vendor landing / overview / home screen — the first screen a vendor /
installer / partner sees after signing in. It summarises the account at a
glance: a **metrics summary** (stat cards that click through to the underlying
list), a **resources section** (downloadable assets, links, promo cards), and an
optional, often flag-gated **announcements / news feed** ("What's New"). On a
vendor's first visit it may also raise a **first-run consent modal** that must
be acknowledged before the screen is usable.

It renders inside the vendor-admin shell (fixed Header + left NavBar) like every
other vendor-admin page, but its **page shell differs** from both the list page
and the form page: it is a responsive **multi-panel layout** — a primary content
panel plus an optional secondary feed panel — not the full-height single scroll
region of the list page and not the `Container`-centered column of the form
page.

## When to use this pattern

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

- A "dashboard", "home", "overview", "landing", or "welcome" screen for a vendor
- A screen that summarises counts / totals across the account (applications
  under review, approved, paid) as headline stats the user can click into
- A screen that bundles resources, downloads, promo cards, and/or a product
  announcements / news feed
- A screen with a first-run consent / onboarding acknowledgement gate

If the screen is primarily a searchable, filterable table of records, use the
list-page pattern; if it is a settings / edit form, use the form-page pattern:

- `node_modules/@spark-web/design-system/patterns/vendor-admin/list-page.md`
- `node_modules/@spark-web/design-system/patterns/vendor-admin/form-page.md`

---

## Component docs to read

Read these before implementing — they own the component-level rules. Only read
the ones the screen actually uses:

- `node_modules/@spark-web/row/CLAUDE.md` — Row API; the primary/secondary panel
  layout (and its `flexDirection` collapse on mobile)
- `node_modules/@spark-web/columns/CLAUDE.md` — Columns API; `collapseBelow`,
  `gap`, `template` — the metrics and resources grids
- `node_modules/@spark-web/box/CLAUDE.md` — Box layout utilities (panels, card
  placeholders, banner)
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking, gap
- `node_modules/@spark-web/heading/CLAUDE.md` — section headings (`level="2"`),
  card sub-headings (`level="3"`)
- `node_modules/@spark-web/text/CLAUDE.md` — Text API (stat numbers, labels,
  card copy)
- `node_modules/@spark-web/icon/CLAUDE.md` — icon sizing / tones
  (`ArrowRightIcon`, `ArrowNarrowRightIcon`, `PhoneIcon`)
- `node_modules/@spark-web/button/CLAUDE.md` — `Button` / `ButtonLink` (resource
  card actions, "Load More", consent Continue), `loading` prop
- `node_modules/@spark-web/text-link/README.md` — `TextLink` (in-prose links,
  e.g. the consent modal's requirements link)
- `node_modules/@spark-web/modal-dialog/CLAUDE.md` — `ContentDialog` (the
  first-run consent modal and any document dialog)
- `node_modules/@spark-web/alert/CLAUDE.md` — sanitized announcement banner /
  page-level feedback

---

## Page structure

```
Page (renders into the vendor-admin shell content region — no Header/NavBar here)
  Row (panel frame)                      — flexDirection row; collapses to column on mobile (responsive prop)
    Primary panel (Box)                  — flexGrow={1}; padding responsive; the metrics + resources content
      Banner (conditional)               — sanitized announcement Alert/Text — NEVER dangerouslySetInnerHTML
      Metrics section
        Heading level="2"                — section heading (dashboard exception: no single page title)
        Columns                          — MetricCard placeholders (COMPONENT GAP) — collapseBelow, gap
      Resources section
        Heading level="2"                — section heading
        Columns                          — resource / promo cards (primitives — Box/Stack/Button/icon)
      Help / contact banner (optional)   — Box with PhoneIcon + Text (+ TextLink/Link)
    Secondary panel (conditional)        — announcements feed; rendered only when the feed flag is on
      AnnouncementsFeed                  — "What's New" timeline + "Load More" (COMPONENT GAP placeholder)
  ContentDialog (conditional)            — first-run consent modal; showCloseButton={false}, Continue persists flag
```

The primary panel and the secondary feed panel sit side by side on tablet and up
and **stack vertically on mobile**. The feed panel is optional and frequently
behind a feature flag (consumer logic — see the surface gating rule).

---

## Page shell — multi-panel layout

The dashboard shell is a responsive two-panel frame, **not** the list page's
`Stack height="full"` scroll region and **not** the form page's
`Container size="medium"`. Use `Row` from `@spark-web/row` for the panel frame:
a primary content panel (`flexGrow={1}`) and an optional secondary feed panel.

`Row` renders a non-wrapping flex row and **locks `flexDirection`** (it is a
removed Box prop on `Row`). To flip the panels to a vertical stack on mobile,
Row alone cannot do it — so the canonical shell uses a `Box display="flex"` with
a **responsive `flexDirection`** prop, which is the supported Spark way to
express the collapse without raw CSS:

```tsx
import { Box } from '@spark-web/box';

<Box display="flex" flexDirection={{ mobile: 'column', tablet: 'row' }}>
  <Box
    flexGrow={1}
    padding={{ mobile: 'medium', tablet: 'xlarge' }}
    width={{ mobile: 'full' }}
  >
    {/* primary panel — metrics + resources */}
  </Box>

  {feedEnabled && (
    <Box
      padding={{ mobile: 'medium', tablet: 'xlarge' }}
      width={{ mobile: 'full' }}
    >
      {/* secondary panel — announcements feed */}
    </Box>
  )}
</Box>;
```

Rules:

- The shell is a `Box display="flex"` with
  `flexDirection={{ mobile: 'column', tablet: 'row' }}` — primary panel
  side-by-side with the optional feed on tablet+, stacked on mobile. This is the
  Spark-native responsive collapse; **do not** reach for a raw
  `@media (max-width: …)` `flexDirection` flip (the vendor-portal source does
  this with `css`/`className`; the canonical pattern does not — see the
  Documented exceptions table).
- `Row` from `@spark-web/row` is the correct primitive **only when the layout
  never needs to flip axis** (e.g. an action bar). Because the dashboard panels
  must stack on mobile and `Row` locks `flexDirection`, use the responsive `Box`
  above for the panel frame. (You may still use `Row` for inner left/right rows
  such as a card's value-plus-arrow line.)
- Each panel sets its own responsive `padding`
  (`{ mobile: 'medium', tablet: 'xlarge' }`) and `width={{ mobile: 'full' }}` —
  spacing comes from tokens, never raw pixels.
- The page does **not** reproduce the shell Header / NavBar — the shell wraps
  the page (surface rule); the page starts at the panel frame.
- A fixed secondary-panel width (e.g. the feed taking ~40% on tablet+) is a
  product-design decision: prefer `Columns template={[…]}` or a panel `width`
  token. If a precise non-token width is genuinely required it goes in the
  Documented exceptions table — do not inline a raw width silently.

---

## Page titling — documented dashboard exception

**The dashboard is a documented exception to the surface page-title rule** (see
the surface rules' "Page title rule"): it leads with **section-level
`Heading level="2"` headings** ("Summary", "Support & resources", "What's New")
standing in for the page title, with **no separate page title**.

Rationale: (1) the fixed shell Header already carries the product identity
("Brighte Partner Portal"), so a redundant in-page "Dashboard" title adds
nothing; (2) a dashboard is inherently multi-panel with several co-equal
sections, none of which is "the page"; (3) it matches the observed
implementation. The surface rules note this exception and point here.

Rules:

- Headings **within** a section (e.g. a card title, the document-dialog title)
  step down to `Heading level="3"` (or `level="4"` for a dialog sub-title where
  the dialog itself owns the `level="3"`).
- Do **not** add a single `Heading level="2">Dashboard</Heading>` above the
  sections and then push the section headings to `level="3"` — that is the
  rejected alternative. Lead with section-level `level="2"` headings directly.
- `Heading`'s `level` union is `'1' | '2' | '3' | '4'`; its `tone` may be
  `neutral` (the observed section-heading tone), `primary`, `primaryActive`, or
  `muted`.

---

## Section 1 — Metrics summary

A row of metric / stat cards in a `Columns` grid: a headline number, a label, a
subtitle (e.g. a financed total), and a click-through that opens the underlying
list filtered to that status.

There is **no `@spark-web` stat / metric / KPI card component** (verified — no
package under `packages/` provides one). The metric card is therefore a
**COMPONENT GAP**. **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**.

```tsx
import { Box } from '@spark-web/box';
import { Heading } from '@spark-web/heading';
import { Columns } from '@spark-web/columns';
import { Stack } from '@spark-web/stack';
import { Text } from '@spark-web/text';
import { ArrowRightIcon } from '@spark-web/icon';

<Box marginTop="small">
  <Heading level="2" tone="neutral">
    Summary
  </Heading>

  <Columns
    marginTop="xlarge"
    gap={{ mobile: 'small', tablet: 'large' }}
    collapseBelow="wide"
  >
    {metrics.map(metric => (
      /* COMPONENT GAP: MetricCard needed — not yet in Spark — protocol: see
         the Section 1 intro; prop contract: see the rules below. */
      <Box
        key={metric.id}
        background="surface"
        border="standard"
        borderRadius="large"
        padding="large"
        cursor="pointer"
        onClick={() => onMetricClick(metric)}
      >
        <Stack gap="medium">
          <Text size="xxlarge" weight="bold" tone="neutral">
            {metric.value}
          </Text>
          <Text weight="semibold" tone="primary">
            {metric.label}
          </Text>
          <Box display="flex" justifyContent="spaceBetween" alignItems="center">
            {metric.subtitle && (
              <Text size="xsmall" tone="muted">
                {metric.subtitle}
              </Text>
            )}
            <ArrowRightIcon size="xsmall" tone="primary" />
          </Box>
        </Stack>
      </Box>
    ))}
  </Columns>
</Box>;
```

Rules:

- **MetricCard is a COMPONENT GAP** — mark the first use with
  `// COMPONENT GAP: MetricCard needed — not yet in Spark` (protocol: see the
  Section 1 intro). Prop contract: `value` (string — the headline number, e.g. a
  `toLocaleString()`'d count), `label` (string), `subtitle` (string, optional),
  and exactly one of `href` / `onClick` for the click-through.
- Lay cards out with `Columns` (`collapseBelow="wide"`, responsive `gap`) — one
  card per column, equal width. Do not hand-roll a flex grid.
- The headline number is a `Text` with a large size/weight — **never** a raw
  `style={{ fontSize: '34px' }}` (the vendor-portal source does this; use a Text
  size token such as `xxlarge` — 35px, the closest token to the raw 34px —
  instead). Status counts are plain numbers here, not `Badge`s — the badge tone
  mapping applies to status _labels_ in tables, not to dashboard headline
  figures.
- The click-through navigates to the underlying list filtered to that metric
  (e.g. `/applications?status=approved`); wire it via `onClick`/`href`. Role /
  flag visibility of individual metrics (e.g. hiding "Paid" for non-vendor
  roles) is consumer gating logic, not a design-system rule.
- Working in vendor-portal? The overlay substitutes the consumer's
  `DashboardMetrics` / `MetricCard` — see the overlay.

---

## Section 2 — Resources

A `Columns` grid of resource / promo cards — a media image, a heading, body
copy, and an action (`Button` / `ButtonLink` / link), and/or a link list that
opens in a document dialog. Resource cards are **lighter than a component gap**:
they compose cleanly from primitives (`Box`/`Stack`/`Text`/`Button`/`Icon`/
`Image`), so build them from primitives directly — no `// COMPONENT GAP` flag is
required. (They are a reusable _consumer_ card, not a missing Spark primitive;
if a repo wants to factor the repeated card chrome into a local component, that
is a consumer convenience, noted in the overlay — not a Spark gap.)

```tsx
import { Box } from '@spark-web/box';
import { Heading } from '@spark-web/heading';
import { Columns } from '@spark-web/columns';
import { Button, ButtonLink } from '@spark-web/button';
import { Stack } from '@spark-web/stack';
import { Text } from '@spark-web/text';
import { ArrowNarrowRightIcon } from '@spark-web/icon';

<Box marginTop="xxlarge">
  <Heading level="2" tone="neutral">
    Support &amp; resources
  </Heading>

  <Columns
    marginTop="xlarge"
    gap={{ mobile: 'medium', tablet: 'medium', desktop: 'large' }}
    collapseBelow="wide"
  >
    {resources.map(resource => (
      <Box
        key={resource.id}
        background="surface"
        border="standard"
        borderRadius="large"
        padding="large"
        display="flex"
        flexDirection="column"
        height="full"
      >
        <Stack gap="large">
          <Text size="large" weight="semibold" tone="neutral">
            {resource.heading}
          </Text>
          <Box>
            <ButtonLink href={resource.href} target="_blank">
              {resource.actionLabel}
              <ArrowNarrowRightIcon size="xxsmall" tone="primary" />
            </ButtonLink>
          </Box>
        </Stack>
      </Box>
    ))}
  </Columns>
</Box>;
```

Rules:

- Build resource / promo cards from primitives (`Box` card chrome —
  `background="surface" border="standard" borderRadius="large" padding="large"`
  — plus `Stack`/`Text`/`Button`). No COMPONENT GAP flag — these are not a
  missing Spark primitive.
- Card actions are `Button` (`onClick`) or `ButtonLink` (`href`, opens in a new
  tab with `target="_blank"`) from `@spark-web/button` — never a raw `<a>`
  styled as a button.
- A long list of downloadable documents that opens in a modal is a
  `ContentDialog` from `@spark-web/modal-dialog` (see Section 4 — same component
  family as the consent modal): a `size="small"` dialog whose body is a `Stack`
  of `TextLink`s. Working in vendor-portal? The overlay maps its
  `DashboardAds`/`AdCard` and `DocumentsDialog`.

---

## Section 3 — Announcements / news feed (optional, flag-gated)

An optional secondary-panel "What's New" feed: a vertical timeline of article
items (date, image, heading, intro, "Learn more" link), with incremental **"Load
More"** loading. The feed is usually behind a feature flag (consumer gating) and
renders only when enabled.

There is **no `@spark-web` feed / timeline / article-list component** — the feed
container is a **COMPONENT GAP** (protocol: see Section 1); the placeholder is
`Box as="article"` items in a `Stack`, plus a "Load More" `Button`.

```tsx
import { Box } from '@spark-web/box';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';
import { Text } from '@spark-web/text';
import { Button } from '@spark-web/button';
import { TextLink } from '@spark-web/text-link';

{
  /* COMPONENT GAP: AnnouncementsFeed needed — not yet in Spark — protocol: see
     Section 1; prop contract: see the rules below. */
}
{
  feedEnabled && (
    <Stack gap="xlarge" width={{ mobile: 'full' }}>
      <Heading level="2" tone="neutral">
        What&apos;s New
      </Heading>

      <Stack gap="xlarge">
        {items.map(item => (
          <Box as="article" key={item.id}>
            <Stack gap="small">
              <Text size="small" tone="muted">
                {item.date}
              </Text>
              <Text weight="bold" tone="neutral">
                {item.heading}
              </Text>
              <Text size="small" tone="neutral">
                {item.intro}
              </Text>
              <TextLink href={item.href} target="_blank">
                Learn more
              </TextLink>
            </Stack>
          </Box>
        ))}
      </Stack>

      {hasNextPage && (
        <Button tone="neutral" loading={isLoading} onClick={onLoadMore}>
          Load More
        </Button>
      )}
    </Stack>
  );
}
```

Rules:

- **AnnouncementsFeed is a COMPONENT GAP** — mark the first use with
  `// COMPONENT GAP: AnnouncementsFeed needed — not yet in Spark` (protocol: see
  Section 1). Prop contract: `items`
  (`Array<{ id, date, heading, intro, href, imageUrl? }>`), `hasNextPage`
  (boolean), `isLoading` (boolean), `onLoadMore` (`() => void`).
- Each feed entry is a `Box as="article"` for correct semantics; the date is a
  muted `Text`, the heading a bold `Text`, and "Learn more" a `TextLink` (in
  body copy) or `ButtonLink` (as a button).
- **"Load More" is a THIRD list-loading mode.** The list-page pattern recognises
  infinite scroll and pagination; this feed uses **button-triggered incremental
  loading** — a "Load More" `Button` that fetches and appends the next page on
  click, with the Button's `loading` prop bound to the in-flight fetch. It is
  distinct from auto-fetch-on-scroll (infinite scroll) and from page controls
  (pagination). Use "Load More" for a short, browse-occasionally feed where the
  user opts in to more rather than paging or endlessly scrolling. The surface
  rules list it as the third recognised mode.
- The feed panel renders only when its feature flag is on (consumer gating) —
  when off, omit the secondary panel entirely (the primary panel then fills the
  shell). Flag gating is consumer logic, not a design-system rule.
- The vertical connector line / image styling in the observed implementation is
  consumer chrome; keep the canonical placeholder to the semantic structure
  above. Working in vendor-portal? The overlay maps its
  `DashboardProductAnnouncements`.

---

## Section 4 — First-run consent / onboarding modal

On first visit a vendor may need to acknowledge a consent / requirements
statement before using the dashboard. This is a **real** `@spark-web` component
— `ContentDialog` from `@spark-web/modal-dialog` — **not** a component gap.

```tsx
import { Button } from '@spark-web/button';
import { ContentDialog } from '@spark-web/modal-dialog';
import { Text } from '@spark-web/text';
import { TextLink } from '@spark-web/text-link';

<ContentDialog
  isOpen={showConsent}
  title="Vendor requirements"
  showCloseButton={false}
  footer={
    <Button tone="primary" onClick={handleConsent}>
      Continue
    </Button>
  }
  onToggle={onConsentToggle}
>
  <Text>
    By clicking Continue, you confirm your understanding and acceptance of the{' '}
    <TextLink href={requirementsUrl} target="_blank">
      vendor requirements
    </TextLink>{' '}
    document.
  </Text>
</ContentDialog>;
```

Rules:

- Build it on `ContentDialog` (`@spark-web/modal-dialog`) — the controlled form
  (`isOpen` + `onToggle`). Read the modal-dialog doc for the full API.
- **`showCloseButton={false}`** — the gate must be acknowledged; do not give the
  user an X to dismiss it.
- The single footer action is a `Button tone="primary"` labelled "Continue" (or
  similar). Its handler **persists the acknowledgement flag** (e.g. POSTs
  `initialized: true` to the profile) so the modal does not reappear on the next
  visit, then closes the modal.
- **Gate placement:** drive `isOpen` from the persisted profile/initialised flag
  — open the modal in an effect when the profile has loaded **and** the flag is
  not yet set (`profile && !profile.initialized`), not unconditionally on mount.
  Render the `ContentDialog` once, at the page root (a sibling of the panel
  frame), with its open state as a prop — not conditionally mounted — so the
  close transition runs. Keep the gate at the page level, not buried inside a
  panel.
- In-prose links inside the modal body are `TextLink` from
  `@spark-web/text-link` (with `target="_blank"` for external docs) — never a
  raw `<a>`.
- A document-list dialog (Section 2) uses the **same** `ContentDialog` component
  with `size="small"`; it differs only in that it may be dismissable.

---

## Security — NEVER use `dangerouslySetInnerHTML`

The observed implementation renders an announcement banner via
`<Text … dangerouslySetInnerHTML={{ __html: title + content }} />`. **Never do
this.** Injecting server- or CMS-supplied HTML straight into the DOM is an XSS
vector (the source even flags it `// this one may cause security issue (XSS)`).

Render banner content as **sanitized text**, not HTML:

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

{
  banner && (
    <Alert tone="info">
      <Text>{banner.message}</Text>
    </Alert>
  );
}
```

Rules:

- A dashboard banner is an `Alert` from `@spark-web/alert` (or a `Box` +
  `Text`), with its message rendered as **plain text children** — `Alert`'s
  `tone` union is `caution | critical | info | positive` (use `info` for a
  neutral announcement, `caution` for a time-sensitive notice).
- If the banner genuinely needs rich content (a link), compose it from `Text` +
  `TextLink` as React children — never pass an HTML string.
- **NEVER** pass `dangerouslySetInnerHTML` to `Text`, `Box`, or any element on a
  vendor-admin page. If the source HTML cannot be expressed as React children,
  sanitize it server-side and render the sanitized text — do not inject raw
  HTML.

---

## 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
import { Box } from '@spark-web/box';

<>
  <Box display="flex" flexDirection={{ mobile: 'column', tablet: 'row' }}>
    {/* Primary panel — metrics + resources */}
    <Box
      flexGrow={1}
      padding={{ mobile: 'medium', tablet: 'xlarge' }}
      width={{ mobile: 'full' }}
    >
      {/* Sanitized banner Alert (conditional) — NEVER dangerouslySetInnerHTML
          — see the Security section above */}

      {/* Section 1 — metrics: Heading level="2" + Columns of MetricCard
          placeholders (COMPONENT GAP) — see above */}

      {/* Section 2 — resources: Heading level="2" + Columns of primitive
          resource / promo cards — see above */}
    </Box>

    {/* Secondary panel — Section 3: AnnouncementsFeed placeholder
        (flag-gated, optional; COMPONENT GAP) — see above */}
  </Box>

  {/* Section 4 — first-run consent ContentDialog (real component, NOT a gap;
      showCloseButton={false}, Continue persists the flag) — see above */}
</>;
```

---

## Documented exceptions summary

No raw CSS is required by this pattern's canonical snippets; if you add any,
list it here with a justification and apply it via the `css` prop — never via
`className` (`Stack` omits `className`; do not use `className` on `Box` either —
the styling escape hatch on both is the `css` prop).

| Value | Property | Reason        |
| ----- | -------- | ------------- |
| —     | —        | none required |

---

## Do NOTs

- NEVER add a single `Heading level="2"` page title above the sections — the
  dashboard is a documented exception: it leads with section-level `level="2"`
  headings and has no separate page title (sub-headings within a section step to
  `level="3"`).
- NEVER use the list page's `Stack height="full"` scroll region or the form
  page's `Container size="medium"` as the dashboard shell — the dashboard shell
  is a multi-panel `Box display="flex"` with a responsive `flexDirection`.
- NEVER use a raw `@media (max-width: …)` `flexDirection` flip (via `css` or
  `className`) for the panel collapse — use the responsive
  `flexDirection={{ mobile: 'column', tablet: 'row' }}` prop.
- NEVER set `className` on `Stack` or `Box` — `Stack` omits `className`, and
  `className` must not be used on `Box` either; the styling escape hatch on both
  is the `css` prop, and only for a Documented exception.
- NEVER reproduce the shell Header or NavBar inside the page — the shell wraps
  the page (surface rule); the page starts at the panel frame.
- NEVER render a metric headline number with a raw `style={{ fontSize: … }}` —
  use a `Text` size token (e.g. `size="xxlarge"`).
- NEVER use a `Badge` for a dashboard headline figure — badges map _status
  labels_ in tables; dashboard stats are plain `Text` numbers.
- NEVER import a metric card, stat card, or announcements feed from `@spark-web`
  — they are COMPONENT GAPs. Build each as a primitives placeholder
  (`Box`/`Stack`/`Text`/`Icon`/`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 flag the consent modal as a component gap or hand-roll a custom overlay
  — it is a real `ContentDialog` from `@spark-web/modal-dialog`.
- NEVER give the first-run consent modal a close button — use
  `showCloseButton={false}`; the Continue action persists the flag and closes
  it.
- NEVER open the consent modal unconditionally on mount — gate it on the
  persisted profile/initialised flag (`profile && !profile.initialized`).
- NEVER use `dangerouslySetInnerHTML` on `Text`, `Box`, or any element to render
  a banner / announcement — render sanitized content via `Text` / `Alert` (XSS
  risk).
- NEVER treat "Load More" as pagination or infinite scroll — it is a distinct
  third loading mode (button-triggered incremental append); bind the Button's
  `loading` prop to the in-flight fetch.

---

## Validation checklist

Run this checklist before marking any vendor-admin dashboard 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. The page leads with **section-level `Heading level="2"`** headings (e.g.
   "Summary", "Support & resources", "What's New") and has **no single page
   title** — the documented dashboard exception; sub-headings within a section
   are `level="3"` (or `level="4"` inside a dialog). No `PageHeader`, no
   `level="1"`.
2. The shell is a multi-panel `Box display="flex"` with
   `flexDirection={{ mobile: 'column', tablet: 'row' }}` — a primary panel
   (`flexGrow={1}`) plus an optional secondary feed panel — **not**
   `Stack height="full"` (list page) and **not** `Container size="medium"` (form
   page); the page does not reproduce the shell Header / NavBar.
3. The responsive panel collapse uses the `flexDirection` responsive prop —
   there is no raw `@media` `flexDirection` flip and no `className` on any
   `Stack` / `Box`; spacing is all tokens.
4. Metrics render in a `Columns` grid (`collapseBelow`, responsive `gap`); each
   metric card is a primitives placeholder (`Box`/`Stack`/`Text`/`Icon`) flagged
   `// COMPONENT GAP: MetricCard needed — not yet in Spark`, with the headline
   number a `Text` size token (not a raw `fontSize`) and a click-through
   (`onClick`/`href`) to the filtered list; no `Badge` for the headline figure.
5. Resource / promo cards are composed from primitives (`Box` card chrome +
   `Stack`/`Text`/`Button`/`ButtonLink`) with actions as `Button`/`ButtonLink`
   (not raw `<a>`); no `// COMPONENT GAP` flag is required for them.
6. If an announcements / news feed is present: it is in the secondary panel,
   rendered only when its feature flag is on, built as a primitives placeholder
   (`Box as="article"` items + "Load More" `Button`) flagged
   `// COMPONENT GAP: AnnouncementsFeed needed — not yet in Spark`, and uses
   **"Load More"** (button-triggered incremental, `Button loading` bound to the
   fetch) — not infinite scroll, not pagination.
7. The first-run consent modal (if present) is a real `ContentDialog` from
   `@spark-web/modal-dialog` with `showCloseButton={false}`, a single
   `Button tone="primary"` Continue footer whose handler persists the
   profile/initialised flag, gated open on `profile && !profile.initialized`,
   rendered once at page root with open state as a prop — not flagged as a
   component gap, not a custom overlay.
8. No element uses `dangerouslySetInnerHTML`; banner / announcement content is
   sanitized text rendered via `Text` / `Alert` (XSS rule).
9. No raw CSS values beyond the Documented exceptions table (which is empty by
   default); every component is `@spark-web/*` or an explicit consumer-overlay
   substitute, and anything missing (MetricCard, AnnouncementsFeed) is flagged
   with a `// COMPONENT GAP:` comment and called out for product design review.
