@lovett/ui 0.0.5 → 0.0.7

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 +16 -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,1861 @@
1
+ /*
2
+ * @lovett/ui v2 — theme.css
3
+ * =========================
4
+ *
5
+ * The v2 design layer. Everything here is namespaced `--lv-*` and `.lv-*`,
6
+ * so this file can load alongside v1's `tokens.css` / `styles.css` without
7
+ * touching a single v1 token. v1 keeps working byte-identically.
8
+ *
9
+ * Usage:
10
+ * import '@lovett/ui/src/v2/theme.css'
11
+ * <div className="lv-root"> … </div>
12
+ *
13
+ * Appearance:
14
+ * - default follows the OS (`prefers-color-scheme`)
15
+ * - <html data-theme="dark"> forces dark
16
+ * - <html data-theme="light"> forces light
17
+ * Both directions win over the OS, in both directions.
18
+ *
19
+ * Colour is authored in OKLCH. Ramps are constant-hue; chroma is a
20
+ * percentage of each hue's sRGB maximum at that lightness, so no step
21
+ * clips and no hue outranks another by accident.
22
+ *
23
+ * The surface ladder is the spine of the whole system:
24
+ *
25
+ * page → shell → section → card → [inset]
26
+ * ────────────── brighter ──────────────► ◄─ recessed
27
+ *
28
+ * Each tier is one notch brighter than the one behind it. The `inset`
29
+ * tier deliberately goes the other way: a recessed panel *inside* a card
30
+ * is the single most premium move in the system, and recession is what
31
+ * makes it read as recessed.
32
+ *
33
+ * Depth is shadow + a top-edge inner highlight. It is never a 1px border:
34
+ * a hairline used for depth reads as a form field. Hairlines here are
35
+ * strictly structural (dividers, table rules, field outlines).
36
+ */
37
+
38
+ /* ══════════════════════════════════════════════════════════════════════
39
+ 1. RAMPS — appearance-independent. Never consume these directly in a
40
+ component; consume the semantic tokens in §2.
41
+ ══════════════════════════════════════════════════════════════════════ */
42
+
43
+ .lv-root,
44
+ :root {
45
+ /* ── Neutral. Hue 255 (navy-tinted), chroma held under 0.023 so it
46
+ never reads as "blue", only as "not-quite-black". A pure-grey ramp
47
+ goes dead next to a warm accent; this one stays alive. ────────── */
48
+ --lv-n-0: oklch(1 0 0);
49
+ --lv-n-25: oklch(0.988 0.002 255);
50
+ --lv-n-50: oklch(0.976 0.003 255);
51
+ --lv-n-100: oklch(0.955 0.005 255);
52
+ --lv-n-150: oklch(0.932 0.006 255);
53
+ --lv-n-200: oklch(0.906 0.008 255);
54
+ --lv-n-300: oklch(0.851 0.011 255);
55
+ --lv-n-400: oklch(0.715 0.017 255);
56
+ --lv-n-500: oklch(0.585 0.021 255);
57
+ --lv-n-600: oklch(0.487 0.022 255);
58
+ --lv-n-700: oklch(0.396 0.022 255);
59
+ --lv-n-750: oklch(0.342 0.022 255);
60
+ --lv-n-800: oklch(0.287 0.021 255);
61
+ --lv-n-850: oklch(0.236 0.019 255);
62
+ --lv-n-900: oklch(0.192 0.017 255);
63
+ --lv-n-950: oklch(0.148 0.014 255);
64
+ --lv-n-975: oklch(0.115 0.012 255);
65
+ --lv-n-1000: oklch(0.09 0.01 255);
66
+
67
+ /* ── Accent. The house red, hue 27.5. sRGB pure red is
68
+ oklch(0.628 0.258 29.2), so the ramp peaks at 500 and every step
69
+ sits at ~85% of that hue's maximum chroma for its lightness. ──── */
70
+ --lv-a-50: oklch(0.972 0.012 27.5);
71
+ --lv-a-100: oklch(0.94 0.026 27.5);
72
+ --lv-a-200: oklch(0.885 0.052 27.5);
73
+ --lv-a-300: oklch(0.806 0.098 27.5);
74
+ --lv-a-400: oklch(0.707 0.17 27.5);
75
+ --lv-a-500: oklch(0.628 0.216 27.5);
76
+ --lv-a-600: oklch(0.554 0.204 27.5);
77
+ --lv-a-700: oklch(0.47 0.176 27.5);
78
+ --lv-a-800: oklch(0.385 0.142 27.5);
79
+ --lv-a-900: oklch(0.305 0.108 27.5);
80
+ --lv-a-950: oklch(0.222 0.074 27.5);
81
+
82
+ /* ── Semantic hues. Held far apart on the wheel so no two states can
83
+ be confused at a glance, and every one of them is ALSO carried by
84
+ an icon and a word in the components that use them.
85
+
86
+ success 150° green
87
+ warning 95° gold
88
+ danger 48° burnt orange
89
+ info 290° violet
90
+
91
+ danger is not red. The brand IS red, and a red brand plus a red
92
+ danger state cannot be told apart when both appear on one screen.
93
+ Moving the meaning is the only fix that works — see README. ──── */
94
+ --lv-ok-text-l: oklch(0.46 0.115 150);
95
+ --lv-ok-text-d: oklch(0.8 0.14 150);
96
+ --lv-ok-mark-l: oklch(0.58 0.145 150);
97
+ --lv-ok-mark-d: oklch(0.72 0.15 150);
98
+
99
+ --lv-wa-text-l: oklch(0.46 0.09 95);
100
+ --lv-wa-text-d: oklch(0.82 0.13 95);
101
+ --lv-wa-mark-l: oklch(0.62 0.115 95);
102
+ --lv-wa-mark-d: oklch(0.76 0.14 95);
103
+
104
+ --lv-da-text-l: oklch(0.47 0.11 48);
105
+ --lv-da-text-d: oklch(0.8 0.13 48);
106
+ --lv-da-mark-l: oklch(0.58 0.135 48);
107
+ --lv-da-mark-d: oklch(0.73 0.15 48);
108
+
109
+ --lv-in-text-l: oklch(0.47 0.19 290);
110
+ --lv-in-text-d: oklch(0.78 0.14 290);
111
+ --lv-in-mark-l: oklch(0.55 0.21 290);
112
+ --lv-in-mark-d: oklch(0.7 0.17 290);
113
+
114
+ /* ── Categorical series. Constant L (0.60) so no series outranks
115
+ another; chroma is per-hue so each is equally vivid rather than
116
+ equally saturated. Ships for charts; never used for state. ───── */
117
+ --lv-series-1: oklch(0.6 0.2 27.5);
118
+ --lv-series-2: oklch(0.6 0.19 290);
119
+ --lv-series-3: oklch(0.6 0.145 150);
120
+ --lv-series-4: oklch(0.6 0.115 95);
121
+ --lv-series-5: oklch(0.6 0.16 245);
122
+ --lv-series-6: oklch(0.6 0.1 195);
123
+ --lv-series-7: oklch(0.6 0.19 340);
124
+ --lv-series-8: oklch(0.6 0.13 128);
125
+
126
+ /* ══════════════════════════════════════════════════════════════════
127
+ SPACING — one 4px scale, honoured everywhere. Off-scale one-off
128
+ values are the single most common reason a nice UI feels "off".
129
+ ══════════════════════════════════════════════════════════════════ */
130
+ --lv-space-0: 0px;
131
+ --lv-space-1: 4px;
132
+ --lv-space-2: 8px;
133
+ --lv-space-3: 12px;
134
+ --lv-space-4: 16px;
135
+ --lv-space-5: 20px;
136
+ --lv-space-6: 24px;
137
+ --lv-space-7: 28px;
138
+ --lv-space-8: 32px;
139
+ --lv-space-10: 40px;
140
+ --lv-space-12: 48px;
141
+ --lv-space-14: 56px;
142
+ --lv-space-16: 64px;
143
+ --lv-space-20: 80px;
144
+ --lv-space-24: 96px;
145
+
146
+ /* ══════════════════════════════════════════════════════════════════
147
+ RADIUS — sharp edges. Concentric by construction: a card at `lg`
148
+ (10px) carries a 4px frame, so its tray lands on `md` (6px).
149
+ outer = inner + padding, in both directions.
150
+ ══════════════════════════════════════════════════════════════════ */
151
+ --lv-radius-xs: 3px;
152
+ --lv-radius-sm: 4px;
153
+ --lv-radius-md: 6px;
154
+ --lv-radius-lg: 10px;
155
+ --lv-radius-xl: 14px;
156
+ --lv-radius-full: 9999px;
157
+
158
+ /* ══════════════════════════════════════════════════════════════════
159
+ TYPE — named by role, not by size. Deviate from this set as little
160
+ as possible; hard-coded sizes are how a scale dies.
161
+ ══════════════════════════════════════════════════════════════════ */
162
+ --lv-font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Inter,
163
+ 'Helvetica Neue', Arial, sans-serif;
164
+ --lv-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
165
+ 'Liberation Mono', monospace;
166
+
167
+ /* Small uppercase labels get positive tracking; large display type
168
+ gets negative. Body copy at reading size gets neither. */
169
+ --lv-text-2xs: 11px;
170
+ --lv-lh-2xs: 1.45;
171
+ --lv-tr-2xs: 0.06em;
172
+
173
+ --lv-text-xs: 12px;
174
+ --lv-lh-xs: 1.45;
175
+ --lv-tr-xs: 0.01em;
176
+
177
+ --lv-text-sm: 13px;
178
+ --lv-lh-sm: 1.5;
179
+ --lv-tr-sm: 0em;
180
+
181
+ --lv-text-base: 14px;
182
+ --lv-lh-base: 1.55;
183
+ --lv-tr-base: 0em;
184
+
185
+ --lv-text-md: 15px;
186
+ --lv-lh-md: 1.6;
187
+ --lv-tr-md: 0em;
188
+
189
+ --lv-text-lg: 17px;
190
+ --lv-lh-lg: 1.4;
191
+ --lv-tr-lg: -0.006em;
192
+
193
+ --lv-text-xl: 20px;
194
+ --lv-lh-xl: 1.3;
195
+ --lv-tr-xl: -0.012em;
196
+
197
+ --lv-text-2xl: 25px;
198
+ --lv-lh-2xl: 1.24;
199
+ --lv-tr-2xl: -0.016em;
200
+
201
+ --lv-text-3xl: 31px;
202
+ --lv-lh-3xl: 1.18;
203
+ --lv-tr-3xl: -0.02em;
204
+
205
+ /* Stat display — always tabular, always in a reserved slot. */
206
+ --lv-text-stat: 28px;
207
+ --lv-lh-stat: 1.1;
208
+ --lv-tr-stat: -0.02em;
209
+
210
+ /* Long-form prose. Briefs and dossiers are read, not scanned. */
211
+ --lv-prose-size: 16px;
212
+ --lv-prose-lh: 1.62;
213
+ --lv-measure: 68ch;
214
+
215
+ --lv-weight-normal: 400;
216
+ --lv-weight-medium: 500;
217
+ --lv-weight-semibold: 600;
218
+ --lv-weight-bold: 680;
219
+
220
+ /* ══════════════════════════════════════════════════════════════════
221
+ MOTION — spring on small, ease on large. Every duration is a token
222
+ so `prefers-reduced-motion` can collapse all of them at once.
223
+ ══════════════════════════════════════════════════════════════════ */
224
+ --lv-dur-instant: 80ms;
225
+ --lv-dur-fast: 140ms;
226
+ --lv-dur-base: 200ms;
227
+ --lv-dur-slow: 320ms;
228
+ --lv-ease-out: cubic-bezier(0.2, 0, 0, 1);
229
+ --lv-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
230
+ /* Small elements only — buttons, toggles, badges. Never on a panel,
231
+ never on a data value: overshoot on a number reads as imprecise. */
232
+ --lv-ease-spring: cubic-bezier(0.34, 1.36, 0.64, 1);
233
+
234
+ /* ══════════════════════════════════════════════════════════════════
235
+ LAYOUT CONSTANTS — mirrored in tokens.ts for JS that needs numbers
236
+ (IntersectionObserver rootMargin, sticky offsets).
237
+ ══════════════════════════════════════════════════════════════════ */
238
+ --lv-doc-header-h: 56px;
239
+ --lv-doc-nav-w: 224px;
240
+ --lv-scroll-offset: 88px;
241
+ --lv-dl-label-w: 184px;
242
+ --lv-state-min-h: 216px;
243
+ }
244
+
245
+ /* ══════════════════════════════════════════════════════════════════════
246
+ 2. SEMANTIC TOKENS — LIGHT
247
+ These are what components consume. Nothing below §2 touches a ramp.
248
+ ══════════════════════════════════════════════════════════════════════ */
249
+
250
+ .lv-root,
251
+ :root {
252
+ color-scheme: light dark;
253
+
254
+ /* Surface ladder — page is the floor, card is the top, inset digs. */
255
+ --lv-surface-page: var(--lv-n-100);
256
+ --lv-surface-shell: var(--lv-n-50);
257
+ --lv-surface-section: var(--lv-n-25);
258
+ --lv-surface-card: var(--lv-n-0);
259
+ --lv-surface-inset: oklch(0.964 0.006 255);
260
+ --lv-surface-raised: var(--lv-n-0);
261
+ --lv-surface-overlay: oklch(0.2 0.017 255 / 0.42);
262
+ /* Ghost/hover wash. Tinted with the neutral hue, never pure black. */
263
+ --lv-surface-hover: oklch(0.585 0.021 255 / 0.06);
264
+ --lv-surface-active: oklch(0.585 0.021 255 / 0.11);
265
+ --lv-surface-selected: oklch(0.628 0.216 27.5 / 0.07);
266
+
267
+ /* Ink. Type carries hierarchy before colour does, so there are only
268
+ four steps here — any more and weight/size stop doing their job. */
269
+ --lv-text-primary: var(--lv-n-950);
270
+ --lv-text-secondary: var(--lv-n-600);
271
+ --lv-text-tertiary: var(--lv-n-500);
272
+ --lv-text-faint: var(--lv-n-400);
273
+ --lv-text-on-accent: oklch(1 0 0);
274
+ --lv-text-disabled: var(--lv-n-400);
275
+
276
+ /* Hairlines — STRUCTURE ONLY. Depth is §elevation. */
277
+ --lv-hairline: oklch(0 0 0 / 0.07);
278
+ --lv-hairline-strong: oklch(0 0 0 / 0.12);
279
+ --lv-border-field: oklch(0 0 0 / 0.14);
280
+
281
+ /* Accent. Scarce by contract: one filled accent surface per view. */
282
+ --lv-accent: var(--lv-a-600);
283
+ --lv-accent-hover: var(--lv-a-700);
284
+ --lv-accent-active: var(--lv-a-800);
285
+ --lv-accent-text: var(--lv-a-600);
286
+ --lv-accent-mark: var(--lv-a-500);
287
+ --lv-accent-soft: oklch(0.628 0.216 27.5 / 0.1);
288
+ --lv-accent-rule: oklch(0.628 0.216 27.5 / 0.42);
289
+ --lv-focus: var(--lv-a-500);
290
+
291
+ /* Semantic states. `-text` is the AA-safe reading colour, `-mark` is
292
+ the dot/icon/track colour (larger, so it may be brighter). */
293
+ --lv-success-text: var(--lv-ok-text-l);
294
+ --lv-success-mark: var(--lv-ok-mark-l);
295
+ --lv-success-soft: oklch(0.58 0.145 150 / 0.1);
296
+ --lv-success-rule: oklch(0.58 0.145 150 / 0.5);
297
+
298
+ --lv-warning-text: var(--lv-wa-text-l);
299
+ --lv-warning-mark: var(--lv-wa-mark-l);
300
+ --lv-warning-soft: oklch(0.62 0.115 95 / 0.14);
301
+ --lv-warning-rule: oklch(0.62 0.115 95 / 0.55);
302
+
303
+ --lv-danger-text: var(--lv-da-text-l);
304
+ --lv-danger-mark: var(--lv-da-mark-l);
305
+ --lv-danger-soft: oklch(0.58 0.135 48 / 0.12);
306
+ --lv-danger-rule: oklch(0.58 0.135 48 / 0.55);
307
+
308
+ --lv-info-text: var(--lv-in-text-l);
309
+ --lv-info-mark: var(--lv-in-mark-l);
310
+ --lv-info-soft: oklch(0.55 0.21 290 / 0.1);
311
+ --lv-info-rule: oklch(0.55 0.21 290 / 0.45);
312
+
313
+ /* Elevation = shadow + a top-edge inner highlight. The highlight is
314
+ what separates "a box with a shadow" from "a lit surface". */
315
+ --lv-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.9);
316
+ --lv-elev-0: none;
317
+ --lv-elev-1: 0 1px 1px -0.5px oklch(0.2 0.02 255 / 0.06),
318
+ 0 1px 2px -1px oklch(0.2 0.02 255 / 0.05), var(--lv-highlight);
319
+ --lv-elev-2: 0 1px 2px -1px oklch(0.2 0.02 255 / 0.08),
320
+ 0 3px 6px -2px oklch(0.2 0.02 255 / 0.07), var(--lv-highlight);
321
+ --lv-elev-3: 0 2px 4px -2px oklch(0.2 0.02 255 / 0.09),
322
+ 0 8px 18px -6px oklch(0.2 0.02 255 / 0.11), var(--lv-highlight);
323
+ --lv-elev-4: 0 4px 8px -4px oklch(0.2 0.02 255 / 0.1),
324
+ 0 20px 44px -12px oklch(0.2 0.02 255 / 0.18), var(--lv-highlight);
325
+ /* The recessed panel. Inner shadow at the top edge + a whisper of a
326
+ containing ring. No outer shadow — it is a hole, not a block. */
327
+ --lv-elev-inset: inset 0 1px 2px 0 oklch(0.2 0.02 255 / 0.07),
328
+ inset 0 0 0 1px oklch(0 0 0 / 0.035);
329
+
330
+ --lv-skeleton-base: oklch(0.585 0.021 255 / 0.1);
331
+ --lv-skeleton-sheen: oklch(0.585 0.021 255 / 0.05);
332
+ }
333
+
334
+ /* ══════════════════════════════════════════════════════════════════════
335
+ 3. SEMANTIC TOKENS — DARK
336
+ Not a mechanical inversion. The ladder is rebuilt from different steps
337
+ of the same ramps, then every pair was re-checked: chroma comes UP on
338
+ accents (a light-mode accent goes muddy once its surround darkens) and
339
+ the layered depth shadows come DOWN to a single ring plus one drop,
340
+ because stacked soft shadows are invisible on a dark surface.
341
+ ══════════════════════════════════════════════════════════════════════ */
342
+
343
+ @media (prefers-color-scheme: dark) {
344
+ :root:not([data-theme='light']),
345
+ .lv-root:not([data-theme='light']),
346
+ /* A nested .lv-root re-declares the whole light ramp at its own specificity, so without
347
+ this it resets its subtree to light under an OS-dark root. */
348
+ :root:not([data-theme='light']) .lv-root:not([data-theme='light']) {
349
+ color-scheme: dark;
350
+
351
+ --lv-surface-page: var(--lv-n-975);
352
+ --lv-surface-shell: var(--lv-n-900);
353
+ --lv-surface-section: oklch(0.222 0.018 255);
354
+ --lv-surface-card: oklch(0.252 0.019 255);
355
+ --lv-surface-inset: oklch(0.178 0.016 255);
356
+ --lv-surface-raised: oklch(0.298 0.02 255);
357
+ --lv-surface-overlay: oklch(0.09 0.01 255 / 0.7);
358
+ --lv-surface-hover: oklch(1 0 0 / 0.055);
359
+ --lv-surface-active: oklch(1 0 0 / 0.09);
360
+ --lv-surface-selected: oklch(0.707 0.17 27.5 / 0.14);
361
+
362
+ --lv-text-primary: oklch(0.972 0.003 255);
363
+ --lv-text-secondary: oklch(0.78 0.012 255);
364
+ --lv-text-tertiary: oklch(0.655 0.015 255);
365
+ --lv-text-faint: oklch(0.545 0.016 255);
366
+ --lv-text-on-accent: oklch(1 0 0);
367
+ --lv-text-disabled: oklch(0.5 0.015 255);
368
+
369
+ --lv-hairline: oklch(1 0 0 / 0.09);
370
+ --lv-hairline-strong: oklch(1 0 0 / 0.16);
371
+ --lv-border-field: oklch(1 0 0 / 0.18);
372
+
373
+ --lv-accent: var(--lv-a-500);
374
+ --lv-accent-hover: var(--lv-a-400);
375
+ --lv-accent-active: var(--lv-a-300);
376
+ --lv-accent-text: var(--lv-a-400);
377
+ --lv-accent-mark: var(--lv-a-400);
378
+ --lv-accent-soft: oklch(0.707 0.17 27.5 / 0.16);
379
+ --lv-accent-rule: oklch(0.707 0.17 27.5 / 0.5);
380
+ --lv-focus: var(--lv-a-400);
381
+
382
+ --lv-success-text: var(--lv-ok-text-d);
383
+ --lv-success-mark: var(--lv-ok-mark-d);
384
+ --lv-success-soft: oklch(0.72 0.15 150 / 0.16);
385
+ --lv-success-rule: oklch(0.72 0.15 150 / 0.55);
386
+
387
+ --lv-warning-text: var(--lv-wa-text-d);
388
+ --lv-warning-mark: var(--lv-wa-mark-d);
389
+ --lv-warning-soft: oklch(0.76 0.14 95 / 0.16);
390
+ --lv-warning-rule: oklch(0.76 0.14 95 / 0.55);
391
+
392
+ --lv-danger-text: var(--lv-da-text-d);
393
+ --lv-danger-mark: var(--lv-da-mark-d);
394
+ --lv-danger-soft: oklch(0.73 0.15 48 / 0.16);
395
+ --lv-danger-rule: oklch(0.73 0.15 48 / 0.55);
396
+
397
+ --lv-info-text: var(--lv-in-text-d);
398
+ --lv-info-mark: var(--lv-in-mark-d);
399
+ --lv-info-soft: oklch(0.7 0.17 290 / 0.18);
400
+ --lv-info-rule: oklch(0.7 0.17 290 / 0.5);
401
+
402
+ --lv-series-1: oklch(0.72 0.17 27.5);
403
+ --lv-series-2: oklch(0.72 0.15 290);
404
+ --lv-series-3: oklch(0.72 0.15 150);
405
+ --lv-series-4: oklch(0.72 0.13 95);
406
+ --lv-series-5: oklch(0.72 0.14 245);
407
+ --lv-series-6: oklch(0.72 0.1 195);
408
+ --lv-series-7: oklch(0.72 0.16 340);
409
+ --lv-series-8: oklch(0.72 0.14 128);
410
+
411
+ --lv-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.055);
412
+ --lv-elev-0: none;
413
+ --lv-elev-1: 0 0 0 1px oklch(1 0 0 / 0.06), var(--lv-highlight);
414
+ --lv-elev-2: 0 0 0 1px oklch(1 0 0 / 0.07),
415
+ 0 2px 6px -2px oklch(0 0 0 / 0.5), var(--lv-highlight);
416
+ --lv-elev-3: 0 0 0 1px oklch(1 0 0 / 0.08),
417
+ 0 8px 20px -6px oklch(0 0 0 / 0.6), var(--lv-highlight);
418
+ --lv-elev-4: 0 0 0 1px oklch(1 0 0 / 0.1),
419
+ 0 22px 48px -12px oklch(0 0 0 / 0.7), var(--lv-highlight);
420
+ --lv-elev-inset: inset 0 1px 3px 0 oklch(0 0 0 / 0.45),
421
+ inset 0 0 0 1px oklch(1 0 0 / 0.04);
422
+
423
+ --lv-skeleton-base: oklch(1 0 0 / 0.07);
424
+ --lv-skeleton-sheen: oklch(1 0 0 / 0.04);
425
+ }
426
+ }
427
+
428
+ /* Explicit override. Duplicated on purpose — a media query and an
429
+ attribute selector cannot share a rule, and the attribute must win in
430
+ BOTH directions (forced dark under a light OS, forced light under a
431
+ dark OS). Keep the two dark blocks in sync. */
432
+ :root[data-theme='dark'],
433
+ .lv-root[data-theme='dark'],
434
+ /* Forced dark must reach nested .lv-root subtrees (artifact bodies, the gallery), which
435
+ otherwise stay light because .lv-root re-declares the light tokens unconditionally. */
436
+ :root[data-theme='dark'] .lv-root:not([data-theme='light']) {
437
+ color-scheme: dark;
438
+
439
+ --lv-surface-page: var(--lv-n-975);
440
+ --lv-surface-shell: var(--lv-n-900);
441
+ --lv-surface-section: oklch(0.222 0.018 255);
442
+ --lv-surface-card: oklch(0.252 0.019 255);
443
+ --lv-surface-inset: oklch(0.178 0.016 255);
444
+ --lv-surface-raised: oklch(0.298 0.02 255);
445
+ --lv-surface-overlay: oklch(0.09 0.01 255 / 0.7);
446
+ --lv-surface-hover: oklch(1 0 0 / 0.055);
447
+ --lv-surface-active: oklch(1 0 0 / 0.09);
448
+ --lv-surface-selected: oklch(0.707 0.17 27.5 / 0.14);
449
+
450
+ --lv-text-primary: oklch(0.972 0.003 255);
451
+ --lv-text-secondary: oklch(0.78 0.012 255);
452
+ --lv-text-tertiary: oklch(0.655 0.015 255);
453
+ --lv-text-faint: oklch(0.545 0.016 255);
454
+ --lv-text-on-accent: oklch(1 0 0);
455
+ --lv-text-disabled: oklch(0.5 0.015 255);
456
+
457
+ --lv-hairline: oklch(1 0 0 / 0.09);
458
+ --lv-hairline-strong: oklch(1 0 0 / 0.16);
459
+ --lv-border-field: oklch(1 0 0 / 0.18);
460
+
461
+ --lv-accent: var(--lv-a-500);
462
+ --lv-accent-hover: var(--lv-a-400);
463
+ --lv-accent-active: var(--lv-a-300);
464
+ --lv-accent-text: var(--lv-a-400);
465
+ --lv-accent-mark: var(--lv-a-400);
466
+ --lv-accent-soft: oklch(0.707 0.17 27.5 / 0.16);
467
+ --lv-accent-rule: oklch(0.707 0.17 27.5 / 0.5);
468
+ --lv-focus: var(--lv-a-400);
469
+
470
+ --lv-success-text: var(--lv-ok-text-d);
471
+ --lv-success-mark: var(--lv-ok-mark-d);
472
+ --lv-success-soft: oklch(0.72 0.15 150 / 0.16);
473
+ --lv-success-rule: oklch(0.72 0.15 150 / 0.55);
474
+
475
+ --lv-warning-text: var(--lv-wa-text-d);
476
+ --lv-warning-mark: var(--lv-wa-mark-d);
477
+ --lv-warning-soft: oklch(0.76 0.14 95 / 0.16);
478
+ --lv-warning-rule: oklch(0.76 0.14 95 / 0.55);
479
+
480
+ --lv-danger-text: var(--lv-da-text-d);
481
+ --lv-danger-mark: var(--lv-da-mark-d);
482
+ --lv-danger-soft: oklch(0.73 0.15 48 / 0.16);
483
+ --lv-danger-rule: oklch(0.73 0.15 48 / 0.55);
484
+
485
+ --lv-info-text: var(--lv-in-text-d);
486
+ --lv-info-mark: var(--lv-in-mark-d);
487
+ --lv-info-soft: oklch(0.7 0.17 290 / 0.18);
488
+ --lv-info-rule: oklch(0.7 0.17 290 / 0.5);
489
+
490
+ --lv-series-1: oklch(0.72 0.17 27.5);
491
+ --lv-series-2: oklch(0.72 0.15 290);
492
+ --lv-series-3: oklch(0.72 0.15 150);
493
+ --lv-series-4: oklch(0.72 0.13 95);
494
+ --lv-series-5: oklch(0.72 0.14 245);
495
+ --lv-series-6: oklch(0.72 0.1 195);
496
+ --lv-series-7: oklch(0.72 0.16 340);
497
+ --lv-series-8: oklch(0.72 0.14 128);
498
+
499
+ --lv-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.055);
500
+ --lv-elev-0: none;
501
+ --lv-elev-1: 0 0 0 1px oklch(1 0 0 / 0.06), var(--lv-highlight);
502
+ --lv-elev-2: 0 0 0 1px oklch(1 0 0 / 0.07), 0 2px 6px -2px oklch(0 0 0 / 0.5),
503
+ var(--lv-highlight);
504
+ --lv-elev-3: 0 0 0 1px oklch(1 0 0 / 0.08), 0 8px 20px -6px oklch(0 0 0 / 0.6),
505
+ var(--lv-highlight);
506
+ --lv-elev-4: 0 0 0 1px oklch(1 0 0 / 0.1), 0 22px 48px -12px oklch(0 0 0 / 0.7),
507
+ var(--lv-highlight);
508
+ --lv-elev-inset: inset 0 1px 3px 0 oklch(0 0 0 / 0.45),
509
+ inset 0 0 0 1px oklch(1 0 0 / 0.04);
510
+
511
+ --lv-skeleton-base: oklch(1 0 0 / 0.07);
512
+ --lv-skeleton-sheen: oklch(1 0 0 / 0.04);
513
+ }
514
+
515
+ /* Forced light under a dark OS. The §2 block already holds every light
516
+ value, so this only needs to re-assert `color-scheme`; the cascade
517
+ does the rest because the dark map lives behind
518
+ `:not([data-theme='light'])`. */
519
+ :root[data-theme='light'],
520
+ .lv-root[data-theme='light'] {
521
+ color-scheme: light;
522
+ }
523
+
524
+ /* ══════════════════════════════════════════════════════════════════════
525
+ 4. ROOT
526
+ ══════════════════════════════════════════════════════════════════════ */
527
+
528
+ .lv-root {
529
+ font-family: var(--lv-font-sans);
530
+ font-size: var(--lv-text-base);
531
+ line-height: var(--lv-lh-base);
532
+ color: var(--lv-text-primary);
533
+ background: var(--lv-surface-page);
534
+ /* macOS renders text heavier than intended; correct once, at the root. */
535
+ -webkit-font-smoothing: antialiased;
536
+ -moz-osx-font-smoothing: grayscale;
537
+ text-rendering: optimizeLegibility;
538
+ }
539
+
540
+ /* One focus treatment, on everything, in one place. `outline-offset`
541
+ does the ring gap without needing to know the surface behind it. */
542
+ .lv-root :is(a, button, input, select, textarea, summary, [tabindex]):focus-visible,
543
+ .lv-root:focus-visible {
544
+ outline: 2px solid var(--lv-focus);
545
+ outline-offset: 2px;
546
+ border-radius: inherit;
547
+ }
548
+
549
+ .lv-root ::selection {
550
+ background: var(--lv-accent-soft);
551
+ color: var(--lv-text-primary);
552
+ }
553
+
554
+ /* Any comparable number. Not opt-in. */
555
+ .lv-num {
556
+ font-variant-numeric: tabular-nums;
557
+ }
558
+
559
+ .lv-mono {
560
+ font-family: var(--lv-font-mono);
561
+ font-variant-numeric: tabular-nums;
562
+ }
563
+
564
+ .lv-truncate {
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ white-space: nowrap;
568
+ min-width: 0;
569
+ }
570
+
571
+ .lv-sr-only {
572
+ position: absolute;
573
+ width: 1px;
574
+ height: 1px;
575
+ padding: 0;
576
+ margin: -1px;
577
+ overflow: hidden;
578
+ clip-path: inset(50%);
579
+ white-space: nowrap;
580
+ border-width: 0;
581
+ }
582
+
583
+ /* Reduced motion — jump to the final state. Collapsing the duration
584
+ tokens covers every transition authored against the scale; the two
585
+ `!important` rules catch keyframes and anything a consumer nests. */
586
+ @media (prefers-reduced-motion: reduce) {
587
+ .lv-root,
588
+ .lv-root * {
589
+ --lv-dur-instant: 1ms;
590
+ --lv-dur-fast: 1ms;
591
+ --lv-dur-base: 1ms;
592
+ --lv-dur-slow: 1ms;
593
+ animation-duration: 1ms !important;
594
+ animation-iteration-count: 1 !important;
595
+ transition-duration: 1ms !important;
596
+ scroll-behavior: auto !important;
597
+ }
598
+ }
599
+
600
+ /* ══════════════════════════════════════════════════════════════════════
601
+ 5. LAYOUT PRIMITIVES
602
+ ══════════════════════════════════════════════════════════════════════ */
603
+
604
+ .lv-stack {
605
+ display: flex;
606
+ flex-direction: column;
607
+ min-width: 0;
608
+ }
609
+
610
+ .lv-inline {
611
+ display: flex;
612
+ flex-direction: row;
613
+ min-width: 0;
614
+ }
615
+
616
+ .lv-grid {
617
+ display: grid;
618
+ min-width: 0;
619
+ }
620
+
621
+ /* Section — a titled region sitting one tier above its shell. */
622
+ .lv-section {
623
+ background: var(--lv-surface-section);
624
+ border-radius: var(--lv-radius-lg);
625
+ box-shadow: var(--lv-elev-1);
626
+ padding: var(--lv-space-5);
627
+ min-width: 0;
628
+ }
629
+
630
+ .lv-section[data-flush='true'] {
631
+ background: transparent;
632
+ box-shadow: none;
633
+ padding: 0;
634
+ }
635
+
636
+ .lv-section-head {
637
+ display: flex;
638
+ align-items: flex-start;
639
+ justify-content: space-between;
640
+ gap: var(--lv-space-4);
641
+ /* Reserved so a section without actions is the same height as one
642
+ with them — the actions slot never resizes the header. */
643
+ min-height: 28px;
644
+ margin-bottom: var(--lv-space-4);
645
+ }
646
+
647
+ .lv-section-eyebrow {
648
+ font-size: var(--lv-text-2xs);
649
+ line-height: var(--lv-lh-2xs);
650
+ letter-spacing: var(--lv-tr-2xs);
651
+ text-transform: uppercase;
652
+ font-weight: var(--lv-weight-semibold);
653
+ color: var(--lv-text-tertiary);
654
+ margin-bottom: var(--lv-space-1);
655
+ }
656
+
657
+ .lv-section-title {
658
+ font-size: var(--lv-text-lg);
659
+ line-height: var(--lv-lh-lg);
660
+ letter-spacing: var(--lv-tr-lg);
661
+ font-weight: var(--lv-weight-semibold);
662
+ color: var(--lv-text-primary);
663
+ text-wrap: balance;
664
+ margin: 0;
665
+ }
666
+
667
+ .lv-section-desc {
668
+ font-size: var(--lv-text-sm);
669
+ line-height: var(--lv-lh-sm);
670
+ color: var(--lv-text-secondary);
671
+ text-wrap: pretty;
672
+ max-width: var(--lv-measure);
673
+ margin-top: var(--lv-space-1);
674
+ }
675
+
676
+ /* Panel — THE recessed inset. A hole cut into whatever surface holds
677
+ it. Never gets an outer shadow; never gets a hard border. */
678
+ .lv-panel {
679
+ background: var(--lv-surface-inset);
680
+ border-radius: var(--lv-radius-sm);
681
+ box-shadow: var(--lv-elev-inset);
682
+ padding: var(--lv-space-4);
683
+ min-width: 0;
684
+ }
685
+
686
+ .lv-panel[data-tone='quiet'] {
687
+ box-shadow: inset 0 0 0 1px var(--lv-hairline);
688
+ background: transparent;
689
+ }
690
+
691
+ .lv-panel[data-tone='raised'] {
692
+ background: var(--lv-surface-raised);
693
+ box-shadow: var(--lv-elev-2);
694
+ border-radius: var(--lv-radius-md);
695
+ }
696
+
697
+ /* Toolbar — controls float above content; the hairline is structural. */
698
+ .lv-toolbar {
699
+ display: flex;
700
+ align-items: center;
701
+ gap: var(--lv-space-2);
702
+ /* Reserved so an empty toolbar does not collapse the layout. */
703
+ min-height: 40px;
704
+ padding-inline: var(--lv-space-1);
705
+ min-width: 0;
706
+ }
707
+
708
+ .lv-toolbar[data-sticky='true'] {
709
+ position: sticky;
710
+ top: 0;
711
+ z-index: 5;
712
+ background: var(--lv-surface-shell);
713
+ padding-block: var(--lv-space-2);
714
+ box-shadow: inset 0 -1px 0 0 var(--lv-hairline);
715
+ }
716
+
717
+ .lv-toolbar-spacer {
718
+ flex: 1 1 auto;
719
+ min-width: var(--lv-space-2);
720
+ }
721
+
722
+ .lv-toolbar-divider {
723
+ flex: 0 0 auto;
724
+ align-self: stretch;
725
+ width: 1px;
726
+ margin-inline: var(--lv-space-1);
727
+ margin-block: var(--lv-space-2);
728
+ background: var(--lv-hairline);
729
+ }
730
+
731
+ /* ══════════════════════════════════════════════════════════════════════
732
+ 6. ACTION (button)
733
+ Exactly one `primary` per view. Everything else is secondary or ghost.
734
+ ══════════════════════════════════════════════════════════════════════ */
735
+
736
+ .lv-action {
737
+ position: relative;
738
+ display: inline-flex;
739
+ align-items: center;
740
+ justify-content: center;
741
+ gap: var(--lv-space-2);
742
+ flex: 0 0 auto;
743
+ height: 32px;
744
+ padding-inline: var(--lv-space-3);
745
+ border: 0;
746
+ border-radius: var(--lv-radius-md);
747
+ font-family: inherit;
748
+ font-size: var(--lv-text-sm);
749
+ font-weight: var(--lv-weight-medium);
750
+ line-height: 1;
751
+ white-space: nowrap;
752
+ cursor: pointer;
753
+ user-select: none;
754
+ /* Never `all`. */
755
+ transition-property: background-color, color, box-shadow, scale, opacity;
756
+ transition-duration: var(--lv-dur-fast);
757
+ transition-timing-function: var(--lv-ease-out);
758
+ }
759
+
760
+ .lv-action[data-size='sm'] {
761
+ height: 26px;
762
+ padding-inline: var(--lv-space-2);
763
+ font-size: var(--lv-text-xs);
764
+ border-radius: var(--lv-radius-sm);
765
+ }
766
+
767
+ .lv-action[data-size='lg'] {
768
+ height: 40px;
769
+ padding-inline: var(--lv-space-4);
770
+ font-size: var(--lv-text-base);
771
+ }
772
+
773
+ .lv-action[data-icon-only='true'] {
774
+ width: 32px;
775
+ padding-inline: 0;
776
+ }
777
+ .lv-action[data-icon-only='true'][data-size='sm'] {
778
+ width: 26px;
779
+ }
780
+ .lv-action[data-icon-only='true'][data-size='lg'] {
781
+ width: 40px;
782
+ }
783
+
784
+ /* Tactile press. 0.96 exactly — below 0.95 reads as exaggerated. */
785
+ .lv-action:not(:disabled):active {
786
+ scale: 0.96;
787
+ transition-duration: var(--lv-dur-instant);
788
+ transition-timing-function: var(--lv-ease-spring);
789
+ }
790
+
791
+ .lv-action:disabled,
792
+ .lv-action[aria-disabled='true'] {
793
+ cursor: not-allowed;
794
+ /* Size is unchanged; only the paint changes. */
795
+ opacity: 0.5;
796
+ }
797
+
798
+ .lv-action[data-variant='primary'] {
799
+ background: var(--lv-accent);
800
+ color: var(--lv-text-on-accent);
801
+ box-shadow: var(--lv-elev-1);
802
+ }
803
+ .lv-action[data-variant='primary']:not(:disabled):hover {
804
+ background: var(--lv-accent-hover);
805
+ }
806
+
807
+ .lv-action[data-variant='secondary'] {
808
+ background: var(--lv-surface-card);
809
+ color: var(--lv-text-primary);
810
+ box-shadow: var(--lv-elev-1);
811
+ }
812
+ .lv-action[data-variant='secondary']:not(:disabled):hover {
813
+ background: var(--lv-surface-hover);
814
+ }
815
+
816
+ .lv-action[data-variant='ghost'] {
817
+ background: transparent;
818
+ color: var(--lv-text-secondary);
819
+ }
820
+ .lv-action[data-variant='ghost']:not(:disabled):hover {
821
+ background: var(--lv-surface-hover);
822
+ color: var(--lv-text-primary);
823
+ }
824
+
825
+ .lv-action[data-variant='danger'] {
826
+ background: transparent;
827
+ color: var(--lv-danger-text);
828
+ box-shadow: inset 0 0 0 1px var(--lv-danger-rule);
829
+ }
830
+ .lv-action[data-variant='danger']:not(:disabled):hover {
831
+ background: var(--lv-danger-soft);
832
+ }
833
+
834
+ /* Loading: the label stays in the DOM (holding the width) and goes
835
+ invisible; the spinner sits on top. The box never resizes. */
836
+ .lv-action[data-loading='true'] {
837
+ cursor: progress;
838
+ }
839
+ .lv-action[data-loading='true'] .lv-action-label {
840
+ visibility: hidden;
841
+ }
842
+ .lv-action-spinner {
843
+ position: absolute;
844
+ inset: 0;
845
+ display: grid;
846
+ place-items: center;
847
+ }
848
+ .lv-spin {
849
+ width: 14px;
850
+ height: 14px;
851
+ border-radius: var(--lv-radius-full);
852
+ border: 1.5px solid currentColor;
853
+ border-top-color: transparent;
854
+ opacity: 0.85;
855
+ animation: lv-spin 720ms linear infinite;
856
+ }
857
+ @keyframes lv-spin {
858
+ to {
859
+ transform: rotate(360deg);
860
+ }
861
+ }
862
+
863
+ /* ══════════════════════════════════════════════════════════════════════
864
+ 7. DOCUMENT SHELL
865
+ ══════════════════════════════════════════════════════════════════════ */
866
+
867
+ .lv-doc {
868
+ display: grid;
869
+ grid-template-columns: 1fr;
870
+ gap: var(--lv-space-6);
871
+ align-items: start;
872
+ background: var(--lv-surface-shell);
873
+ min-width: 0;
874
+ }
875
+
876
+ /* Hold the two-column structure until the content genuinely stops
877
+ fitting — not at a device preset. 900px is where a 224px rail plus a
878
+ readable measure runs out of room. */
879
+ @media (min-width: 900px) {
880
+ .lv-doc {
881
+ grid-template-columns: var(--lv-doc-nav-w) minmax(0, 1fr);
882
+ }
883
+ }
884
+
885
+ .lv-doc-header {
886
+ position: sticky;
887
+ top: 0;
888
+ z-index: 10;
889
+ grid-column: 1 / -1;
890
+ display: flex;
891
+ align-items: center;
892
+ gap: var(--lv-space-4);
893
+ /* Reserved: the header is this tall whether or not it has actions,
894
+ a status pill, or a two-line title. */
895
+ min-height: var(--lv-doc-header-h);
896
+ padding: var(--lv-space-3) var(--lv-space-5);
897
+ background: var(--lv-surface-shell);
898
+ box-shadow: inset 0 -1px 0 0 var(--lv-hairline);
899
+ }
900
+
901
+ .lv-doc-headline {
902
+ display: flex;
903
+ flex-direction: column;
904
+ gap: 2px;
905
+ min-width: 0;
906
+ flex: 1 1 auto;
907
+ }
908
+
909
+ .lv-doc-eyebrow {
910
+ font-size: var(--lv-text-2xs);
911
+ line-height: var(--lv-lh-2xs);
912
+ letter-spacing: var(--lv-tr-2xs);
913
+ text-transform: uppercase;
914
+ font-weight: var(--lv-weight-semibold);
915
+ color: var(--lv-text-tertiary);
916
+ /* Reserved so a document with no eyebrow is the same height. */
917
+ min-height: calc(var(--lv-text-2xs) * var(--lv-lh-2xs));
918
+ }
919
+
920
+ .lv-doc-title {
921
+ font-size: var(--lv-text-xl);
922
+ line-height: var(--lv-lh-xl);
923
+ letter-spacing: var(--lv-tr-xl);
924
+ font-weight: var(--lv-weight-semibold);
925
+ color: var(--lv-text-primary);
926
+ margin: 0;
927
+ }
928
+
929
+ .lv-doc-header-actions {
930
+ display: flex;
931
+ align-items: center;
932
+ gap: var(--lv-space-2);
933
+ flex: 0 0 auto;
934
+ }
935
+
936
+ /* Nav rail. On narrow viewports it becomes a horizontal scroller with
937
+ the next item peeking past the edge, so hidden items announce
938
+ themselves instead of silently disappearing. */
939
+ .lv-doc-nav {
940
+ position: sticky;
941
+ top: var(--lv-doc-header-h);
942
+ align-self: start;
943
+ padding: var(--lv-space-4) var(--lv-space-2) var(--lv-space-4)
944
+ var(--lv-space-5);
945
+ min-width: 0;
946
+ }
947
+
948
+ @media (max-width: 899px) {
949
+ .lv-doc-nav {
950
+ position: sticky;
951
+ top: var(--lv-doc-header-h);
952
+ z-index: 9;
953
+ display: flex;
954
+ gap: var(--lv-space-1);
955
+ overflow-x: auto;
956
+ scrollbar-width: none;
957
+ padding: var(--lv-space-2) var(--lv-space-5);
958
+ background: var(--lv-surface-shell);
959
+ box-shadow: inset 0 -1px 0 0 var(--lv-hairline);
960
+ /* Let the next item peek so the scroller never looks complete. */
961
+ scroll-padding-inline: var(--lv-space-6);
962
+ }
963
+ .lv-doc-nav::-webkit-scrollbar {
964
+ display: none;
965
+ }
966
+ .lv-doc-nav-list {
967
+ display: contents;
968
+ }
969
+ .lv-doc-nav-item {
970
+ flex: 0 0 auto;
971
+ }
972
+ }
973
+
974
+ .lv-doc-nav-label {
975
+ font-size: var(--lv-text-2xs);
976
+ line-height: var(--lv-lh-2xs);
977
+ letter-spacing: var(--lv-tr-2xs);
978
+ text-transform: uppercase;
979
+ font-weight: var(--lv-weight-semibold);
980
+ color: var(--lv-text-faint);
981
+ padding-inline-start: var(--lv-space-3);
982
+ margin-bottom: var(--lv-space-2);
983
+ }
984
+
985
+ @media (max-width: 899px) {
986
+ .lv-doc-nav-label {
987
+ display: none;
988
+ }
989
+ }
990
+
991
+ .lv-doc-nav-list {
992
+ display: flex;
993
+ flex-direction: column;
994
+ gap: 1px;
995
+ list-style: none;
996
+ margin: 0;
997
+ padding: 0;
998
+ }
999
+
1000
+ .lv-doc-nav-item {
1001
+ display: flex;
1002
+ align-items: center;
1003
+ gap: var(--lv-space-2);
1004
+ width: 100%;
1005
+ min-height: 30px;
1006
+ padding: var(--lv-space-1) var(--lv-space-3);
1007
+ border: 0;
1008
+ background: transparent;
1009
+ /* The active indicator is ALWAYS present, just transparent. Adding a
1010
+ rule on activation would shift every label by 2px. */
1011
+ border-inline-start: 2px solid transparent;
1012
+ border-radius: 0 var(--lv-radius-sm) var(--lv-radius-sm) 0;
1013
+ font-family: inherit;
1014
+ font-size: var(--lv-text-sm);
1015
+ line-height: var(--lv-lh-sm);
1016
+ font-weight: var(--lv-weight-medium);
1017
+ color: var(--lv-text-secondary);
1018
+ text-align: start;
1019
+ cursor: pointer;
1020
+ transition-property: background-color, color, border-color;
1021
+ transition-duration: var(--lv-dur-fast);
1022
+ transition-timing-function: var(--lv-ease-out);
1023
+ }
1024
+
1025
+ .lv-doc-nav-item:hover {
1026
+ background: var(--lv-surface-hover);
1027
+ color: var(--lv-text-primary);
1028
+ }
1029
+
1030
+ .lv-doc-nav-item[aria-current='true'] {
1031
+ color: var(--lv-text-primary);
1032
+ border-inline-start-color: var(--lv-accent-mark);
1033
+ background: var(--lv-surface-selected);
1034
+ font-weight: var(--lv-weight-semibold);
1035
+ }
1036
+
1037
+ @media (max-width: 899px) {
1038
+ .lv-doc-nav-item {
1039
+ border-inline-start: 0;
1040
+ border-block-end: 2px solid transparent;
1041
+ border-radius: var(--lv-radius-sm);
1042
+ white-space: nowrap;
1043
+ }
1044
+ .lv-doc-nav-item[aria-current='true'] {
1045
+ border-block-end-color: var(--lv-accent-mark);
1046
+ }
1047
+ }
1048
+
1049
+ .lv-doc-nav-count {
1050
+ margin-inline-start: auto;
1051
+ font-size: var(--lv-text-2xs);
1052
+ font-variant-numeric: tabular-nums;
1053
+ color: var(--lv-text-faint);
1054
+ /* Reserved slot: a count appearing must not shove the label. */
1055
+ min-width: 2ch;
1056
+ text-align: end;
1057
+ }
1058
+
1059
+ .lv-doc-body {
1060
+ display: flex;
1061
+ flex-direction: column;
1062
+ gap: var(--lv-space-6);
1063
+ padding: var(--lv-space-4) var(--lv-space-5) var(--lv-space-20);
1064
+ min-width: 0;
1065
+ }
1066
+
1067
+ /* ── Document section ─────────────────────────────────────────────── */
1068
+
1069
+ .lv-docsection {
1070
+ scroll-margin-top: var(--lv-scroll-offset);
1071
+ min-width: 0;
1072
+ }
1073
+
1074
+ .lv-docsection-head {
1075
+ display: flex;
1076
+ align-items: flex-start;
1077
+ justify-content: space-between;
1078
+ gap: var(--lv-space-4);
1079
+ min-height: 28px;
1080
+ margin-bottom: var(--lv-space-3);
1081
+ }
1082
+
1083
+ .lv-docsection-title {
1084
+ font-size: var(--lv-text-lg);
1085
+ line-height: var(--lv-lh-lg);
1086
+ letter-spacing: var(--lv-tr-lg);
1087
+ font-weight: var(--lv-weight-semibold);
1088
+ color: var(--lv-text-primary);
1089
+ text-wrap: balance;
1090
+ margin: 0;
1091
+ scroll-margin-top: var(--lv-scroll-offset);
1092
+ }
1093
+
1094
+ .lv-docsection-desc {
1095
+ font-size: var(--lv-text-sm);
1096
+ line-height: var(--lv-lh-sm);
1097
+ color: var(--lv-text-secondary);
1098
+ text-wrap: pretty;
1099
+ max-width: var(--lv-measure);
1100
+ margin-top: var(--lv-space-1);
1101
+ }
1102
+
1103
+ .lv-docsection-body {
1104
+ min-width: 0;
1105
+ }
1106
+
1107
+ /* Long-form prose inside a document section. */
1108
+ .lv-prose {
1109
+ font-size: var(--lv-prose-size);
1110
+ line-height: var(--lv-prose-lh);
1111
+ color: var(--lv-text-secondary);
1112
+ max-width: var(--lv-measure);
1113
+ text-wrap: pretty;
1114
+ }
1115
+ .lv-prose > * + * {
1116
+ margin-top: var(--lv-space-4);
1117
+ }
1118
+
1119
+ /* ══════════════════════════════════════════════════════════════════════
1120
+ 8. FIELD ROW / DEFINITION LIST
1121
+ Every row shares one label column, so labels and values across
1122
+ unrelated rows sit on the same two edges.
1123
+ ══════════════════════════════════════════════════════════════════════ */
1124
+
1125
+ .lv-dl {
1126
+ display: flex;
1127
+ flex-direction: column;
1128
+ gap: var(--lv-space-1);
1129
+ margin: 0;
1130
+ min-width: 0;
1131
+ }
1132
+
1133
+ .lv-dl[data-dividers='true'] .lv-field-row + .lv-field-row {
1134
+ border-block-start: 1px solid var(--lv-hairline);
1135
+ padding-block-start: var(--lv-space-2);
1136
+ margin-block-start: var(--lv-space-1);
1137
+ }
1138
+
1139
+ .lv-field-row {
1140
+ display: grid;
1141
+ grid-template-columns: var(--lv-dl-label-w) minmax(0, 1fr);
1142
+ gap: var(--lv-space-4);
1143
+ align-items: baseline;
1144
+ min-width: 0;
1145
+ padding-block: var(--lv-space-1);
1146
+ }
1147
+
1148
+ /* Below this the label column eats the value; stack instead of
1149
+ truncating something the reader came for. */
1150
+ @media (max-width: 639px) {
1151
+ .lv-field-row,
1152
+ .lv-field-row[data-layout='inline'] {
1153
+ grid-template-columns: minmax(0, 1fr);
1154
+ gap: 2px;
1155
+ }
1156
+ }
1157
+
1158
+ .lv-field-row[data-layout='stacked'] {
1159
+ grid-template-columns: minmax(0, 1fr);
1160
+ gap: 2px;
1161
+ }
1162
+
1163
+ .lv-field-label {
1164
+ font-size: var(--lv-text-sm);
1165
+ line-height: var(--lv-lh-sm);
1166
+ font-weight: var(--lv-weight-medium);
1167
+ color: var(--lv-text-tertiary);
1168
+ margin: 0;
1169
+ min-width: 0;
1170
+ overflow-wrap: break-word;
1171
+ }
1172
+
1173
+ .lv-field-value {
1174
+ font-size: var(--lv-text-sm);
1175
+ line-height: var(--lv-lh-sm);
1176
+ color: var(--lv-text-primary);
1177
+ margin: 0;
1178
+ min-width: 0;
1179
+ overflow-wrap: break-word;
1180
+ /* Reserved: an empty value keeps the row at one line's height. */
1181
+ min-height: calc(var(--lv-text-sm) * var(--lv-lh-sm));
1182
+ }
1183
+
1184
+ .lv-field-value[data-empty='true'] {
1185
+ color: var(--lv-text-faint);
1186
+ }
1187
+
1188
+ .lv-field-hint {
1189
+ display: block;
1190
+ font-size: var(--lv-text-xs);
1191
+ line-height: var(--lv-lh-xs);
1192
+ color: var(--lv-text-faint);
1193
+ margin-top: 2px;
1194
+ }
1195
+
1196
+ /* ══════════════════════════════════════════════════════════════════════
1197
+ 9. STAT TILE
1198
+ ══════════════════════════════════════════════════════════════════════ */
1199
+
1200
+ .lv-stat {
1201
+ display: flex;
1202
+ flex-direction: column;
1203
+ gap: var(--lv-space-1);
1204
+ padding: var(--lv-space-3) var(--lv-space-4);
1205
+ background: var(--lv-surface-card);
1206
+ border-radius: var(--lv-radius-md);
1207
+ box-shadow: var(--lv-elev-1);
1208
+ min-width: 0;
1209
+ }
1210
+
1211
+ .lv-stat[data-inset='true'] {
1212
+ background: var(--lv-surface-inset);
1213
+ box-shadow: var(--lv-elev-inset);
1214
+ border-radius: var(--lv-radius-sm);
1215
+ }
1216
+
1217
+ .lv-stat-label {
1218
+ font-size: var(--lv-text-2xs);
1219
+ line-height: var(--lv-lh-2xs);
1220
+ letter-spacing: var(--lv-tr-2xs);
1221
+ text-transform: uppercase;
1222
+ font-weight: var(--lv-weight-semibold);
1223
+ color: var(--lv-text-tertiary);
1224
+ white-space: nowrap;
1225
+ overflow: hidden;
1226
+ text-overflow: ellipsis;
1227
+ }
1228
+
1229
+ .lv-stat-valuerow {
1230
+ display: flex;
1231
+ align-items: baseline;
1232
+ gap: var(--lv-space-1);
1233
+ /* Reserved so the tile's height never depends on whether a value has
1234
+ arrived yet. */
1235
+ min-height: calc(var(--lv-text-stat) * var(--lv-lh-stat));
1236
+ }
1237
+
1238
+ .lv-stat-value {
1239
+ font-size: var(--lv-text-stat);
1240
+ line-height: var(--lv-lh-stat);
1241
+ letter-spacing: var(--lv-tr-stat);
1242
+ font-weight: var(--lv-weight-semibold);
1243
+ font-variant-numeric: tabular-nums;
1244
+ color: var(--lv-text-primary);
1245
+ /* The slot. Sized in `ch` for the largest plausible value, so a
1246
+ counter ticking 9 → 10 → 100 never reflows its neighbours. */
1247
+ min-width: calc(var(--lv-stat-slots, 6) * 1ch);
1248
+ display: inline-block;
1249
+ }
1250
+
1251
+ .lv-stat-unit {
1252
+ font-size: var(--lv-text-sm);
1253
+ font-weight: var(--lv-weight-medium);
1254
+ color: var(--lv-text-tertiary);
1255
+ white-space: nowrap;
1256
+ }
1257
+
1258
+ /* The delta row is ALWAYS in the box. When there is no delta it is
1259
+ hidden, not removed — `display: none` would resize the tile. */
1260
+ .lv-stat-footrow {
1261
+ display: flex;
1262
+ align-items: center;
1263
+ gap: var(--lv-space-1);
1264
+ font-size: var(--lv-text-xs);
1265
+ line-height: var(--lv-lh-xs);
1266
+ font-variant-numeric: tabular-nums;
1267
+ color: var(--lv-text-tertiary);
1268
+ min-height: calc(var(--lv-text-xs) * var(--lv-lh-xs));
1269
+ }
1270
+
1271
+ .lv-stat-footrow[data-hidden='true'] {
1272
+ visibility: hidden;
1273
+ }
1274
+
1275
+ .lv-stat-delta[data-tone='up'] {
1276
+ color: var(--lv-success-text);
1277
+ }
1278
+ .lv-stat-delta[data-tone='down'] {
1279
+ color: var(--lv-danger-text);
1280
+ }
1281
+ .lv-stat-delta[data-tone='flat'] {
1282
+ color: var(--lv-text-tertiary);
1283
+ }
1284
+
1285
+ .lv-stat-hint {
1286
+ color: var(--lv-text-faint);
1287
+ overflow: hidden;
1288
+ text-overflow: ellipsis;
1289
+ white-space: nowrap;
1290
+ min-width: 0;
1291
+ }
1292
+
1293
+ /* ══════════════════════════════════════════════════════════════════════
1294
+ 10. CALLOUT
1295
+ Tone is carried by an icon, a word, and a leading rule — never by
1296
+ colour alone.
1297
+ ══════════════════════════════════════════════════════════════════════ */
1298
+
1299
+ .lv-callout {
1300
+ display: grid;
1301
+ grid-template-columns: 18px minmax(0, 1fr);
1302
+ gap: var(--lv-space-3);
1303
+ align-items: start;
1304
+ padding: var(--lv-space-3) var(--lv-space-4);
1305
+ border-radius: var(--lv-radius-md);
1306
+ /* Leading rule, not a full border. A boxed callout reads as a form
1307
+ field; a ruled one reads as an aside. */
1308
+ border-inline-start: 2px solid var(--lv-info-rule);
1309
+ background: var(--lv-info-soft);
1310
+ min-width: 0;
1311
+ }
1312
+
1313
+ .lv-callout[data-tone='info'] {
1314
+ border-inline-start-color: var(--lv-info-rule);
1315
+ background: var(--lv-info-soft);
1316
+ }
1317
+ .lv-callout[data-tone='success'] {
1318
+ border-inline-start-color: var(--lv-success-rule);
1319
+ background: var(--lv-success-soft);
1320
+ }
1321
+ .lv-callout[data-tone='warn'] {
1322
+ border-inline-start-color: var(--lv-warning-rule);
1323
+ background: var(--lv-warning-soft);
1324
+ }
1325
+ .lv-callout[data-tone='danger'] {
1326
+ border-inline-start-color: var(--lv-danger-rule);
1327
+ background: var(--lv-danger-soft);
1328
+ }
1329
+
1330
+ .lv-callout-icon {
1331
+ display: grid;
1332
+ place-items: center;
1333
+ width: 18px;
1334
+ height: 18px;
1335
+ /* Optical: nudge onto the title's cap-height rather than its box. */
1336
+ margin-top: 1px;
1337
+ flex: 0 0 auto;
1338
+ }
1339
+
1340
+ .lv-callout[data-tone='info'] .lv-callout-icon {
1341
+ color: var(--lv-info-text);
1342
+ }
1343
+ .lv-callout[data-tone='success'] .lv-callout-icon {
1344
+ color: var(--lv-success-text);
1345
+ }
1346
+ .lv-callout[data-tone='warn'] .lv-callout-icon {
1347
+ color: var(--lv-warning-text);
1348
+ }
1349
+ .lv-callout[data-tone='danger'] .lv-callout-icon {
1350
+ color: var(--lv-danger-text);
1351
+ }
1352
+
1353
+ .lv-callout-title {
1354
+ font-size: var(--lv-text-sm);
1355
+ line-height: var(--lv-lh-sm);
1356
+ font-weight: var(--lv-weight-semibold);
1357
+ color: var(--lv-text-primary);
1358
+ margin: 0;
1359
+ }
1360
+
1361
+ .lv-callout-body {
1362
+ font-size: var(--lv-text-sm);
1363
+ line-height: var(--lv-lh-sm);
1364
+ color: var(--lv-text-secondary);
1365
+ text-wrap: pretty;
1366
+ max-width: var(--lv-measure);
1367
+ margin-top: 2px;
1368
+ overflow-wrap: break-word;
1369
+ }
1370
+
1371
+ .lv-callout-actions {
1372
+ display: flex;
1373
+ gap: var(--lv-space-2);
1374
+ margin-top: var(--lv-space-3);
1375
+ }
1376
+
1377
+ /* ══════════════════════════════════════════════════════════════════════
1378
+ 11. STATUS PILL
1379
+ The marker SHAPE differs per tone as well as the colour, so the pill
1380
+ still reads correctly in greyscale.
1381
+ ══════════════════════════════════════════════════════════════════════ */
1382
+
1383
+ .lv-pill {
1384
+ display: inline-flex;
1385
+ align-items: center;
1386
+ gap: var(--lv-space-1);
1387
+ /* Fixed height. A pill changing tone must not change size. */
1388
+ height: 20px;
1389
+ padding-inline: var(--lv-space-2);
1390
+ border-radius: var(--lv-radius-full);
1391
+ font-size: var(--lv-text-2xs);
1392
+ line-height: 1;
1393
+ letter-spacing: 0.01em;
1394
+ font-weight: var(--lv-weight-semibold);
1395
+ white-space: nowrap;
1396
+ font-variant-numeric: tabular-nums;
1397
+ max-width: 100%;
1398
+ min-width: 0;
1399
+ background: var(--lv-surface-hover);
1400
+ color: var(--lv-text-secondary);
1401
+ box-shadow: inset 0 0 0 1px var(--lv-hairline);
1402
+ }
1403
+
1404
+ .lv-pill-text {
1405
+ overflow: hidden;
1406
+ text-overflow: ellipsis;
1407
+ min-width: 0;
1408
+ }
1409
+
1410
+ .lv-pill-mark {
1411
+ flex: 0 0 auto;
1412
+ width: 7px;
1413
+ height: 7px;
1414
+ background: currentColor;
1415
+ }
1416
+
1417
+ /* Shape per tone — filled dot, ring, square, triangle. */
1418
+ .lv-pill-mark[data-shape='dot'] {
1419
+ border-radius: var(--lv-radius-full);
1420
+ }
1421
+ .lv-pill-mark[data-shape='ring'] {
1422
+ border-radius: var(--lv-radius-full);
1423
+ background: transparent;
1424
+ box-shadow: inset 0 0 0 1.5px currentColor;
1425
+ }
1426
+ .lv-pill-mark[data-shape='square'] {
1427
+ border-radius: 1px;
1428
+ }
1429
+ .lv-pill-mark[data-shape='triangle'] {
1430
+ background: transparent;
1431
+ width: 0;
1432
+ height: 0;
1433
+ border-inline: 4px solid transparent;
1434
+ border-block-end: 7px solid currentColor;
1435
+ }
1436
+ .lv-pill-mark[data-shape='bar'] {
1437
+ width: 3px;
1438
+ height: 9px;
1439
+ border-radius: 1px;
1440
+ }
1441
+
1442
+ .lv-pill[data-tone='accent'] {
1443
+ background: var(--lv-accent-soft);
1444
+ color: var(--lv-accent-text);
1445
+ box-shadow: inset 0 0 0 1px var(--lv-accent-rule);
1446
+ }
1447
+ .lv-pill[data-tone='success'] {
1448
+ background: var(--lv-success-soft);
1449
+ color: var(--lv-success-text);
1450
+ box-shadow: inset 0 0 0 1px var(--lv-success-rule);
1451
+ }
1452
+ .lv-pill[data-tone='warning'] {
1453
+ background: var(--lv-warning-soft);
1454
+ color: var(--lv-warning-text);
1455
+ box-shadow: inset 0 0 0 1px var(--lv-warning-rule);
1456
+ }
1457
+ .lv-pill[data-tone='danger'] {
1458
+ background: var(--lv-danger-soft);
1459
+ color: var(--lv-danger-text);
1460
+ box-shadow: inset 0 0 0 1px var(--lv-danger-rule);
1461
+ }
1462
+ .lv-pill[data-tone='info'] {
1463
+ background: var(--lv-info-soft);
1464
+ color: var(--lv-info-text);
1465
+ box-shadow: inset 0 0 0 1px var(--lv-info-rule);
1466
+ }
1467
+
1468
+ /* ══════════════════════════════════════════════════════════════════════
1469
+ 12. PROGRESS TRACK
1470
+ ══════════════════════════════════════════════════════════════════════ */
1471
+
1472
+ .lv-progress {
1473
+ display: flex;
1474
+ flex-direction: column;
1475
+ gap: var(--lv-space-2);
1476
+ min-width: 0;
1477
+ }
1478
+
1479
+ .lv-progress-head {
1480
+ display: flex;
1481
+ align-items: baseline;
1482
+ justify-content: space-between;
1483
+ gap: var(--lv-space-3);
1484
+ /* Reserved: label row exists whether or not a label was passed. */
1485
+ min-height: calc(var(--lv-text-xs) * var(--lv-lh-xs));
1486
+ }
1487
+
1488
+ .lv-progress-label {
1489
+ font-size: var(--lv-text-xs);
1490
+ line-height: var(--lv-lh-xs);
1491
+ font-weight: var(--lv-weight-medium);
1492
+ color: var(--lv-text-secondary);
1493
+ overflow: hidden;
1494
+ text-overflow: ellipsis;
1495
+ white-space: nowrap;
1496
+ min-width: 0;
1497
+ }
1498
+
1499
+ .lv-progress-value {
1500
+ font-size: var(--lv-text-xs);
1501
+ line-height: var(--lv-lh-xs);
1502
+ font-variant-numeric: tabular-nums;
1503
+ font-weight: var(--lv-weight-semibold);
1504
+ color: var(--lv-text-tertiary);
1505
+ /* Slot for "100%" so 9% → 100% never nudges the label. */
1506
+ min-width: 4ch;
1507
+ text-align: end;
1508
+ flex: 0 0 auto;
1509
+ }
1510
+
1511
+ /* The track is a recessed groove — the inset treatment, in miniature. */
1512
+ .lv-progress-track {
1513
+ position: relative;
1514
+ height: 6px;
1515
+ border-radius: var(--lv-radius-full);
1516
+ background: var(--lv-surface-inset);
1517
+ box-shadow: var(--lv-elev-inset);
1518
+ overflow: hidden;
1519
+ }
1520
+
1521
+ .lv-progress-fill {
1522
+ height: 100%;
1523
+ border-radius: inherit;
1524
+ background: var(--lv-accent-mark);
1525
+ /* Ease, never spring: overshoot on a measured value reads as wrong. */
1526
+ transition-property: width;
1527
+ transition-duration: var(--lv-dur-slow);
1528
+ transition-timing-function: var(--lv-ease-out);
1529
+ }
1530
+
1531
+ .lv-progress[data-tone='success'] .lv-progress-fill {
1532
+ background: var(--lv-success-mark);
1533
+ }
1534
+ .lv-progress[data-tone='warning'] .lv-progress-fill {
1535
+ background: var(--lv-warning-mark);
1536
+ }
1537
+ .lv-progress[data-tone='danger'] .lv-progress-fill {
1538
+ background: var(--lv-danger-mark);
1539
+ }
1540
+ .lv-progress[data-tone='neutral'] .lv-progress-fill {
1541
+ background: var(--lv-text-faint);
1542
+ }
1543
+
1544
+ .lv-progress-indeterminate {
1545
+ position: absolute;
1546
+ inset-block: 0;
1547
+ inline-size: 40%;
1548
+ border-radius: inherit;
1549
+ background: var(--lv-accent-mark);
1550
+ animation: lv-indeterminate 1.4s var(--lv-ease-in-out) infinite;
1551
+ }
1552
+
1553
+ @keyframes lv-indeterminate {
1554
+ 0% {
1555
+ transform: translateX(-110%);
1556
+ }
1557
+ 100% {
1558
+ transform: translateX(320%);
1559
+ }
1560
+ }
1561
+
1562
+ /* Reduced motion: no travelling bar. Show a static, obviously-partial
1563
+ fill instead — the final state, not a frozen animation. */
1564
+ @media (prefers-reduced-motion: reduce) {
1565
+ .lv-progress-indeterminate {
1566
+ animation: none;
1567
+ inline-size: 100%;
1568
+ opacity: 0.4;
1569
+ transform: none;
1570
+ }
1571
+ }
1572
+
1573
+ /* ══════════════════════════════════════════════════════════════════════
1574
+ 13. TIMELINE
1575
+ The rail is a structural line, which is exactly what a border is for.
1576
+ ══════════════════════════════════════════════════════════════════════ */
1577
+
1578
+ .lv-timeline {
1579
+ display: flex;
1580
+ flex-direction: column;
1581
+ list-style: none;
1582
+ margin: 0;
1583
+ padding: 0;
1584
+ min-width: 0;
1585
+ }
1586
+
1587
+ .lv-timeline-item {
1588
+ position: relative;
1589
+ display: grid;
1590
+ grid-template-columns: 20px minmax(0, 1fr);
1591
+ gap: var(--lv-space-3);
1592
+ padding-block-end: var(--lv-space-5);
1593
+ min-width: 0;
1594
+ }
1595
+
1596
+ .lv-timeline-item:last-child {
1597
+ padding-block-end: 0;
1598
+ }
1599
+
1600
+ /* The rail. Drawn on the marker column, stopped on the last item. */
1601
+ .lv-timeline-item::before {
1602
+ content: '';
1603
+ position: absolute;
1604
+ inset-block-start: 18px;
1605
+ inset-block-end: 0;
1606
+ inset-inline-start: 9px;
1607
+ width: 2px;
1608
+ background: var(--lv-hairline-strong);
1609
+ }
1610
+
1611
+ .lv-timeline-item:last-child::before {
1612
+ display: none;
1613
+ }
1614
+
1615
+ .lv-timeline-marker {
1616
+ position: relative;
1617
+ z-index: 1;
1618
+ display: grid;
1619
+ place-items: center;
1620
+ width: 20px;
1621
+ height: 20px;
1622
+ border-radius: var(--lv-radius-full);
1623
+ background: var(--lv-surface-card);
1624
+ box-shadow: inset 0 0 0 2px var(--lv-hairline-strong);
1625
+ color: var(--lv-text-tertiary);
1626
+ }
1627
+
1628
+ .lv-timeline-item[data-state='done'] .lv-timeline-marker {
1629
+ background: var(--lv-success-soft);
1630
+ box-shadow: inset 0 0 0 2px var(--lv-success-rule);
1631
+ color: var(--lv-success-text);
1632
+ }
1633
+
1634
+ .lv-timeline-item[data-state='current'] .lv-timeline-marker {
1635
+ background: var(--lv-accent-soft);
1636
+ box-shadow: inset 0 0 0 2px var(--lv-accent-rule);
1637
+ color: var(--lv-accent-text);
1638
+ }
1639
+
1640
+ .lv-timeline-item[data-state='blocked'] .lv-timeline-marker {
1641
+ background: var(--lv-danger-soft);
1642
+ box-shadow: inset 0 0 0 2px var(--lv-danger-rule);
1643
+ color: var(--lv-danger-text);
1644
+ }
1645
+
1646
+ .lv-timeline-content {
1647
+ min-width: 0;
1648
+ padding-block-start: 1px;
1649
+ }
1650
+
1651
+ .lv-timeline-titlerow {
1652
+ display: flex;
1653
+ align-items: baseline;
1654
+ gap: var(--lv-space-2);
1655
+ justify-content: space-between;
1656
+ min-width: 0;
1657
+ }
1658
+
1659
+ .lv-timeline-title {
1660
+ font-size: var(--lv-text-sm);
1661
+ line-height: var(--lv-lh-sm);
1662
+ font-weight: var(--lv-weight-semibold);
1663
+ color: var(--lv-text-primary);
1664
+ min-width: 0;
1665
+ overflow-wrap: break-word;
1666
+ }
1667
+
1668
+ .lv-timeline-meta {
1669
+ font-size: var(--lv-text-xs);
1670
+ line-height: var(--lv-lh-xs);
1671
+ font-variant-numeric: tabular-nums;
1672
+ color: var(--lv-text-faint);
1673
+ white-space: nowrap;
1674
+ flex: 0 0 auto;
1675
+ }
1676
+
1677
+ .lv-timeline-desc {
1678
+ font-size: var(--lv-text-sm);
1679
+ line-height: var(--lv-lh-sm);
1680
+ color: var(--lv-text-secondary);
1681
+ text-wrap: pretty;
1682
+ max-width: var(--lv-measure);
1683
+ margin-top: 2px;
1684
+ overflow-wrap: break-word;
1685
+ }
1686
+
1687
+ /* ══════════════════════════════════════════════════════════════════════
1688
+ 14. STATES — empty / loading / error
1689
+ All three share ONE frame with the same reserved min-height, so the
1690
+ swap between them (and to the loaded content) shifts nothing.
1691
+ ══════════════════════════════════════════════════════════════════════ */
1692
+
1693
+ .lv-state {
1694
+ display: flex;
1695
+ flex-direction: column;
1696
+ align-items: center;
1697
+ justify-content: center;
1698
+ gap: var(--lv-space-3);
1699
+ text-align: center;
1700
+ padding: var(--lv-space-8) var(--lv-space-6);
1701
+ min-height: var(--lv-state-min-h);
1702
+ min-width: 0;
1703
+ }
1704
+
1705
+ .lv-state[data-surface='inset'] {
1706
+ background: var(--lv-surface-inset);
1707
+ border-radius: var(--lv-radius-sm);
1708
+ box-shadow: var(--lv-elev-inset);
1709
+ }
1710
+
1711
+ .lv-state[data-surface='card'] {
1712
+ background: var(--lv-surface-card);
1713
+ border-radius: var(--lv-radius-md);
1714
+ box-shadow: var(--lv-elev-1);
1715
+ }
1716
+
1717
+ .lv-state-icon {
1718
+ display: grid;
1719
+ place-items: center;
1720
+ width: 36px;
1721
+ height: 36px;
1722
+ border-radius: var(--lv-radius-full);
1723
+ background: var(--lv-surface-hover);
1724
+ color: var(--lv-text-tertiary);
1725
+ }
1726
+
1727
+ .lv-state[data-tone='error'] .lv-state-icon {
1728
+ background: var(--lv-danger-soft);
1729
+ color: var(--lv-danger-text);
1730
+ }
1731
+
1732
+ .lv-state-title {
1733
+ font-size: var(--lv-text-md);
1734
+ line-height: var(--lv-lh-md);
1735
+ font-weight: var(--lv-weight-semibold);
1736
+ color: var(--lv-text-primary);
1737
+ text-wrap: balance;
1738
+ margin: 0;
1739
+ }
1740
+
1741
+ .lv-state-body {
1742
+ font-size: var(--lv-text-sm);
1743
+ line-height: var(--lv-lh-sm);
1744
+ color: var(--lv-text-secondary);
1745
+ text-wrap: pretty;
1746
+ max-width: 46ch;
1747
+ margin: 0;
1748
+ }
1749
+
1750
+ .lv-state-detail {
1751
+ width: 100%;
1752
+ max-width: 56ch;
1753
+ margin-top: var(--lv-space-1);
1754
+ text-align: start;
1755
+ }
1756
+
1757
+ .lv-state-detail summary {
1758
+ font-size: var(--lv-text-xs);
1759
+ color: var(--lv-text-tertiary);
1760
+ cursor: pointer;
1761
+ list-style: none;
1762
+ text-align: center;
1763
+ }
1764
+
1765
+ .lv-state-detail summary::-webkit-details-marker {
1766
+ display: none;
1767
+ }
1768
+
1769
+ .lv-state-detail pre {
1770
+ margin-top: var(--lv-space-2);
1771
+ padding: var(--lv-space-3);
1772
+ background: var(--lv-surface-inset);
1773
+ box-shadow: var(--lv-elev-inset);
1774
+ border-radius: var(--lv-radius-sm);
1775
+ font-family: var(--lv-font-mono);
1776
+ font-size: var(--lv-text-xs);
1777
+ line-height: 1.5;
1778
+ color: var(--lv-text-secondary);
1779
+ overflow-x: auto;
1780
+ white-space: pre-wrap;
1781
+ overflow-wrap: break-word;
1782
+ }
1783
+
1784
+ .lv-state-actions {
1785
+ display: flex;
1786
+ gap: var(--lv-space-2);
1787
+ margin-top: var(--lv-space-1);
1788
+ /* Reserved so an action-less state is the same height. */
1789
+ min-height: 32px;
1790
+ align-items: center;
1791
+ }
1792
+
1793
+ /* ── Skeleton ─────────────────────────────────────────────────────── */
1794
+
1795
+ .lv-skeleton {
1796
+ display: block;
1797
+ background: var(--lv-skeleton-base);
1798
+ border-radius: var(--lv-radius-sm);
1799
+ position: relative;
1800
+ overflow: hidden;
1801
+ }
1802
+
1803
+ .lv-skeleton::after {
1804
+ content: '';
1805
+ position: absolute;
1806
+ inset: 0;
1807
+ background: linear-gradient(
1808
+ 90deg,
1809
+ transparent,
1810
+ var(--lv-skeleton-sheen),
1811
+ transparent
1812
+ );
1813
+ transform: translateX(-100%);
1814
+ animation: lv-sheen 1.6s var(--lv-ease-in-out) infinite;
1815
+ }
1816
+
1817
+ @keyframes lv-sheen {
1818
+ 100% {
1819
+ transform: translateX(100%);
1820
+ }
1821
+ }
1822
+
1823
+ /* Reduced motion: no sheen at all. The static bar IS the final state. */
1824
+ @media (prefers-reduced-motion: reduce) {
1825
+ .lv-skeleton::after {
1826
+ display: none;
1827
+ }
1828
+ }
1829
+
1830
+ .lv-skeleton-group {
1831
+ display: flex;
1832
+ flex-direction: column;
1833
+ gap: var(--lv-space-2);
1834
+ min-width: 0;
1835
+ }
1836
+
1837
+ /* Same surfaces as .lv-state, so a skeleton and the empty/error state it
1838
+ swaps with sit on the identical box. */
1839
+ .lv-skeleton-group[data-surface='inset'] {
1840
+ background: var(--lv-surface-inset);
1841
+ border-radius: var(--lv-radius-sm);
1842
+ box-shadow: var(--lv-elev-inset);
1843
+ padding: var(--lv-space-4);
1844
+ }
1845
+
1846
+ .lv-skeleton-group[data-surface='card'] {
1847
+ background: var(--lv-surface-card);
1848
+ border-radius: var(--lv-radius-md);
1849
+ box-shadow: var(--lv-elev-1);
1850
+ padding: var(--lv-space-4);
1851
+ }
1852
+
1853
+ /* ══════════════════════════════════════════════════════════════════════
1854
+ 15. SHOWCASE-ONLY helpers (the demo page uses these; apps do not).
1855
+ ══════════════════════════════════════════════════════════════════════ */
1856
+
1857
+ .lv-swatch {
1858
+ height: 44px;
1859
+ border-radius: var(--lv-radius-sm);
1860
+ box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.08);
1861
+ }