@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.
@@ -2,10 +2,9 @@
2
2
 
3
3
  ## Before using this pattern
4
4
 
5
- Read node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md
6
- fully before implementing this pattern. The interaction rules, badge tone
7
- mapping, row clickability rules, and overflow menu rules defined there all apply
8
- to this pattern.
5
+ Surface rules: read
6
+ `node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md` in
7
+ full first its rules all apply here.
9
8
 
10
9
  ## What this pattern is
11
10
 
@@ -15,11 +14,9 @@ admin surfaces.
15
14
 
16
15
  ## When to use this pattern
17
16
 
18
- Use this pattern when the PRD describes any of the following:
19
-
20
- - A list of records that can be searched, filtered, or sorted
21
- - A management interface where records can be viewed or acted upon
22
- - The words "list", "manage", "view all", "records", or "results"
17
+ Any list/management view of records the registry
18
+ (`node_modules/@spark-web/design-system/patterns/CLAUDE.md`) owns surface and
19
+ feature-type classification.
23
20
 
24
21
  ---
25
22
 
@@ -27,17 +24,24 @@ Use this pattern when the PRD describes any of the following:
27
24
 
28
25
  Read these before implementing — they own the component-level rules:
29
26
 
30
- - `packages/page-header/CLAUDE.md` — PageHeader API, action type rules
31
- - `packages/data-table/CLAUDE.md` — DataTable API, column defs, loading/empty
32
- states, headerClassName tokens
33
- - `packages/badge/CLAUDE.md` status tone mapping
34
- - `packages/meatball-menu/CLAUDE.md` — MeatballMenu API
35
- - `packages/stack/CLAUDE.md` — vertical stacking and gap
36
- - `packages/box/CLAUDE.md` — flex layout utilities
37
- - `packages/columns/CLAUDE.md` — responsive multi-column layout
38
- - `packages/field/CLAUDE.md` — Field API, labelVisibility
39
- - `packages/text/CLAUDE.md` — Text API
40
- - `packages/text-input/CLAUDE.md` — TextInput API
27
+ - `node_modules/@spark-web/page-header/CLAUDE.md` — PageHeader API, action type
28
+ rules
29
+ - `node_modules/@spark-web/data-table/CLAUDE.md` — DataTable API, column defs,
30
+ loading/empty states, headerClassName tokens
31
+ - `node_modules/@spark-web/badge/CLAUDE.md` — status tone mapping
32
+ - `node_modules/@spark-web/meatball-menu/CLAUDE.md` — MeatballMenu API
33
+ - `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking and gap
34
+ - `node_modules/@spark-web/box/CLAUDE.md` — flex layout utilities
35
+ - `node_modules/@spark-web/columns/CLAUDE.md` — responsive multi-column layout
36
+ - `node_modules/@spark-web/field/CLAUDE.md` — Field API, labelVisibility
37
+ - `node_modules/@spark-web/select/CLAUDE.md` — Select API, filter dropdown
38
+ pattern
39
+ - `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect filter
40
+ dropdowns
41
+ - `node_modules/@spark-web/text/CLAUDE.md` — Text API
42
+ - `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput API
43
+ - `node_modules/@spark-web/alert/CLAUDE.md` — page-level feedback Alert
44
+ - `node_modules/@spark-web/icon/CLAUDE.md` — icon sizing and tones (SearchIcon)
41
45
 
42
46
  ---
43
47
 
@@ -66,8 +70,8 @@ manual `Stack + Heading`.
66
70
  <PageHeader label={pageTitle} />
67
71
  ```
68
72
 
69
- See `packages/page-header/CLAUDE.md` for action types (button, link, meatball),
70
- statusBadge, and controls props.
73
+ See `node_modules/@spark-web/page-header/CLAUDE.md` for action types (button,
74
+ link, meatball), statusBadge, and controls props.
71
75
 
72
76
  Rules:
73
77
 
@@ -111,7 +115,7 @@ Rules:
111
115
  The outermost container fills the full viewport height.
112
116
 
113
117
  ```tsx
114
- <Stack height="full" className={css({ minHeight: '100vh' })}>
118
+ <Stack height="full" css={{ minHeight: '100vh' }}>
115
119
  ```
116
120
 
117
121
  Documented exception:
