@lovett/ui 0.0.4 → 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 (59) hide show
  1. package/dist/index.d.ts +2659 -0
  2. package/dist/index.js +14451 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/styles.css +636 -0
  5. package/dist/tokens.css +700 -0
  6. package/package.json +44 -17
  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/allocation-sparkbar.tsx +90 -0
  16. package/src/card.tsx +1 -1
  17. package/src/collapsible-card.tsx +85 -0
  18. package/src/data-grid/table-body.tsx +8 -1
  19. package/src/dropdown-menu.tsx +1 -1
  20. package/src/floating-status-bar.tsx +112 -0
  21. package/src/folder-tree-picker.tsx +5 -6
  22. package/src/frame-stack.tsx +27 -10
  23. package/src/hero-form-card.tsx +2 -2
  24. package/src/icons/brand.tsx +187 -0
  25. package/src/index.ts +43 -0
  26. package/src/lib/clipboard.ts +14 -0
  27. package/src/lib/color.ts +111 -0
  28. package/src/meta-cell.tsx +52 -0
  29. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  30. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  31. package/src/microsoft-logo.tsx +33 -0
  32. package/src/modal.tsx +77 -6
  33. package/src/pill-button.tsx +23 -5
  34. package/src/profile-section.tsx +40 -9
  35. package/src/sortable-table.tsx +5 -1
  36. package/src/styles.css +74 -0
  37. package/src/tabs.tsx +4 -0
  38. package/src/tag-chip-input.tsx +1 -1
  39. package/src/theme-v2.css +466 -0
  40. package/src/token-badge.tsx +92 -0
  41. package/src/tokens.css +181 -60
  42. package/src/v2/README.md +208 -0
  43. package/src/v2/__demo__/showcase.tsx +1045 -0
  44. package/src/v2/action.tsx +91 -0
  45. package/src/v2/callout.tsx +76 -0
  46. package/src/v2/document-section.tsx +82 -0
  47. package/src/v2/document-shell.tsx +0 -0
  48. package/src/v2/field-row.tsx +113 -0
  49. package/src/v2/icons.tsx +165 -0
  50. package/src/v2/index.ts +147 -0
  51. package/src/v2/layout.tsx +293 -0
  52. package/src/v2/progress-track.tsx +89 -0
  53. package/src/v2/stat-tile.tsx +129 -0
  54. package/src/v2/states.tsx +271 -0
  55. package/src/v2/status-pill.tsx +74 -0
  56. package/src/v2/theme.css +1861 -0
  57. package/src/v2/timeline.tsx +81 -0
  58. package/src/v2/tokens.ts +228 -0
  59. package/src/value-chip.tsx +76 -0
package/src/tokens.css CHANGED
@@ -30,7 +30,7 @@
30
30
  * • Text: --foreground, --text-secondary, --text-tertiary,
31
31
  * --text-muted, --muted-foreground
32
32
  * • Semantic: --success(-bg), --warning(-bg), --info(-bg),
33
- * --destructive(-foreground)
33
+ * --destructive(-bg, -foreground)
34
34
  * • Folder: --folder-red/amber/emerald/teal/blue/violet/pink/
35
35
  * slate/ink (categorical palette for folder accents)
36
36
  * • Spacing: --space-1…12 (4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48)
@@ -125,32 +125,23 @@
125
125
  /* ============================================================
126
126
  DARK THEME SURFACES — INTERNAL (primitives only)
127
127
  ============================================================ */
128
- --background: 9 9 11;
129
128
  --foreground: 244 244 245;
130
- --bg-surface: 17 17 19;
131
- --bg-elevated: 24 24 27;
132
- --bg-card: 20 20 22;
133
- --bg-card-hover: 26 26 29;
134
- --bg-input: 15 15 17;
135
-
136
- --card: 20 20 22;
129
+ /* PUBLIC (ADR-063 D1): opaque elevated surface for bespoke lens/app
130
+ floating chrome (pills, capsules, one-off raised panels) that can't be a
131
+ primitive. Same values as the INTERNAL --bg-elevated by design. */
137
132
  --card-foreground: 244 244 245;
