UNPKG

@spark-web/design-system

Version:

--- title: Design System ---

682 lines (585 loc) 29.9 kB
# 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/TextNEVER dangerouslySetInnerHTML Metrics section Heading level="2" — section heading (dashboard exception: no single page title) ColumnsMetricCard 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 1Metrics 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 2Resources 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 3Announcements / 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 4First-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. --- ## SecurityNEVER 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.