@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.
- 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,504 @@
|
|
|
1
|
+
# Vendor admin — form / settings 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 (`Heading level="2"`), the spacing/density rule, and the role/feature-flag
|
|
8
|
+
gating rule defined there all apply to this pattern and take precedence over
|
|
9
|
+
component-level rules.
|
|
10
|
+
|
|
11
|
+
## What this pattern is
|
|
12
|
+
|
|
13
|
+
A full page layout for a vendor-admin **form or settings screen** — one or more
|
|
14
|
+
sections of inputs the vendor edits and saves. Unlike the list page, a form page
|
|
15
|
+
is a centered, bounded, standalone flow: it is **not** opened in a side panel
|
|
16
|
+
(see the surface "Detail interaction rule" — settings is a full page, not a
|
|
17
|
+
panel).
|
|
18
|
+
|
|
19
|
+
## When to use this pattern
|
|
20
|
+
|
|
21
|
+
Use this pattern when the PRD describes any of the following:
|
|
22
|
+
|
|
23
|
+
- A settings, configuration, or preferences screen the vendor edits
|
|
24
|
+
- A "create" or "edit a record" flow with input fields and a save action
|
|
25
|
+
- The words "settings", "configure", "preferences", "form", "edit", or "manage
|
|
26
|
+
your …"
|
|
27
|
+
|
|
28
|
+
If the screen instead displays a list of records, use the list-page pattern:
|
|
29
|
+
|
|
30
|
+
`node_modules/@spark-web/design-system/patterns/vendor-admin/list-page.md`
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Component docs to read
|
|
35
|
+
|
|
36
|
+
Read these before implementing — they own the component-level rules. Only read
|
|
37
|
+
the ones the screen actually uses:
|
|
38
|
+
|
|
39
|
+
- `node_modules/@spark-web/container/CLAUDE.md` — Container `size` keys (page
|
|
40
|
+
centering)
|
|
41
|
+
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking, gap, align
|
|
42
|
+
- `node_modules/@spark-web/heading/CLAUDE.md` — page title (`level="2"`) and
|
|
43
|
+
section titles (`level="3"`)
|
|
44
|
+
- `node_modules/@spark-web/divider/CLAUDE.md` — Divider, section separation
|
|
45
|
+
- `node_modules/@spark-web/field/CLAUDE.md` — Field API (label, message, tone),
|
|
46
|
+
and the Field-context disabled mechanism that wraps every input
|
|
47
|
+
- `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput API
|
|
48
|
+
- `node_modules/@spark-web/text-area/CLAUDE.md` — TextArea API
|
|
49
|
+
- `node_modules/@spark-web/select/CLAUDE.md` — Select API (single-select)
|
|
50
|
+
- `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect API (grouped
|
|
51
|
+
options)
|
|
52
|
+
- `node_modules/@spark-web/checkbox/CLAUDE.md` — Checkbox API (tone, size)
|
|
53
|
+
- `node_modules/@spark-web/currency-input/CLAUDE.md` — CurrencyInput API (money
|
|
54
|
+
fields)
|
|
55
|
+
- `node_modules/@spark-web/date-picker/CLAUDE.md` — DatePicker API (date fields)
|
|
56
|
+
- `node_modules/@spark-web/alert/CLAUDE.md` — inline success/error feedback
|
|
57
|
+
- `node_modules/@spark-web/button/CLAUDE.md` — submit Button, `loading` prop
|
|
58
|
+
- `node_modules/@spark-web/a11y/CLAUDE.md` — VisuallyHidden (accessible labels)
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Page structure
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
Page
|
|
66
|
+
Container size="medium" — centers + bounds the page (940px)
|
|
67
|
+
Outer Stack — gap="xxlarge" marginY="xxlarge" marginX="large"
|
|
68
|
+
Heading level="2" — page title (surface rule — NOT PageHeader, NOT level 1)
|
|
69
|
+
Branch / context guard (conditional) — render the form only once a branch/context is selected
|
|
70
|
+
Section (per form section) — separated by Divider
|
|
71
|
+
Divider — between sections
|
|
72
|
+
Section Stack gap="xlarge"
|
|
73
|
+
Heading level="3" — section title
|
|
74
|
+
Alert (conditional) — section-level success/error feedback
|
|
75
|
+
Field-wrapped inputs — checkbox / select / multi-select / text-input /
|
|
76
|
+
text-area / currency-input / date-picker
|
|
77
|
+
Stack align="right" — submit row
|
|
78
|
+
Button loading={saving} — section-level save (when sections save independently)
|
|
79
|
+
Stack align="right" (conditional) — page-level save (when the whole form saves at once)
|
|
80
|
+
Button loading={saving}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Section 1 — Page shell
|
|
86
|
+
|
|
87
|
+
The page is centered and width-bounded by `Container`, then given vertical
|
|
88
|
+
rhythm by an outer `Stack`.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { Container } from '@spark-web/container';
|
|
92
|
+
import { Heading } from '@spark-web/heading';
|
|
93
|
+
import { Stack } from '@spark-web/stack';
|
|
94
|
+
|
|
95
|
+
<Container size="medium">
|
|
96
|
+
<Stack gap="xxlarge" marginY="xxlarge" marginX="large">
|
|
97
|
+
<Heading level="2">{pageTitle}</Heading>
|
|
98
|
+
{/* sections */}
|
|
99
|
+
</Stack>
|
|
100
|
+
</Container>;
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Rules:
|
|
104
|
+
|
|
105
|
+
- The outer wrapper is **`Container size="medium"`** (940px) — this is the
|
|
106
|
+
form-page centering rule. Do not use `Stack height="full"` here (that is the
|
|
107
|
+
list-page wrapper) and do not omit the Container.
|
|
108
|
+
- The page body is a single `Stack` with `gap="xxlarge"` (between major
|
|
109
|
+
sections), `marginY="xxlarge"`, and `marginX="large"`. These spacing tokens
|
|
110
|
+
come straight from the surface spacing/density rule — never raw pixel gaps.
|
|
111
|
+
- The page title is **`Heading level="2"`** per the surface page-title rule —
|
|
112
|
+
never `PageHeader`, never `level="1"`. The fixed shell Header already carries
|
|
113
|
+
product identity; the in-page title is a section heading within the shell.
|
|
114
|
+
|
|
115
|
+
`Container`'s `size` keys are `xsmall | small | medium | large | xlarge`
|
|
116
|
+
(`medium` = 940px). Use `size="medium"` for a standard settings/form page; use a
|
|
117
|
+
narrower size only when the PRD calls for a tight single-column form.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Section 2 — Sectioned layout
|
|
122
|
+
|
|
123
|
+
A form page is a vertical sequence of titled sections separated by a `Divider`.
|
|
124
|
+
Each section groups a coherent set of fields plus (when each section saves
|
|
125
|
+
independently) its own save button.
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { Divider } from '@spark-web/divider';
|
|
129
|
+
import { Heading } from '@spark-web/heading';
|
|
130
|
+
import { Stack } from '@spark-web/stack';
|
|
131
|
+
|
|
132
|
+
<>
|
|
133
|
+
<Divider />
|
|
134
|
+
<Stack gap="xlarge">
|
|
135
|
+
<Heading level="3">{sectionTitle}</Heading>
|
|
136
|
+
{/* fields */}
|
|
137
|
+
</Stack>
|
|
138
|
+
</>;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Rules:
|
|
142
|
+
|
|
143
|
+
- Separate major sections with `<Divider />` from `@spark-web/divider`. A
|
|
144
|
+
`Divider` precedes each section so sections read as distinct blocks within the
|
|
145
|
+
`gap="xxlarge"` outer rhythm.
|
|
146
|
+
- `Divider`'s `color` defaults to `'standard'` — do not pass a color unless the
|
|
147
|
+
PRD requires a specific divider tone; the default is correct for section
|
|
148
|
+
separation.
|
|
149
|
+
- Each section is a `Stack gap="xlarge"` titled with **`Heading level="3"`** (a
|
|
150
|
+
sub-section step-down from the page's `level="2"` title, per the surface
|
|
151
|
+
page-title rule). Never use `level="2"` for a section title (reserved for the
|
|
152
|
+
page) and never jump to `level="4"+`.
|
|
153
|
+
- Render `null` for a section that is conditionally hidden — never render an
|
|
154
|
+
empty titled section or a dangling `Divider`.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Section 3 — Form fields (every input wrapped in Field)
|
|
159
|
+
|
|
160
|
+
Every input on a vendor-admin form page is wrapped in `Field` from
|
|
161
|
+
`@spark-web/field`. `Field` owns the visible label, the help/error `message`,
|
|
162
|
+
and the `tone`, and it propagates `disabled` to its child input via React
|
|
163
|
+
context.
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
import { Field } from '@spark-web/field';
|
|
167
|
+
import { TextInput } from '@spark-web/text-input';
|
|
168
|
+
|
|
169
|
+
<Field
|
|
170
|
+
label="Trading name"
|
|
171
|
+
message={error?.message}
|
|
172
|
+
tone={error ? 'critical' : 'neutral'}
|
|
173
|
+
>
|
|
174
|
+
<TextInput value={name} onChange={e => setName(e.target.value)} />
|
|
175
|
+
</Field>;
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`Field` rules:
|
|
179
|
+
|
|
180
|
+
- `Field`'s `tone` union is **`critical | neutral | positive`** — there is no
|
|
181
|
+
`info` or `caution` tone on a field. Use `critical` for validation errors,
|
|
182
|
+
`positive` for confirmed-valid state, `neutral` (default) otherwise.
|
|
183
|
+
- A `message` only renders the error styling when paired with `tone="critical"`
|
|
184
|
+
(`invalid = message && tone === 'critical'`). Always set `tone="critical"`
|
|
185
|
+
alongside an error `message`.
|
|
186
|
+
- `Field` disables its child input by passing `disabled` down through context —
|
|
187
|
+
set `disabled` on the `Field`, not on the input, so the input, label, and
|
|
188
|
+
message all reflect the disabled state together.
|
|
189
|
+
|
|
190
|
+
### CRITICAL — DatePicker and CurrencyInput MUST be inside a Field
|
|
191
|
+
|
|
192
|
+
`DatePicker` (`@spark-web/date-picker`) and `CurrencyInput`
|
|
193
|
+
(`@spark-web/currency-input`) read their `disabled` state from the **Field
|
|
194
|
+
context** (`useFieldContext()`), not from a prop. Rendered outside a `Field`,
|
|
195
|
+
they have no context to read and the disabled/placeholder behaviour breaks.
|
|
196
|
+
**Always wrap them in a `Field`.**
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import { Field } from '@spark-web/field';
|
|
200
|
+
import { CurrencyInput } from '@spark-web/currency-input';
|
|
201
|
+
import { DatePicker } from '@spark-web/date-picker';
|
|
202
|
+
|
|
203
|
+
<Field label="Amount" disabled={isLocked}>
|
|
204
|
+
<CurrencyInput value={amount} onChange={setAmount} />
|
|
205
|
+
</Field>
|
|
206
|
+
|
|
207
|
+
<Field label="Start date" disabled={isLocked}>
|
|
208
|
+
<DatePicker value={startDate} onChange={setStartDate} />
|
|
209
|
+
</Field>;
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`DatePicker.onChange` is `(day: Date | undefined) => void`; `CurrencyInput`
|
|
213
|
+
takes a controlled `value` / `onChange` pair.
|
|
214
|
+
|
|
215
|
+
### Input components
|
|
216
|
+
|
|
217
|
+
- **Text** — `TextInput` from `@spark-web/text-input` (single line), `TextArea`
|
|
218
|
+
from `@spark-web/text-area` (multi line).
|
|
219
|
+
- **Single-select** — `Select` from `@spark-web/select`: pass `options`
|
|
220
|
+
(`Array<Option | Group>`), `value`, `onChange={e => …e.target.value}`, and a
|
|
221
|
+
`placeholder`. Always inside a `Field`.
|
|
222
|
+
- **Multi-select** — `MultiSelect` from `@spark-web/multi-select`: pass grouped
|
|
223
|
+
`options` (`Array<{ label, options: Option[] }>`), `onChange` (receives a
|
|
224
|
+
`SelectedOptions` object keyed by group label), `placeholder`, and optional
|
|
225
|
+
`defaultOptions`. `MultiSelect` renders no visible label, so either wrap it in
|
|
226
|
+
a `Field` or label it with `aria-labelledby` pointing at a `VisuallyHidden`
|
|
227
|
+
element.
|
|
228
|
+
- **Checkbox** — `Checkbox` from `@spark-web/checkbox`: controlled via `checked`
|
|
229
|
+
/ `onChange`. Its `tone` union is **`critical | neutral | positive`** (no
|
|
230
|
+
`info`/`caution`) and `size` is **`small | medium`** (`small` is the default).
|
|
231
|
+
Provide an accessible label (its `children`, or a `VisuallyHidden` child when
|
|
232
|
+
the visible label lives elsewhere, e.g. in a settings-row layout).
|
|
233
|
+
- **Currency** — `CurrencyInput` (see the CRITICAL Field-wrapping rule above).
|
|
234
|
+
- **Date** — `DatePicker` (see the CRITICAL Field-wrapping rule above).
|
|
235
|
+
|
|
236
|
+
### React-hook-form binding convention
|
|
237
|
+
|
|
238
|
+
Wire forms with `react-hook-form`, validating with a Zod schema via
|
|
239
|
+
`@hookform/resolvers` (`zodResolver`). Bind each input through `register` (or a
|
|
240
|
+
`Controller` for non-native inputs like `Select`/`MultiSelect`/`DatePicker`/
|
|
241
|
+
`CurrencyInput`) and drive each `Field`'s error presentation from the form's
|
|
242
|
+
`errors[name]`:
|
|
243
|
+
|
|
244
|
+
- when `errors[name]` is present → `tone="critical"` and
|
|
245
|
+
`message={errors[name].message}`;
|
|
246
|
+
- otherwise → `tone="neutral"` (and any static help `message`).
|
|
247
|
+
|
|
248
|
+
In the **vendor-portal** repo this `errors[name] → tone/message` mapping is
|
|
249
|
+
already packaged in a local `FormField` wrapper (exported as `Field`) — use it
|
|
250
|
+
as the consumer substitution for `@spark-web/field`. See the overlay (below); do
|
|
251
|
+
not re-derive the mapping by hand on vendor-portal pages.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Section 4 — Feedback
|
|
256
|
+
|
|
257
|
+
Surface save success/error with an `Alert` from `@spark-web/alert`, rendered
|
|
258
|
+
inside the section it relates to (above the submit row), or at page level for a
|
|
259
|
+
page-wide save.
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import { Alert } from '@spark-web/alert';
|
|
263
|
+
|
|
264
|
+
{
|
|
265
|
+
updateState && (
|
|
266
|
+
<Alert
|
|
267
|
+
tone={updateState.success ? 'positive' : 'critical'}
|
|
268
|
+
onClose={() => setUpdateState(null)}
|
|
269
|
+
closeLabel="Dismiss"
|
|
270
|
+
>
|
|
271
|
+
{updateState.message}
|
|
272
|
+
</Alert>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Rules:
|
|
278
|
+
|
|
279
|
+
- `Alert`'s `tone` union is `caution | critical | info | positive`. For save
|
|
280
|
+
feedback use `positive` (success) or `critical` (failure).
|
|
281
|
+
- `onClose` and `closeLabel` must be provided together — `Alert` only renders
|
|
282
|
+
the close button when both are set. Reset the feedback state on close and when
|
|
283
|
+
the form context changes.
|
|
284
|
+
- Working in vendor-portal? Its `FlashMessage` is the local success/error
|
|
285
|
+
binding over `Alert` — apply the overlay substitution.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Section 5 — Submit
|
|
290
|
+
|
|
291
|
+
The save action is a single `Button` from `@spark-web/button`, right-aligned in
|
|
292
|
+
a `Stack align="right"`, with its `loading` prop bound to the in-flight save
|
|
293
|
+
state.
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
import { Button } from '@spark-web/button';
|
|
297
|
+
import { Stack } from '@spark-web/stack';
|
|
298
|
+
|
|
299
|
+
<Stack align="right">
|
|
300
|
+
<Button type="submit" onClick={handleSave} loading={saving}>
|
|
301
|
+
Save changes
|
|
302
|
+
</Button>
|
|
303
|
+
</Stack>;
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Rules:
|
|
307
|
+
|
|
308
|
+
- Right-align the submit with `Stack align="right"` — do not center or
|
|
309
|
+
left-align the primary save.
|
|
310
|
+
- Bind `loading={saving}` so the button shows a spinner and is non-interactive
|
|
311
|
+
during the mutation — never render a separate external spinner.
|
|
312
|
+
- One primary save per save-scope (see conditional rules) — do not stack
|
|
313
|
+
multiple primary buttons in one row.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Conditional rules
|
|
318
|
+
|
|
319
|
+
### Branch / context-selection guard
|
|
320
|
+
|
|
321
|
+
A vendor-admin form often depends on a selected branch/vendor context. Guard the
|
|
322
|
+
form: when no context is selected, render a short selection prompt instead of
|
|
323
|
+
the form, and render the sections only once the context exists.
|
|
324
|
+
|
|
325
|
+
```tsx
|
|
326
|
+
if (!selectedContextId) {
|
|
327
|
+
return (
|
|
328
|
+
<Container size="small">
|
|
329
|
+
<Stack padding="large" margin="large" align="center">
|
|
330
|
+
<Text size="large">Please select a branch first.</Text>
|
|
331
|
+
</Stack>
|
|
332
|
+
</Container>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- Use a narrower `Container size="small"` for the guard prompt; the form itself
|
|
338
|
+
uses `size="medium"`.
|
|
339
|
+
- Also guard each section's data: when the underlying record is absent, fall
|
|
340
|
+
back to the same prompt rather than rendering empty sections. Adding a `key`
|
|
341
|
+
per section tied to the selected context forces section state to reset when
|
|
342
|
+
the context changes.
|
|
343
|
+
|
|
344
|
+
### Section-level save vs page-level save
|
|
345
|
+
|
|
346
|
+
- **Section-level save** — when each section persists independently (e.g. a
|
|
347
|
+
settings page where "Product settings" and "Notification settings" each have
|
|
348
|
+
their own Save). Put a `Stack align="right"` + `Button loading` **inside each
|
|
349
|
+
section** and give each section its own in-flight + feedback state.
|
|
350
|
+
- **Page-level save** — when the whole form submits as one unit (e.g. a create
|
|
351
|
+
flow). Put a single `Stack align="right"` + `Button loading` **after the last
|
|
352
|
+
section**, driven by one form-submit handler and one in-flight state.
|
|
353
|
+
|
|
354
|
+
Choose one per section/page; do not mix a page-level save with per-section saves
|
|
355
|
+
for the same fields.
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Structural skeleton
|
|
360
|
+
|
|
361
|
+
Use this skeleton as the starting point for new builds and uplifts. Do not use
|
|
362
|
+
existing page implementations as a structural reference. (This example shows
|
|
363
|
+
section-level saves; collapse to a single trailing save row for a page-level
|
|
364
|
+
save.)
|
|
365
|
+
|
|
366
|
+
```tsx
|
|
367
|
+
import { Container } from '@spark-web/container';
|
|
368
|
+
import { Divider } from '@spark-web/divider';
|
|
369
|
+
import { Heading } from '@spark-web/heading';
|
|
370
|
+
import { Stack } from '@spark-web/stack';
|
|
371
|
+
import { Text } from '@spark-web/text';
|
|
372
|
+
|
|
373
|
+
// Branch / context guard — see Conditional rules. When a section's underlying
|
|
374
|
+
// record is absent, fall back to this same prompt instead of rendering empty
|
|
375
|
+
// sections.
|
|
376
|
+
if (!selectedContextId) {
|
|
377
|
+
return (
|
|
378
|
+
<Container size="small">
|
|
379
|
+
<Stack padding="large" margin="large" align="center">
|
|
380
|
+
<Text size="large">Please select a branch first.</Text>
|
|
381
|
+
</Stack>
|
|
382
|
+
</Container>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
<Container size="medium">
|
|
387
|
+
<Stack gap="xxlarge" marginY="xxlarge" marginX="large">
|
|
388
|
+
<Heading level="2">{pageTitle}</Heading>
|
|
389
|
+
|
|
390
|
+
{/* One block per form section — Section 2 (see above) */}
|
|
391
|
+
<Divider />
|
|
392
|
+
<Stack gap="xlarge" key={`${record.id}-details`}>
|
|
393
|
+
<Heading level="3">Details</Heading>
|
|
394
|
+
|
|
395
|
+
{/* Section 4 — feedback Alert (conditional) — see above */}
|
|
396
|
+
|
|
397
|
+
{/* Section 3 — Field-wrapped inputs; DatePicker / CurrencyInput per the
|
|
398
|
+
CRITICAL Field-wrapping rule — see above */}
|
|
399
|
+
|
|
400
|
+
{/* Section 5 — submit row (Stack align="right" + Button loading) — see
|
|
401
|
+
above; section-level save shown here, or collapse to a single
|
|
402
|
+
trailing save row for a page-level save */}
|
|
403
|
+
</Stack>
|
|
404
|
+
</Stack>
|
|
405
|
+
</Container>;
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## Working in vendor-portal?
|
|
411
|
+
|
|
412
|
+
Apply the substitutions in
|
|
413
|
+
`node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.
|
|
414
|
+
The relevant ones for a form page: the `FormField` wrapper (exported as `Field`)
|
|
415
|
+
in place of hand-wiring `@spark-web/field` error props; `FlashMessage` in place
|
|
416
|
+
of a hand-built `Alert` success/error toggle; `MultiselectCheckbox` is being
|
|
417
|
+
superseded by `@spark-web/multi-select` (the shapes match — prefer `MultiSelect`
|
|
418
|
+
in new code); and the `SettingsPanel` label-beside-control settings row.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Documented exceptions summary
|
|
423
|
+
|
|
424
|
+
No raw CSS is required by this pattern's canonical snippets; if you add any,
|
|
425
|
+
list it here with a justification and use the `css` prop.
|
|
426
|
+
|
|
427
|
+
| Value | Property | Reason |
|
|
428
|
+
| ----- | -------- | ------------- |
|
|
429
|
+
| — | — | none required |
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## Do NOTs
|
|
434
|
+
|
|
435
|
+
- NEVER place `DatePicker` or `CurrencyInput` outside a `Field` — both read
|
|
436
|
+
`disabled` from `Field` context (`useFieldContext()`); without a `Field`
|
|
437
|
+
wrapper their disabled/placeholder behaviour breaks.
|
|
438
|
+
- NEVER use a `tone` outside `critical | neutral | positive` on `Checkbox` or
|
|
439
|
+
`Field` — there is no `info` or `caution` tone there (those exist on `Alert`,
|
|
440
|
+
not on fields/checkboxes).
|
|
441
|
+
- NEVER pass a `Checkbox` `size` other than `small | medium`.
|
|
442
|
+
- NEVER set an error `message` on a `Field` without also setting
|
|
443
|
+
`tone="critical"` — the error styling only renders when both are present.
|
|
444
|
+
- NEVER use `Stack height="full"` as the form-page wrapper — the form page is
|
|
445
|
+
centered/bounded with `Container size="medium"` (that full-height wrapper
|
|
446
|
+
belongs to the list page).
|
|
447
|
+
- NEVER replace the page title with `PageHeader` or promote it to `level="1"` —
|
|
448
|
+
vendor-admin page titles are `Heading level="2"`, section titles are
|
|
449
|
+
`level="3"`.
|
|
450
|
+
- NEVER open a settings/form screen in a side panel — settings is a full page
|
|
451
|
+
(see the surface "Detail interaction rule").
|
|
452
|
+
- NEVER render an empty titled section or a dangling `Divider` for a hidden
|
|
453
|
+
section — return `null` instead.
|
|
454
|
+
- NEVER left-align or center the primary save — it is right-aligned via
|
|
455
|
+
`Stack align="right"`.
|
|
456
|
+
- NEVER render an external loading spinner for the save — use the Button
|
|
457
|
+
`loading` prop.
|
|
458
|
+
- NEVER pass a `color` to `Divider` for plain section separation — the default
|
|
459
|
+
`'standard'` is correct.
|
|
460
|
+
- NEVER import `Divider` from a non-Spark package (e.g. `rsuite`) — always
|
|
461
|
+
`@spark-web/divider`.
|
|
462
|
+
- NEVER hand-wire `@spark-web/field` error props on a vendor-portal page when
|
|
463
|
+
the `FormField` (`Field`) wrapper already maps `errors[name]` — use the
|
|
464
|
+
overlay substitution.
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## Validation checklist
|
|
469
|
+
|
|
470
|
+
Run this checklist before marking any form-page task complete. Fix every
|
|
471
|
+
violation first. The uplift protocol in
|
|
472
|
+
`node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
|
|
473
|
+
against existing pages and reports PASS/FAIL per item.
|
|
474
|
+
|
|
475
|
+
1. Page wrapper is `Container size="medium"` (not `Stack height="full"`, not a
|
|
476
|
+
missing Container), with an inner
|
|
477
|
+
`Stack gap="xxlarge" marginY="xxlarge" marginX="large"`.
|
|
478
|
+
2. Page title is `Heading level="2"`; every section title is `Heading level="3"`
|
|
479
|
+
— no `PageHeader`, no `level="1"`, no `level="2"` section titles.
|
|
480
|
+
3. Sections are separated by `<Divider />` from `@spark-web/divider` with the
|
|
481
|
+
default `color="standard"`; hidden sections return `null` (no empty section,
|
|
482
|
+
no dangling Divider).
|
|
483
|
+
4. Every input is wrapped in a `Field` from `@spark-web/field` (or the
|
|
484
|
+
consumer-overlay `Field`/`FormField`), with the label on the `Field`.
|
|
485
|
+
5. `DatePicker` and `CurrencyInput` are each inside a `Field` — verified by
|
|
486
|
+
inspection (they read `disabled` from Field context).
|
|
487
|
+
6. `Field`/`Checkbox` tones are only `critical | neutral | positive`; an error
|
|
488
|
+
`message` is always paired with `tone="critical"`; `Checkbox` `size` is
|
|
489
|
+
`small` or `medium` only.
|
|
490
|
+
7. Feedback uses `Alert` from `@spark-web/alert` (or the overlay `FlashMessage`)
|
|
491
|
+
with `tone` `positive`/`critical` and paired `onClose` + `closeLabel`.
|
|
492
|
+
8. The save is a single right-aligned `Button` per save-scope inside
|
|
493
|
+
`Stack align="right"`, with `loading` bound to the in-flight state — no
|
|
494
|
+
external spinner.
|
|
495
|
+
9. Save scope is consistent — section-level saves live inside each section; a
|
|
496
|
+
page-level save is a single trailing save row; the two are not mixed for the
|
|
497
|
+
same fields.
|
|
498
|
+
10. If the PRD describes a vendor/branch-scoped form, a context-selection guard
|
|
499
|
+
renders a `Container size="small"` prompt when no context is selected, and
|
|
500
|
+
the form renders only once the context exists; if the form is account-wide,
|
|
501
|
+
no guard is required.
|
|
502
|
+
11. No raw CSS values are used (the Documented exceptions table is empty); every
|
|
503
|
+
component is `@spark-web/*` or an explicit consumer-overlay substitute, and
|
|
504
|
+
anything missing is flagged with a `// COMPONENT GAP:` comment.
|