138
- --popover: 24 24 27;
139
133
  --popover-foreground: 244 244 245;
140
-
141
- --muted: 39 39 42;
142
134
  --muted-foreground: 161 161 170;
143
135
  --text-secondary: 161 161 170;
144
- --text-tertiary: 113 113 122;
145
- --text-muted: 82 82 91;
146
-
147
- --secondary: 39 39 42;
136
+ /* Lifted 113->132 / 82->106: at the old values --text-tertiary measured
137
+ 3.56:1 and --text-muted 2.22:1 on --bg-card, i.e. both under the 3:1
138
+ floor on the surface they are most often used on. */
139
+ --text-tertiary: 132 132 141;
140
+ --text-muted: 106 106 115;
148
141
  --secondary-foreground: 244 244 245;
149
142
 
150
143
  --border: 255 255 255 / 0.08;
151
144
  --border-strong: 255 255 255 / 0.14;
152
- --input: 39 39 42;
153
-
154
145
  /* Semantic — PUBLIC */
155
146
  --success: 16 185 129;
156
147
  --success-bg: 16 185 129 / 0.12;
@@ -159,6 +150,7 @@
159
150
  --info: 59 130 246;
160
151
  --info-bg: 59 130 246 / 0.12;
161
152
  --destructive: 239 68 68;
153
+ --destructive-bg: 239 68 68 / 0.12;
162
154
  --destructive-foreground: 255 255 255;
163
155
 
164
156
  /* Folder palette — PUBLIC. Categorical accents for Asset folders
@@ -211,8 +203,6 @@
211
203
  --bullet-offset-y: 7px;
212
204
 
213
205
  /* Recessed card footer strip — INTERNAL (<Card.Foot>) */
214
- --card-foot-bg: 255 255 255 / 0.025;
215
-
216
206
  /* Card surface — INTERNAL (<Card>) */
217
207
  --border-card: 255 255 255 / 0.08;
218
208
  --card-shadow-rest:
@@ -221,13 +211,10 @@
221
211
  0 4px 12px rgb(0 0 0 / 0.28);
222
212
 
223
213
  /* Card-tray — INTERNAL (<Card.Tray>) */
224
- --tray-bg: 24 24 27;
225
- --tray-inset: 10px;
214
+ --tray-inset: 4px;
226
215
  --radius-tray: var(--radius-md);
227
216
 
228
217
  /* Card frame — INTERNAL (outer .ds-card-surface bg in framed mode) */
229
- --card-frame-bg: 20 20 22;
230
-
231
218
  /* ============================================================
232
219
  CALCULATOR SHELL v2 — INTERNAL (ADR-076 D7). Consumed only by
233
220
  the v2 primitives (CalculatorShellV2 header, StatRow tiles) and
@@ -250,7 +237,6 @@
250
237
  /* ============================================================
251
238
  MAIN CONTENT AREA — INTERNAL (layout primitives only)
252
239
  ============================================================ */
253
- --main-content-bg: 10 10 11;
254
240
  --main-content-border: 255 255 255 / 0.08;
255
241
 
256
242
  /* ============================================================
@@ -294,9 +280,6 @@
294
280
  --surface-overlay: 255 255 255 / 0.06;
295
281
  --surface-overlay-strong: 255 255 255 / 0.10;
296
282
  --surface-overlay-stronger: 255 255 255 / 0.16;
297
-
298
- --page-bg: 5 5 6;
299
- --bg-input-hover: 19 19 22;
300
283
  --modal-overlay: 0 0 0 / 0.65;
301
284
 
302
285
  /* `--surface-on-accent` — PUBLIC (Phase Sidebar-E MIN-1, ADR-049).
@@ -349,7 +332,43 @@
349
332
  --toast-border: 255 255 255 / 0.10;
350
333
 
351
334
  /* Canvas placement bg — INTERNAL (placement primitive) */
