@spark-web/design-system 5.1.4 → 5.1.6
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.
- package/CLAUDE.md +38 -192
- package/package.json +20 -21
- package/patterns/CLAUDE.md +75 -35
- package/patterns/internal-admin/CLAUDE.md +7 -19
- package/patterns/internal-admin/{details-page.md → detail-page.md} +114 -51
- package/patterns/internal-admin/list-page.md +199 -137
- package/patterns/internal-admin/portal-hub.md +165 -0
- package/patterns/vendor-admin/CLAUDE.md +216 -0
- package/patterns/vendor-admin/dashboard.md +681 -0
- package/patterns/vendor-admin/form-page.md +504 -0
- package/patterns/vendor-admin/list-page.md +709 -0
- package/patterns/vendor-admin/vendor-portal.md +309 -0
- package/ai-context/layer-1-root.md +0 -158
- package/ai-context/layer-2-surface-pattern.md +0 -236
- package/ai-context/layer-3-component.md +0 -271
|
@@ -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 & 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'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.
|