@@ -129,13 +133,11 @@ Sits inside the outer wrapper. Owns all page padding and gap between sections.
129
133
  <Stack
130
134
  padding="large"
131
135
  gap="large"
132
- className={css({
133
- backgroundColor: theme.color.background.neutral,
134
- flex: 1,
135
- display: 'flex',
136
- flexDirection: 'column',
137
- overflow: 'hidden',
138
- })}
136
+ flex={1}
137
+ display="flex"
138
+ flexDirection="column"
139
+ overflow="hidden"
140
+ css={{ backgroundColor: theme.color.background.neutral }}
139
141
  >
140
142
  ```
141
143
 
@@ -145,11 +147,17 @@ Token mappings:
145
147
  - `gap="large"` — between all sections, Spark token
146
148
  - `backgroundColor` — `theme.color.background.neutral` via `useTheme()`
147
149
 
148
- Documented exceptions:
150
+ Spark props (not exceptions):
151
+
152
+ - `flex={1}` — makes content area fill remaining height (Box `flex` prop)
153
+ - `display="flex"` + `flexDirection="column"` — required to activate `flex={1}`
154
+ (Box props)
155
+ - `overflow="hidden"` — scroll containment (Box `overflow` prop)
156
+
157
+ Documented exception:
149
158
 
150
- - `flex: 1` — makes content area fill remaining height, no Spark flex prop
151
- - `display: 'flex'` + `flexDirection: 'column'` required to activate flex: 1
152
- - `overflow: 'hidden'` — scroll containment, no Spark overflow prop on Stack
159
+ - `backgroundColor: theme.color.background.neutral` — accessed via `useTheme()`,
160
+ not a Box prop, so it goes through the `css` prop
153
161
 
154
162
  ---
155
163
 
@@ -158,29 +166,43 @@ Documented exceptions:
158
166
  Renders filter dropdowns and a search input in a horizontal row.
159
167
 
160
168
  ```tsx
161
- const FieldProps = { labelVisibility: 'hidden' as const };
162
-
163
169
  <Columns gap="large" collapseBelow="desktop">
164
- <TextInputField
165
- control={control}
166
- name="search"
167
- label="Search"
168
- placeholder="Search by..."
169
- FieldProps={FieldProps}
170
- >
171
- <InputAdornment placement="start">
172
- <SearchIcon size="xxsmall" tone="muted" />
173
- </InputAdornment>
174
- </TextInputField>
175
- <MultiSelectField
176
- control={control}
177
- name="fieldName"
178
- label="Label"
179
- options={options}
180
- placeholder="Filter by..."
181
- fieldProps={FieldProps}
182
- />
183
- </Columns>;
170
+ {/* Search — always first */}
171
+ <Field label="Search" labelVisibility="hidden">
172
+ <TextInput
173
+ placeholder="Search by..."
174
+ value={search}
175
+ onChange={e => setSearch(e.target.value)}
176
+ >
177
+ <InputAdornment placement="start">
178
+ <SearchIcon size="xxsmall" tone="muted" />
179
+ </InputAdornment>
180
+ </TextInput>
181
+ </Field>
182
+
183
+ {/* Multi-select filter */}
184
+ <Box>
185
+ <VisuallyHidden as="span" id="status-filter-label">
186
+ Status
187
+ </VisuallyHidden>
188
+ <MultiSelect
189
+ aria-labelledby="status-filter-label"
190
+ options={[{ label: '', options: statusOptions }]}
191
+ placeholder="Filter by status..."
192
+ onChange={setStatusFilter}
193
+ />
194
+ </Box>
195
+
196
+ {/* Single-select filter */}
197
+ <Field label="Role" labelVisibility="hidden">
198
+ <Select
199
+ placeholder="Filter by role..."
200
+ options={roleOptions}
201
+ value={roleFilter}
202
+ onChange={e => setRoleFilter(e.target.value)}
203
+ />
204
+ </Field>
205
+ </Columns>
184
206
  ```
185
207
 
186
208
  Rules:
@@ -190,13 +212,18 @@ Rules:
190
212
  - **Search input always appears first (leftmost)** in the filter row
191
213
  - Filter dropdowns follow the search input, ordered by specificity (broadest
192
214
  filter first, most specific last)
193
- - Multi-select filter dropdowns use `MultiSelectField` from
194
- `@brighte/ui-components` (portal-hub only — `@spark-web/multi-select` is not
195
- available in portal-hub)
196
- - Single-select filter dropdowns use `SelectField` from `@brighte/ui-components`
197
- - Always pass `fieldProps={{ labelVisibility: 'hidden' as const }}` on both
198
- `MultiSelectField` and `SelectField` define it as a constant outside the
199
- component to avoid re-renders
215
+ - Multi-select filter dropdowns use `MultiSelect` from
216
+ `@spark-web/multi-select`. Its `options` are grouped — pass
217
+ `[{ label: '', options }]` for a flat list. It renders no visible label, so
218
+ label it for assistive technology via `aria-labelledby` pointing at a
219
+ `VisuallyHidden` element from `@spark-web/a11y`
220
+ - Single-select filter dropdowns use `Select` from `@spark-web/select`, always
221
+ wrapped in a `Field`
222
+ - Filter labels are never visible — wrap `TextInput` and `Select` in a `Field`
223
+ with `labelVisibility="hidden"`; the `label` is still required for
224
+ accessibility
225
+ - Working in portal-hub? Apply the substitutions in
226
+ `node_modules/@spark-web/design-system/patterns/internal-admin/portal-hub.md`.
200
227
  - If no filtering or searching is needed, omit this section entirely
201
228
 
202
229
  ---
@@ -207,32 +234,33 @@ Wraps the DataTable to enable vertical scrolling without the page scrolling.
207
234
 
208
235
  ```tsx
