@jmcombs/pi-steward 0.0.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/core/disconnected-source.ts +110 -0
  4. package/core/drift.ts +247 -0
  5. package/core/format.ts +317 -0
  6. package/core/host-metrics.ts +121 -0
  7. package/core/llama-config.ts +72 -0
  8. package/core/llama-connection.ts +215 -0
  9. package/core/llama-models.ts +261 -0
  10. package/core/llama-slots.ts +104 -0
  11. package/core/llama-source.ts +1523 -0
  12. package/core/log-parse.ts +440 -0
  13. package/core/model-color.ts +59 -0
  14. package/core/select.ts +2923 -0
  15. package/core/slot-activity.ts +658 -0
  16. package/core/source.ts +84 -0
  17. package/core/state.ts +609 -0
  18. package/core/status-widget.ts +222 -0
  19. package/core/temperature.ts +149 -0
  20. package/core/types.ts +431 -0
  21. package/index.ts +503 -0
  22. package/package.json +51 -0
  23. package/server/api.ts +216 -0
  24. package/server/assets.ts +198 -0
  25. package/server/config-wiring.ts +490 -0
  26. package/server/drift-probe.ts +150 -0
  27. package/server/host-collector.ts +272 -0
  28. package/server/index.ts +228 -0
  29. package/server/log-tailer.ts +432 -0
  30. package/server/service-control.ts +337 -0
  31. package/server/service-probe.ts +71 -0
  32. package/server/steward-config.ts +430 -0
  33. package/setup/init-prompt.ts +214 -0
  34. package/setup/steward-setup.d.mts +16 -0
  35. package/setup/steward-setup.mjs +1398 -0
  36. package/ui/components/console.ts +511 -0
  37. package/ui/components/gauges.ts +120 -0
  38. package/ui/components/metrics.ts +63 -0
  39. package/ui/components/models.ts +296 -0
  40. package/ui/components/service.ts +358 -0
  41. package/ui/components/slots.ts +114 -0
  42. package/ui/components/sparkline.ts +59 -0
  43. package/ui/components/toolbar.ts +211 -0
  44. package/ui/dom.ts +120 -0
  45. package/ui/favicon.svg +17 -0
  46. package/ui/index.html +34 -0
  47. package/ui/main.ts +678 -0
  48. package/ui/steward.css +2008 -0