352
- --canvas-placement-bg: 17 17 19;
335
+
336
+ /* Surface ladder — dark. Same five roles, same hue family. */
337
+ --surface-page: 11 11 12;
338
+ --surface-frame: 21 21 23;
339
+ --surface-card: 27 27 30;
340
+ --surface-inset: 45 45 48;
341
+ --surface-hover: 55 55 59;
342
+ /* A control you can act on — input, select, option tile, chip-button.
343
+ Sits at CARD level on purpose: in light that is white against the
344
+ frame behind it, which reads "type here"; in dark it is raised above
345
+ the frame. --muted/--surface-inset is the RECESSED role (wells, zebra,
346
+ table headers) and reads as disabled when a control borrows it. The
347
+ lenses were borrowing it because --bg-input is internal and this
348
+ public role did not exist. */
349
+ --surface-control: var(--surface-card);
350
+ --surface-elevated: 37 37 40;
351
+
352
+ --page-bg: var(--surface-page);
353
+ --background: var(--surface-page);
354
+ --main-content-bg: var(--surface-page);
355
+ --bg-surface: var(--surface-frame);
356
+ --bg-card: var(--surface-card);
357
+ --card: var(--surface-card);
358
+ --tray-bg: var(--surface-card);
359
+ --card-frame-bg: var(--surface-frame);
360
+ --card-foot-bg: var(--surface-frame);
361
+ --bg-elevated: var(--surface-elevated);
362
+ --surface-raised: var(--surface-elevated);
363
+ --popover: var(--surface-elevated);
364
+ --bg-input: var(--surface-frame);
365
+ --muted: var(--surface-inset);
366
+ --input: var(--surface-inset);
367
+ --secondary: var(--surface-inset);
368
+ --canvas-placement-bg: var(--surface-frame);
369
+ --bg-card-hover: var(--surface-hover);
370
+ --bg-input-hover: var(--surface-inset);
371
+
353
372
  }
354
373
 
355
374
  /* ============================================================
@@ -382,27 +401,15 @@
382
401
  * almost entirely on --border-card (0.07). If cards visually
383
402
  * disappear, bump --border-card. Hover/muted/input-hover stay
384
403
  * darker on purpose so state changes remain visible. */
385
- --background: 254 254 254;
386
404
  --foreground: 17 17 19;
387
- --bg-surface: 254 254 254;
388
- --bg-elevated: 255 255 255;
389
- --bg-card: 255 255 255;
390
- --bg-card-hover: 244 244 244;
391
- --bg-input: 255 255 255;
392
- --bg-input-hover: 253 253 253;
393
-
394
- --card: 255 255 255;
405
+ /* PUBLIC (ADR-063 D1) see the dark block note. */
395
406
  --card-foreground: 17 17 19;
396
- --popover: 255 255 255;
397
407
  --popover-foreground: 17 17 19;
398
-
399
- --muted: 245 245 245;
400
408
  --muted-foreground: 82 82 91;
401
409
  --text-secondary: 82 82 91;
402
410
  /* --text-tertiary stays from :root */
403
- --text-muted: 161 161 170;
404
-
405
- --secondary: 244 244 245;
411
+ /* Lifted 161->137 — was 2.56:1 on a white card. */
412
+ --text-muted: 137 137 146;
406
413
  --secondary-foreground: 17 17 19;
407
414
 
408
415
  --border: 0 0 0 / 0.12;
@@ -412,8 +419,6 @@
412
419
  * to delineate. The 0.16 value worked when --bg-input was slate-tinted; on
413
420
  * white-on-white the prior border was too subtle. */
414
421
  --border-input: 0 0 0 / 0.22;
415
- --input: 244 244 245;
416
-
417
422
  --success: 5 150 105;
418
423
  --success-bg: 5 150 105 / 0.10;
419
424
  /* `--warning` and the folder palette are intentionally split out
@@ -423,13 +428,12 @@
423
428
  --info: 37 99 235;
424
429
  --info-bg: 37 99 235 / 0.10;
425
430
  --destructive: 220 38 38;
431
+ --destructive-bg: 220 38 38 / 0.10;
426
432
 
427
433
  --surface-overlay-soft: 0 0 0 / 0.03;
428
434
  --surface-overlay: 0 0 0 / 0.05;
429
435
  --surface-overlay-strong: 0 0 0 / 0.08;
430
436
  --surface-overlay-stronger: 0 0 0 / 0.12;
431
-
432
- --page-bg: 254 254 254;
433
437
  --modal-overlay: 0 0 0 / 0.40;
434
438
  /* `--surface-on-accent` stays white in light mode too — see :root
435
439
  * comment. Declared here for symmetry / future-proofing. */