209
236
  <Box display="flex" flexDirection="column">
210
- <div
211
- className={css({
212
- position: 'relative',
213
- flex: 1,
214
- minHeight: 0,
215
- overflowY: 'auto',
216
- })}
237
+ <Box
238
+ position="relative"
239
+ flex={1}
240
+ css={{ minHeight: 0, overflowY: 'auto' }}
217
241
  >
218
242
  <DataTable ... />
219
- </div>
243
+ </Box>
220
244
  </Box>
221
245
  ```
222
246
 
223
- Documented exceptions — all required for flex scroll behaviour:
247
+ Spark props and documented exceptions — all required for flex scroll behaviour:
224
248
 
225
- - `position: 'relative'` — scroll container positioning
226
- - `flex: 1` — fills available height
227
- - `minHeight: 0` — classic flex scroll fix, prevents overflow
228
- - `overflowY: 'auto'` enables vertical scrolling
249
+ - `position="relative"` — scroll container positioning (Box `position` prop)
250
+ - `flex={1}` — fills available height (Box `flex` prop)
251
+ - `minHeight: 0` — classic flex scroll fix, prevents overflow (documented
252
+ exception — Box `minHeight` only accepts `0` via the prop, so this goes
253
+ through `css`)
254
+ - `overflowY: 'auto'` — enables vertical scrolling (documented exception — no
255
+ axis-specific overflow prop, so this goes through `css`)
229
256
 
230
257
  ---
231
258
 
232
259
  ## Section 6 — Table
233
260
 
234
- Always uses `@spark-web/data-table`. See `packages/data-table/CLAUDE.md` for
235
- column definition API, loading/empty states, sorting, and expansion.
261
+ Always uses `@spark-web/data-table`. See
262
+ `node_modules/@spark-web/data-table/CLAUDE.md` for column definition API,
263
+ loading/empty states, sorting, and expansion.
236
264
 
237
265
  Apply canonical list-page header styling via `headerClassName`. Use the named
238
266
  import `import { css as reactCss } from '@emotion/react'` — `headerClassName`
@@ -270,14 +298,14 @@ Column rules:
270
298
  exist
271
299
 
272
300
  Row interaction rules — see
273
- `packages/design-system/patterns/internal-admin/CLAUDE.md`:
301
+ `node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md`:
274
302
 
275
303
  - Pass `onRowClick` and `enableClickableRow` when the record has a detail view
276
304
  - Hover state is applied automatically when `enableClickableRow` is true
277
305
 
278
306
  Status tone mapping — authoritative rules are in
279
- `packages/design-system/patterns/internal-admin/CLAUDE.md`. Do not duplicate
280
- them here.
307
+ `node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md`. Do
308
+ not duplicate them here.
281
309
 
282
310
  ---
283
311
 
@@ -285,12 +313,30 @@ them here.
285
313
 
286
314
  Render `TablePagination` outside and below DataTable — never nested inside it.
287
315
 
288
- - **Only rendered when `total > pageSize`** — hide it when all records fit on
289
- one page
290
- - Default `pageSize` for full list pages: **20**
291
- - Use the real total count from a dedicated count query never derive total
292
- from the length of the current page's result set
293
- - No additional wrapper needed — content area `gap="large"` handles spacing
316
+ ```tsx
317
+ {
318
+ /* COMPONENT GAP: TablePagination is NOT a spark-web component — build a
319
+ placeholder from @spark-web/box + @spark-web/button (prev/next + "Show N of
320
+ M") and flag it for product design review before production. Props:
321
+ total, pageSize, current, dataShowing, onChange(page). */
322
+ }
323
+ {
324
+ total > PAGE_SIZE && (
325
+ <TablePagination
326
+ total={total}
327
+ pageSize={PAGE_SIZE}
328
+ dataShowing={rows.length}
329
+ onChange={setPage}
330
+ current={page}
331
+ />
332
+ );
333
+ }
334
+ ```
335
+
336
+ Render-only-when `total > pageSize`, default `pageSize` **20**, and
337
+ total-from-a-dedicated-count-query are enforced by the Do NOTs and checklist
338
+ below. No additional wrapper needed — content area `gap="large"` handles
339
+ spacing.
294
340
 
295
341
  ---
296
342
 
@@ -300,42 +346,23 @@ Use this skeleton as the starting point for new builds and uplifts. Do not use
300
346
  existing page implementations as a structural reference.
301
347
 
302
348
  ```tsx