package/ui/steward.css ADDED
@@ -0,0 +1,2008 @@
1
+ /* Steward — the llama.cpp control panel for Pi.
2
+ *
3
+ * Self-contained: the Netservant design tokens are vendored below rather than
4
+ * linked, so the dashboard renders identically wherever it is served from and
5
+ * survives having no network. Only the webfonts are remote, and the fallback
6
+ * stacks keep the layout honest when they fail to load.
7
+ *
8
+ * Components set colors through custom properties (--bg, --fg, --bd, --fill,
9
+ * …) instead of writing `style.background` directly. That leaves :hover,
10
+ * :active and :focus-visible free to win in the cascade, which is what makes
11
+ * the control states below actually apply to tinted controls.
12
+ */
13
+
14
+ @import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700;800&family=Manrope:wght@400;500;600;700;800&display=swap");
15
+
16
+ /* ===================================================================
17
+ Tokens — Catppuccin Latte with the Path Blue accent.
18
+ The syntax-highlighting and diff tiers of the design system are left
19
+ out: the dashboard renders no code.
20
+ =================================================================== */
21
+
22
+ :root {
23
+ /* Raw palette: Latte accents */
24
+ --latte-rosewater: #dc8a78;
25
+ --latte-flamingo: #dd7878;
26
+ --latte-pink: #ea76cb;
27
+ --latte-mauve: #8839ef;
28
+ --latte-red: #d20f39;
29
+ --latte-maroon: #e64553;
30
+ --latte-peach: #fe640b;
31
+ --latte-yellow: #df8e1d;
32
+ --latte-green: #40a02b;
33
+ --latte-teal: #179299;
34
+ --latte-sky: #04a5e5;
35
+ --latte-sapphire: #209fb5;
36
+ --latte-blue: #1e66f5;
37
+ --latte-lavender: #7287fd;
38
+
39
+ /* Raw palette: Latte neutrals, dark to light */
40
+ --latte-text: #4c4f69;
41
+ --latte-subtext1: #5c5f77;
42
+ --latte-subtext0: #6c6f85;
43
+ --latte-overlay2: #7c7f93;
44
+ --latte-overlay1: #8c8fa1;
45
+ --latte-overlay0: #9ca0b0;
46
+ --latte-surface2: #acb0be;
47
+ --latte-surface1: #bcc0cc;
48
+ --latte-surface0: #ccd0da;
49
+ --latte-base: #eff1f5;
50
+ --latte-mantle: #e6e9ef;
51
+ --latte-crust: #dce0e8;
52
+
53
+ /* Signature colors */
54
+ --psl-path-blue: #3465a4;
55
+ --psl-path-blue-active: #2855a3;
56
+ --psl-path-blue-soft: #5a85c2;
57
+ --psl-selection-bg: #a9c1e2;
58
+ --psl-selection-fg: #24292f;
59
+
60
+ /* Surfaces */
61
+ --surface-page: var(--latte-base);
62
+ --surface-panel: var(--latte-mantle);
63
+ --surface-chrome: var(--latte-crust);
64
+ --surface-raised: var(--latte-surface0);
65
+ --surface-raised-1: var(--latte-surface1);
66
+ --surface-raised-2: var(--latte-surface2);
67
+
68
+ /* Text */
69
+ --text-primary: var(--latte-text);
70
+ --text-secondary: var(--latte-subtext1);
71
+ --text-tertiary: var(--latte-subtext0);
72
+ --text-muted: var(--latte-overlay1);
73
+ --text-subtle: var(--latte-overlay0);
74
+ --text-on-accent: var(--latte-base);
75
+
76
+ /* Borders */
77
+ --border: var(--latte-surface0);
78
+ --border-strong: var(--latte-overlay0);
79
+ --border-active: var(--psl-path-blue);
80
+
81
+ /* Accent */
82
+ --accent: var(--psl-path-blue);
83
+ --accent-hover: var(--psl-path-blue-active);
84
+ --accent-soft: var(--psl-path-blue-soft);
85
+ --accent-fg: var(--latte-base);
86
+
87
+ /* Status */
88
+ --success: var(--latte-green);
89
+ --warning: var(--latte-yellow);
90
+ --error: var(--latte-red);
91
+ --info: var(--latte-teal);
92
+ /* Text ON an amber fill. Fixed near-black in BOTH themes on purpose: the
93
+ crust token inverts per theme, and the light theme's #dce0e8 on #df8e1d is
94
+ roughly 1.7:1 — unreadable exactly where the WARN badge needs to shout. */
95
+ --on-warning: #11111b;
96
+
97
+ /* Links, selection, cursor */
98
+ --link: var(--latte-blue);
99
+ --link-hover: var(--latte-sky);
100
+ --link-visited: var(--latte-lavender);
101
+ --selection-bg: var(--psl-selection-bg);
102
+ --selection-fg: var(--psl-selection-fg);
103
+ --cursor: var(--latte-rosewater);
104
+
105
+ /* Type */
106
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Menlo", monospace;
107
+ --font-sans: "Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
108
+ --font-heading: var(--font-sans);
109
+ --font-ui: var(--font-sans);
110
+
111
+ /* Radius */
112
+ --radius-sm: 4px;
113
+ --radius-md: 6px;
114
+ --radius-lg: 10px;
115
+ --radius-full: 999px;
116
+
117
+ /* Shadows — cool and low, tuned for the Latte surface */
118
+ --shadow-xs: 0 1px 2px rgba(76, 79, 105, 0.06);
119
+ --shadow-sm: 0 1px 3px rgba(76, 79, 105, 0.08), 0 1px 2px rgba(76, 79, 105, 0.06);
120
+ --shadow-md: 0 4px 12px rgba(76, 79, 105, 0.1), 0 2px 4px rgba(76, 79, 105, 0.06);
121
+ --shadow-lg: 0 12px 28px rgba(76, 79, 105, 0.14), 0 4px 8px rgba(76, 79, 105, 0.07);
122
+
123
+ /* Focus ring — always Path Blue, never the browser default */
124
+ --ring: 0 0 0 3px color-mix(in srgb, var(--psl-path-blue) 35%, transparent);
125
+
126
+ /* Motion */
127
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
128
+ --ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
129
+ --dur-fast: 120ms;
130
+ --dur-base: 180ms;
131
+ --dur-slow: 300ms;
132
+ --dur-bar: 400ms;
133
+ }
134
+
135
+ /* Dark mode is a Catppuccin Mocha ramp with a lightened Path Blue. The
136
+ semantic aliases are declared with var() on :root, so they were already
137
+ resolved light there — overriding the raw ramp alone is not enough and each
138
+ alias has to be re-declared in this scope. */
139
+ [data-theme="dark"] {
140
+ --latte-rosewater: #f5e0dc;
141
+ --latte-flamingo: #f2cdcd;
142
+ --latte-pink: #f5c2e7;
143
+ --latte-mauve: #cba6f7;
144
+ --latte-red: #f38ba8;
145
+ --latte-maroon: #eba0ac;
146
+ --latte-peach: #fab387;
147
+ --latte-yellow: #f9e2af;
148
+ --latte-green: #a6e3a1;
149
+ --latte-teal: #94e2d5;
150
+ --latte-sky: #89dceb;
151
+ --latte-sapphire: #74c7ec;
152
+ --latte-blue: #89b4fa;
153
+ --latte-lavender: #b4befe;
154
+
155
+ --latte-text: #cdd6f4;
156
+ --latte-subtext1: #bac2de;
157
+ --latte-subtext0: #a6adc8;
158
+ --latte-overlay2: #9399b2;
159
+ --latte-overlay1: #7f849c;
160
+ --latte-overlay0: #6c7086;
161
+ --latte-surface2: #585b70;
162
+ --latte-surface1: #45475a;
163
+ --latte-surface0: #313244;
164
+ --latte-base: #1e1e2e;
165
+ --latte-mantle: #181825;
166
+ --latte-crust: #11111b;
167
+
168
+ --psl-path-blue: #7aa2d6;
169
+ --psl-path-blue-active: #8fb3e0;
170
+ --psl-path-blue-soft: #3465a4;
171
+ --psl-selection-bg: #3a4a6b;
172
+ --psl-selection-fg: #cdd6f4;
173
+
174
+ /* On-accent text reads dark on the lightened blue */
175
+ --text-on-accent: #11111b;
176
+ --accent-fg: #11111b;
177
+
178
+ --surface-page: var(--latte-base);
179
+ --surface-panel: var(--latte-mantle);
180
+ --surface-chrome: var(--latte-crust);
181
+ --surface-raised: var(--latte-surface0);
182
+ --surface-raised-1: var(--latte-surface1);
183
+ --surface-raised-2: var(--latte-surface2);
184
+ --text-primary: var(--latte-text);
185
+ --text-secondary: var(--latte-subtext1);
186
+ --text-tertiary: var(--latte-subtext0);
187
+ --text-muted: var(--latte-overlay1);
188
+ --text-subtle: var(--latte-overlay0);
189
+ --border: var(--latte-surface0);
190
+ --border-strong: var(--latte-overlay0);
191
+ --border-active: var(--psl-path-blue);
192
+ --accent: var(--psl-path-blue);
193
+ --accent-hover: var(--psl-path-blue-active);
194
+ --accent-soft: var(--psl-path-blue-soft);
195
+ --success: var(--latte-green);
196
+ --warning: var(--latte-yellow);
197
+ --error: var(--latte-red);
198
+ --info: var(--latte-teal);
199
+ --on-warning: #11111b;
200
+ --link: var(--latte-blue);
201
+ --link-hover: var(--latte-sky);
202
+ --link-visited: var(--latte-lavender);
203
+ --selection-bg: var(--psl-selection-bg);
204
+ --selection-fg: var(--psl-selection-fg);
205
+ --cursor: var(--latte-rosewater);
206
+
207
+ /* Shadows go to black on dark surfaces */
208
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
209
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.36), 0 1px 2px rgba(0, 0, 0, 0.3);
210
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
211
+ --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.46), 0 4px 8px rgba(0, 0, 0, 0.32);
212
+ }
213
+
214
+ /* ===================================================================
215
+ Base
216
+ =================================================================== */
217
+
218
+ *,
219
+ *::before,
220
+ *::after {
221
+ box-sizing: border-box;
222
+ }
223
+
224
+ html,
225
+ body {
226
+ height: 100%;
227
+ }
228
+
229
+ body {
230
+ margin: 0;
231
+ background: var(--surface-page);
232
+ color: var(--text-primary);
233
+ font-family: var(--font-ui);
234
+ -webkit-font-smoothing: antialiased;
235
+ }
236
+
237
+ input,
238
+ button {
239
+ font-family: inherit;
240
+ }
241
+
242
+ ::selection {
243
+ background: var(--selection-bg);
244
+ color: var(--selection-fg);
245
+ }
246
+
247
+ .visually-hidden {
248
+ position: absolute;
249
+ width: 1px;
250
+ height: 1px;
251
+ margin: -1px;
252
+ padding: 0;
253
+ overflow: hidden;
254
+ clip-path: inset(50%);
255
+ white-space: nowrap;
256
+ border: 0;
257
+ }
258
+
259
+ /* ===================================================================
260
+ Shell
261
+ =================================================================== */
262
+
263
+ .shell {
264
+ display: grid;
265
+ grid-template-columns: 340px 1fr;
266
+ grid-template-rows: minmax(0, 1fr);
267
+ height: 100vh;
268
+ overflow: hidden;
269
+ background: var(--surface-page);
270
+ }
271
+
272
+ .rail {
273
+ display: flex;
274
+ flex-direction: column;
275
+ min-height: 0;
276
+ overflow-y: auto;
277
+ background: var(--surface-panel);
278
+ border-right: 1px solid var(--border);
279
+ }
280
+
281
+ .rail__block {
282
+ border-bottom: 1px solid var(--border);
283
+ }
284
+
285
+ .rail__block--service {
286
+ padding: 20px 20px 18px;
287
+ }
288
+
289
+ .rail__block--host,
290
+ .rail__block--models {
291
+ padding: 18px 20px;
292
+ }
293
+
294
+ /* MODELS is now the last rail block, so it closes the rail rather than CONFIG. */
295
+ .rail__block--models {
296
+ padding-bottom: 22px;
297
+ border-bottom: 0;
298
+ }
299
+
300
+ .main {
301
+ display: flex;
302
+ flex-direction: column;
303
+ min-width: 0;
304
+ min-height: 0;
305
+ }
306
+
307
+ /* ===================================================================
308
+ Shared atoms
309
+ =================================================================== */
310
+
311
+ /* The block eyebrows are the page's headings, so the class carries the reset
312
+ that keeps an <h2> looking exactly like the label it has always been. */
313
+ .eyebrow {
314
+ margin: 0;
315
+ font-family: var(--font-mono);
316
+ font-size: 10.5px;
317
+ font-weight: 400;
318
+ letter-spacing: 0.09em;
319
+ text-transform: uppercase;
320
+ color: var(--text-muted);
321
+ }
322
+
323
+ /* Blocks whose eyebrow stands alone need the same 12px breathing room that
324
+ `.block__head` gives the ones with a control on the right. */
325
+ .rail__block > .eyebrow {
326
+ margin-bottom: 12px;
327
+ }
328
+
329
+ .block__head {
330
+ display: flex;
331
+ align-items: center;
332
+ justify-content: space-between;
333
+ margin-bottom: 12px;
334
+ }
335
+
336
+ .bar {
337
+ height: 6px;
338
+ border-radius: var(--radius-full);
339
+ background: var(--surface-raised);
340
+ overflow: hidden;
341
+ }
342
+
343
+ .bar__fill {
344
+ height: 100%;
345
+ width: 0;
346
+ border-radius: var(--radius-full);
347
+ background: var(--fill, var(--accent));
348
+ transition: width var(--dur-bar) var(--ease-out);
349
+ }
350
+
351
+ /* Honest gauge tracks. A bar the host could not read (`hatched`) — or a held
352
+ last-seen value (`dashed`, reserved for a later phase) — gets a visible
353
+ TEXTURE, not color alone, so an empty track is never mistaken for a real,
354
+ solid 0% fill. The two patterns are deliberately different shapes (diagonal
355
+ weave vs. horizontal dashes) so they don't both read as speckle at 6px, and
356
+ both reuse `--border-strong`, which is theme-defined, so they stay legible
357
+ in light and dark. The value token (`—`) is the primary signal; these
358
+ reinforce it. `solid` (the default) leaves the plain filled track. */
359
+ .bar[data-track="hatched"] {
360
+ background-image: repeating-linear-gradient(
361
+ -45deg,
362
+ transparent 0,
363
+ transparent 2px,
364
+ var(--border-strong) 2px,
365
+ var(--border-strong) 4px
366
+ );
367
+ }
368
+
369
+ .bar[data-track="dashed"] {
370
+ background-image: repeating-linear-gradient(
371
+ 90deg,
372
+ var(--border-strong) 0,
373
+ var(--border-strong) 4px,
374
+ transparent 4px,
375
+ transparent 8px
376
+ );
377
+ }
378
+
379
+ /* Buttons. Neutral controls repaint their surface on hover; filled and tinted
380
+ ones keep their assigned color and darken one step instead. */
381
+ .btn {
382
+ display: inline-flex;
383
+ align-items: center;
384
+ justify-content: center;
385
+ border-radius: var(--radius-md);
386
+ border: 1px solid var(--border);
387
+ background: var(--surface-page);
388
+ color: var(--text-secondary);
389
+ font-family: var(--font-ui);
390
+ font-size: 13px;
391
+ font-weight: 600;
392
+ cursor: pointer;
393
+ transition:
394
+ background var(--dur-fast) var(--ease-out),
395
+ border-color var(--dur-fast) var(--ease-out),
396
+ filter var(--dur-fast) var(--ease-out),
397
+ transform var(--dur-fast) var(--ease-out),
398
+ box-shadow var(--dur-fast) var(--ease-out);
399
+ }
400
+
401
+ .btn:hover {
402
+ background: var(--surface-raised);
403
+ border-color: var(--border-strong);
404
+ }
405
+
406
+ .btn:active {
407
+ transform: translateY(0.5px) scale(0.99);
408
+ }
409
+
410
+ .btn:focus-visible {
411
+ outline: none;
412
+ box-shadow: var(--ring);
413
+ }
414
+
415
+ .btn:disabled {
416
+ cursor: default;
417
+ opacity: 0.62;
418
+ }
419
+
420
+ .btn:disabled:hover {
421
+ background: var(--surface-page);
422
+ border-color: var(--border);
423
+ filter: none;
424
+ }
425
+
426
+ .btn:disabled:active {
427
+ transform: none;
428
+ }
429
+
430
+ .btn--filled {
431
+ border-color: var(--bd, transparent);
432
+ background: var(--bg, var(--accent));
433
+ color: var(--fg, var(--accent-fg));
434
+ font-weight: 700;
435
+ }
436
+
437
+ .btn--filled:hover {
438
+ filter: brightness(0.94);
439
+ background: var(--bg, var(--accent));
440
+ border-color: var(--bd, transparent);
441
+ }
442
+
443
+ .btn--filled:disabled:hover {
444
+ background: var(--bg, var(--accent));
445
+ border-color: var(--bd, transparent);
446
+ }
447
+
448
+ .btn--block {
449
+ flex: 1;
450
+ height: 36px;
451
+ }
452
+
453
+ .btn--md {
454
+ height: 36px;
455
+ padding: 0 14px;
456
+ }
457
+
458
+ .btn--icon {
459
+ width: 36px;
460
+ height: 36px;
461
+ padding: 0;
462
+ font-size: 13px;
463
+ }
464
+
465
+ .btn--sm {
466
+ height: 26px;
467
+ padding: 0 11px;
468
+ font-size: 11.5px;
469
+ font-weight: 700;
470
+ }
471
+
472
+ .btn--toolbar {
473
+ height: 28px;
474
+ padding: 0 13px;
475
+ font-size: 12px;
476
+ font-weight: 600;
477
+ }
478
+
479
+ /* Chips and pills carry their own tint, so hover darkens rather than repaints. */
480
+ .chip {
481
+ display: inline-flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ height: 28px;
485
+ padding: 0 11px;
486
+ border-radius: var(--radius-md);
487
+ border: 1px solid var(--bd, var(--border));
488
+ background: var(--bg, var(--surface-page));
489
+ color: var(--fg, var(--text-tertiary));
490
+ font-family: var(--font-mono);
491
+ font-size: 11.5px;
492
+ font-weight: 700;
493
+ cursor: pointer;
494
+ transition:
495
+ filter var(--dur-fast) var(--ease-out),
496
+ border-color var(--dur-fast) var(--ease-out),
497
+ transform var(--dur-fast) var(--ease-out),
498
+ box-shadow var(--dur-fast) var(--ease-out);
499
+ }
500
+
501
+ .chip:hover {
502
+ filter: brightness(0.95);
503
+ border-color: var(--border-strong);
504
+ }
505
+
506
+ .chip:active {
507
+ transform: translateY(0.5px) scale(0.99);
508
+ }
509
+
510
+ .chip:focus-visible {
511
+ outline: none;
512
+ box-shadow: var(--ring);
513
+ }
514
+
515
+ .chip--pause {
516
+ font-family: var(--font-ui);
517
+ font-size: 12px;
518
+ padding: 0 13px;
519
+ }
520
+
521
+ .pill {
522
+ display: inline-flex;
523
+ align-items: center;
524
+ justify-content: center;
525
+ height: 24px;
526
+ padding: 0 9px;
527
+ border-radius: var(--radius-full);
528
+ border: 1px solid var(--bd, var(--border));
529
+ background: var(--bg, var(--surface-page));
530
+ color: var(--fg, var(--text-tertiary));
531
+ font-family: var(--font-mono);
532
+ font-size: 11px;
533
+ font-weight: 700;
534
+ cursor: pointer;
535
+ transition:
536
+ filter var(--dur-fast) var(--ease-out),
537
+ border-color var(--dur-fast) var(--ease-out),
538
+ transform var(--dur-fast) var(--ease-out),
539
+ box-shadow var(--dur-fast) var(--ease-out);
540
+ }
541
+
542
+ .pill:hover {
543
+ filter: brightness(0.95);
544
+ border-color: var(--border-strong);
545
+ }
546
+
547
+ .pill:active {
548
+ transform: translateY(0.5px) scale(0.99);
549
+ }
550
+
551
+ .pill:focus-visible {
552
+ outline: none;
553
+ box-shadow: var(--ring);
554
+ }
555
+
556
+ /* ===================================================================
557
+ Rail — service
558
+ =================================================================== */
559
+
560
+ /* The lockup carries four things: the mark, the wordmark, the status chip and
561
+ the theme toggle. It wraps rather than overflowing, because below 1100px the
562
+ rail becomes a two-column band and this row loses most of its width. */
563
+ .lockup {
564
+ display: flex;
565
+ align-items: center;
566
+ flex-wrap: wrap;
567
+ gap: 9px;
568
+ margin-bottom: 5px;
569
+ color: var(--accent);
570
+ }
571
+
572
+ .lockup__mark {
573
+ flex: none;
574
+ }
575
+
576
+ .lockup__name {
577
+ /* The lockup is the page's h1. */
578
+ margin: 0;
579
+ /* Pushes the chip and the theme toggle to the far end of the row. */
580
+ margin-right: auto;
581
+ font-family: var(--font-heading);
582
+ font-size: 17px;
583
+ font-weight: 800;
584
+ letter-spacing: -0.015em;
585
+ color: var(--text-primary);
586
+ }
587
+
588
+ /* The icon control is a 36px target in the toolbar; in the lockup it sits
589
+ beside a 22px chip, so it comes down to a size that still clears the 24px
590
+ minimum target without setting the row's height on its own. */
591
+ .lockup .btn--icon {
592
+ width: 28px;
593
+ height: 28px;
594
+ }
595
+
596
+ /* The status readout: a chip in the lockup, not a slab of its own.
597
+ Deliberately inert — no hover, no focus ring, no press. It reports state and
598
+ nothing more; the buttons below it do the acting.
599
+
600
+ The WORD is painted with a text token rather than the state's hue. Latte's
601
+ `--success` is 2.75:1 and `--error` 4.47:1 on this panel ground, and at
602
+ 11.5px there is no large-text exemption — a state word that cannot be read
603
+ is not a readout. The hue rides the dot, which is backed by the word and by
604
+ the dot's own shape. */
605
+ .service__status {
606
+ flex: none;
607
+ height: 22px;
608
+ display: inline-flex;
609
+ align-items: center;
610
+ gap: 6px;
611
+ padding: 0 8px;
612
+ border-radius: var(--radius-full);
613
+ border: 1px solid var(--border);
614
+ background: transparent;
615
+ color: var(--text-secondary);
616
+ font-family: var(--font-ui);
617
+ font-size: 11.5px;
618
+ font-weight: 700;
619
+ cursor: default;
620
+ }
621
+
622
+ /* Three states, three FORMS. Colour is the third signal and never the only
623
+ one: `started` is a filled disc, `stopped` an open ring, and a machine
624
+ Steward has not been pointed at yet a dotted one. The word beside the dot
625
+ says the same thing in text, so neither signal is load-bearing alone. */
626
+ .service__status-dot {
627
+ width: 8px;
628
+ height: 8px;
629
+ flex: none;
630
+ border-radius: 50%;
631
+ background: var(--fill, var(--success));
632
+ }
633
+
634
+ .service__status[data-state="down"] .service__status-dot {
635
+ background: transparent;
636
+ border: 2px solid var(--fill, var(--error));
637
+ }
638
+
639
+ .service__status[data-state="unknown"] .service__status-dot {
640
+ background: transparent;
641
+ border: 2px dotted var(--fill, var(--text-muted));
642
+ }
643
+
644
+ .service__status-label {
645
+ text-transform: capitalize;
646
+ }
647
+
648
+ /* The control row: one button per action this machine has a consented command
649
+ for, sharing the width evenly. Hidden outright when control is not set up —
650
+ the setup note takes its place rather than a row of dead buttons. */
651
+ .service__controls {
652
+ display: flex;
653
+ gap: 6px;
654
+ margin-top: 8px;
655
+ }
656
+
657
+ .service__controls[hidden] {
658
+ display: none;
659
+ }
660
+
661
+ .service__control {
662
+ flex: 1;
663
+ min-width: 0;
664
+ }
665
+
666
+ /* Stop and restart are destructive, so they carry the error hue — reinforcing
667
+ the verb on the button, never standing in for it. */
668
+ .service__control[data-tone="danger"] {
669
+ color: var(--error);
670
+ border-color: color-mix(in srgb, var(--error) 38%, transparent);
671
+ }
672
+
673
+ .service__control[data-tone="danger"]:hover:not(:disabled) {
674
+ background: color-mix(in srgb, var(--error) 12%, transparent);
675
+ border-color: color-mix(in srgb, var(--error) 55%, transparent);
676
+ }
677
+
678
+ /* Control is not configured: one instruction naming the skill that configures
679
+ it. Deliberately not a button — the page cannot run a Pi command. */
680
+ .service__setup {
681
+ margin-top: 10px;
682
+ padding: 10px 11px;
683
+ border: 1px dashed var(--border-strong);
684
+ border-radius: var(--radius-md);
685
+ background: var(--surface-page);
686
+ }
687
+
688
+ .service__setup[hidden] {
689
+ display: none;
690
+ }
691
+
692
+ .service__setup-label {
693
+ margin: 0;
694
+ font-size: 12px;
695
+ font-weight: 700;
696
+ color: var(--text-secondary);
697
+ }
698
+
699
+ .service__setup-detail {
700
+ margin: 4px 0 0;
701
+ font-size: 11.5px;
702
+ line-height: 1.45;
703
+ color: var(--text-tertiary);
704
+ }
705
+
706
+ .service__setup-command {
707
+ display: inline-block;
708
+ margin-top: 7px;
709
+ font-family: var(--font-mono);
710
+ font-size: 11.5px;
711
+ color: var(--accent);
712
+ }
713
+
714
+ /* The consequence gate. It states what the action costs, in words, and puts
715
+ Cancel first — focus lands there when the strip opens, and Esc backs out. */
716
+ .service__confirm {
717
+ margin-top: 8px;
718
+ padding: 10px 11px;
719
+ border: 1px solid color-mix(in srgb, var(--error) 38%, transparent);
720
+ border-radius: var(--radius-md);
721
+ background: color-mix(in srgb, var(--error) 8%, transparent);
722
+ }
723
+
724
+ .service__confirm[hidden] {
725
+ display: none;
726
+ }
727
+
728
+ .service__confirm-text {
729
+ margin: 0;
730
+ font-size: 11.5px;
731
+ line-height: 1.45;
732
+ color: var(--text-secondary);
733
+ }
734
+
735
+ .service__confirm-row {
736
+ display: flex;
737
+ gap: 6px;
738
+ margin-top: 9px;
739
+ }
740
+
741
+ .service__confirm-row > .btn {
742
+ flex: 1;
743
+ min-width: 0;
744
+ }
745
+
746
+ /* A refused command says so where the operator is looking, in the command's
747
+ own words. Never color-only: it is a sentence, prefixed by the failed verb. */
748
+ .service__notice {
749
+ margin: 8px 0 0;
750
+ padding: 8px 10px;
751
+ border: 1px solid color-mix(in srgb, var(--error) 40%, transparent);
752
+ border-left-width: 3px;
753
+ border-radius: var(--radius-md);
754
+ background: color-mix(in srgb, var(--error) 10%, transparent);
755
+ font-size: 11.5px;
756
+ line-height: 1.45;
757
+ color: var(--error);
758
+ overflow-wrap: anywhere;
759
+ }
760
+
761
+ .service__notice[hidden] {
762
+ display: none;
763
+ }
764
+
765
+ /* Config drift: the machine no longer matches what `steward.json` says about
766
+ it. Warning-toned and set off by a left rule so it reads as a break in the
767
+ block, but it is only ever as loud as it needs to be — it appears at all only
768
+ when something is genuinely wrong, and a compliant machine renders nothing
769
+ here. The tone is never the only signal: the heading says "Configuration
770
+ drift", the glyph repeats it, and every line is a sentence. */
771
+ .service__drift {
772
+ margin-top: 10px;
773
+ padding: 9px 10px;
774
+ border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent);
775
+ border-left-width: 3px;
776
+ border-radius: var(--radius-md);
777
+ background: color-mix(in srgb, var(--warning) 10%, transparent);
778
+ }
779
+
780
+ .service__drift[hidden] {
781
+ display: none;
782
+ }
783
+
784
+ .service__drift-head {
785
+ display: flex;
786
+ align-items: center;
787
+ gap: 6px;
788
+ }
789
+
790
+ .service__drift-icon {
791
+ display: inline-flex;
792
+ align-items: center;
793
+ justify-content: center;
794
+ width: 14px;
795
+ height: 14px;
796
+ flex: none;
797
+ border-radius: 50%;
798
+ background: color-mix(in srgb, var(--warning) 28%, transparent);
799
+ font-size: 10px;
800
+ font-weight: 800;
801
+ line-height: 1;
802
+ color: var(--warning);
803
+ }
804
+
805
+ .service__drift-title {
806
+ flex: 1;
807
+ min-width: 0;
808
+ margin: 0;
809
+ font-size: 12px;
810
+ font-weight: 700;
811
+ color: var(--warning);
812
+ }
813
+
814
+ .service__drift-dismiss {
815
+ flex: none;
816
+ }
817
+
818
+ .service__drift-list {
819
+ margin: 6px 0 0;
820
+ padding: 0;
821
+ list-style: none;
822
+ }
823
+
824
+ .service__drift-item {
825
+ margin-top: 3px;
826
+ font-size: 11.5px;
827
+ line-height: 1.45;
828
+ color: var(--text-secondary);
829
+ overflow-wrap: anywhere;
830
+ }
831
+
832
+ .service__drift-fix {
833
+ margin: 7px 0 0;
834
+ font-family: var(--font-mono);
835
+ font-size: 11px;
836
+ line-height: 1.45;
837
+ color: var(--text-tertiary);
838
+ overflow-wrap: anywhere;
839
+ }
840
+
841
+ /* The router identity, divided from the live state/control row above by a
842
+ hairline. Uptime and pid live in the metrics band's service tile, not here. */
843
+ .service__facts {
844
+ margin-top: 14px;
845
+ padding-top: 14px;
846
+ border-top: 1px solid var(--border);
847
+ }
848
+
849
+ /* ===================================================================
850
+ Rail — host gauges
851
+ =================================================================== */
852
+
853
+ /* The unit control, in the block head beside the eyebrow. A text button rather
854
+ than a cycling glyph: its value IS a two-character string, so the label can
855
+ simply be it. Absent — never disabled — on a machine with no temperature
856
+ sensor, because there would be nothing on screen for it to change. */
857
+ .host__unit {
858
+ font-family: var(--font-mono);
859
+ color: var(--text-secondary);
860
+ }
861
+
862
+ .host__unit[hidden] {
863
+ display: none;
864
+ }
865
+
866
+ .gauges {
867
+ display: flex;
868
+ flex-direction: column;
869
+ gap: 12px;
870
+ }
871
+
872
+ .gauge {
873
+ display: flex;
874
+ flex-direction: column;
875
+ gap: 5px;
876
+ }
877
+
878
+ .gauge__head {
879
+ display: flex;
880
+ justify-content: space-between;
881
+ font-family: var(--font-mono);
882
+ font-size: 11.5px;
883
+ }
884
+
885
+ .gauge__label {
886
+ color: var(--text-secondary);
887
+ }
888
+
889
+ .gauge__value {
890
+ color: var(--fill, var(--accent));
891
+ font-weight: 700;
892
+ }
893
+
894
+ /* ===================================================================
895
+ Rail — models
896
+ =================================================================== */
897
+
898
+ .model-list {
899
+ display: flex;
900
+ flex-direction: column;
901
+ gap: 8px;
902
+ }
903
+
904
+ .model-card {
905
+ padding: 12px 13px;
906
+ border: 1px solid var(--bd, var(--border));
907
+ border-radius: var(--radius-md);
908
+ background: var(--bg, var(--surface-page));
909
+ cursor: pointer;
910
+ transition:
911
+ box-shadow var(--dur-base) var(--ease-out),
912
+ transform var(--dur-fast) var(--ease-out);
913
+ }
914
+
915
+ .model-card:hover {
916
+ box-shadow: var(--shadow-md);
917
+ }
918
+
919
+ .model-card:active {
920
+ transform: translateY(0.5px);
921
+ }
922
+
923
+ .model-card:focus-visible {
924
+ outline: none;
925
+ box-shadow: var(--ring);
926
+ }
927
+
928
+ /* Top row: the model identity on the left, its live status and rate on the
929
+ right. The title takes the slack so the name ellipsizes before it crowds the
930
+ status, which always shows whole. */
931
+ /* Header row: name (takes the slack, ellipsizes), then the status and the
932
+ Load/Unload button pinned to the right. The button lives here — the one row
933
+ every card always has — so it never moves with the data below. */
934
+ .model-card__top {
935
+ display: flex;
936
+ /* Top-align so a name that wraps to two lines keeps the button beside its
937
+ first line rather than dragging it to the vertical centre. */
938
+ align-items: flex-start;
939
+ gap: 8px;
940
+ margin-bottom: 8px;
941
+ }
942
+
943
+ .model-card__title {
944
+ display: flex;
945
+ align-items: center;
946
+ gap: 7px;
947
+ min-width: 0;
948
+ flex: 1 1 auto;
949
+ }
950
+
951
+ .model-card__top > .btn {
952
+ flex: none;
953
+ }
954
+
955
+ .model-card__dot {
956
+ width: 7px;
957
+ height: 7px;
958
+ border-radius: 50%;
959
+ background: var(--model-color, var(--accent));
960
+ flex: none;
961
+ }
962
+
963
+ /* The model name is never truncated — a clipped name hides the very suffix that
964
+ tells two builds apart. It wraps in full (breaking anywhere for a long raw id)
965
+ and the header row grows to fit. */
966
+ .model-card__name {
967
+ font-family: var(--font-mono);
968
+ font-size: 12px;
969
+ font-weight: 700;
970
+ color: var(--text-primary);
971
+ min-width: 0;
972
+ overflow-wrap: anywhere;
973
+ }
974
+
975
+ /* The card body: a fixed two-column grid of `Label: value` cells, same seven
976
+ labels on every card. All mono, since every value is data. The muted label and
977
+ the brighter value sit on one line per cell; a value that is `n/a` dims further
978
+ so the eye skips it without the label losing its footing. */
979
+ .model-card__fields {
980
+ display: grid;
981
+ grid-template-columns: 1fr 1fr;
982
+ gap: 2px 10px;
983
+ margin-top: 6px;
984
+ }
985
+
986
+ .model-card__field {
987
+ display: flex;
988
+ gap: 5px;
989
+ min-width: 0;
990
+ font-family: var(--font-mono);
991
+ font-size: 11px;
992
+ }
993
+
994
+ .model-card__label {
995
+ flex: none;
996
+ white-space: nowrap;
997
+ color: var(--text-muted);
998
+ }
999
+
1000
+ .model-card__value {
1001
+ color: var(--text-secondary);
1002
+ overflow: hidden;
1003
+ text-overflow: ellipsis;
1004
+ white-space: nowrap;
1005
+ }
1006
+
1007
+ /* An unconfirmed field: the value recedes, the label does not, so the row still
1008
+ reads as "this fact is unknown" rather than the whole cell going quiet. */
1009
+ .model-card__value[data-na="true"] {
1010
+ color: var(--text-subtle);
1011
+ }
1012
+
1013
+ /* The header's right-hand controls — the info legend and the "all logs" pill —
1014
+ share the space the block__head justifies against the eyebrow. */
1015
+ .block__head-controls {
1016
+ display: flex;
1017
+ align-items: center;
1018
+ gap: 7px;
1019
+ }
1020
+
1021
+ /* The info legend's toggle: a neutral icon control, sized to sit level with the
1022
+ pill beside it and lit like the other neutral buttons on hover/focus. */
1023
+ .legend-toggle {
1024
+ display: inline-flex;
1025
+ align-items: center;
1026
+ justify-content: center;
1027
+ width: 22px;
1028
+ height: 22px;
1029
+ padding: 0;
1030
+ border-radius: var(--radius-full);
1031
+ border: 1px solid var(--border);
1032
+ background: var(--surface-page);
1033
+ color: var(--text-tertiary);
1034
+ font-family: var(--font-ui);
1035
+ font-size: 13px;
1036
+ line-height: 1;
1037
+ cursor: pointer;
1038
+ transition:
1039
+ background var(--dur-fast) var(--ease-out),
1040
+ border-color var(--dur-fast) var(--ease-out),
1041
+ color var(--dur-fast) var(--ease-out),
1042
+ box-shadow var(--dur-fast) var(--ease-out);
1043
+ }
1044
+
1045
+ .legend-toggle:hover {
1046
+ background: var(--surface-raised);
1047
+ border-color: var(--border-strong);
1048
+ color: var(--text-secondary);
1049
+ }
1050
+
1051
+ .legend-toggle:focus-visible {
1052
+ outline: none;
1053
+ box-shadow: var(--ring);
1054
+ }
1055
+
1056
+ .legend-toggle[aria-expanded="true"] {
1057
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
1058
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1059
+ color: var(--accent);
1060
+ }
1061
+
1062
+ /* The definitions panel. `hidden` zeroes it when collapsed, so it costs nothing
1063
+ until opened and never pushes the cards down the rail. */
1064
+ .legend-panel {
1065
+ margin-bottom: 12px;
1066
+ padding: 11px 12px;
1067
+ border: 1px solid var(--border);
1068
+ border-radius: var(--radius-md);
1069
+ background: var(--surface-raised);
1070
+ }
1071
+
1072
+ .legend-panel[hidden] {
1073
+ display: none;
1074
+ }
1075
+
1076
+ .legend-list {
1077
+ margin: 0;
1078
+ display: grid;
1079
+ gap: 9px;
1080
+ }
1081
+
1082
+ .legend-term {
1083
+ font-family: var(--font-mono);
1084
+ font-size: 11px;
1085
+ font-weight: 700;
1086
+ color: var(--text-secondary);
1087
+ }
1088
+
1089
+ .legend-def {
1090
+ margin: 2px 0 0;
1091
+ font-family: var(--font-ui);
1092
+ font-size: 11.5px;
1093
+ line-height: 1.45;
1094
+ color: var(--text-muted);
1095
+ }
1096
+
1097
+ /* ===================================================================
1098
+ Rail — config
1099
+ =================================================================== */
1100
+
1101
+ .config-list {
1102
+ display: flex;
1103
+ flex-direction: column;
1104
+ gap: 7px;
1105
+ }
1106
+
1107
+ /* The label hugs the left edge and its value is pushed to the right, so the
1108
+ values line up as a scannable column down the block. */
1109
+ .config-row {
1110
+ display: flex;
1111
+ align-items: baseline;
1112
+ justify-content: space-between;
1113
+ gap: 12px;
1114
+ font-family: var(--font-mono);
1115
+ font-size: 11.5px;
1116
+ }
1117
+
1118
+ .config-row__key {
1119
+ color: var(--text-muted);
1120
+ white-space: nowrap;
1121
+ }
1122
+
1123
+ /* Right-justified so the values read as a column. Unlike the dense card grid,
1124
+ the facts wrap rather than ellipsize, so a long engine build hash or host is
1125
+ never clipped. */
1126
+ .config-row__value {
1127
+ color: var(--text-secondary);
1128
+ min-width: 0;
1129
+ overflow-wrap: anywhere;
1130
+ text-align: right;
1131
+ }
1132
+
1133
+ /* ===================================================================
1134
+ Metrics band
1135
+ =================================================================== */
1136
+
1137
+ .metrics {
1138
+ display: grid;
1139
+ grid-template-columns: repeat(3, 1fr) 1.4fr;
1140
+ gap: 1px;
1141
+ background: var(--border);
1142
+ border-bottom: 1px solid var(--border);
1143
+ }
1144
+
1145
+ .metric {
1146
+ display: flex;
1147
+ flex-direction: column;
1148
+ gap: 7px;
1149
+ padding: 16px 20px;
1150
+ background: var(--surface-page);
1151
+ min-width: 0;
1152
+ }
1153
+
1154
+ .metric .eyebrow {
1155
+ font-size: 11px;
1156
+ }
1157
+
1158
+ .kpi__row {
1159
+ display: flex;
1160
+ align-items: baseline;
1161
+ gap: 6px;
1162
+ }
1163
+
1164
+ .kpi__value {
1165
+ font-family: var(--font-mono);
1166
+ font-size: 26px;
1167
+ font-weight: 700;
1168
+ line-height: 1;
1169
+ color: var(--fill, var(--accent));
1170
+ }
1171
+
1172
+ .kpi__unit,
1173
+ .kpi__sub {
1174
+ font-size: 12px;
1175
+ color: var(--text-tertiary);
1176
+ }
1177
+
1178
+ .bar--kpi {
1179
+ height: 4px;
1180
+ }
1181
+
1182
+ .spark__head {
1183
+ display: flex;
1184
+ align-items: baseline;
1185
+ justify-content: space-between;
1186
+ gap: 10px;
1187
+ }
1188
+
1189
+ /* Under the 1280px design width the chart cell is too narrow to hold the label
1190
+ and the summary side by side. Let the summary drop to its own line rather
1191
+ than be clipped by the shell — the design width itself is untouched. */
1192
+ @media (max-width: 1279px) {
1193
+ .spark__head {
1194
+ flex-wrap: wrap;
1195
+ }
1196
+ }
1197
+
1198
+ .spark__summary {
1199
+ font-family: var(--font-mono);
1200
+ font-size: 11.5px;
1201
+ color: var(--text-tertiary);
1202
+ /* At the narrow end of the design range the header row runs out of room;
1203
+ the label may wrap, but breaking "peak 96 tok/s" across lines would read
1204
+ as two different numbers. */
1205
+ white-space: nowrap;
1206
+ }
1207
+
1208
+ .spark__plot {
1209
+ position: relative;
1210
+ height: 46px;
1211
+ }
1212
+
1213
+ .spark__avg {
1214
+ position: absolute;
1215
+ left: 0;
1216
+ right: 0;
1217
+ bottom: 0;
1218
+ height: 1px;
1219
+ border-top: 1px dashed var(--border-strong);
1220
+ opacity: 0.7;
1221
+ }
1222
+
1223
+ .spark__bars {
1224
+ display: flex;
1225
+ align-items: flex-end;
1226
+ gap: 2px;
1227
+ height: 100%;
1228
+ }
1229
+
1230
+ /* The chart redraws every sample, so the bars and the average rule are not
1231
+ animated: only the gauge and KPI fills transition. */
1232
+ .spark__bar {
1233
+ flex: 1;
1234
+ min-width: 0;
1235
+ border-radius: 1px;
1236
+ background: var(--fill, var(--accent));
1237
+ }
1238
+
1239
+ .spark__axis {
1240
+ display: flex;
1241
+ justify-content: space-between;
1242
+ font-family: var(--font-mono);
1243
+ font-size: 10.5px;
1244
+ color: var(--text-subtle);
1245
+ }
1246
+
1247
+ /* ===================================================================
1248
+ Console toolbar
1249
+ =================================================================== */
1250
+
1251
+ /* Two explicit rows, not one wrapping row: two chip groups that wrapped into
1252
+ each other would read as one control set with two resets in it. */
1253
+ .toolbar {
1254
+ display: flex;
1255
+ flex-direction: column;
1256
+ align-items: stretch;
1257
+ gap: 8px;
1258
+ padding: 12px 18px;
1259
+ border-bottom: 1px solid var(--border);
1260
+ background: var(--surface-chrome);
1261
+ }
1262
+
1263
+ .toolbar__row {
1264
+ display: flex;
1265
+ align-items: center;
1266
+ gap: 10px;
1267
+ flex-wrap: wrap;
1268
+ }
1269
+
1270
+ .toolbar__label {
1271
+ font-family: var(--font-mono);
1272
+ font-size: 11.5px;
1273
+ color: var(--text-tertiary);
1274
+ }
1275
+
1276
+ .toolbar__active {
1277
+ padding: 4px 11px;
1278
+ border-radius: var(--radius-full);
1279
+ background: var(--bg, transparent);
1280
+ color: var(--fg, var(--accent));
1281
+ font-family: var(--font-mono);
1282
+ font-size: 11.5px;
1283
+ font-weight: 700;
1284
+ max-width: 260px;
1285
+ overflow: hidden;
1286
+ text-overflow: ellipsis;
1287
+ white-space: nowrap;
1288
+ }
1289
+
1290
+ /* One rule for both chip groups — they are the same component on two axes. */
1291
+ .toolbar__chips {
1292
+ display: flex;
1293
+ gap: 6px;
1294
+ }
1295
+
1296
+ /* Tabular digits, or the whole toolbar jitters every time a count ticks. */
1297
+ .toolbar__count,
1298
+ .chip__count {
1299
+ font-variant-numeric: tabular-nums;
1300
+ }
1301
+
1302
+ .toolbar__count {
1303
+ font-family: var(--font-mono);
1304
+ font-size: 11.5px;
1305
+ color: var(--text-muted);
1306
+ }
1307
+
1308
+ .chip__label + .chip__count {
1309
+ margin-left: 6px;
1310
+ }
1311
+
1312
+ /* The count segment is muted in every chip state. A zero must never read as a
1313
+ tick — llama-server logs nothing at all for a rejected request, so `ERROR 0`
1314
+ is a fact about the file and not a health check. */
1315
+ .chip__count {
1316
+ color: var(--text-secondary);
1317
+ font-weight: 400;
1318
+ }
1319
+
1320
+ /* The proxied toggle sits with the level group, so it inherits the chip's
1321
+ shape; only its glyph and its pressed state distinguish it. */
1322
+ .chip--proxy[aria-pressed="true"] {
1323
+ color: var(--text-primary);
1324
+ border-color: var(--border-strong);
1325
+ }
1326
+
1327
+ .search {
1328
+ height: 28px;
1329
+ flex: 1;
1330
+ min-width: 140px;
1331
+ padding: 0 11px;
1332
+ border-radius: var(--radius-md);
1333
+ border: 1px solid var(--border);
1334
+ background: var(--surface-page);
1335
+ color: var(--text-primary);
1336
+ font-family: var(--font-mono);
1337
+ font-size: 12px;
1338
+ transition:
1339
+ border-color var(--dur-fast) var(--ease-out),
1340
+ box-shadow var(--dur-fast) var(--ease-out);
1341
+ }
1342
+
1343
+ .search::placeholder {
1344
+ color: var(--text-subtle);
1345
+ }
1346
+
1347
+ .search:hover {
1348
+ border-color: var(--border-strong);
1349
+ }
1350
+
1351
+ .search:focus {
1352
+ outline: none;
1353
+ border-color: var(--border-active);
1354
+ box-shadow: var(--ring);
1355
+ }
1356
+
1357
+ /* ===================================================================
1358
+ Log console
1359
+ =================================================================== */
1360
+
1361
+ .console {
1362
+ flex: 1;
1363
+ min-height: 0;
1364
+ overflow-y: auto;
1365
+ background: var(--surface-chrome);
1366
+ /* A browser scroll anchor would fight the follow logic for the scroll top. */
1367
+ overflow-anchor: none;
1368
+ }
1369
+
1370
+ /* The console is focusable and keyboard-scrollable, so it needs a visible ring
1371
+ like every other control in this sheet. */
1372
+ .console:focus-visible {
1373
+ outline: none;
1374
+ box-shadow: var(--ring);
1375
+ }
1376
+
1377
+ /* Four columns become five. The model cell pays 24px of the task cell's bill;
1378
+ a `short` name is already trimmed of its repo prefix and quant suffix. */
1379
+ .log-row {
1380
+ display: grid;
1381
+ grid-template-columns: 92px 54px minmax(0, 176px) 64px minmax(0, 1fr);
1382
+ gap: 12px;
1383
+ padding: 3px 18px;
1384
+ font-family: var(--font-mono);
1385
+ font-size: 12px;
1386
+ line-height: 1.6;
1387
+ }
1388
+
1389
+ /* Nothing in the matched set is framed — an idle router showing eleven banner
1390
+ lines gets the original four-column grid back, not 64px of dead space. The
1391
+ attribute sits on the console rather than the row list so the header collapses
1392
+ with the column it labels. */
1393
+ .console[data-tasks="none"] .log-row {
1394
+ grid-template-columns: 92px 54px minmax(0, 200px) minmax(0, 1fr);
1395
+ }
1396
+
1397
+ /* The column labels. LABELS, not controls: no pointer cursor, no hover, no
1398
+ press state — nothing that offers to sort. File order is the only valid order
1399
+ here, and task ids are not monotonic, so a sorted view would be wrong rather
1400
+ than merely different. */
1401
+ .console__head {
1402
+ padding-top: 6px;
1403
+ padding-bottom: 6px;
1404
+ border-bottom: 1px solid var(--border);
1405
+ font-size: 10.5px;
1406
+ letter-spacing: 0.06em;
1407
+ text-transform: uppercase;
1408
+ cursor: default;
1409
+ user-select: none;
1410
+ }
1411
+
1412
+ .console__head[hidden] {
1413
+ display: none;
1414
+ }
1415
+
1416
+ /* The header and the strips above it are one sticky block, so the labels can
1417
+ never separate from the strips or slide out from over their own columns. */
1418
+ .console__sticky {
1419
+ position: sticky;
1420
+ top: 0;
1421
+ z-index: 1;
1422
+ background: var(--surface-chrome);
1423
+ }
1424
+
1425
+ .log-row__ts {
1426
+ color: var(--text-subtle);
1427
+ }
1428
+
1429
+ .log-row__level {
1430
+ font-weight: 700;
1431
+ justify-self: start;
1432
+ }
1433
+
1434
+ /* Severity in FORM, not hue: WARN and ERROR read as a filled box, INFO and
1435
+ DEBUG as plain text. That difference survives a monochrome display and a
1436
+ colour-blind reader, and it fixes the contrast — every level colour fails AA
1437
+ as 12px text on the light theme's console ground. */
1438
+ /* Quiet, but still readable: the plain-token-vs-filled-box difference is what
1439
+ encodes severity here, so the token does not also need to be dim. The
1440
+ tertiary token is 3.73:1 on this ground in the light theme, and swapping one
1441
+ AA failure for another would miss the point of moving severity into form. */
1442
+ .log-row[data-level="INFO"] .log-row__level,
1443
+ .log-row[data-level="DEBUG"] .log-row__level {
1444
+ color: var(--text-secondary);
1445
+ font-weight: 400;
1446
+ }
1447
+
1448
+ .log-row[data-level="WARN"] .log-row__level {
1449
+ background: var(--warning);
1450
+ color: var(--on-warning);
1451
+ border-radius: var(--radius-sm);
1452
+ padding: 0 4px;
1453
+ }
1454
+
1455
+ .log-row[data-level="ERROR"] .log-row__level {
1456
+ background: var(--error);
1457
+ color: var(--accent-fg);
1458
+ border-radius: var(--radius-sm);
1459
+ padding: 0 4px;
1460
+ }
1461
+
1462
+ /* Redundant with the badge, and the point: an error is scannable down the
1463
+ gutter without reading a single token. */
1464
+ .log-row[data-level="ERROR"] {
1465
+ border-left: 2px solid var(--error);
1466
+ padding-left: 16px;
1467
+ }
1468
+
1469
+ .log-row__model {
1470
+ color: var(--model-color, var(--text-muted));
1471
+ overflow: hidden;
1472
+ text-overflow: ellipsis;
1473
+ white-space: nowrap;
1474
+ }
1475
+
1476
+ /* A router-wide line says the word `router`, italic — a real word a screen
1477
+ reader can read, where an em dash is silence. The dash stays reserved for a
1478
+ child line whose port Steward has not mapped. */
1479
+ .log-row__model[data-scope="router"] {
1480
+ font-style: italic;
1481
+ color: var(--text-muted);
1482
+ }
1483
+
1484
+ .log-row__model[data-scope="unknown"] {
1485
+ color: var(--text-subtle);
1486
+ }
1487
+
1488
+ /* The task cell: the same inline-button vocabulary as the args fold, glyph
1489
+ included — `▸` already means "press to reveal more" in this console. */
1490
+ .log-row__task {
1491
+ justify-self: start;
1492
+ padding: 0;
1493
+ border: 0;
1494
+ background: none;
1495
+ font: inherit;
1496
+ color: var(--text-secondary);
1497
+ font-variant-numeric: tabular-nums;
1498
+ text-align: left;
1499
+ }
1500
+
1501
+ /* Scoped to the BUTTON. The header cell carries the same class for the grid's
1502
+ sake, and a pointer cursor plus a hover colour on a column label is exactly
1503
+ the sort affordance a header on this data must never offer — file order is
1504
+ the only valid order. */
1505
+ button.log-row__task {
1506
+ cursor: pointer;
1507
+ }
1508
+
1509
+ /* No task to trace: the cell keeps its grid column so the rhythm never shifts,
1510
+ and `visibility` takes it out of the tab order and the accessibility tree. */
1511
+ .log-row__task[data-empty="true"] {
1512
+ visibility: hidden;
1513
+ }
1514
+
1515
+ button.log-row__task:hover {
1516
+ color: var(--text-primary);
1517
+ }
1518
+
1519
+ .log-row__task[aria-pressed="true"] {
1520
+ color: var(--text-primary);
1521
+ font-weight: 700;
1522
+ }
1523
+
1524
+ .log-row__task:focus-visible {
1525
+ outline: none;
1526
+ box-shadow: var(--ring);
1527
+ }
1528
+
1529
+ /* Last, so it wins: with no framed line in the matched set the cell is gone
1530
+ from the header and every row together, not merely emptied. */
1531
+ .console[data-tasks="none"] .log-row__task {
1532
+ display: none;
1533
+ }
1534
+
1535
+ .log-row__msg {
1536
+ color: var(--text-secondary);
1537
+ }
1538
+
1539
+ .log-row__text {
1540
+ white-space: pre-wrap;
1541
+ /* `pre-wrap` alone will not break an unbroken token: an absolute path or a
1542
+ JSON blob would push the layout sideways. Wrap, never truncate — the
1543
+ operator needs the whole error string. */
1544
+ overflow-wrap: anywhere;
1545
+ }
1546
+
1547
+ /* Trailing annotations. A leading badge would shift the message's left edge per
1548
+ row and break the rhythm that makes a log scannable. */
1549
+ .log-badges {
1550
+ display: inline-flex;
1551
+ flex-wrap: wrap;
1552
+ gap: 6px;
1553
+ margin-left: 8px;
1554
+ }
1555
+
1556
+ .log-badges:empty {
1557
+ display: none;
1558
+ margin-left: 0;
1559
+ }
1560
+
1561
+ .log-badge {
1562
+ padding: 0 5px;
1563
+ border-radius: var(--radius-sm);
1564
+ font-size: 11px;
1565
+ /* Severity in FORM: a glyph and a tinted ground, with the text at full
1566
+ contrast. `--warning` as text is ~2.2:1 on Latte and is never used here. */
1567
+ color: var(--text-primary);
1568
+ background: color-mix(in srgb, var(--text-muted) 16%, transparent);
1569
+ white-space: nowrap;
1570
+ }
1571
+
1572
+ .log-badge[data-tone="warn"] {
1573
+ background: color-mix(in srgb, var(--warning) 22%, transparent);
1574
+ }
1575
+
1576
+ /* A traced row is part of one request's story rather than the general tail. */
1577
+ .log-row[data-traced="true"] {
1578
+ background: color-mix(in srgb, var(--accent) 5%, transparent);
1579
+ }
1580
+
1581
+ /* The header reuses the row's cell classes so its grid can never drift apart
1582
+ from the columns it labels — which means it also inherits their per-cell
1583
+ colours. `--text-subtle` (time) is 1.97:1 and `--text-muted` (model) 2.42:1
1584
+ on the chrome ground in Latte, and these are 10.5px labels. They are text
1585
+ here, not decoration, so every cell is pinned to the same legible token.
1586
+ Declared after the cells themselves so specificity and source order agree. */
1587
+ .console__head .log-row__ts,
1588
+ .console__head .log-row__level,
1589
+ .console__head .log-row__model,
1590
+ .console__head .log-row__task,
1591
+ .console__head .log-row__msg {
1592
+ color: var(--text-secondary);
1593
+ font-weight: 400;
1594
+ font-style: normal;
1595
+ }
1596
+
1597
+ /* The launch-argument fold: a real button sitting inline in the scrollback,
1598
+ which is where the operator is looking and the only interactive thing there. */
1599
+ .log-row--fold {
1600
+ width: 100%;
1601
+ text-align: left;
1602
+ background: none;
1603
+ border: 0;
1604
+ cursor: pointer;
1605
+ font: inherit;
1606
+ color: inherit;
1607
+ }
1608
+
1609
+ .log-row--fold:hover .log-row__text {
1610
+ color: var(--text-primary);
1611
+ }
1612
+
1613
+ .log-row--fold:focus-visible {
1614
+ outline: none;
1615
+ box-shadow: var(--ring);
1616
+ }
1617
+
1618
+ .log-row[data-fold="member"] .log-row__msg {
1619
+ padding-left: 12px;
1620
+ }
1621
+
1622
+ /* Strips that coexist with rows. Sticky so they stay legible in a long
1623
+ scrollback — a truncation warning that scrolls away has not warned anyone.
1624
+ The top strips get their stickiness from `.console__sticky`, which pins them
1625
+ together with the column header. */
1626
+ .console__banners {
1627
+ background: var(--surface-chrome);
1628
+ }
1629
+
1630
+ /* The rows carry the console's padding, not the console, so a sticky strip can
1631
+ sit flush against the scrollport edge with no gap of content above it. */
1632
+ .console__rows {
1633
+ padding: 10px 0;
1634
+ }
1635
+
1636
+ .console__banners--bottom {
1637
+ position: sticky;
1638
+ bottom: 0;
1639
+ z-index: 1;
1640
+ }
1641
+
1642
+ .console__banners:empty {
1643
+ display: none;
1644
+ }
1645
+
1646
+ /* `--text-secondary`, not `--text-tertiary`: these strips carry real sentences
1647
+ ("showing the latest 500 of 1,412", "nothing new since 14:19:02") and the
1648
+ tertiary token is 3.73:1 on the light theme's console ground, under AA at
1649
+ 11.5px. Muted enough to recede, legible enough to be read. */
1650
+ .console__banner {
1651
+ display: flex;
1652
+ align-items: baseline;
1653
+ flex-wrap: wrap;
1654
+ gap: 8px;
1655
+ padding: 6px 18px;
1656
+ border-bottom: 1px solid var(--border);
1657
+ font-family: var(--font-mono);
1658
+ font-size: 11.5px;
1659
+ color: var(--text-secondary);
1660
+ }
1661
+
1662
+ .console__banners--bottom .console__banner {
1663
+ border-bottom: 0;
1664
+ border-top: 1px solid var(--border);
1665
+ }
1666
+
1667
+ /* Warn tone is carried by the ⚠ glyph, the tinted ground and the amber rule —
1668
+ form, not hue. The amber token itself is ~2.2:1 as text on the light theme's
1669
+ console ground and would fail AA on exactly the surfaces whose whole job is
1670
+ telling the operator the console is not showing the truth. It stays where it
1671
+ is decoration and never where it is the sentence. */
1672
+ .console__banner[data-tone="warn"] {
1673
+ color: var(--text-primary);
1674
+ background: color-mix(in srgb, var(--warning) 10%, transparent);
1675
+ border-left: 2px solid var(--warning);
1676
+ padding-left: 16px;
1677
+ }
1678
+
1679
+ .console__banner-detail {
1680
+ flex-basis: 100%;
1681
+ color: var(--text-secondary);
1682
+ overflow-wrap: anywhere;
1683
+ }
1684
+
1685
+ .console__banner[data-tone="warn"] .console__banner-detail {
1686
+ color: var(--text-primary);
1687
+ }
1688
+
1689
+ /* The trace banner. It needs no new machinery, only a stronger ground than the
1690
+ muted strips it sits among — a trace is a mode, and a mode should look like
1691
+ one. Text stays at full contrast; the accent is the ground and the rule. */
1692
+ .console__banner[data-tone="trace"] {
1693
+ color: var(--text-primary);
1694
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
1695
+ border-left: 2px solid var(--accent);
1696
+ padding-left: 16px;
1697
+ }
1698
+
1699
+ .console__banner[data-tone="trace"] .console__banner-detail {
1700
+ color: var(--text-primary);
1701
+ }
1702
+
1703
+ .console__banner-action[hidden],
1704
+ .console__notice-action[hidden] {
1705
+ display: none;
1706
+ }
1707
+
1708
+ .console__notice {
1709
+ display: grid;
1710
+ gap: 6px;
1711
+ padding: 20px 18px;
1712
+ font-family: var(--font-mono);
1713
+ font-size: 12px;
1714
+ color: var(--text-secondary);
1715
+ justify-items: start;
1716
+ }
1717
+
1718
+ .console__notice-head {
1719
+ display: flex;
1720
+ gap: 8px;
1721
+ margin: 0;
1722
+ font-weight: 700;
1723
+ color: var(--text-primary);
1724
+ }
1725
+
1726
+ .console__notice[data-tone="warn"] {
1727
+ border-left: 2px solid var(--warning);
1728
+ background: color-mix(in srgb, var(--warning) 8%, transparent);
1729
+ padding-left: 16px;
1730
+ }
1731
+
1732
+ /* Same reasoning as the warn banner: the glyph and the amber rule carry the
1733
+ tone, the words stay at full contrast in both themes. */
1734
+ .console__notice[data-tone="warn"] .console__notice-head {
1735
+ color: var(--text-primary);
1736
+ }
1737
+
1738
+ .console__notice-detail {
1739
+ margin: 0;
1740
+ max-width: 68ch;
1741
+ line-height: 1.6;
1742
+ overflow-wrap: anywhere;
1743
+ }
1744
+
1745
+ /* The glyph inherits full-contrast text on purpose. It is the FORM half of the
1746
+ tone cue, so it has to be legible; amber survives in the rule and the tint,
1747
+ which are decoration and carry nothing on their own. */
1748
+ .console__notice[data-tone="warn"] .console__notice-detail {
1749
+ color: var(--text-primary);
1750
+ }
1751
+
1752
+ .console__notice-glyph:empty {
1753
+ display: none;
1754
+ }
1755
+
1756
+ /* The soft counterpart to Pause: scrolling up holds the view while the buffer
1757
+ keeps filling, and this offers the way back. */
1758
+ .console__jump {
1759
+ position: sticky;
1760
+ bottom: 12px;
1761
+ display: block;
1762
+ margin: 8px auto 0;
1763
+ height: 28px;
1764
+ padding: 0 13px;
1765
+ border-radius: var(--radius-full);
1766
+ border: 1px solid var(--border-strong);
1767
+ background: var(--surface-page);
1768
+ color: var(--text-secondary);
1769
+ font-family: var(--font-mono);
1770
+ font-size: 11.5px;
1771
+ font-weight: 700;
1772
+ cursor: pointer;
1773
+ }
1774
+
1775
+ .console__jump[hidden] {
1776
+ display: none;
1777
+ }
1778
+
1779
+ .console__jump:focus-visible {
1780
+ outline: none;
1781
+ box-shadow: var(--ring);
1782
+ }
1783
+
1784
+ /* ===================================================================
1785
+ Slots panel — grouped under the model that owns them
1786
+ =================================================================== */
1787
+
1788
+ .slots {
1789
+ border-top: 1px solid var(--border);
1790
+ background: var(--surface-page);
1791
+ padding: 12px 18px 14px;
1792
+ }
1793
+
1794
+ .slots__head {
1795
+ display: flex;
1796
+ align-items: center;
1797
+ justify-content: space-between;
1798
+ margin-bottom: 10px;
1799
+ }
1800
+
1801
+ .slots__summary {
1802
+ font-family: var(--font-mono);
1803
+ font-size: 11.5px;
1804
+ color: var(--text-tertiary);
1805
+ }
1806
+
1807
+ /* Shown in place of the groups when no model is loaded. */
1808
+ .slots__empty {
1809
+ margin: 0;
1810
+ font-family: var(--font-mono);
1811
+ font-size: 11.5px;
1812
+ color: var(--text-subtle);
1813
+ }
1814
+
1815
+ .slots__empty[hidden] {
1816
+ display: none;
1817
+ }
1818
+
1819
+ /* One line of chips, one per loaded model, wrapping only when many are loaded. */
1820
+ .slots__chips {
1821
+ display: flex;
1822
+ flex-wrap: wrap;
1823
+ gap: 8px;
1824
+ }
1825
+
1826
+ /* A chip: the model's color dot, its short name, and its `busy/total` slots.
1827
+ A busy chip is tinted green so activity reads at a glance; the fraction and
1828
+ the word in the hover title carry it too, so color is never the only signal. */
1829
+ .slot-chip {
1830
+ display: inline-flex;
1831
+ align-items: center;
1832
+ gap: 7px;
1833
+ padding: 4px 10px;
1834
+ border: 1px solid var(--border);
1835
+ border-radius: var(--radius-full);
1836
+ background: var(--surface-page);
1837
+ font-family: var(--font-mono);
1838
+ font-size: 11.5px;
1839
+ cursor: default;
1840
+ }
1841
+
1842
+ .slot-chip[data-busy="true"] {
1843
+ border-color: color-mix(in srgb, var(--success) 45%, transparent);
1844
+ background: color-mix(in srgb, var(--success) 10%, transparent);
1845
+ }
1846
+
1847
+ .slot-chip__dot {
1848
+ width: 8px;
1849
+ height: 8px;
1850
+ border-radius: 50%;
1851
+ background: var(--model-color, var(--accent));
1852
+ flex: none;
1853
+ }
1854
+
1855
+ /* Never clipped — a truncated model name is indistinguishable from a full one,
1856
+ so the name always shows whole and the chip sizes to it (chips wrap as units
1857
+ when a row runs out of room). */
1858
+ .slot-chip__name {
1859
+ font-weight: 700;
1860
+ color: var(--text-secondary);
1861
+ white-space: nowrap;
1862
+ }
1863
+
1864
+ .slot-chip__count {
1865
+ color: var(--text-tertiary);
1866
+ white-space: nowrap;
1867
+ }
1868
+
1869
+ .slot-chip[data-busy="true"] .slot-chip__count {
1870
+ color: var(--success);
1871
+ }
1872
+
1873
+ /* Generation rate on a working chip, beside the fraction. Hidden when the model
1874
+ is idle or its child reports no rate. */
1875
+ .slot-chip__rate {
1876
+ color: var(--text-tertiary);
1877
+ white-space: nowrap;
1878
+ }
1879
+
1880
+ .slot-chip__rate[hidden] {
1881
+ display: none;
1882
+ }
1883
+
1884
+ /* The busiest lane's context fill, colored by threshold from the view model
1885
+ (tertiary, then warning, then error). Shown only on a busy chip. */
1886
+ .slot-chip__peak {
1887
+ color: var(--fg, var(--text-tertiary));
1888
+ white-space: nowrap;
1889
+ }
1890
+
1891
+ .slot-chip__peak[hidden] {
1892
+ display: none;
1893
+ }
1894
+
1895
+ /* ===================================================================
1896
+ Narrow viewports — the rail becomes a top bar
1897
+ =================================================================== */
1898
+
1899
+ @media (max-width: 1100px) {
1900
+ /* The rail stops being a column and becomes a band across the top: the shell
1901
+ splits into two rows, and the band lays its blocks out side by side. It is
1902
+ never a scroller of its own — it is exactly as tall as its content, and the
1903
+ console takes what is left. */
1904
+ .shell {
1905
+ grid-template-columns: minmax(0, 1fr);
1906
+ grid-template-rows: auto minmax(0, 1fr);
1907
+ height: auto;
1908
+ min-height: 100vh;
1909
+ overflow: visible;
1910
+ }
1911
+
1912
+ .rail {
1913
+ display: grid;
1914
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1915
+ overflow: visible;
1916
+ border-right: 0;
1917
+ border-bottom: 1px solid var(--border);
1918
+ }
1919
+
1920
+ /* Service and Host pair off in the first row; MODELS takes the full width so
1921
+ its cards can spread sideways instead of stacking. Reading order is
1922
+ untouched — the blocks land where they already were. */
1923
+ .rail__block--service {
1924
+ border-right: 1px solid var(--border);
1925
+ }
1926
+
1927
+ .rail__block--models {
1928
+ grid-column: 1 / -1;
1929
+ }
1930
+
1931
+ /* The block contents reflow into as many columns as their rows can hold, so
1932
+ a block that was six rows tall in the 340px rail is two or three rows tall
1933
+ in the band. */
1934
+ .gauges {
1935
+ display: grid;
1936
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1937
+ gap: 12px 20px;
1938
+ }
1939
+
1940
+ .model-list {
1941
+ display: grid;
1942
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1943
+ }
1944
+
1945
+ /* In the band the card is no longer in the 340px rail: a single column of
1946
+ `Label: value` rows reads cleaner than two cramped ones side by side. */
1947
+ .model-card__fields {
1948
+ grid-template-columns: 1fr;
1949
+ }
1950
+
1951
+ .config-list {
1952
+ display: grid;
1953
+ grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
1954
+ gap: 7px 24px;
1955
+ }
1956
+
1957
+ /* A definite basis: the console asks for 240px so the document never sizes
1958
+ itself to the whole log buffer, then grows into whatever the viewport has
1959
+ left over. */
1960
+ .console {
1961
+ flex: 1 1 240px;
1962
+ }
1963
+
1964
+ /* Nothing clips the page below the design range, so the throughput summary —
1965
+ which must not break mid-figure — drops to its own line rather than push
1966
+ the document sideways. */
1967
+ .spark__head {
1968
+ flex-wrap: wrap;
1969
+ }
1970
+ }
1971
+
1972
+ @media (max-width: 720px) {
1973
+ /* Too narrow for two blocks abreast; the band becomes one column, and each
1974
+ block still spreads its own rows across the full width. */
1975
+ .rail {
1976
+ grid-template-columns: minmax(0, 1fr);
1977
+ }
1978
+
1979
+ .rail__block--service {
1980
+ border-right: 0;
1981
+ }
1982
+
1983
+ /* With no rail beside it the main column is wider than it was at the design
1984
+ width, so the band only gives up columns once the window itself is
1985
+ narrower than that. The slot grid tracks the viewport on its own — its
1986
+ `auto-fill` template reflows without a breakpoint. */
1987
+ .metrics {
1988
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1989
+ }
1990
+ }
1991
+
1992
+ @media (max-width: 480px) {
1993
+ .metrics {
1994
+ grid-template-columns: minmax(0, 1fr);
1995
+ }
1996
+ }
1997
+
1998
+ /* Every transition in this sheet is timed by one of the duration tokens, so
1999
+ collapsing the tokens is enough to honour a reduced-motion preference — no
2000
+ universal `!important` override, and nothing left animating behind it. */
2001
+ @media (prefers-reduced-motion: reduce) {
2002
+ :root {
2003
+ --dur-fast: 1ms;
2004
+ --dur-base: 1ms;
2005
+ --dur-slow: 1ms;
2006
+ --dur-bar: 1ms;
2007
+ }
2008
+ }