@spark-web/design-system 5.1.3 → 5.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,681 @@
1
+ # Vendor admin — dashboard / overview page pattern
2
+
3
+ ## Before using this pattern
4
+
5
+ Read `node_modules/@spark-web/design-system/patterns/vendor-admin/CLAUDE.md`
6
+ fully before implementing this pattern. The layout-shell rule, the page-title
7
+ rule (and its **documented dashboard exception** — see "Page titling" below),
8
+ the spacing/density rule, the role/feature-flag gating rule, and the badge tone
9
+ mapping defined there all apply to this pattern and take precedence over
10
+ component-level rules.
11
+
12
+ Working in vendor-portal? Apply the substitutions in
13
+ `node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.
14
+
15
+ ## What this pattern is
16
+
17
+ The vendor landing / overview / home screen — the first screen a vendor /
18
+ installer / partner sees after signing in. It summarises the account at a
19
+ glance: a **metrics summary** (stat cards that click through to the underlying
20
+ list), a **resources section** (downloadable assets, links, promo cards), and an
21
+ optional, often flag-gated **announcements / news feed** ("What's New"). On a
22
+ vendor's first visit it may also raise a **first-run consent modal** that must
23
+ be acknowledged before the screen is usable.
24
+
25
+ It renders inside the vendor-admin shell (fixed Header + left NavBar) like every
26
+ other vendor-admin page, but its **page shell differs** from both the list page
27
+ and the form page: it is a responsive **multi-panel layout** — a primary content
28
+ panel plus an optional secondary feed panel — not the full-height single scroll
29
+ region of the list page and not the `Container`-centered column of the form
30
+ page.
31
+
32
+ ## When to use this pattern
33
+
34
+ Use this pattern when the PRD describes any of the following:
35
+
36
+ - A "dashboard", "home", "overview", "landing", or "welcome" screen for a vendor
37
+ - A screen that summarises counts / totals across the account (applications
38
+ under review, approved, paid) as headline stats the user can click into
39
+ - A screen that bundles resources, downloads, promo cards, and/or a product
40
+ announcements / news feed
41
+ - A screen with a first-run consent / onboarding acknowledgement gate
42
+
43
+ If the screen is primarily a searchable, filterable table of records, use the
44
+ list-page pattern; if it is a settings / edit form, use the form-page pattern:
45
+
46
+ - `node_modules/@spark-web/design-system/patterns/vendor-admin/list-page.md`
47
+ - `node_modules/@spark-web/design-system/patterns/vendor-admin/form-page.md`
48
+
49
+ ---
50
+
51
+ ## Component docs to read
52
+
53
+ Read these before implementing — they own the component-level rules. Only read
54
+ the ones the screen actually uses:
55
+
56
+ - `node_modules/@spark-web/row/CLAUDE.md` — Row API; the primary/secondary panel
57
+ layout (and its `flexDirection` collapse on mobile)
58
+ - `node_modules/@spark-web/columns/CLAUDE.md` — Columns API; `collapseBelow`,
59
+ `gap`, `template` — the metrics and resources grids
60
+ - `node_modules/@spark-web/box/CLAUDE.md` — Box layout utilities (panels, card
61
+ placeholders, banner)
62
+ - `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking, gap
63
+ - `node_modules/@spark-web/heading/CLAUDE.md` — section headings (`level="2"`),
64
+ card sub-headings (`level="3"`)
65
+ - `node_modules/@spark-web/text/CLAUDE.md` — Text API (stat numbers, labels,
66
+ card copy)
67
+ - `node_modules/@spark-web/icon/CLAUDE.md` — icon sizing / tones
68
+ (`ArrowRightIcon`, `ArrowNarrowRightIcon`, `PhoneIcon`)
69
+ - `node_modules/@spark-web/button/CLAUDE.md` — `Button` / `ButtonLink` (resource
70
+ card actions, "Load More", consent Continue), `loading` prop
71
+ - `node_modules/@spark-web/text-link/README.md` — `TextLink` (in-prose links,
72
+ e.g. the consent modal's requirements link)
73
+ - `node_modules/@spark-web/modal-dialog/CLAUDE.md` — `ContentDialog` (the
74
+ first-run consent modal and any document dialog)
75
+ - `node_modules/@spark-web/alert/CLAUDE.md` — sanitized announcement banner /
76
+ page-level feedback
77
+
78
+ ---
79
+
80
+ ## Page structure
81
+
82
+ ```
83
+ Page (renders into the vendor-admin shell content region — no Header/NavBar here)
84
+ Row (panel frame) — flexDirection row; collapses to column on mobile (responsive prop)
85
+ Primary panel (Box) — flexGrow={1}; padding responsive; the metrics + resources content
86
+ Banner (conditional) — sanitized announcement Alert/Text — NEVER dangerouslySetInnerHTML
87
+ Metrics section
88
+ Heading level="2" — section heading (dashboard exception: no single page title)
89
+ Columns — MetricCard placeholders (COMPONENT GAP) — collapseBelow, gap
90
+ Resources section
91
+ Heading level="2" — section heading
92
+ Columns — resource / promo cards (primitives — Box/Stack/Button/icon)
93
+ Help / contact banner (optional) — Box with PhoneIcon + Text (+ TextLink/Link)
94
+ Secondary panel (conditional) — announcements feed; rendered only when the feed flag is on
95
+ AnnouncementsFeed — "What's New" timeline + "Load More" (COMPONENT GAP placeholder)
96
+ ContentDialog (conditional) — first-run consent modal; showCloseButton={false}, Continue persists flag
97
+ ```
98
+
99
+ The primary panel and the secondary feed panel sit side by side on tablet and up
100
+ and **stack vertically on mobile**. The feed panel is optional and frequently
101
+ behind a feature flag (consumer logic — see the surface gating rule).
102
+
103
+ ---
104
+
105
+ ## Page shell — multi-panel layout
106
+
107
+ The dashboard shell is a responsive two-panel frame, **not** the list page's
108
+ `Stack height="full"` scroll region and **not** the form page's
109
+ `Container size="medium"`. Use `Row` from `@spark-web/row` for the panel frame:
110
+ a primary content panel (`flexGrow={1}`) and an optional secondary feed panel.
111
+
112
+ `Row` renders a non-wrapping flex row and **locks `flexDirection`** (it is a
113
+ removed Box prop on `Row`). To flip the panels to a vertical stack on mobile,
114
+ Row alone cannot do it — so the canonical shell uses a `Box display="flex"` with
115
+ a **responsive `flexDirection`** prop, which is the supported Spark way to
116
+ express the collapse without raw CSS:
117
+
118
+ ```tsx
119
+ import { Box } from '@spark-web/box';
120
+
121
+ <Box display="flex" flexDirection={{ mobile: 'column', tablet: 'row' }}>
122
+ <Box
123
+ flexGrow={1}
124
+ padding={{ mobile: 'medium', tablet: 'xlarge' }}
125
+ width={{ mobile: 'full' }}
126
+ >
127
+ {/* primary panel — metrics + resources */}
128
+ </Box>
129
+
130
+ {feedEnabled && (
131
+ <Box
132
+ padding={{ mobile: 'medium', tablet: 'xlarge' }}
133
+ width={{ mobile: 'full' }}
134
+ >
135
+ {/* secondary panel — announcements feed */}
136
+ </Box>
137
+ )}
138
+ </Box>;
139
+ ```
140
+
141
+ Rules:
142
+
143
+ - The shell is a `Box display="flex"` with
144
+ `flexDirection={{ mobile: 'column', tablet: 'row' }}` — primary panel
145
+ side-by-side with the optional feed on tablet+, stacked on mobile. This is the
146
+ Spark-native responsive collapse; **do not** reach for a raw
147
+ `@media (max-width: …)` `flexDirection` flip (the vendor-portal source does
148
+ this with `css`/`className`; the canonical pattern does not — see the
149
+ Documented exceptions table).
150
+ - `Row` from `@spark-web/row` is the correct primitive **only when the layout
151
+ never needs to flip axis** (e.g. an action bar). Because the dashboard panels
152
+ must stack on mobile and `Row` locks `flexDirection`, use the responsive `Box`
153
+ above for the panel frame. (You may still use `Row` for inner left/right rows
154
+ such as a card's value-plus-arrow line.)
155
+ - Each panel sets its own responsive `padding`
156
+ (`{ mobile: 'medium', tablet: 'xlarge' }`) and `width={{ mobile: 'full' }}` —
157
+ spacing comes from tokens, never raw pixels.
158
+ - The page does **not** reproduce the shell Header / NavBar — the shell wraps
159
+ the page (surface rule); the page starts at the panel frame.
160
+ - A fixed secondary-panel width (e.g. the feed taking ~40% on tablet+) is a
161
+ product-design decision: prefer `Columns template={[…]}` or a panel `width`
162
+ token. If a precise non-token width is genuinely required it goes in the
163
+ Documented exceptions table — do not inline a raw width silently.
164
+
165
+ ---
166
+
167
+ ## Page titling — documented dashboard exception
168
+
169
+ **The dashboard is a documented exception to the surface page-title rule** (see
170
+ the surface rules' "Page title rule"): it leads with **section-level
171
+ `Heading level="2"` headings** ("Summary", "Support & resources", "What's New")
172
+ standing in for the page title, with **no separate page title**.
173
+
174
+ Rationale: (1) the fixed shell Header already carries the product identity
175
+ ("Brighte Partner Portal"), so a redundant in-page "Dashboard" title adds
176
+ nothing; (2) a dashboard is inherently multi-panel with several co-equal
177
+ sections, none of which is "the page"; (3) it matches the observed
178
+ implementation. The surface rules note this exception and point here.
179
+
180
+ Rules:
181
+
182
+ - Headings **within** a section (e.g. a card title, the document-dialog title)
183
+ step down to `Heading level="3"` (or `level="4"` for a dialog sub-title where
184
+ the dialog itself owns the `level="3"`).
185
+ - Do **not** add a single `Heading level="2">Dashboard</Heading>` above the
186
+ sections and then push the section headings to `level="3"` — that is the
187
+ rejected alternative. Lead with section-level `level="2"` headings directly.
188
+ - `Heading`'s `level` union is `'1' | '2' | '3' | '4'`; its `tone` may be
189
+ `neutral` (the observed section-heading tone), `primary`, `primaryActive`, or
190
+ `muted`.
191
+
192
+ ---
193
+
194
+ ## Section 1 — Metrics summary
195
+
196
+ A row of metric / stat cards in a `Columns` grid: a headline number, a label, a
197
+ subtitle (e.g. a financed total), and a click-through that opens the underlying
198
+ list filtered to that status.
199
+
200
+ There is **no `@spark-web` stat / metric / KPI card component** (verified — no
201
+ package under `packages/` provides one). The metric card is therefore a
202
+ **COMPONENT GAP**. **COMPONENT GAP protocol** (this applies to every gap in this
203
+ file): build the placeholder from primitives, mark the first use with a
204
+ `// COMPONENT GAP: <Name> needed — not yet in Spark` comment, and flag it for
205
+ **product design review before production — the placeholder is a stop-gap; do
206
+ not ship as-is**.
207
+
208
+ ```tsx
209
+ import { Box } from '@spark-web/box';
210
+ import { Heading } from '@spark-web/heading';
211
+ import { Columns } from '@spark-web/columns';
212
+ import { Stack } from '@spark-web/stack';
213
+ import { Text } from '@spark-web/text';
214
+ import { ArrowRightIcon } from '@spark-web/icon';
215
+
216
+ <Box marginTop="small">
217
+ <Heading level="2" tone="neutral">
218
+ Summary
219
+ </Heading>
220
+
221
+ <Columns
222
+ marginTop="xlarge"
223
+ gap={{ mobile: 'small', tablet: 'large' }}
224
+ collapseBelow="wide"
225
+ >
226
+ {metrics.map(metric => (
227
+ /* COMPONENT GAP: MetricCard needed — not yet in Spark — protocol: see
228
+ the Section 1 intro; prop contract: see the rules below. */
229
+ <Box
230
+ key={metric.id}
231
+ background="surface"
232
+ border="standard"
233
+ borderRadius="large"
234
+ padding="large"
235
+ cursor="pointer"
236
+ onClick={() => onMetricClick(metric)}
237
+ >
238
+ <Stack gap="medium">
239
+ <Text size="xxlarge" weight="bold" tone="neutral">
240
+ {metric.value}
241
+ </Text>
242
+ <Text weight="semibold" tone="primary">
243
+ {metric.label}
244
+ </Text>
245
+ <Box display="flex" justifyContent="spaceBetween" alignItems="center">
246
+ {metric.subtitle && (
247
+ <Text size="xsmall" tone="muted">
248
+ {metric.subtitle}
249
+ </Text>
250
+ )}
251
+ <ArrowRightIcon size="xsmall" tone="primary" />
252
+ </Box>
253
+ </Stack>
254
+ </Box>
255
+ ))}
256
+ </Columns>
257
+ </Box>;
258
+ ```
259
+
260
+ Rules:
261
+
262
+ - **MetricCard is a COMPONENT GAP** — mark the first use with
263
+ `// COMPONENT GAP: MetricCard needed — not yet in Spark` (protocol: see the
264
+ Section 1 intro). Prop contract: `value` (string — the headline number, e.g. a
265
+ `toLocaleString()`'d count), `label` (string), `subtitle` (string, optional),
266
+ and exactly one of `href` / `onClick` for the click-through.
267
+ - Lay cards out with `Columns` (`collapseBelow="wide"`, responsive `gap`) — one
268
+ card per column, equal width. Do not hand-roll a flex grid.
269
+ - The headline number is a `Text` with a large size/weight — **never** a raw
270
+ `style={{ fontSize: '34px' }}` (the vendor-portal source does this; use a Text
271
+ size token such as `xxlarge` — 35px, the closest token to the raw 34px —
272
+ instead). Status counts are plain numbers here, not `Badge`s — the badge tone
273
+ mapping applies to status _labels_ in tables, not to dashboard headline
274
+ figures.
275
+ - The click-through navigates to the underlying list filtered to that metric
276
+ (e.g. `/applications?status=approved`); wire it via `onClick`/`href`. Role /
277
+ flag visibility of individual metrics (e.g. hiding "Paid" for non-vendor
278
+ roles) is consumer gating logic, not a design-system rule.
279
+ - Working in vendor-portal? The overlay substitutes the consumer's
280
+ `DashboardMetrics` / `MetricCard` — see the overlay.
281
+
282
+ ---
283
+
284
+ ## Section 2 — Resources
285
+
286
+ A `Columns` grid of resource / promo cards — a media image, a heading, body
287
+ copy, and an action (`Button` / `ButtonLink` / link), and/or a link list that
288
+ opens in a document dialog. Resource cards are **lighter than a component gap**:
289
+ they compose cleanly from primitives (`Box`/`Stack`/`Text`/`Button`/`Icon`/
290
+ `Image`), so build them from primitives directly — no `// COMPONENT GAP` flag is
291
+ required. (They are a reusable _consumer_ card, not a missing Spark primitive;
292
+ if a repo wants to factor the repeated card chrome into a local component, that
293
+ is a consumer convenience, noted in the overlay — not a Spark gap.)
294
+
295
+ ```tsx
296
+ import { Box } from '@spark-web/box';
297
+ import { Heading } from '@spark-web/heading';
298
+ import { Columns } from '@spark-web/columns';
299
+ import { Button, ButtonLink } from '@spark-web/button';
300
+ import { Stack } from '@spark-web/stack';
301
+ import { Text } from '@spark-web/text';
302
+ import { ArrowNarrowRightIcon } from '@spark-web/icon';
303
+
304
+ <Box marginTop="xxlarge">
305
+ <Heading level="2" tone="neutral">
306
+ Support &amp; resources
307
+ </Heading>
308
+
309
+ <Columns
310
+ marginTop="xlarge"
311
+ gap={{ mobile: 'medium', tablet: 'medium', desktop: 'large' }}
312
+ collapseBelow="wide"
313
+ >
314
+ {resources.map(resource => (
315
+ <Box
316
+ key={resource.id}
317
+ background="surface"
318
+ border="standard"
319
+ borderRadius="large"
320
+ padding="large"
321
+ display="flex"
322
+ flexDirection="column"
323
+ height="full"
324
+ >
325
+ <Stack gap="large">
326
+ <Text size="large" weight="semibold" tone="neutral">
327
+ {resource.heading}
328
+ </Text>
329
+ <Box>
330
+ <ButtonLink href={resource.href} target="_blank">
331
+ {resource.actionLabel}
332
+ <ArrowNarrowRightIcon size="xxsmall" tone="primary" />
333
+ </ButtonLink>
334
+ </Box>
335
+ </Stack>
336
+ </Box>
337
+ ))}
338
+ </Columns>
339
+ </Box>;
340
+ ```
341
+
342
+ Rules:
343
+
344
+ - Build resource / promo cards from primitives (`Box` card chrome —
345
+ `background="surface" border="standard" borderRadius="large" padding="large"`
346
+ — plus `Stack`/`Text`/`Button`). No COMPONENT GAP flag — these are not a
347
+ missing Spark primitive.
348
+ - Card actions are `Button` (`onClick`) or `ButtonLink` (`href`, opens in a new
349
+ tab with `target="_blank"`) from `@spark-web/button` — never a raw `<a>`
350
+ styled as a button.
351
+ - A long list of downloadable documents that opens in a modal is a
352
+ `ContentDialog` from `@spark-web/modal-dialog` (see Section 4 — same component
353
+ family as the consent modal): a `size="small"` dialog whose body is a `Stack`
354
+ of `TextLink`s. Working in vendor-portal? The overlay maps its
355
+ `DashboardAds`/`AdCard` and `DocumentsDialog`.
356
+
357
+ ---
358
+
359
+ ## Section 3 — Announcements / news feed (optional, flag-gated)
360
+
361
+ An optional secondary-panel "What's New" feed: a vertical timeline of article
362
+ items (date, image, heading, intro, "Learn more" link), with incremental **"Load
363
+ More"** loading. The feed is usually behind a feature flag (consumer gating) and
364
+ renders only when enabled.
365
+
366
+ There is **no `@spark-web` feed / timeline / article-list component** — the feed
367
+ container is a **COMPONENT GAP** (protocol: see Section 1); the placeholder is
368
+ `Box as="article"` items in a `Stack`, plus a "Load More" `Button`.
369
+
370
+ ```tsx
371
+ import { Box } from '@spark-web/box';
372
+ import { Heading } from '@spark-web/heading';
373
+ import { Stack } from '@spark-web/stack';
374
+ import { Text } from '@spark-web/text';
375
+ import { Button } from '@spark-web/button';
376
+ import { TextLink } from '@spark-web/text-link';
377
+
378
+ {
379
+ /* COMPONENT GAP: AnnouncementsFeed needed — not yet in Spark — protocol: see
380
+ Section 1; prop contract: see the rules below. */
381
+ }
382
+ {
383
+ feedEnabled && (
384
+ <Stack gap="xlarge" width={{ mobile: 'full' }}>
385
+ <Heading level="2" tone="neutral">
386
+ What&apos;s New
387
+ </Heading>
388
+
389
+ <Stack gap="xlarge">
390
+ {items.map(item => (
391
+ <Box as="article" key={item.id}>
392
+ <Stack gap="small">
393
+ <Text size="small" tone="muted">
394
+ {item.date}
395
+ </Text>
396
+ <Text weight="bold" tone="neutral">
397
+ {item.heading}
398
+ </Text>
399
+ <Text size="small" tone="neutral">
400
+ {item.intro}
401
+ </Text>
402
+ <TextLink href={item.href} target="_blank">
403
+ Learn more
404
+ </TextLink>
405
+ </Stack>
406
+ </Box>
407
+ ))}
408
+ </Stack>
409
+
410
+ {hasNextPage && (
411
+ <Button tone="neutral" loading={isLoading} onClick={onLoadMore}>
412
+ Load More
413
+ </Button>
414
+ )}
415
+ </Stack>
416
+ );
417
+ }
418
+ ```
419
+
420
+ Rules:
421
+
422
+ - **AnnouncementsFeed is a COMPONENT GAP** — mark the first use with
423
+ `// COMPONENT GAP: AnnouncementsFeed needed — not yet in Spark` (protocol: see
424
+ Section 1). Prop contract: `items`
425
+ (`Array<{ id, date, heading, intro, href, imageUrl? }>`), `hasNextPage`
426
+ (boolean), `isLoading` (boolean), `onLoadMore` (`() => void`).
427
+ - Each feed entry is a `Box as="article"` for correct semantics; the date is a
428
+ muted `Text`, the heading a bold `Text`, and "Learn more" a `TextLink` (in
429
+ body copy) or `ButtonLink` (as a button).
430
+ - **"Load More" is a THIRD list-loading mode.** The list-page pattern recognises
431
+ infinite scroll and pagination; this feed uses **button-triggered incremental
432
+ loading** — a "Load More" `Button` that fetches and appends the next page on
433
+ click, with the Button's `loading` prop bound to the in-flight fetch. It is
434
+ distinct from auto-fetch-on-scroll (infinite scroll) and from page controls
435
+ (pagination). Use "Load More" for a short, browse-occasionally feed where the
436
+ user opts in to more rather than paging or endlessly scrolling. The surface
437
+ rules list it as the third recognised mode.
438
+ - The feed panel renders only when its feature flag is on (consumer gating) —
439
+ when off, omit the secondary panel entirely (the primary panel then fills the
440
+ shell). Flag gating is consumer logic, not a design-system rule.
441
+ - The vertical connector line / image styling in the observed implementation is
442
+ consumer chrome; keep the canonical placeholder to the semantic structure
443
+ above. Working in vendor-portal? The overlay maps its
444
+ `DashboardProductAnnouncements`.
445
+
446
+ ---
447
+
448
+ ## Section 4 — First-run consent / onboarding modal
449
+
450
+ On first visit a vendor may need to acknowledge a consent / requirements
451
+ statement before using the dashboard. This is a **real** `@spark-web` component
452
+ — `ContentDialog` from `@spark-web/modal-dialog` — **not** a component gap.
453
+
454
+ ```tsx
455
+ import { Button } from '@spark-web/button';
456
+ import { ContentDialog } from '@spark-web/modal-dialog';
457
+ import { Text } from '@spark-web/text';
458
+ import { TextLink } from '@spark-web/text-link';
459
+
460
+ <ContentDialog
461
+ isOpen={showConsent}
462
+ title="Vendor requirements"
463
+ showCloseButton={false}
464
+ footer={
465
+ <Button tone="primary" onClick={handleConsent}>
466
+ Continue
467
+ </Button>
468
+ }
469
+ onToggle={onConsentToggle}
470
+ >
471
+ <Text>
472
+ By clicking Continue, you confirm your understanding and acceptance of the{' '}
473
+ <TextLink href={requirementsUrl} target="_blank">
474
+ vendor requirements
475
+ </TextLink>{' '}
476
+ document.
477
+ </Text>
478
+ </ContentDialog>;
479
+ ```
480
+
481
+ Rules:
482
+
483
+ - Build it on `ContentDialog` (`@spark-web/modal-dialog`) — the controlled form
484
+ (`isOpen` + `onToggle`). Read the modal-dialog doc for the full API.
485
+ - **`showCloseButton={false}`** — the gate must be acknowledged; do not give the
486
+ user an X to dismiss it.
487
+ - The single footer action is a `Button tone="primary"` labelled "Continue" (or
488
+ similar). Its handler **persists the acknowledgement flag** (e.g. POSTs
489
+ `initialized: true` to the profile) so the modal does not reappear on the next
490
+ visit, then closes the modal.
491
+ - **Gate placement:** drive `isOpen` from the persisted profile/initialised flag
492
+ — open the modal in an effect when the profile has loaded **and** the flag is
493
+ not yet set (`profile && !profile.initialized`), not unconditionally on mount.
494
+ Render the `ContentDialog` once, at the page root (a sibling of the panel
495
+ frame), with its open state as a prop — not conditionally mounted — so the
496
+ close transition runs. Keep the gate at the page level, not buried inside a
497
+ panel.
498
+ - In-prose links inside the modal body are `TextLink` from
499
+ `@spark-web/text-link` (with `target="_blank"` for external docs) — never a
500
+ raw `<a>`.
501
+ - A document-list dialog (Section 2) uses the **same** `ContentDialog` component
502
+ with `size="small"`; it differs only in that it may be dismissable.
503
+
504
+ ---
505
+
506
+ ## Security — NEVER use `dangerouslySetInnerHTML`
507
+
508
+ The observed implementation renders an announcement banner via
509
+ `<Text … dangerouslySetInnerHTML={{ __html: title + content }} />`. **Never do
510
+ this.** Injecting server- or CMS-supplied HTML straight into the DOM is an XSS
511
+ vector (the source even flags it `// this one may cause security issue (XSS)`).
512
+
513
+ Render banner content as **sanitized text**, not HTML:
514
+
515
+ ```tsx
516
+ import { Alert } from '@spark-web/alert';
517
+ import { Text } from '@spark-web/text';
518
+
519
+ {
520
+ banner && (
521
+ <Alert tone="info">
522
+ <Text>{banner.message}</Text>
523
+ </Alert>
524
+ );
525
+ }
526
+ ```
527
+
528
+ Rules:
529
+
530
+ - A dashboard banner is an `Alert` from `@spark-web/alert` (or a `Box` +
531
+ `Text`), with its message rendered as **plain text children** — `Alert`'s
532
+ `tone` union is `caution | critical | info | positive` (use `info` for a
533
+ neutral announcement, `caution` for a time-sensitive notice).
534
+ - If the banner genuinely needs rich content (a link), compose it from `Text` +
535
+ `TextLink` as React children — never pass an HTML string.
536
+ - **NEVER** pass `dangerouslySetInnerHTML` to `Text`, `Box`, or any element on a
537
+ vendor-admin page. If the source HTML cannot be expressed as React children,
538
+ sanitize it server-side and render the sanitized text — do not inject raw
539
+ HTML.
540
+
541
+ ---
542
+
543
+ ## Structural skeleton
544
+
545
+ Use this skeleton as the starting point for new builds and uplifts. Do not use
546
+ existing page implementations as a structural reference.
547
+
548
+ ```tsx
549
+ import { Box } from '@spark-web/box';
550
+
551
+ <>
552
+ <Box display="flex" flexDirection={{ mobile: 'column', tablet: 'row' }}>
553
+ {/* Primary panel — metrics + resources */}
554
+ <Box
555
+ flexGrow={1}
556
+ padding={{ mobile: 'medium', tablet: 'xlarge' }}
557
+ width={{ mobile: 'full' }}
558
+ >
559
+ {/* Sanitized banner Alert (conditional) — NEVER dangerouslySetInnerHTML
560
+ — see the Security section above */}
561
+
562
+ {/* Section 1 — metrics: Heading level="2" + Columns of MetricCard
563
+ placeholders (COMPONENT GAP) — see above */}
564
+
565
+ {/* Section 2 — resources: Heading level="2" + Columns of primitive
566
+ resource / promo cards — see above */}
567
+ </Box>
568
+
569
+ {/* Secondary panel — Section 3: AnnouncementsFeed placeholder
570
+ (flag-gated, optional; COMPONENT GAP) — see above */}
571
+ </Box>
572
+
573
+ {/* Section 4 — first-run consent ContentDialog (real component, NOT a gap;
574
+ showCloseButton={false}, Continue persists the flag) — see above */}
575
+ </>;
576
+ ```
577
+
578
+ ---
579
+
580
+ ## Documented exceptions summary
581
+
582
+ No raw CSS is required by this pattern's canonical snippets; if you add any,
583
+ list it here with a justification and apply it via the `css` prop — never via
584
+ `className` (`Stack` omits `className`; do not use `className` on `Box` either —
585
+ the styling escape hatch on both is the `css` prop).
586
+
587
+ | Value | Property | Reason |
588
+ | ----- | -------- | ------------- |
589
+ | — | — | none required |
590
+
591
+ ---
592
+
593
+ ## Do NOTs
594
+
595
+ - NEVER add a single `Heading level="2"` page title above the sections — the
596
+ dashboard is a documented exception: it leads with section-level `level="2"`
597
+ headings and has no separate page title (sub-headings within a section step to
598
+ `level="3"`).
599
+ - NEVER use the list page's `Stack height="full"` scroll region or the form
600
+ page's `Container size="medium"` as the dashboard shell — the dashboard shell
601
+ is a multi-panel `Box display="flex"` with a responsive `flexDirection`.
602
+ - NEVER use a raw `@media (max-width: …)` `flexDirection` flip (via `css` or
603
+ `className`) for the panel collapse — use the responsive
604
+ `flexDirection={{ mobile: 'column', tablet: 'row' }}` prop.
605
+ - NEVER set `className` on `Stack` or `Box` — `Stack` omits `className`, and
606
+ `className` must not be used on `Box` either; the styling escape hatch on both
607
+ is the `css` prop, and only for a Documented exception.
608
+ - NEVER reproduce the shell Header or NavBar inside the page — the shell wraps
609
+ the page (surface rule); the page starts at the panel frame.
610
+ - NEVER render a metric headline number with a raw `style={{ fontSize: … }}` —
611
+ use a `Text` size token (e.g. `size="xxlarge"`).
612
+ - NEVER use a `Badge` for a dashboard headline figure — badges map _status
613
+ labels_ in tables; dashboard stats are plain `Text` numbers.
614
+ - NEVER import a metric card, stat card, or announcements feed from `@spark-web`
615
+ — they are COMPONENT GAPs. Build each as a primitives placeholder
616
+ (`Box`/`Stack`/`Text`/`Icon`/`Button`), mark it with a `// COMPONENT GAP:`
617
+ comment, and flag it for product design review before production — do not ship
618
+ the placeholder as-is. (In vendor-portal, the overlay's local components
619
+ remain the canonical substitution.)
620
+ - NEVER flag the consent modal as a component gap or hand-roll a custom overlay
621
+ — it is a real `ContentDialog` from `@spark-web/modal-dialog`.
622
+ - NEVER give the first-run consent modal a close button — use
623
+ `showCloseButton={false}`; the Continue action persists the flag and closes
624
+ it.
625
+ - NEVER open the consent modal unconditionally on mount — gate it on the
626
+ persisted profile/initialised flag (`profile && !profile.initialized`).
627
+ - NEVER use `dangerouslySetInnerHTML` on `Text`, `Box`, or any element to render
628
+ a banner / announcement — render sanitized content via `Text` / `Alert` (XSS
629
+ risk).
630
+ - NEVER treat "Load More" as pagination or infinite scroll — it is a distinct
631
+ third loading mode (button-triggered incremental append); bind the Button's
632
+ `loading` prop to the in-flight fetch.
633
+
634
+ ---
635
+
636
+ ## Validation checklist
637
+
638
+ Run this checklist before marking any vendor-admin dashboard task complete. Fix
639
+ every violation first. The uplift protocol in
640
+ `node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
641
+ against existing pages and reports PASS/FAIL per item.
642
+
643
+ 1. The page leads with **section-level `Heading level="2"`** headings (e.g.
644
+ "Summary", "Support & resources", "What's New") and has **no single page
645
+ title** — the documented dashboard exception; sub-headings within a section
646
+ are `level="3"` (or `level="4"` inside a dialog). No `PageHeader`, no
647
+ `level="1"`.
648
+ 2. The shell is a multi-panel `Box display="flex"` with
649
+ `flexDirection={{ mobile: 'column', tablet: 'row' }}` — a primary panel
650
+ (`flexGrow={1}`) plus an optional secondary feed panel — **not**
651
+ `Stack height="full"` (list page) and **not** `Container size="medium"` (form
652
+ page); the page does not reproduce the shell Header / NavBar.
653
+ 3. The responsive panel collapse uses the `flexDirection` responsive prop —
654
+ there is no raw `@media` `flexDirection` flip and no `className` on any
655
+ `Stack` / `Box`; spacing is all tokens.
656
+ 4. Metrics render in a `Columns` grid (`collapseBelow`, responsive `gap`); each
657
+ metric card is a primitives placeholder (`Box`/`Stack`/`Text`/`Icon`) flagged
658
+ `// COMPONENT GAP: MetricCard needed — not yet in Spark`, with the headline
659
+ number a `Text` size token (not a raw `fontSize`) and a click-through
660
+ (`onClick`/`href`) to the filtered list; no `Badge` for the headline figure.
661
+ 5. Resource / promo cards are composed from primitives (`Box` card chrome +
662
+ `Stack`/`Text`/`Button`/`ButtonLink`) with actions as `Button`/`ButtonLink`
663
+ (not raw `<a>`); no `// COMPONENT GAP` flag is required for them.
664
+ 6. If an announcements / news feed is present: it is in the secondary panel,
665
+ rendered only when its feature flag is on, built as a primitives placeholder
666
+ (`Box as="article"` items + "Load More" `Button`) flagged
667
+ `// COMPONENT GAP: AnnouncementsFeed needed — not yet in Spark`, and uses
668
+ **"Load More"** (button-triggered incremental, `Button loading` bound to the
669
+ fetch) — not infinite scroll, not pagination.
670
+ 7. The first-run consent modal (if present) is a real `ContentDialog` from
671
+ `@spark-web/modal-dialog` with `showCloseButton={false}`, a single
672
+ `Button tone="primary"` Continue footer whose handler persists the
673
+ profile/initialised flag, gated open on `profile && !profile.initialized`,
674
+ rendered once at page root with open state as a prop — not flagged as a
675
+ component gap, not a custom overlay.
676
+ 8. No element uses `dangerouslySetInnerHTML`; banner / announcement content is
677
+ sanitized text rendered via `Text` / `Alert` (XSS rule).
678
+ 9. No raw CSS values beyond the Documented exceptions table (which is empty by
679
+ default); every component is `@spark-web/*` or an explicit consumer-overlay
680
+ substitute, and anything missing (MetricCard, AnnouncementsFeed) is flagged
681
+ with a `// COMPONENT GAP:` comment and called out for product design review.