303
- <Stack height="full" className={css({ minHeight: '100vh' })}>
349
+ <Stack height="full" css={{ minHeight: '100vh' }}>
304
350
  <PageHeader label={pageTitle} />
305
351
 
306
- <Stack
307
- padding="large"
308
- gap="large"
309
- className={css({
310
- backgroundColor: theme.color.background.neutral,
311
- flex: 1,
312
- display: 'flex',
313
- flexDirection: 'column',
314
- overflow: 'hidden',
315
- })}
316
- >
352
+ <Stack /* Section 3 content-area props — copy verbatim from Section 3 */>
317
353
  <Columns gap="large" collapseBelow="desktop">
318
- {/* search first, then filters */}
354
+ {/* search first, then filters — Section 4 */}
319
355
  </Columns>
320
356
 
321
- <Box display="flex" flexDirection="column">
322
- <div
323
- className={css({
324
- position: 'relative',
325
- flex: 1,
326
- minHeight: 0,
327
- overflowY: 'auto',
328
- })}
329
- >
330
- <DataTable
331
- items={items}
332
- columns={columns}
333
- isLoading={isLoading}
334
- emptyState={emptyState}
335
- />
336
- </div>
337
- </Box>
357
+ {/* Section 5 scroll wrapper — copy verbatim from Section 5, wrapping: */}
358
+ <DataTable
359
+ items={items}
360
+ columns={columns}
361
+ isLoading={isLoading}
362
+ emptyState={emptyState}
363
+ />
338
364
 
