@lovett/ui 0.0.5 → 0.0.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.
Files changed (56) hide show
  1. package/dist/index.d.ts +279 -115
  2. package/dist/index.js +479 -66
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +74 -0
  5. package/dist/tokens.css +181 -60
  6. package/package.json +21 -4
  7. package/src/__tests__/button.test.tsx +137 -0
  8. package/src/__tests__/card.test.tsx +103 -0
  9. package/src/__tests__/dead-render.test.tsx +117 -0
  10. package/src/__tests__/input.test.tsx +134 -0
  11. package/src/__tests__/modal.test.tsx +154 -0
  12. package/src/__tests__/page-shell.test.tsx +128 -0
  13. package/src/__tests__/setup.ts +43 -0
  14. package/src/__tests__/token-shape.test.ts +193 -0
  15. package/src/card.tsx +1 -1
  16. package/src/collapsible-card.tsx +85 -0
  17. package/src/data-grid/table-body.tsx +8 -1
  18. package/src/dropdown-menu.tsx +1 -1
  19. package/src/floating-status-bar.tsx +1 -1
  20. package/src/folder-tree-picker.tsx +5 -6
  21. package/src/frame-stack.tsx +27 -10
  22. package/src/hero-form-card.tsx +2 -2
  23. package/src/icons/brand.tsx +187 -0
  24. package/src/index.ts +32 -0
  25. package/src/lib/clipboard.ts +14 -0
  26. package/src/lib/color.ts +111 -0
  27. package/src/meta-cell.tsx +52 -0
  28. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  29. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  30. package/src/modal.tsx +77 -6
  31. package/src/pill-button.tsx +23 -5
  32. package/src/profile-section.tsx +40 -9
  33. package/src/sortable-table.tsx +5 -1
  34. package/src/styles.css +74 -0
  35. package/src/tabs.tsx +4 -0
  36. package/src/tag-chip-input.tsx +1 -1
  37. package/src/theme-v2.css +466 -0
  38. package/src/tokens.css +181 -60
  39. package/src/v2/README.md +208 -0
  40. package/src/v2/__demo__/showcase.tsx +1045 -0
  41. package/src/v2/action.tsx +91 -0
  42. package/src/v2/callout.tsx +76 -0
  43. package/src/v2/document-section.tsx +82 -0
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +113 -0
  46. package/src/v2/icons.tsx +165 -0
  47. package/src/v2/index.ts +147 -0
  48. package/src/v2/layout.tsx +293 -0
  49. package/src/v2/progress-track.tsx +89 -0
  50. package/src/v2/stat-tile.tsx +129 -0
  51. package/src/v2/states.tsx +271 -0
  52. package/src/v2/status-pill.tsx +74 -0
  53. package/src/v2/theme.css +1861 -0
  54. package/src/v2/timeline.tsx +81 -0
  55. package/src/v2/tokens.ts +228 -0
  56. package/src/value-chip.tsx +76 -0
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * @lovett/ui v2 — showcase
3
+ *
4
+ * Every v2 primitive, in every state, on one page. This is the review
5
+ * surface: if a change is not visible here, it is not reviewable.
6
+ *
7
+ * Mount it anywhere:
8
+ *
9
+ * import '@lovett/ui/src/v2/theme.css'
10
+ * import { V2Showcase } from '@lovett/ui/src/v2/__demo__/showcase'
11
+ * root.render(<V2Showcase />)
12
+ *
13
+ * The appearance toggle writes `data-theme` onto the showcase root
14
+ * rather than `<html>`, so the same page can be checked in both
15
+ * appearances without touching the host document.
16
+ */
17
+
18
+ import { useState, type ReactNode } from 'react'
19
+ import {
20
+ Action,
21
+ Callout,
22
+ DefinitionList,
23
+ DocumentSection,
24
+ DocumentShell,
25
+ EmptyState,
26
+ ErrorState,
27
+ FieldRow,
28
+ Grid,
29
+ IconChevronRight,
30
+ IconRetry,
31
+ Inline,
32
+ LoadingSkeleton,
33
+ Panel,
34
+ ProgressTrack,
35
+ Prose,
36
+ Section,
37
+ Stack,
38
+ StatTile,
39
+ StatusPill,
40
+ Timeline,
41
+ Toolbar,
42
+ space,
43
+ type CalloutTone,
44
+ type StatusTone,
45
+ type TimelineItem,
46
+ } from '../index'
47
+
48
+ /* ── Local helpers (showcase only) ───────────────────────────────── */
49
+
50
+ function Row({ label, children }: { label: string; children: ReactNode }) {
51
+ return (
52
+ <Stack gap={2}>
53
+ <div
54
+ style={{
55
+ fontSize: 'var(--lv-text-2xs)',
56
+ letterSpacing: 'var(--lv-tr-2xs)',
57
+ textTransform: 'uppercase',
58
+ fontWeight: 600,
59
+ color: 'var(--lv-text-faint)',
60
+ }}
61
+ >
62
+ {label}
63
+ </div>
64
+ <Inline gap={3} wrap align="center">
65
+ {children}
66
+ </Inline>
67
+ </Stack>
68
+ )
69
+ }
70
+
71
+ function Swatch({ token, name }: { token: string; name: string }) {
72
+ return (
73
+ <Stack gap={1} style={{ minWidth: 0 }}>
74
+ <div className="lv-swatch" style={{ background: `var(${token})` }} />
75
+ <div
76
+ style={{
77
+ fontSize: 'var(--lv-text-2xs)',
78
+ color: 'var(--lv-text-tertiary)',
79
+ }}
80
+ className="lv-truncate"
81
+ >
82
+ {name}
83
+ </div>
84
+ </Stack>
85
+ )
86
+ }
87
+
88
+ const TIMELINE_ITEMS: readonly TimelineItem[] = [
89
+ {
90
+ id: 'ingest',
91
+ title: 'Source material ingested',
92
+ meta: '09:14',
93
+ state: 'done',
94
+ description: '41 pages across 6 documents. Deduplicated to 28 unique sections.',
95
+ },
96
+ {
97
+ id: 'extract',
98
+ title: 'Claims extracted',
99
+ meta: '09:22',
100
+ state: 'done',
101
+ description: 'Every claim carries a citation back to its source paragraph.',
102
+ },
103
+ {
104
+ id: 'review',
105
+ title: 'Awaiting human review',
106
+ meta: 'now',
107
+ state: 'current',
108
+ description:
109
+ 'Three claims could not be traced to a primary source and are held back.',
110
+ },
111
+ {
112
+ id: 'publish',
113
+ title: 'Publish to workspace',
114
+ meta: '—',
115
+ state: 'todo',
116
+ },
117
+ {
118
+ id: 'syndicate',
119
+ title: 'Syndicate to channel partners',
120
+ meta: '—',
121
+ state: 'blocked',
122
+ description: 'Blocked: partner agreement has not been countersigned.',
123
+ },
124
+ ]
125
+
126
+ const CALLOUT_TONES: readonly CalloutTone[] = ['info', 'success', 'warn', 'danger']
127
+ const PILL_TONES: readonly StatusTone[] = [
128
+ 'neutral',
129
+ 'accent',
130
+ 'success',
131
+ 'warning',
132
+ 'danger',
133
+ 'info',
134
+ ]
135
+
136
+ /* ══════════════════════════════════════════════════════════════════ */
137
+
138
+ export function V2Showcase() {
139
+ const [theme, setTheme] = useState<'light' | 'dark'>('light')
140
+ const [progress, setProgress] = useState(64)
141
+ const [loading, setLoading] = useState(false)
142
+
143
+ return (
144
+ <div className="lv-root" data-theme={theme} style={{ minHeight: '100vh' }}>
145
+ <DocumentShell
146
+ eyebrow="Design system"
147
+ title="@lovett/ui v2 — every primitive, every state"
148
+ status={<StatusPill tone="accent">Review</StatusPill>}
149
+ actions={
150
+ <>
151
+ <Action
152
+ variant="ghost"
153
+ size="sm"
154
+ onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))}
155
+ >
156
+ {theme === 'light' ? 'Dark' : 'Light'}
157
+ </Action>
158
+ <Action variant="secondary" size="sm">
159
+ Export
160
+ </Action>
161
+ {/* The one primary action on this view. */}
162
+ <Action variant="primary" size="sm">
163
+ Approve
164
+ </Action>
165
+ </>
166
+ }
167
+ sections={[
168
+ { id: 'foundations', label: 'Foundations' },
169
+ { id: 'ladder', label: 'Surface ladder' },
170
+ { id: 'layout', label: 'Layout' },
171
+ { id: 'actions', label: 'Actions', count: 12 },
172
+ { id: 'document', label: 'Document' },
173
+ { id: 'fields', label: 'Fields', count: 7 },
174
+ { id: 'stats', label: 'Stats' },
175
+ { id: 'callouts', label: 'Callouts', count: 4 },
176
+ { id: 'pills', label: 'Status pills' },
177
+ { id: 'progress', label: 'Progress' },
178
+ { id: 'timeline', label: 'Timeline' },
179
+ { id: 'states', label: 'Empty / loading / error' },
180
+ { id: 'overflow', label: 'Overflow' },
181
+ ]}
182
+ >
183
+ {/* ── Foundations ───────────────────────────────────────── */}
184
+ <DocumentSection
185
+ id="foundations"
186
+ title="Foundations"
187
+ description="OKLCH ramps, constant hue, chroma as a percentage of each hue's sRGB maximum at that lightness. Nothing clips; no hue outranks another."
188
+ >
189
+ <Stack gap={5}>
190
+ <Section eyebrow="Neutral" title="Hue 255, chroma ≤ 0.023" flush>
191
+ <Grid gap={2} minColumnWidth={72}>
192
+ {[
193
+ '0',
194
+ '50',
195
+ '100',
196
+ '200',
197
+ '300',
198
+ '400',
199
+ '500',
200
+ '600',
201
+ '700',
202
+ '800',
203
+ '900',
204
+ '950',
205
+ ].map((s) => (
206
+ <Swatch key={s} token={`--lv-n-${s}`} name={`n-${s}`} />
207
+ ))}
208
+ </Grid>
209
+ </Section>
210
+
211
+ <Section eyebrow="Accent" title="Hue 27.5 — the house red" flush>
212
+ <Grid gap={2} minColumnWidth={72}>
213
+ {[
214
+ '50',
215
+ '100',
216
+ '200',
217
+ '300',
218
+ '400',
219
+ '500',
220
+ '600',
221
+ '700',
222
+ '800',
223
+ '900',
224
+ '950',
225
+ ].map((s) => (
226
+ <Swatch key={s} token={`--lv-a-${s}`} name={`a-${s}`} />
227
+ ))}
228
+ </Grid>
229
+ </Section>
230
+
231
+ <Section
232
+ eyebrow="Semantic"
233
+ title="Held far apart on the wheel"
234
+ description="danger is burnt orange, not red — the brand is red, and two reds on one page cannot be told apart. Every tone is also carried by an icon and a word."
235
+ flush
236
+ >
237
+ <Grid gap={2} minColumnWidth={100}>
238
+ <Swatch token="--lv-success-mark" name="success 150°" />
239
+ <Swatch token="--lv-warning-mark" name="warning 95°" />
240
+ <Swatch token="--lv-danger-mark" name="danger 48°" />
241
+ <Swatch token="--lv-info-mark" name="info 290°" />
242
+ <Swatch token="--lv-accent-mark" name="accent 27.5°" />
243
+ </Grid>
244
+ </Section>
245
+
246
+ <Section eyebrow="Series" title="Constant L 0.60, chroma per hue" flush>
247
+ <Grid gap={2} minColumnWidth={72}>
248
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((n) => (
249
+ <Swatch key={n} token={`--lv-series-${n}`} name={`series-${n}`} />
250
+ ))}
251
+ </Grid>
252
+ </Section>
253
+
254
+ <Section eyebrow="Type" title="Named by role, never by size" flush>
255
+ <Stack gap={3}>
256
+ <div
257
+ style={{
258
+ fontSize: 'var(--lv-text-3xl)',
259
+ lineHeight: 'var(--lv-lh-3xl)',
260
+ letterSpacing: 'var(--lv-tr-3xl)',
261
+ fontWeight: 600,
262
+ }}
263
+ >
264
+ 3xl — tightened display type
265
+ </div>
266
+ <div
267
+ style={{
268
+ fontSize: 'var(--lv-text-xl)',
269
+ lineHeight: 'var(--lv-lh-xl)',
270
+ letterSpacing: 'var(--lv-tr-xl)',
271
+ fontWeight: 600,
272
+ }}
273
+ >
274
+ xl — a document title
275
+ </div>
276
+ <div
277
+ style={{
278
+ fontSize: 'var(--lv-text-lg)',
279
+ lineHeight: 'var(--lv-lh-lg)',
280
+ fontWeight: 600,
281
+ }}
282
+ >
283
+ lg — a section title
284
+ </div>
285
+ <Prose>
286
+ Prose sits at 16px / 1.62 and is capped at 68 characters, because a
287
+ brief is read rather than scanned. Long lines make it hard for the
288
+ eye to find the start of the next one, and{' '}
289
+ <code style={{ fontFamily: 'var(--lv-font-mono)' }}>
290
+ text-wrap: pretty
291
+ </code>{' '}
292
+ keeps the last line from stranding a single word.
293
+ </Prose>
294
+ <div className="lv-num" style={{ fontSize: 'var(--lv-text-sm)' }}>
295
+ Tabular figures: 1,284,930 · 0.001 · 11:11:11 · 99.9%
296
+ </div>
297
+ <div
298
+ style={{
299
+ fontSize: 'var(--lv-text-2xs)',
300
+ letterSpacing: 'var(--lv-tr-2xs)',
301
+ textTransform: 'uppercase',
302
+ fontWeight: 600,
303
+ color: 'var(--lv-text-tertiary)',
304
+ }}
305
+ >
306
+ 2xs — small caps get positive tracking
307
+ </div>
308
+ </Stack>
309
+ </Section>
310
+ </Stack>
311
+ </DocumentSection>
312
+
313
+ {/* ── Surface ladder ────────────────────────────────────── */}
314
+ <DocumentSection
315
+ id="ladder"
316
+ title="Surface ladder"
317
+ description="page → shell → section → card → inset. Each tier one notch brighter than the one behind it; inset goes the other way on purpose."
318
+ >
319
+ <div
320
+ style={{
321
+ background: 'var(--lv-surface-page)',
322
+ padding: space(4),
323
+ borderRadius: 'var(--lv-radius-xl)',
324
+ }}
325
+ >
326
+ <div
327
+ style={{
328
+ background: 'var(--lv-surface-shell)',
329
+ padding: space(4),
330
+ borderRadius: 'var(--lv-radius-lg)',
331
+ }}
332
+ >
333
+ <Section eyebrow="section" title="A titled region">
334
+ <Stack gap={3}>
335
+ <div
336
+ style={{
337
+ background: 'var(--lv-surface-card)',
338
+ boxShadow: 'var(--lv-elev-2)',
339
+ borderRadius: 'var(--lv-radius-lg)',
340
+ padding: space(1),
341
+ }}
342
+ >
343
+ <div style={{ padding: space(3) }}>
344
+ <div
345
+ style={{
346
+ fontSize: 'var(--lv-text-sm)',
347
+ fontWeight: 600,
348
+ marginBottom: space(2),
349
+ }}
350
+ >
351
+ card — 10px radius, 4px frame
352
+ </div>
353
+ {/* THE move: a recessed panel inside a card. */}
354
+ <Panel>
355
+ <Stack gap={1}>
356
+ <div
357
+ style={{
358
+ fontSize: 'var(--lv-text-2xs)',
359
+ letterSpacing: 'var(--lv-tr-2xs)',
360
+ textTransform: 'uppercase',
361
+ fontWeight: 600,
362
+ color: 'var(--lv-text-tertiary)',
363
+ }}
364
+ >
365
+ inset panel
366
+ </div>
367
+ <div style={{ fontSize: 'var(--lv-text-sm)' }}>
368
+ The recess is the strongest single move in the system.
369
+ Put the thing the reader actually came for in here.
370
+ </div>
371
+ </Stack>
372
+ </Panel>
373
+ </div>
374
+ </div>
375
+
376
+ <Inline gap={3} wrap align="stretch">
377
+ <Panel tone="quiet" style={{ flex: 1, minWidth: 200 }}>
378
+ <div style={{ fontSize: 'var(--lv-text-sm)' }}>
379
+ Panel tone=&quot;quiet&quot; — hairline, no fill
380
+ </div>
381
+ </Panel>
382
+ <Panel tone="raised" style={{ flex: 1, minWidth: 200 }}>
383
+ <div style={{ fontSize: 'var(--lv-text-sm)' }}>
384
+ Panel tone=&quot;raised&quot; — a floating tray
385
+ </div>
386
+ </Panel>
387
+ </Inline>
388
+
389
+ <Row label="Elevation">
390
+ {[0, 1, 2, 3, 4].map((n) => (
391
+ <div
392
+ key={n}
393
+ style={{
394
+ background: 'var(--lv-surface-card)',
395
+ boxShadow: `var(--lv-elev-${n})`,
396
+ borderRadius: 'var(--lv-radius-md)',
397
+ padding: `${space(2)} ${space(3)}`,
398
+ fontSize: 'var(--lv-text-xs)',
399
+ fontWeight: 500,
400
+ }}
401
+ >
402
+ elev-{n}
403
+ </div>
404
+ ))}
405
+ </Row>
406
+ </Stack>
407
+ </Section>
408
+ </div>
409
+ </div>
410
+ </DocumentSection>
411
+
412
+ {/* ── Layout ────────────────────────────────────────────── */}
413
+ <DocumentSection
414
+ id="layout"
415
+ title="Layout primitives"
416
+ description="Gap takes a step on the 4px grid and nothing else. gap={13} does not compile."
417
+ >
418
+ <Stack gap={5}>
419
+ <Section eyebrow="Toolbar" title="Controls float above content" flush>
420
+ <Toolbar aria-label="Showcase toolbar">
421
+ <Action variant="ghost" size="sm">
422
+ Filter
423
+ </Action>
424
+ <Action variant="ghost" size="sm">
425
+ Sort
426
+ </Action>
427
+ <Toolbar.Divider />
428
+ <Action variant="ghost" size="sm">
429
+ Columns
430
+ </Action>
431
+ <Toolbar.Spacer />
432
+ <StatusPill tone="neutral">28 items</StatusPill>
433
+ <Action variant="secondary" size="sm">
434
+ Save view
435
+ </Action>
436
+ </Toolbar>
437
+ </Section>
438
+
439
+ <Section
440
+ eyebrow="Grid"
441
+ title="minColumnWidth, not breakpoints"
442
+ description="Columns are added as space allows. The layout breaks where the content stops fitting, not at a device preset."
443
+ flush
444
+ >
445
+ <Grid gap={3} minColumnWidth={180}>
446
+ {['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'].map((n) => (
447
+ <Panel key={n} tone="quiet">
448
+ <div style={{ fontSize: 'var(--lv-text-sm)' }}>{n}</div>
449
+ </Panel>
450
+ ))}
451
+ </Grid>
452
+ </Section>
453
+
454
+ <Section eyebrow="Stack / Inline" title="Every step on the scale" flush>
455
+ <Stack gap={3}>
456
+ {([1, 2, 3, 4, 6, 8] as const).map((g) => (
457
+ <Inline key={g} gap={g} align="center">
458
+ <span
459
+ style={{
460
+ fontSize: 'var(--lv-text-2xs)',
461
+ color: 'var(--lv-text-faint)',
462
+ minWidth: '5ch',
463
+ }}
464
+ className="lv-num"
465
+ >
466
+ gap {g}
467
+ </span>
468
+ {[0, 1, 2, 3].map((i) => (
469
+ <span
470
+ key={i}
471
+ style={{
472
+ width: 24,
473
+ height: 24,
474
+ borderRadius: 'var(--lv-radius-sm)',
475
+ background: 'var(--lv-surface-inset)',
476
+ boxShadow: 'var(--lv-elev-inset)',
477
+ }}
478
+ />
479
+ ))}
480
+ </Inline>
481
+ ))}
482
+ </Stack>
483
+ </Section>
484
+ </Stack>
485
+ </DocumentSection>
486
+
487
+ {/* ── Actions ───────────────────────────────────────────── */}
488
+ <DocumentSection
489
+ id="actions"
490
+ title="Actions"
491
+ description="Exactly one primary per view. Size is invariant across every state — loading hides the label behind a spinner rather than replacing it."
492
+ >
493
+ <Stack gap={5}>
494
+ <Row label="Variants — default">
495
+ <Action variant="primary">Primary</Action>
496
+ <Action variant="secondary">Secondary</Action>
497
+ <Action variant="ghost">Ghost</Action>
498
+ <Action variant="danger">Delete brief</Action>
499
+ </Row>
500
+
501
+ <Row label="Disabled — paint changes, box does not">
502
+ <Action variant="primary" disabled>
503
+ Primary
504
+ </Action>
505
+ <Action variant="secondary" disabled>
506
+ Secondary
507
+ </Action>
508
+ <Action variant="ghost" disabled>
509
+ Ghost
510
+ </Action>
511
+ <Action variant="danger" disabled>
512
+ Delete brief
513
+ </Action>
514
+ </Row>
515
+
516
+ <Row label="Loading — identical width to the row above">
517
+ <Action variant="primary" loading>
518
+ Primary
519
+ </Action>
520
+ <Action variant="secondary" loading>
521
+ Secondary
522
+ </Action>
523
+ <Action variant="ghost" loading>
524
+ Ghost
525
+ </Action>
526
+ <Action variant="danger" loading>
527
+ Delete brief
528
+ </Action>
529
+ </Row>
530
+
531
+ <Row label="Sizes">
532
+ <Action variant="secondary" size="sm">
533
+ Small
534
+ </Action>
535
+ <Action variant="secondary" size="md">
536
+ Medium
537
+ </Action>
538
+ <Action variant="secondary" size="lg">
539
+ Large
540
+ </Action>
541
+ </Row>
542
+
543
+ <Row label="With icons / icon-only">
544
+ <Action variant="secondary" icon={<IconRetry size={14} />}>
545
+ Retry
546
+ </Action>
547
+ <Action
548
+ variant="secondary"
549
+ trailingIcon={<IconChevronRight size={14} />}
550
+ >
551
+ Continue
552
+ </Action>
553
+ <Action
554
+ variant="ghost"
555
+ aria-label="Retry"
556
+ icon={<IconRetry size={14} />}
557
+ />
558
+ </Row>
559
+
560
+ <Callout tone="info" title="Focus">
561
+ Tab through this section. Every interactive element gets the same
562
+ 2px accent ring at a 2px offset — one focus treatment, everywhere,
563
+ set once at the root.
564
+ </Callout>
565
+ </Stack>
566
+ </DocumentSection>
567
+
568
+ {/* ── Document ──────────────────────────────────────────── */}
569
+ <DocumentSection
570
+ id="document"
571
+ title="Document surfaces"
572
+ description="This whole page is a DocumentShell. The rail on the left is scroll-spied; the header is sticky; the active indicator is always rendered and transparent when inactive, so nothing shifts as you scroll."
573
+ actions={
574
+ <Action variant="ghost" size="sm">
575
+ Edit
576
+ </Action>
577
+ }
578
+ >
579
+ <Section
580
+ eyebrow="Brief"
581
+ title="Positioning statement"
582
+ description="A DocumentSection can hold Sections, and a Section can hold a Panel. That is the ladder doing its job."
583
+ actions={<StatusPill tone="success">Approved</StatusPill>}
584
+ >
585
+ <Stack gap={4}>
586
+ <Prose>
587
+ For mid-market operators who have outgrown spreadsheets but cannot
588
+ justify an enterprise rollout, the product is the only planning
589
+ surface that keeps the model and the narrative in one document —
590
+ so the number in the deck and the number in the sheet cannot drift
591
+ apart.
592
+ </Prose>
593
+ <Panel>
594
+ <Stack gap={2}>
595
+ <div
596
+ style={{
597
+ fontSize: 'var(--lv-text-2xs)',
598
+ letterSpacing: 'var(--lv-tr-2xs)',
599
+ textTransform: 'uppercase',
600
+ fontWeight: 600,
601
+ color: 'var(--lv-text-tertiary)',
602
+ }}
603
+ >
604
+ Extracted claim · needs a citation
605
+ </div>
606
+ <div style={{ fontSize: 'var(--lv-text-sm)' }}>
607
+ &ldquo;Teams recover four hours a week within the first
608
+ month.&rdquo;
609
+ </div>
610
+ </Stack>
611
+ </Panel>
612
+ </Stack>
613
+ </Section>
614
+ </DocumentSection>
615
+
616
+ {/* ── Fields ────────────────────────────────────────────── */}
617
+ <DocumentSection
618
+ id="fields"
619
+ title="Fields"
620
+ description="One shared label column across every row, so labels and values line up between unrelated panels. Below 640px it stacks rather than truncating the value."
621
+ >
622
+ <Grid gap={5} minColumnWidth={340}>
623
+ <Section eyebrow="Default" title="No dividers" flush>
624
+ <DefinitionList>
625
+ <FieldRow label="Brief ID" value="BRF-2026-0148" numeric />
626
+ <FieldRow label="Owner" value="Edwin Lovett" />
627
+ <FieldRow
628
+ label="Source documents"
629
+ value="6"
630
+ numeric
631
+ hint="Deduplicated from 11 uploads"
632
+ />
633
+ <FieldRow label="Last generated" value="2026-08-07 09:22" numeric />
634
+ <FieldRow label="Reviewer" />
635
+ <FieldRow
636
+ label="A deliberately long label that has to wrap somewhere"
637
+ value="Wraps in the label column rather than pushing the value off the grid."
638
+ />
639
+ <FieldRow
640
+ label="Unbroken value"
641
+ value="urn:brandy:brief:0148:artifact:a7f3c9e1b2d84f60a5c7e2b91d3f8a04"
642
+ />
643
+ </DefinitionList>
644
+ </Section>
645
+
646
+ <Section eyebrow="Variants" title="Dividers and stacked" flush>
647
+ <Stack gap={5}>
648
+ <DefinitionList dividers>
649
+ <FieldRow label="Status" value="In review" />
650
+ <FieldRow label="Confidence" value="0.82" numeric />
651
+ <FieldRow label="Model" value="claude-opus-5" />
652
+ </DefinitionList>
653
+ <DefinitionList>
654
+ <FieldRow
655
+ layout="stacked"
656
+ label="Summary"
657
+ value="Stacked layout, for values that need the full width."
658
+ />
659
+ <FieldRow layout="stacked" label="Empty stacked" />
660
+ </DefinitionList>
661
+ </Stack>
662
+ </Section>
663
+ </Grid>
664
+ </DocumentSection>
665
+
666
+ {/* ── Stats ─────────────────────────────────────────────── */}
667
+ <DocumentSection
668
+ id="stats"
669
+ title="Stats"
670
+ description="Tabular figures plus a min-width slot sized in ch for the largest plausible value. Toggle loading — the tiles do not move."
671
+ actions={
672
+ <Action
673
+ variant="secondary"
674
+ size="sm"
675
+ onClick={() => setLoading((l) => !l)}
676
+ >
677
+ {loading ? 'Show values' : 'Show loading'}
678
+ </Action>
679
+ }
680
+ >
681
+ <Stack gap={4}>
682
+ <Grid gap={3} minColumnWidth={190}>
683
+ <StatTile
684
+ label="Impressions"
685
+ value="1,284,930"
686
+ slots={9}
687
+ delta="+12.4%"
688
+ deltaTone="up"
689
+ hint="vs. last 30 days"
690
+ loading={loading}
691
+ />
692
+ <StatTile
693
+ label="Cost per result"
694
+ value="4.18"
695
+ unit="USD"
696
+ slots={6}
697
+ delta="-2.1%"
698
+ deltaTone="down"
699
+ hint="vs. last 30 days"
700
+ loading={loading}
701
+ />
702
+ <StatTile
703
+ label="Completion"
704
+ value="99.9"
705
+ unit="%"
706
+ slots={5}
707
+ delta="0.0%"
708
+ deltaTone="flat"
709
+ loading={loading}
710
+ />
711
+ {/* No delta and no hint — the foot row is still reserved, so
712
+ this tile is exactly as tall as its neighbours. */}
713
+ <StatTile label="Sources" value="6" slots={5} loading={loading} />
714
+ {/* No value at all. */}
715
+ <StatTile label="Awaiting data" slots={5} />
716
+ </Grid>
717
+
718
+ <Section eyebrow="Inset variant" title="Stats inside a card" flush>
719
+ <div
720
+ style={{
721
+ background: 'var(--lv-surface-card)',
722
+ boxShadow: 'var(--lv-elev-2)',
723
+ borderRadius: 'var(--lv-radius-lg)',
724
+ padding: space(4),
725
+ }}
726
+ >
727
+ <Grid gap={3} minColumnWidth={160}>
728
+ <StatTile inset label="Claims" value="28" slots={4} />
729
+ <StatTile inset label="Cited" value="25" slots={4} />
730
+ <StatTile
731
+ inset
732
+ label="Unverified"
733
+ value="3"
734
+ slots={4}
735
+ delta="+3"
736
+ deltaTone="down"
737
+ />
738
+ </Grid>
739
+ </div>
740
+ </Section>
741
+ </Stack>
742
+ </DocumentSection>
743
+
744
+ {/* ── Callouts ──────────────────────────────────────────── */}
745
+ <DocumentSection
746
+ id="callouts"
747
+ title="Callouts"
748
+ description="Every tone carries a distinct icon silhouette and a screen-reader word. Squint until the colour goes — the tones are still legible."
749
+ >
750
+ <Stack gap={3}>
751
+ {CALLOUT_TONES.map((tone) => (
752
+ <Callout
753
+ key={tone}
754
+ tone={tone}
755
+ title={
756
+ tone === 'info'
757
+ ? 'Three claims are missing a primary source'
758
+ : tone === 'success'
759
+ ? 'Brief approved and locked'
760
+ : tone === 'warn'
761
+ ? 'The comparison period is incomplete'
762
+ : 'Syndication failed for two partners'
763
+ }
764
+ >
765
+ {tone === 'danger'
766
+ ? 'Danger is burnt orange rather than red, because the brand accent is red and two reds on one page cannot be told apart. The icon and the verb do the work colour used to.'
767
+ : 'The tone is drawn as a leading rule plus a soft fill — never a full border, which would read as a form field rather than an aside.'}
768
+ </Callout>
769
+ ))}
770
+
771
+ <Callout
772
+ tone="warn"
773
+ title="With actions"
774
+ actions={
775
+ <>
776
+ <Action variant="secondary" size="sm">
777
+ Recalculate
778
+ </Action>
779
+ <Action variant="ghost" size="sm">
780
+ Dismiss
781
+ </Action>
782
+ </>
783
+ }
784
+ >
785
+ Actions inside a callout are secondary or ghost. The view&apos;s one
786
+ primary action lives in the header.
787
+ </Callout>
788
+
789
+ <Callout tone="info">
790
+ A callout with no title — body only, still on the grid.
791
+ </Callout>
792
+ </Stack>
793
+ </DocumentSection>
794
+
795
+ {/* ── Status pills ──────────────────────────────────────── */}
796
+ <DocumentSection
797
+ id="pills"
798
+ title="Status pills"
799
+ description="The marker shape changes with the tone as well as the colour, so a pill reads correctly in greyscale. Fixed 20px height in every tone."
800
+ >
801
+ <Stack gap={4}>
802
+ <Row label="Tones — default shape per tone">
803
+ {PILL_TONES.map((tone) => (
804
+ <StatusPill key={tone} tone={tone}>
805
+ {tone}
806
+ </StatusPill>
807
+ ))}
808
+ </Row>
809
+ <Row label="Explicit shapes">
810
+ <StatusPill tone="neutral" shape="dot">
811
+ dot
812
+ </StatusPill>
813
+ <StatusPill tone="neutral" shape="ring">
814
+ ring
815
+ </StatusPill>
816
+ <StatusPill tone="neutral" shape="square">
817
+ square
818
+ </StatusPill>
819
+ <StatusPill tone="neutral" shape="triangle">
820
+ triangle
821
+ </StatusPill>
822
+ <StatusPill tone="neutral" shape="bar">
823
+ bar
824
+ </StatusPill>
825
+ <StatusPill tone="neutral" hideMark>
826
+ v2.0.1
827
+ </StatusPill>
828
+ </Row>
829
+ <Row label="Overflow — ellipsizes, never wraps">
830
+ <span style={{ maxWidth: 180, display: 'inline-flex', minWidth: 0 }}>
831
+ <StatusPill tone="warning">
832
+ Blocked on a countersignature from the partner legal team
833
+ </StatusPill>
834
+ </span>
835
+ </Row>
836
+ </Stack>
837
+ </DocumentSection>
838
+
839
+ {/* ── Progress ──────────────────────────────────────────── */}
840
+ <DocumentSection
841
+ id="progress"
842
+ title="Progress"
843
+ description="The track is a recessed groove. The readout sits in a 4ch slot, so 9% → 100% cannot nudge the label."
844
+ actions={
845
+ <Inline gap={2}>
846
+ <Action
847
+ variant="ghost"
848
+ size="sm"
849
+ onClick={() => setProgress((p) => Math.max(0, p - 17))}
850
+ >
851
+ −17
852
+ </Action>
853
+ <Action
854
+ variant="ghost"
855
+ size="sm"
856
+ onClick={() => setProgress((p) => Math.min(100, p + 17))}
857
+ >
858
+ +17
859
+ </Action>
860
+ </Inline>
861
+ }
862
+ >
863
+ <Grid gap={5} minColumnWidth={280}>
864
+ <Stack gap={4}>
865
+ <ProgressTrack value={progress} label="Interactive" />
866
+ <ProgressTrack value={0} label="Zero" />
867
+ <ProgressTrack value={100} label="Complete" tone="success" />
868
+ </Stack>
869
+ <Stack gap={4}>
870
+ <ProgressTrack value={62} label="Warning" tone="warning" />
871
+ <ProgressTrack value={19} label="Danger" tone="danger" />
872
+ <ProgressTrack
873
+ indeterminate
874
+ label="Indeterminate"
875
+ aria-label="Generating brief"
876
+ />
877
+ <ProgressTrack value={41} label="No readout" hideValue />
878
+ </Stack>
879
+ </Grid>
880
+ </DocumentSection>
881
+
882
+ {/* ── Timeline ──────────────────────────────────────────── */}
883
+ <DocumentSection
884
+ id="timeline"
885
+ title="Timeline"
886
+ description="The rail is one of the few places a line is the right answer — it communicates continuity, which spacing alone cannot."
887
+ >
888
+ <Timeline items={TIMELINE_ITEMS} />
889
+ </DocumentSection>
890
+
891
+ {/* ── States ────────────────────────────────────────────── */}
892
+ <DocumentSection
893
+ id="states"
894
+ title="Empty · loading · error"
895
+ description="All three take the same reserve prop. These three boxes are byte-identical in height, which is what stops a region jumping through its lifecycle."
896
+ >
897
+ <Stack gap={5}>
898
+ <Grid gap={4} minColumnWidth={300} align="stretch">
899
+ <EmptyState
900
+ surface="card"
901
+ reserve={240}
902
+ title="No briefs yet"
903
+ description="Briefs appear here once a source document has been ingested and reviewed."
904
+ actions={<Action variant="primary">Create brief</Action>}
905
+ />
906
+ <div
907
+ style={{
908
+ background: 'var(--lv-surface-card)',
909
+ boxShadow: 'var(--lv-elev-1)',
910
+ borderRadius: 'var(--lv-radius-md)',
911
+ minHeight: 240,
912
+ padding: space(6),
913
+ display: 'flex',
914
+ }}
915
+ >
916
+ <LoadingSkeleton variant="document" lines={5} style={{ flex: 1 }} />
917
+ </div>
918
+ <ErrorState
919
+ surface="card"
920
+ reserve={240}
921
+ title="Could not load briefs"
922
+ description="The workspace responded but the payload failed validation."
923
+ detail={
924
+ 'ZodError: expected array, received object\n at parseBriefList (briefs.ts:41)\n at loadWorkspace (workspace.ts:118)'
925
+ }
926
+ actions={
927
+ <Action variant="secondary" icon={<IconRetry size={14} />}>
928
+ Retry
929
+ </Action>
930
+ }
931
+ />
932
+ </Grid>
933
+
934
+ <Section eyebrow="Skeleton variants" title="Shaped like the real thing" flush>
935
+ <Grid gap={4} minColumnWidth={260}>
936
+ <Panel tone="quiet">
937
+ <Stack gap={3}>
938
+ <div
939
+ style={{
940
+ fontSize: 'var(--lv-text-2xs)',
941
+ color: 'var(--lv-text-faint)',
942
+ }}
943
+ >
944
+ stat
945
+ </div>
946
+ <LoadingSkeleton variant="stat" />
947
+ </Stack>
948
+ </Panel>
949
+ <Panel tone="quiet">
950
+ <Stack gap={3}>
951
+ <div
952
+ style={{
953
+ fontSize: 'var(--lv-text-2xs)',
954
+ color: 'var(--lv-text-faint)',
955
+ }}
956
+ >
957
+ fields
958
+ </div>
959
+ <LoadingSkeleton variant="fields" lines={4} />
960
+ </Stack>
961
+ </Panel>
962
+ <Panel tone="quiet">
963
+ <Stack gap={3}>
964
+ <div
965
+ style={{
966
+ fontSize: 'var(--lv-text-2xs)',
967
+ color: 'var(--lv-text-faint)',
968
+ }}
969
+ >
970
+ text / block
971
+ </div>
972
+ <LoadingSkeleton variant="text" lines={3} />
973
+ <LoadingSkeleton variant="block" height={48} />
974
+ </Stack>
975
+ </Panel>
976
+ </Grid>
977
+ </Section>
978
+
979
+ <Section eyebrow="Bare" title="Without a surface" flush>
980
+ <EmptyState
981
+ title="Nothing matches this filter"
982
+ description="Clear one of the four active filters to widen the result set."
983
+ actions={<Action variant="ghost">Clear filters</Action>}
984
+ />
985
+ </Section>
986
+ </Stack>
987
+ </DocumentSection>
988
+
989
+ {/* ── Overflow ──────────────────────────────────────────── */}
990
+ <DocumentSection
991
+ id="overflow"
992
+ title="Overflow"
993
+ description="Every place a string can be longer than its box, and what happens there."
994
+ >
995
+ <Grid gap={4} minColumnWidth={300}>
996
+ <Panel tone="quiet">
997
+ <Stack gap={3}>
998
+ <StatTile
999
+ label="A metric label long enough that it has to ellipsize somewhere"
1000
+ value="1,284,930,004"
1001
+ slots={13}
1002
+ delta="+1,204.9%"
1003
+ deltaTone="up"
1004
+ hint="Compared against a hint that is also far too long to fit"
1005
+ />
1006
+ <ProgressTrack
1007
+ value={73}
1008
+ label="A progress label that has to give way to the readout beside it"
1009
+ />
1010
+ </Stack>
1011
+ </Panel>
1012
+ <Panel tone="quiet">
1013
+ <DefinitionList>
1014
+ <FieldRow
1015
+ label="Unbreakable token"
1016
+ value="sk_live_51NxAbCdEfGhIjKlMnOpQrStUvWxYz0123456789abcdefghijklmnop"
1017
+ />
1018
+ <FieldRow
1019
+ label="Long prose value"
1020
+ value="Values wrap and the row grows downward; the label column keeps its width so the grid stays aligned with every other row on the page."
1021
+ />
1022
+ </DefinitionList>
1023
+ </Panel>
1024
+ <Panel tone="quiet">
1025
+ <Timeline
1026
+ items={[
1027
+ {
1028
+ id: 'long',
1029
+ title:
1030
+ 'A timeline entry whose title runs long enough to wrap onto a second and probably a third line',
1031
+ meta: '2026-08-07',
1032
+ state: 'current',
1033
+ description:
1034
+ 'The metadata stays on one line at the trailing edge while the title wraps beneath it, because a wrapped timestamp reads as broken.',
1035
+ },
1036
+ { id: 'short', title: 'Short one', meta: '09:14', state: 'done' },
1037
+ ]}
1038
+ />
1039
+ </Panel>
1040
+ </Grid>
1041
+ </DocumentSection>
1042
+ </DocumentShell>
1043
+ </div>
1044
+ )
1045
+ }