@@ -449,17 +453,7 @@
449
453
  * what was actually painting "the steely background" the owner
450
454
  * flagged; --page-bg is the body, but MainLayout's content area
451
455
  * paints with this token. Both must move together. */
452
- --main-content-bg: 254 254 254;
453
456
  --main-content-border: 0 0 0 / 0.10;
454
-
455
- --canvas-placement-bg: 241 245 249;
456
-
457
- --card-foot-bg: 248 250 252;
458
-
459
- --tray-bg: 255 255 255;
460
-
461
- --card-frame-bg: 248 250 252;
462
-
463
457
  /* Calculator Shell v2 — light overrides (ADR-076 D7). */
464
458
  --brand-ink: 20 20 23;
465
459
  --shell-shadow:
@@ -483,6 +477,68 @@
483
477
  0 2px 4px rgb(0 0 0 / 0.07),
484
478
  0 8px 24px rgb(0 0 0 / 0.09);
485
479
 
480
+ /* ============================================================
481
+ SURFACE LADDER — the canonical set (2026-08-04)
482
+ ------------------------------------------------------------
483
+ Before this, 19 surface tokens carried 4 distinct values, in
484
+ THREE different grey families: --card-frame-bg / --card-foot-bg
485
+ at hue 248 (slate), --muted / --bg-card-hover at 90
486
+ (achromatic), --input / --secondary at 286 (zinc). Mixing grey
487
+ families is what reads as "inconsistent" long before anyone can
488
+ say which value is wrong.
489
+
490
+ Everything now sits on hue 286 — the family the TEXT ramp
491
+ already uses (--foreground and every --text-* measure 285.8-286.4),
492
+ so greys and type finally agree.
493
+
494
+ FIVE roles. Every legacy name below is an alias onto one of them,
495
+ so no component changes and nothing is orphaned:
496
+
497
+ page the document. PURE WHITE — no grey backdrop.
498
+ card cards, trays, inputs, popovers. Also white; separation
499
+ is border + shadow, not a fill step.
500
+ frame the CONTAINED BACKGROUND for a group of sections —
501
+ <FrameStack> wrapping several <SectionCard>. The only
502
+ place a grey surface is correct.
503
+ inset recessed wells, table header strips, zebra rows.
504
+ hover the deepest step; hover on an inset.
505
+ ============================================================ */
506
+ --surface-page: 255 255 255;
507
+ --surface-card: 255 255 255;
508
+ --surface-frame: 247 247 249;
509
+ --surface-inset: 242 242 244;
510
+ --surface-hover: 238 238 240;
511
+ /* A control you can act on — input, select, option tile, chip-button.
512
+ Sits at CARD level on purpose: in light that is white against the
513
+ frame behind it, which reads "type here"; in dark it is raised above
514
+ the frame. --muted/--surface-inset is the RECESSED role (wells, zebra,
515
+ table headers) and reads as disabled when a control borrows it. The
516
+ lenses were borrowing it because --bg-input is internal and this
517
+ public role did not exist. */
518
+ --surface-control: var(--surface-card);
519
+
520
+ /* Legacy names — aliases onto the five above. Kept so the ~1,300
521
+ existing call sites keep working; prefer the canonical names. */
522
+ --page-bg: var(--surface-page);
523
+ --background: var(--surface-page);
524
+ --bg-surface: var(--surface-page);
525
+ --main-content-bg: var(--surface-page);
526
+ --bg-card: var(--surface-card);
527
+ --card: var(--surface-card);
528
+ --tray-bg: var(--surface-card);
529
+ --bg-elevated: var(--surface-card);
530
+ --surface-raised: var(--surface-card);
531
+ --popover: var(--surface-card);
532
+ --bg-input: var(--surface-card);
533
+ --card-frame-bg: var(--surface-frame);
534
+ --card-foot-bg: var(--surface-frame);
535
+ --muted: var(--surface-inset);
536
+ --input: var(--surface-inset);
537
+ --secondary: var(--surface-inset);
538
+ --canvas-placement-bg: var(--surface-inset);
539
+ --bg-card-hover: var(--surface-hover);
540
+ --bg-input-hover: var(--surface-frame);
541
+
486
542
  }