365
+ {/* COMPONENT GAP: TablePagination — see Section 7 */}
339
366
  {total > PAGE_SIZE && (
340
367
  <TablePagination
341
368
  total={total}
@@ -356,16 +383,15 @@ existing page implementations as a structural reference.
356
383
  These raw CSS values are required and have no Spark token equivalent. Use them
357
384
  exactly as written. Do not substitute alternatives.
358
385
 
359
- | Value | Property | Reason |
360
- | --------------------------------------------- | ---------------------------- | --------------------------------------- |
361
- | `minHeight: '100vh'` | Outer Stack | No Spark minHeight prop |
362
- | `flex: 1` | Content area, scroll wrapper | No Spark flex prop |
363
- | `display: 'flex'` + `flexDirection: 'column'` | Content area | Required for flex: 1 |
364
- | `overflow: 'hidden'` | Content area | No Spark overflow on Stack |
365
- | `position: 'relative'` | Scroll div | No Spark position prop |
366
- | `minHeight: 0` | Scroll div | Flex scroll fix |
367
- | `overflowY: 'auto'` | Scroll div | No Spark overflow prop |
368
- | `backgroundColor: background.neutral` | Content area | Accessed via useTheme(), not a Box prop |
386
+ | Value | Property | Reason |
387
+ | ------------------------------------------- | ------------------------------ | ------------------------------------------------ |
388
+ | `minHeight: '100vh'` | Outer Stack | No Spark minHeight prop |
389
+ | `minHeight: 0` | Scroll Box | Flex scroll fix Box `minHeight` only accepts 0 |
390
+ | `overflowY: 'auto'` | Scroll Box | No axis-specific overflow prop |
391
+ | `backgroundColor: background.neutral` | Content area | Accessed via useTheme(), not a Box prop |
392
+ | `boxShadow: inset 0 -2px ...` | DataTable `headerClassName` | Canonical header underline — no token equivalent |
393
+ | `textTransform: 'capitalize'`, `svg stroke` | DataTable `headerClassName` th | Canonical header text/icon styling |
394
+ | `width: '100%'` | DataTable `className` | Table fills the scroll wrapper |
369
395
 
370
396
  ---
371
397
 
@@ -375,8 +401,8 @@ exactly as written. Do not substitute alternatives.
375
401
  `<Stack height="full">`. Container constrains width and removes full-height
376
402
  layout and scroll containment behaviour
377
403
  - NEVER place DataTable directly inside the content area Stack — always use the
378
- `<Box display="flex" flexDirection="column"> <div className={...}>` scroll
379
- wrapper from Section 5. Omitting it breaks page scroll containment
404
+ `<Box display="flex" flexDirection="column">` + inner scroll `<Box>` wrapper
405
+ from Section 5. Omitting it breaks page scroll containment
380
406
  - NEVER put pagination inside DataTable
381
407
  - NEVER render pagination when all records fit on one page — only show it when
382
408
  total > pageSize
@@ -401,13 +427,49 @@ exactly as written. Do not substitute alternatives.
401
427
  list page — those values belong in detail-page.md only
402
428
  - NEVER render an external loading spinner/text outside DataTable — always use
403
429
  the `isLoading` prop on DataTable to show loading state
404
- - NEVER use `Stack + Text weight="semibold"` to label a MultiSelectField or
405
- SelectField filter — always use
406
- `fieldProps={{ labelVisibility: 'hidden' as const }}`
407
- - NEVER omit `fieldProps={{ labelVisibility: 'hidden' as const }}` on
408
- SelectField filter dropdowns — the same hidden label rule that applies to
409
- MultiSelectField applies to SelectField equally
410
- - NEVER use `@spark-web/multi-select` in portal-hub — it is not installed; use
411
- `MultiSelectField` from `@brighte/ui-components`
430
+ - NEVER use `Stack + Text weight="semibold"` to label a select or multi-select
431
+ filter — labels are provided accessibly but never visibly:
432
+ `labelVisibility="hidden"` on `Field`, or `aria-labelledby` + `VisuallyHidden`
433
+ for `MultiSelect`
412
434
  - NEVER use `className` with a string from `@emotion/css` on DataTable — use
413
435
  `SerializedStyles` from `@emotion/react`'s `css` tagged template
436
+
437
+ ---
438
+
439
+ ## Validation checklist
440
+
441
+ Run before marking any list-page task complete and fix every violation; the
442
+ uplift protocol in `node_modules/@spark-web/design-system/CLAUDE.md` runs this
443
+ list PASS/FAIL against existing pages.
444
+
445
+ 1. PageHeader from `@spark-web/page-header` renders the page H1 via `label` — no
446
+ manual Stack + Heading.
447
+ 2. Outer wrapper is `Stack height="full"` with the documented
448
+ `minHeight: '100vh'` exception — not Container, and no padding on the outer
449
+ wrapper.
450
+ 3. Content area Stack has `padding="large" gap="large"`, neutral background via
451
+ the `css` prop, and `flex={1}` / `display="flex"` / `flexDirection="column"`
452
+ / `overflow="hidden"` as Spark Box props — nothing else.
453
+ 4. If search/filtering exists: filter row is
454
+ `Columns gap="large" collapseBelow="desktop"`, search input first (leftmost),
455
+ all labels hidden but accessible — `labelVisibility="hidden"` on each
456
+ `Field`, `aria-labelledby` + `VisuallyHidden` for `MultiSelect` (or the
457
+ consumer-overlay equivalent).
458
+ 5. DataTable sits inside the flex scroll wrapper from Section 5 — never directly
459
+ in the content Stack.
460
+ 6. DataTable receives `isLoading` and `emptyState` — no external spinner/loading
461
+ text.
462
+ 7. Status columns use `<Badge>` (dot + label) with a tone from the surface tone
463
+ mapping — never plain text, never StatusBadge, never `tone="pending"`.
464
+ 8. Actions column (if present) is last, `size: 80`, empty header; MeatballMenu
465
+ when 2+ row actions; row click/hover follows the surface rules; no hardcoded
466
+ column widths except the actions column.
467
+ 9. Pagination renders outside DataTable, only when `total > pageSize` (default
468
+ 20), with `total` from a dedicated count query.
469
+ 10. No raw CSS values beyond the Documented exceptions table (which includes the
470
+ Section 6 canonical header styling).
471
+ 11. Every component is `@spark-web/*` or an explicit consumer-overlay
472
+ substitute; anything missing is flagged with a `// COMPONENT GAP:` comment.
473
+ 12. DataTable header uses the canonical `headerClassName` styling from Section
474
+ 6, passed as `SerializedStyles` from `@emotion/react`'s `css` — never a
475
+ string class from `@emotion/css`.
@@ -0,0 +1,165 @@
1
+ # Internal admin — portal-hub consumer overlay
2
+
3
+ Consumer overlay for the **portal-hub** repo (see Consumer overlays in
4
+ `node_modules/@spark-web/design-system/patterns/CLAUDE.md`) — it overrides the
5
+ internal-admin pattern files and component-level CLAUDE.md files ONLY where it
6
+ explicitly says so; every other rule applies to portal-hub unchanged.
7
+
8
+ ---
9
+
10
+ ## Filter fields (overrides list-page.md Section 4)
11
+
12
+ `@spark-web/multi-select` is not available to app code in portal-hub (it is a
13
+ dependency of the internal ui-components package only). Filter and search fields
14
+ come from `@brighte/ui-components` instead. This override applies ONLY to
15
+ component substitutions; all other Section 4 rules — search input always first,
16
+ filter ordering broadest to most specific, hidden labels, omit the section when
17
+ no filtering exists — apply unchanged. The substitutions:
18
+
19
+ - Multi-select filter dropdowns use `MultiSelectField` from
20
+ `@brighte/ui-components`
21
+ - Single-select filter dropdowns use `SelectField` from `@brighte/ui-components`
22
+ - The search input uses `TextInputField` from `@brighte/ui-components` with the
23
+ same start-adornment `SearchIcon`
24
+
25
+ Always pass `fieldProps={{ labelVisibility: 'hidden' as const }}` on both
26
+ `MultiSelectField` and `SelectField` — define it as a constant outside the
27
+ component to avoid re-renders:
28
+
29
+ ```tsx
30
+ const FieldProps = { labelVisibility: 'hidden' as const };
31
+
32
+ <Columns gap="large" collapseBelow="desktop">
33
+ <TextInputField
34
+ control={control}
35
+ name="search"
36
+ label="Search"
37
+ placeholder="Search by..."
38
+ FieldProps={FieldProps}
39
+ >
40
+ <InputAdornment placement="start">
41
+ <SearchIcon size="xxsmall" tone="muted" />
42
+ </InputAdornment>
43
+ </TextInputField>
44
+ <MultiSelectField
45
+ control={control}
46
+ name="fieldName"
47
+ label="Label"
48
+ options={options}
49
+ placeholder="Filter by..."
50
+ fieldProps={FieldProps}
51
+ />
52
+ </Columns>;
53
+ ```
54
+
55
+ Note the casing: the prop is `FieldProps` (capital F) on `TextInputField` and
56
+ `fieldProps` (lowercase f) on `MultiSelectField`/`SelectField` — match the
57
+ snippet exactly.
58
+
59
+ Do NOTs (portal-hub):
60
+
61
+ - NEVER import `@spark-web/multi-select` directly in portal-hub app code; use
62
+ `MultiSelectField` from `@brighte/ui-components`
63
+ - NEVER omit the hidden-label `fieldProps` constant on SelectField filter
64
+ dropdowns — define it once outside the component and pass it everywhere
65
+
66
+ ---
67
+
68
+ ## Text inputs (overrides the 'Controlled usage with form state' sections in text-input/text-area CLAUDE.md)
69
+
70
+ In portal-hub, prefer the `Field`-wrapped bindings from `@brighte/ui-components`
71
+ over hand-wiring `@spark-web/text-input` / `@spark-web/text-area` into forms:
72
+
73
+ - Prefer `TextInputField` from `@brighte/ui-components` when used with
74
+ `react-hook-form`. Pass `placeholder` directly, and use `FieldProps` to pass
75
+ `description` (renders as muted hint text below the label):
76
+
77
+ ```tsx
78
+ <TextInputField
79
+ control={control}
80
+ name="subject"
81
+ label="Subject"
82
+ placeholder="Type here..."
83
+ FieldProps={{ description: 'Enter a brief summary of the issue' }}
84
+ />
85
+ ```
86
+
87
+ - Prefer `TextAreaField` from `@brighte/ui-components` when used with
88
+ `react-hook-form`. Pass `placeholder` directly:
89
+
90
+ ```tsx
91
+ <TextAreaField
92
+ control={control}
93
+ name="note"
94
+ label="Note"
95
+ placeholder="Type your note here..."
96
+ />
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Section cards (overrides detail-page.md Section 7)
102
+
103
+ portal-hub uses a custom wrapper at `@components/PortalTable/SectionCard` — not
104
+ `@spark-web/section-card`. The API differs from the Spark component:
105
+
106
+ ```tsx
107
+ import { SectionCard } from '@components/PortalTable/SectionCard';
108
+
109
+ <SectionCard label="Section Title">{/* section content */}</SectionCard>;
110
+ ```
111
+
112
+ | Prop | Type | Notes |
113
+ | ---------- | ----------- | ---------------------------------------------------------------------------- |
114
+ | `label` | `string` | Optional — card header text; usually provided — header only renders when set |
115
+ | `tag` | `TagProps` | Optional — tag rendered in the card header |
116
+ | `action` | `ReactNode` | Optional — right-side header control |
117
+ | `controls` | `ReactNode` | Optional — additional header controls |
118
+
119
+ Return `null` for sections conditionally hidden — never render an empty card.
120
+
121
+ ---
122
+
123
+ ## Modal sizing (ACCREDITATION_MODAL_CSS)
124
+
125
+ The shared admin modal size constant described in
126
+ `node_modules/@spark-web/modal-dialog/CLAUDE.md` already exists in portal-hub —
127
+ do not define a new one. The standard size constant used across all admin
128
+ confirmation modals is defined in `apps/admin-portal/src/utils/constants.tsx`:
129
+
130
+ ```ts
131
+ export const ACCREDITATION_MODAL_CSS = {
132
+ width: '100vw',
133
+ maxWidth: '550px',
134
+ } as const;
135
+ ```
136
+
137
+ Always pass this constant via `css={ACCREDITATION_MODAL_CSS}` — never set width
138
+ inline or use a raw pixel value.
139
+
140
+ ---
141
+
142
+ ## Row-as-link reference implementation
143
+
144
+ The row-as-link navigation pattern documented in
145
+ `node_modules/@spark-web/data-table/CLAUDE.md` has a reference implementation in
146
+ portal-hub at `apps/admin-portal/src/components/RowLink` (uses TanStack Router).
147
+
148
+ ---
149
+
150
+ ## TablePagination
151
+
152
+ portal-hub supplies its own `TablePagination` component. It is the
153
+ `TablePagination` referenced by both the list-page and detail-page patterns.
154
+ Props used by the patterns:
155
+
156
+ | Prop | Notes |
157
+ | ------------- | ------------------------------------------------- |
158
+ | `total` | Total record count — from a dedicated count query |
159
+ | `pageSize` | 20 on list pages; 5 on detail-page section tables |
160
+ | `dataShowing` | Number of rows on the current page |
161
+ | `onChange` | Page change handler |
162
+ | `current` | Current page number |
163
+
164
+ This is a confirmed COMPONENT GAP — no `@spark-web` pagination component exists
165
+ yet. Until one ships, every consumer supplies its own pagination component.