487
543
 
488
544
  /* ============================================================
@@ -510,7 +566,12 @@
510
566
  dark even though the rest of the shell is light.
511
567
  ============================================================ */
512
568
  [data-theme="light"] {
513
- --sidebar-bg: 244 244 248;
569
+ /* Flat white — this token is the app FRAME (main-layout.tsx paints the
570
+ whole shell with it), so a grey here tints every light page behind the
571
+ floating content card. The card's border + shadow carry the separation.
572
+ Matches meta-ads-audit-dashboard, which consumes the same tokens and
573
+ sets its rail to --page-bg. */
574
+ --sidebar-bg: 255 255 255;
514
575
  --sidebar-surface: 0 0 0 / 0.04;
515
576
  --sidebar-surface-hover: 0 0 0 / 0.08;
516
577
  --sidebar-surface-active: 207 14 15 / 0.10;
@@ -571,9 +632,69 @@
571
632
  so the hex literals are permitted here and nowhere in apps/.
572
633
  ============================================================ */
573
634
  :root {
574
- --color-meta-blue: #1877f2;
635
+ --color-meta-blue: rgb(var(--platform-facebook));
575
636
  --color-meta-ink: #050505;
576
637
  --color-surface-50: #fafafa;
577
638
  --color-surface-100: #f4f4f5;
578
639
  --color-surface-200: #e4e4e7;
579
640
  }
641
+
642
+ /* ============================================================
643
+ PLATFORM BRAND + MEDIA-OVERLAY TOKENS (theme-INDEPENDENT)
644
+ ------------------------------------------------------------
645
+ Third-party brand colours. Facebook is #1877F2 whether the
646
+ workspace is light or dark, so these deliberately sit OUTSIDE
647
+ the theme blocks — putting them in the theme would make a
648
+ platform badge change colour with the app, which is wrong.
649
+
650
+ This block exists because `swipes.css` and `collections.css`
651
+ each said, in their own header: "when a second consumer of
652
+ these exact tokens lands, promote to packages/ui/src/tokens.css".
653
+ Three landed (collections, ad-intel, the social previews), and
654
+ the same Facebook blue ended up defined in seven places under
655
+ seven names — plus an eighth, `--adstage-fb-like`, at a DIFFERENT
656
+ and wrong value (#2078f4). This is that promotion.
657
+
658
+ BARE TRIPLES, not complete colours: the same brand colour is
659
+ used at different opacities per surface (swipes/collections
660
+ badges at 0.78, ad-intel at 0.85), so the alpha belongs at the
661
+ call site — `rgb(var(--platform-facebook) / 0.78)`. Baking an
662
+ alpha into the token is what makes it un-shareable.
663
+
664
+ The `--media-*` overlays DO carry their alpha, because there the
665
+ opacity ramp IS the shared definition: swipes and collections
666
+ used byte-identical values.
667
+
668
+ Platform-chrome and exempt substrate (packages/ui), so the
669
+ literals are permitted here and nowhere in apps/.
670
+ ============================================================ */
671
+ :root {
672
+ /* Platform brand colours. */
673
+ --platform-facebook: 24 119 242;
674
+ --platform-instagram: 225 48 108;
675
+ --platform-linkedin: 10 102 194;
676
+ --platform-google: 66 133 244;
677
+ /* Google's logo green — the discovery report's tiles use it where the
678
+ * badge surfaces use the logo blue above. Both are real, both are used. */
679
+ --platform-google-green: 52 168 83;
680
+ --platform-youtube: 255 0 0;
681
+ /* TikTok's brand is black, which only works as a BADGE FILL (white text
682
+ * on it). As an icon colour on a dark card it measures 1.22:1, so the
683
+ * discovery tiles use theme-aware --folder-slate instead. Same reason
684
+ * there is no --platform-x: X is brand-black too, and its one consumer
685
+ * uses --folder-ink, which is byte-identical in light mode. */
686
+ --platform-tiktok: 0 0 0;
687
+ --platform-yelp: 211 35 35;
688
+ --platform-email: 245 158 11;
689
+ --platform-website: 255 255 255;
690
+ /* Near-white reads on every brand fill above. */
691
+ --platform-on-color: 255 255 255;
692
+
693
+ /* Overlays on creative media. Theme-independent because the thing
694
+ * underneath is an image, not a themed surface. */
695
+ --media-scrim-strong: 0 0 0 / 0.78;
696
+ --media-scrim-mid: 0 0 0 / 0.2;
697
+ --media-scrim-label: 0 0 0 / 0.55;
698
+ --media-on-scrim: 255 255 255 / 0.88;
699
+ --media-overlay-btn: 255 255 255 / 0.14;
700
+ }
@@ -0,0 +1,208 @@
1
+ # `@lovett/ui` v2
2
+
3
+ The document layer. Built for dense, text-heavy surfaces — briefs, checklists,
4
+ dossiers, report pages — where the job is to make a lot of information legible
5
+ rather than to entertain anyone.
6
+
7
+ ```tsx
8
+ import '@lovett/ui/src/v2/theme.css'
9
+ import { DocumentShell, DocumentSection, FieldRow } from '@lovett/ui/v2'
10
+ ```
11
+
12
+ v2 is **additive**. Every token is namespaced `--lv-*`, every class `.lv-*`.
13
+ `theme.css` loads alongside v1's `tokens.css` and `styles.css` without changing
14
+ a single v1 pixel, so a page migrates one section at a time. Nothing in v1 was
15
+ touched.
16
+
17
+ ---
18
+
19
+ ## The surface ladder
20
+
21
+ This is the spine. Everything else is detail.
22
+
23
+ ```
24
+ page → shell → section → card → [inset]
25
+ ─────────────── brighter ──────────────► ◄─ recessed
26
+ ```
27
+
28
+ | Tier | Token | What sits here |
29
+ | --- | --- | --- |
30
+ | page | `--lv-surface-page` | the viewport floor |
31
+ | shell | `--lv-surface-shell` | `DocumentShell` — the document's own field |
32
+ | section | `--lv-surface-section` | `Section` — a titled region |
33
+ | card | `--lv-surface-card` | `StatTile`, a tray |
34
+ | inset | `--lv-surface-inset` | `Panel` — **the recessed panel** |
35
+
36
+ Each tier is one notch brighter than the one behind it. `inset` deliberately
37
+ goes the other way, because a recessed panel *inside* a card is the single most
38
+ premium move in the system and recession is the only thing that makes it read
39
+ as recessed. Use `Panel` for the thing inside a card the reader actually came
40
+ for: the extracted quote, the computed figure, the diff, the raw payload.
41
+
42
+ **Depth is shadow plus a top-edge inner highlight, never a border.** A 1px
43
+ hairline used for elevation reads as a form field. Hairlines here are strictly
44
+ structural — dividers, table rules, field outlines, the `Timeline` rail.
45
+
46
+ Elevation runs `--lv-elev-0` → `-4` for lifting and `--lv-elev-inset` for
47
+ digging. In dark mode the layered soft shadows collapse to a single ring plus
48
+ one drop, because stacked low-alpha shadows are invisible on a dark surface.
49
+
50
+ ---
51
+
52
+ ## The colour formula
53
+
54
+ Authored in OKLCH. Ramps are **constant-hue**; chroma at each step is a
55
+ percentage of that hue's sRGB maximum *at that lightness*, so nothing clips and
56
+ no hue accidentally outranks another.
57
+
58
+ **Neutral** — hue 255, chroma capped at 0.023. Navy-tinted, not black. A pure
59
+ grey ramp goes dead next to a warm accent; this one stays alive without ever
60
+ reading as "blue".
61
+
62
+ **Accent** — hue 27.5, the house red. `--lv-accent` is the AA-safe action step
63
+ (L 0.554); `--lv-accent-mark` is the brighter step used for dots, rails, and
64
+ fills that carry no text on them.
65
+
66
+ > **The accent earns its impact from scarcity.** Exactly one filled accent
67
+ > surface per view. `Action variant="primary"` is that one thing; everything
68
+ > else is `secondary`, `ghost`, or `danger`. Three red buttons on a screen means
69
+ > there is no primary action on that screen.
70
+
71
+ **Semantic hues, held far apart:**
72
+
73
+ | Role | Hue | Marker shape |
74
+ | --- | --- | --- |
75
+ | success | 150° green | filled dot |
76
+ | warning | 95° gold | triangle |
77
+ | danger | 48° burnt orange | square / octagon |
78
+ | info | 290° violet | circle |
79
+
80
+ **Danger is not red, and that is on purpose.** The brand *is* red. A red brand
81
+ plus a red danger state cannot be told apart on a page that shows both, and no
82
+ credible danger red sits far enough from the brand hue to fix it. Moving the
83
+ meaning is the only thing that works. The cost is real — orange is a weaker
84
+ danger signal than red — so every component that carries a tone also carries an
85
+ **icon with a distinct silhouette** and a **screen-reader word**. Colour is
86
+ never the only signal. Destructive actions must additionally use an explicit
87
+ verb ("Delete brief", not "Confirm").
88
+
89
+ Each tone exposes four tokens: `-text` (the AA-safe reading colour), `-mark`
90
+ (dots/icons/tracks, which may be brighter because they are larger), `-soft` (the
91
+ tinted fill), and `-rule` (the leading rule).
92
+
93
+ **Both appearances, both directions.** Light is the default; dark comes from
94
+ `prefers-color-scheme` *and* from `[data-theme="dark"]`, and `[data-theme="light"]`
95
+ forces light back under a dark OS. Dark is not an inversion — the ladder is
96
+ rebuilt from different steps of the same ramps and every foreground/background
97
+ pair was re-checked. Accents gain chroma in dark, because a light-mode accent
98
+ goes muddy once its surround darkens.
99
+
100
+ ---
101
+
102
+ ## The spacing scale
103
+
104
+ One 4px grid: `0 1 2 3 4 5 6 7 8 10 12 14 16 20 24` → `0px … 96px`.
105
+
106
+ `SpaceStep` is a union type, so `gap={13}` does not compile. That single
107
+ constraint removes the most common reason a nice interface feels subtly off.
108
+
109
+ Grouping goes **space first, background shape second, line last**. The gap
110
+ between groups is at least 2× the gap within one. There is deliberately no
111
+ `Divider` primitive — reaching for a divider is almost always a failure of
112
+ spacing.
113
+
114
+ **Radius is sharp and concentric.** `xs 3 · sm 4 · md 6 · lg 10 · xl 14`.
115
+ A card at `lg` (10px) with a 4px frame gives its tray `md` (6px):
116
+ `outer = inner + padding`, both directions.
117
+
118
+ ---
119
+
120
+ ## Type
121
+
122
+ Named by role, never by size: `2xs xs sm base md lg xl 2xl 3xl stat`, plus
123
+ `--lv-prose-*` for long-form.
124
+
125
+ - UI chrome sits at 13–14px; long-form prose at 16px / 1.62, capped at `68ch`.
126
+ - Large type gets negative tracking, small uppercase labels get positive.
127
+ - **`tabular-nums` on every comparable number**, by default, not opt-in —
128
+ `StatTile`, `ProgressTrack`, `FieldRow numeric`, `StatusPill`, `Timeline meta`
129
+ all set it themselves.
130
+ - Type carries hierarchy before colour does. There are only four ink steps on
131
+ purpose; a fifth would mean weight and size had stopped doing their job.
132
+
133
+ ---
134
+
135
+ ## Layout stability
136
+
137
+ A premium UI is mechanically solid: surfaces hold still and only contents
138
+ change. Every v2 component reserves its space.
139
+
140
+ - **`StatTile`** — `slots` sizes a `min-width` in `ch` for the largest plausible
141
+ value. Set it to your worst case *including separators* (`1,284,930` → `9`).
142
+ The delta row is always in the box; when there is no delta it is
143
+ `visibility: hidden`, never `display: none`.
144
+ - **`ProgressTrack`** — the percentage lives in a 4ch slot, so 9% → 100% cannot
145
+ nudge the label.
146
+ - **`EmptyState` / `LoadingSkeleton` / `ErrorState`** — all three take the same
147
+ `reserve` prop feeding one `min-height`. Give all three the loaded content's
148
+ typical height and the region stops jumping through the whole lifecycle.
149
+ `LoadingSkeleton`'s `variant` mirrors the real primitives (`stat`, `fields`,
150
+ `document`) so the skeleton is the same shape, not just the same area.
151
+ - **`Action`** — loading keeps the label in the DOM holding the width and hides
152
+ it behind the spinner. Disabled changes paint only. The button never resizes
153
+ under the pointer.
154
+ - **`DocumentShell`** — the nav's active indicator is a 2px leading rule that is
155
+ always rendered and transparent when inactive. Adding it on activation would
156
+ shove every label sideways as you scroll.
157
+
158
+ ---
159
+
160
+ ## Motion
161
+
162
+ Spring on small, ease on large. `--lv-ease-spring` is for buttons, toggles, and
163
+ badges only — never a panel, and never a data value, because overshoot on a
164
+ number reads as imprecise. Press scale is `0.96` exactly; below `0.95` feels
165
+ exaggerated. Never `transition: all`.
166
+
167
+ `prefers-reduced-motion` collapses every duration token to 1ms and kills the
168
+ skeleton sheen and the indeterminate travel outright — reduced motion shows the
169
+ *final* state, not a frozen animation.
170
+
171
+ ---
172
+
173
+ ## When to reach for which
174
+
175
+ | You have | Use |
176
+ | --- | --- |
177
+ | a long document with sections | `DocumentShell` + `DocumentSection` |
178
+ | body copy inside one | `Prose` (capped measure, `text-wrap: pretty`) |
179
+ | label/value pairs | `DefinitionList` + `FieldRow` — one shared label column |
180
+ | a number someone will compare | `StatTile` (set `slots`) |
181
+ | a state the reader must not miss | `Callout` (icon + word + rule) |
182
+ | a state in a row or a header | `StatusPill` (shape carries it) |
183
+ | a measured quantity 0–100 | `ProgressTrack` |
184
+ | an ordered sequence of events | `Timeline` |
185
+ | the thing inside the card that matters | `Panel` (the recess) |
186
+ | vertical / horizontal flow | `Stack` / `Inline` |
187
+ | a responsive tile field | `Grid minColumnWidth` — breaks where content stops fitting, not at a device preset |
188
+ | controls above content | `Toolbar` (`Toolbar.Spacer` before `Toolbar.Divider`) |
189
+ | nothing yet / loading / broken | `EmptyState` / `LoadingSkeleton` / `ErrorState` — same `reserve` |
190
+
191
+ ---
192
+
193
+ ## Reviewing it
194
+
195
+ `__demo__/showcase.tsx` exports `<V2Showcase />`, which renders every primitive
196
+ in every state — default, hover, focus, disabled, loading, empty, error,
197
+ overflow — on one page, with a light/dark toggle. That page is the review
198
+ surface; if a change is not visible there, it is not reviewable.
199
+
200
+ ## Rules for adding to v2
201
+
202
+ 1. New tokens are `--lv-*`. New classes are `.lv-*`. No exceptions — the
203
+ namespace is what keeps v1 safe.
204
+ 2. Spacing props take a `SpaceStep`. Never a raw number, never a string.
205
+ 3. Every state a component can be in must occupy the same box as every other.
206
+ 4. Any tone must be carried by an icon or a shape as well as a hue.
207
+ 5. When in doubt, remove. Fewer borders, fills, colours, and font sizes.
208
+ Premium reads as confident and quiet; busy reads as cheap.