@wtfalch/design 0.1.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/components/Brand.d.ts +10 -0
  4. package/dist/components/Brand.js +212 -0
  5. package/dist/components/Button.d.ts +63 -0
  6. package/dist/components/Button.js +74 -0
  7. package/dist/components/Callout.d.ts +37 -0
  8. package/dist/components/Callout.js +71 -0
  9. package/dist/components/Card.d.ts +42 -0
  10. package/dist/components/Card.js +30 -0
  11. package/dist/components/Checkbox.d.ts +32 -0
  12. package/dist/components/Checkbox.js +31 -0
  13. package/dist/components/DangerZone.d.ts +59 -0
  14. package/dist/components/DangerZone.js +50 -0
  15. package/dist/components/Dialog.d.ts +28 -0
  16. package/dist/components/Dialog.js +29 -0
  17. package/dist/components/Empty.d.ts +45 -0
  18. package/dist/components/Empty.js +35 -0
  19. package/dist/components/Field.d.ts +58 -0
  20. package/dist/components/Field.js +46 -0
  21. package/dist/components/Icon.d.ts +64 -0
  22. package/dist/components/Icon.js +235 -0
  23. package/dist/components/Illustration.d.ts +36 -0
  24. package/dist/components/Illustration.js +48 -0
  25. package/dist/components/Input.d.ts +14 -0
  26. package/dist/components/Input.js +65 -0
  27. package/dist/components/Markdown.d.ts +21 -0
  28. package/dist/components/Markdown.js +29 -0
  29. package/dist/components/Modal.d.ts +59 -0
  30. package/dist/components/Modal.js +72 -0
  31. package/dist/components/Pill.d.ts +40 -0
  32. package/dist/components/Pill.js +41 -0
  33. package/dist/components/Progress.d.ts +35 -0
  34. package/dist/components/Progress.js +27 -0
  35. package/dist/components/Rows.d.ts +101 -0
  36. package/dist/components/Rows.js +55 -0
  37. package/dist/components/Select.d.ts +28 -0
  38. package/dist/components/Select.js +56 -0
  39. package/dist/components/SizeGrid.d.ts +34 -0
  40. package/dist/components/SizeGrid.js +41 -0
  41. package/dist/components/Skeleton.d.ts +45 -0
  42. package/dist/components/Skeleton.js +47 -0
  43. package/dist/components/Slider.d.ts +70 -0
  44. package/dist/components/Slider.js +100 -0
  45. package/dist/components/Table.d.ts +43 -0
  46. package/dist/components/Table.js +13 -0
  47. package/dist/components/Tabs.d.ts +72 -0
  48. package/dist/components/Tabs.js +82 -0
  49. package/dist/components/Textarea.d.ts +9 -0
  50. package/dist/components/Textarea.js +22 -0
  51. package/dist/components/Toast.d.ts +43 -0
  52. package/dist/components/Toast.js +78 -0
  53. package/dist/components/Toggle.d.ts +56 -0
  54. package/dist/components/Toggle.js +189 -0
  55. package/dist/components/Tooltip.d.ts +22 -0
  56. package/dist/components/Tooltip.js +62 -0
  57. package/dist/components/Tour.d.ts +33 -0
  58. package/dist/components/Tour.js +108 -0
  59. package/dist/components/iconNames.d.ts +18 -0
  60. package/dist/components/iconNames.js +60 -0
  61. package/dist/components/tourMarker.d.ts +29 -0
  62. package/dist/components/tourMarker.js +58 -0
  63. package/dist/contrast.d.ts +18 -0
  64. package/dist/contrast.js +27 -0
  65. package/dist/hooks/useTrapFocus.d.ts +24 -0
  66. package/dist/hooks/useTrapFocus.js +67 -0
  67. package/dist/illustrations.d.ts +11 -0
  68. package/dist/illustrations.js +55 -0
  69. package/dist/index.d.ts +72 -0
  70. package/dist/index.js +65 -0
  71. package/dist/styles/index.css +3124 -0
  72. package/dist/themes.d.ts +210 -0
  73. package/dist/themes.js +300 -0
  74. package/dist/tokens.css +251 -0
  75. package/package.json +74 -0
@@ -0,0 +1,3124 @@
1
+ /* The cascade, written down.
2
+ *
3
+ * Two rules of equal specificity are decided by which came last, so this
4
+ * order is behaviour rather than tidiness. It follows the order the rules
5
+ * appeared in the stylesheet they were lifted from; `base.css` is first
6
+ * because it holds the reset and the shared utilities everything else
7
+ * assumes.
8
+ *
9
+ * `scripts/copy-css.mjs` flattens these into one file at build time.
10
+ */
11
+ /* ---- ./base.css ---- */
12
+ /* Light, only under the `system` theme. This used to override `:root`
13
+ unconditionally, so choosing a dark theme on a light-mode laptop got
14
+ silently repainted. Every other theme states its own palette. */
15
+ @media (prefers-color-scheme: light) {
16
+ :root[data-theme='system'] {
17
+ --bg: #f3f5f7;
18
+ --panel: #ffffff;
19
+ --panel-2: #e9edf1;
20
+ --border: #e4e8ec;
21
+ --border-strong: #8792a1;
22
+ --text: #191d23;
23
+ --muted: #5d6773;
24
+ --accent: #0e7872;
25
+ --accent-dim: #7fbdb8;
26
+ --on-accent: #ffffff;
27
+ /* Overridden here, which the old light theme never did: the base set is
28
+ tuned for a dark panel, and `#4ade80` on white is 1.74:1. */
29
+ --good: #1c7a4a;
30
+ --warn: #8a6216;
31
+ --bad: #b3312c;
32
+ --info: #216bc9;
33
+ --shadow-1: 0 4px 14px rgba(16, 24, 40, 0.08);
34
+ --shadow-2: 0 8px 24px rgba(16, 24, 40, 0.10);
35
+ --shadow-3: 0 12px 32px rgba(16, 24, 40, 0.12);
36
+ --scrim: rgba(16, 24, 40, 0.32);
37
+ }
38
+ }
39
+
40
+ /* Derived from the two surfaces every theme already defines, so a theme gets a
41
+ control colour without knowing this exists. */
42
+ /* Pseudo-elements too. `*` does not match them, and `box-sizing` is not
43
+ inherited, so every `::before` and `::after` in the app was quietly
44
+ content-box. That is invisible until one of them has a border: the checkbox
45
+ ring (`.choice::after`, 16px + 1px each side) and the tick that is supposed
46
+ to sit inside it (`.choice::before`, 16px flat) were given the same `top` and
47
+ `right` and came out two pixels different in size -- so the tick sat up and
48
+ to the right of the ring it was meant to be centred in, by exactly one
49
+ pixel each way. */
50
+ *,
51
+ *::before,
52
+ *::after { box-sizing: border-box; }
53
+
54
+ body {
55
+ margin: 0;
56
+ /* `background`, not `background-color`: a theme may hand this a gradient or
57
+ an image, and `background-color` would refuse both. */
58
+ background: var(--app-bg);
59
+ color: var(--text);
60
+ font-family: var(--font);
61
+ font-size: var(--font-size);
62
+ line-height: var(--line-height);
63
+ letter-spacing: var(--tracking);
64
+ font-weight: var(--weight);
65
+ min-height: 100vh;
66
+ }
67
+
68
+ /* The overlay slot. Always present, inert until a theme fills it -- which is
69
+ how a theme gets a grain, a wash or a vignette without shipping a rule to
70
+ create the layer it needs. */
71
+ body::before {
72
+ content: '';
73
+ position: fixed;
74
+ inset: 0;
75
+ z-index: 0;
76
+ pointer-events: none;
77
+ background: var(--app-overlay);
78
+ opacity: var(--app-overlay-opacity);
79
+ }
80
+
81
+ .app { position: relative; z-index: 1; }
82
+
83
+ /* Controls -----------------------------------------------------------------
84
+ One definition for everything you can press, type in, or open. They differ
85
+ in what they do, not in how they look or how they answer a pointer.
86
+ That last part is why this is one block and not four. The states used to be
87
+ written per element: buttons got a hover border, fields did not; fields got
88
+ a focus border, buttons did not; buttons dimmed when disabled, fields stayed
89
+ solid. So a select sat inert under the cursor beside a picker that lit up,
90
+ and read as disabled rather than as a different kind of control.
91
+ `--control` is `--panel-2` mixed toward `--panel`: the surface every theme
92
+ already defines for controls, lightened because these sit on white cards
93
+ where the flat grey reads as recessed rather than as something to use. It is
94
+ derived rather than declared -- a new token would have to be added to every
95
+ theme in `themes.ts`, and a theme that missed it would lose its controls. */
96
+ button,
97
+ input,
98
+ textarea,
99
+ select {
100
+ font: inherit;
101
+ color: var(--text);
102
+ background: var(--control);
103
+ /* `--border-strong`, not `--border`: this rule is what outlines every input,
104
+ select, textarea and button, and an outline you cannot see is a control you
105
+ cannot find. Dividers keep the quiet one. */
106
+ border: 1px solid var(--border-strong);
107
+ border-radius: var(--radius);
108
+ }
109
+
110
+ /* The two differences, both deliberate: a button's box is padding around a
111
+ word and is the size of that word; a field's holds a line of text and fills
112
+ what it is given. */
113
+ /* A button lays its contents out, which it did not before.
114
+ An `<svg>` is an inline box, so it sat on the text baseline -- a glyph
115
+ drawn to a 24-unit grid with its optical centre in the middle ends up a
116
+ couple of pixels low against the cap height beside it. Nothing was wrong with
117
+ the icon; the button simply had no opinion about how to arrange two things.
118
+ `inline-flex` and a gap gives it one, and the gap replaces the space
119
+ character people were putting between an icon and its label. */
120
+ button {
121
+ display: inline-flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ gap: var(--space-2);
125
+ cursor: pointer;
126
+ }
127
+
128
+ /* A button that stays pressed. `aria-pressed`, not a checkbox: a tick box
129
+ collects an answer and applies it on save, and this narrows the list under
130
+ you as you press it. It sits in a row of Selects and has to be the same
131
+ control height and the same shape as they are -- a 17px tick box beside three
132
+ 38px dropdowns reads as something left over rather than as the fourth filter.
133
+
134
+ Several places already drew this by hand as `.chat-bar-btn.on`. This is the
135
+ same look, said once, on a plain button. */
136
+ button[aria-pressed='true'] {
137
+ border-color: var(--accent);
138
+ color: var(--accent);
139
+ background: color-mix(in srgb, var(--accent) 10%, var(--panel));
140
+ }
141
+
142
+ button[aria-pressed='true']:hover:not(:disabled) { border-color: var(--accent); }
143
+
144
+ /* The glyph never shrinks: in a narrow row flexbox would take it from the icon
145
+ before the text, and half an icon is not a smaller icon. */
146
+ button > svg { flex: none; }
147
+
148
+ .size-sm { font-size: var(--text-xs); padding: var(--space-1) var(--space-2); }
149
+
150
+ .size-md { font-size: var(--text-base); padding: var(--space-2) var(--space-3); }
151
+
152
+ .size-lg { font-size: var(--text-md); padding: var(--space-3) var(--space-4); }
153
+
154
+ /* Square, for the icon-only ones: equal padding or the glyph sits off-centre. */
155
+ .icon-btn.size-sm { padding: var(--space-1); }
156
+
157
+ .icon-btn.size-md { padding: var(--space-2); }
158
+
159
+ .icon-btn.size-lg { padding: var(--space-3); }
160
+
161
+ input, textarea, select { width: 100%; }
162
+
163
+ /* Never by the drag handle.
164
+ How tall a box is is a decision this app makes -- the composer grows with
165
+ what you type and shrinks when you send, the studio's editor fills its pane
166
+ -- and a corner somebody can drag fights whichever code owns that height.
167
+ Three rules said `resize: vertical` and the rest said `none`, which is the
168
+ same disagreement written twice. */
169
+ textarea { resize: none; }
170
+
171
+ button:hover:not(:disabled),
172
+ input:hover:not(:disabled),
173
+ textarea:hover:not(:disabled),
174
+ select:hover:not(:disabled) { border-color: var(--accent); }
175
+
176
+ /* `:focus-visible` on the button so a click does not leave a ring behind it,
177
+ `:focus` on the fields because a focused field is a place you are typing and
178
+ should say so however you got there. */
179
+ button:focus-visible,
180
+ input:focus,
181
+ textarea:focus,
182
+ select:focus { outline: none; border-color: var(--accent); }
183
+
184
+ /* The checkbox, drawn rather than inherited.
185
+ The native one is the platform's, which means it is a different shape, a
186
+ different blue and a different size on every machine tf runs on -- the one
187
+ control in the app that refused to join the design system. It is also the
188
+ only form control that can be replaced in pure CSS: unlike a select, whose
189
+ dropdown belongs to the OS, a checkbox is a box and a tick.
190
+ Which matters here more than usual, because the chat applet is plain HTML in
191
+ a sandboxed frame with no React and no bundler. A CSS answer is the only kind
192
+ both halves of the app can share.
193
+ This also ends a duplication: `width: auto; margin: 0; flex: none` appeared
194
+ four times in this file, each one working around the global
195
+ `input { width: 100% }` that would otherwise stretch a checkbox across its
196
+ row. Sized here once, so there is nothing left to work around. */
197
+ input[type='checkbox'],
198
+ input[type='radio'] {
199
+ appearance: none;
200
+ -webkit-appearance: none;
201
+ flex: none;
202
+ width: 17px;
203
+ height: 17px;
204
+ margin: 0;
205
+ padding: 0;
206
+ display: inline-grid;
207
+ place-content: center;
208
+ border: var(--border-width) solid var(--border-strong);
209
+ background: var(--panel);
210
+ cursor: pointer;
211
+ transition: background var(--dur-fast) var(--ease),
212
+ border-color var(--dur-fast) var(--ease);
213
+ }
214
+
215
+ input[type='checkbox'] { border-radius: var(--radius-sm); }
216
+
217
+ input[type='radio'] { border-radius: var(--radius-pill); }
218
+
219
+ /* The tick and the dot, scaled from nothing so the change is visible rather
220
+ than instantaneous -- "instant state changes" being the anti-pattern the
221
+ control most often commits. */
222
+ input[type='checkbox']::before,
223
+ input[type='radio']::before {
224
+ content: '';
225
+ transform: scale(0);
226
+ transition: transform var(--dur-fast) var(--ease);
227
+ background: var(--on-accent);
228
+ }
229
+
230
+ input[type='checkbox']::before {
231
+ width: 11px;
232
+ height: 11px;
233
+ /* A tick as a clip path rather than a glyph: a character would be the
234
+ platform's font, which is the thing this rule exists to escape. */
235
+ clip-path: polygon(14% 46%, 0 60%, 39% 100%, 100% 22%, 86% 8%, 39% 72%);
236
+ }
237
+
238
+ input[type='radio']::before {
239
+ width: 7px;
240
+ height: 7px;
241
+ border-radius: var(--radius-pill);
242
+ }
243
+
244
+ input[type='checkbox']:checked,
245
+ input[type='radio']:checked,
246
+ input[type='checkbox']:indeterminate {
247
+ background: var(--accent);
248
+ border-color: var(--accent);
249
+ }
250
+
251
+ input[type='checkbox']:checked::before,
252
+ input[type='radio']:checked::before { transform: scale(1); }
253
+
254
+ /* Neither on nor off, which the settings panes need for a group half-ticked. */
255
+ input[type='checkbox']:indeterminate::before {
256
+ transform: scale(1);
257
+ width: 9px;
258
+ height: 2px;
259
+ clip-path: none;
260
+ border-radius: 1px;
261
+ }
262
+
263
+ input[type='checkbox']:hover:not(:disabled),
264
+ input[type='radio']:hover:not(:disabled) { border-color: var(--accent); }
265
+
266
+ input[type='checkbox']:focus-visible,
267
+ input[type='radio']:focus-visible { outline: none; box-shadow: var(--focus-ring); }
268
+
269
+ input[type='checkbox']:disabled,
270
+ input[type='radio']:disabled { cursor: not-allowed; background: var(--panel-2); }
271
+
272
+ button:disabled,
273
+ input:disabled,
274
+ textarea:disabled,
275
+ select:disabled { opacity: 0.45; cursor: not-allowed; }
276
+
277
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
278
+
279
+ /* A disabled primary is not a faded primary.
280
+ `opacity: .45` was the whole of it, and a coloured fill at 45% is still a
281
+ coloured fill: the one button you cannot press stayed the most prominent
282
+ thing on the panel. That got worse when onboarding started holding people at
283
+ a disabled Next. So it gives up the fill entirely and becomes the shape of a
284
+ disabled control -- the opacity then has nothing left to do. */
285
+ button.primary:disabled {
286
+ background: var(--panel-2);
287
+ border-color: var(--border);
288
+ color: var(--muted);
289
+ opacity: 1;
290
+ }
291
+
292
+ /* Working, which is not the same as disabled.
293
+ Both were spelled `disabled` with a changed label, so a 160 MB download and a
294
+ button you may never press looked identical -- for two minutes, with nothing
295
+ moving. This keeps the accent, because something *is* happening, and puts a
296
+ line under it that travels.
297
+ `aria-busy` rather than a class: the attribute is what a screen reader reads,
298
+ and styling the thing that carries the meaning is how they stay in step. */
299
+ button[aria-busy='true'] {
300
+ position: relative;
301
+ overflow: hidden;
302
+ cursor: progress;
303
+ }
304
+
305
+ /* Busy is unpressable *and* undimmed, which needs saying because the two rules
306
+ above pull opposite ways. A download that has started cannot be started
307
+ again, so the button is genuinely `disabled` -- but `:disabled` is drawn to
308
+ say "not for you", and a primary one gives up its fill entirely. Applied to
309
+ something that is working, that reads as a control that failed and went
310
+ grey. So a busy button keeps every colour it had and takes only the loss of
311
+ the cursor and the press; the travelling line is what says why. */
312
+ button[aria-busy='true']:disabled {
313
+ opacity: 1;
314
+ cursor: progress;
315
+ }
316
+
317
+ button.primary[aria-busy='true']:disabled {
318
+ background: var(--accent);
319
+ border-color: var(--accent);
320
+ color: var(--on-accent);
321
+ }
322
+
323
+ button[aria-busy='true']::after {
324
+ content: '';
325
+ position: absolute;
326
+ left: 0;
327
+ bottom: 0;
328
+ height: 2px;
329
+ width: 40%;
330
+ background: currentColor;
331
+ opacity: 0.55;
332
+ animation: tf-busy 1.1s var(--ease) infinite;
333
+ }
334
+
335
+ @keyframes tf-busy {
336
+ 0% { transform: translateX(-100%); }
337
+ 100% { transform: translateX(350%); }
338
+ }
339
+
340
+ /* Motion is an accessibility setting first. Without it the bar sits still at
341
+ the leading edge, which still says "this one is working" without moving. */
342
+ @media (prefers-reduced-motion: reduce) {
343
+ button[aria-busy='true']::after { animation: none; width: 100%; opacity: 0.35; }
344
+ }
345
+
346
+ /* Four kinds, four appearances.
347
+ `ghost` had no rule at all -- it was a default button with a class that did
348
+ nothing, so two of the four were the same control with two names. And
349
+ `danger` was a default button that turned red *on hover*, which is the one
350
+ moment it is too late to be told: you learn an action is destructive by
351
+ pointing at it. Both now say what they are before you touch them. */
352
+
353
+ /* Quiet: no border, no fill. For the action beside the action -- Cancel next to
354
+ Save -- where a bordered box competes with the thing it is next to. */
355
+ button.ghost {
356
+ background: transparent;
357
+ border-color: transparent;
358
+ color: var(--muted);
359
+ }
360
+
361
+ button.ghost:hover:not(:disabled) {
362
+ background: var(--panel-2);
363
+ border-color: transparent;
364
+ color: var(--text);
365
+ }
366
+
367
+ /* layout ------------------------------------------------------------- */
368
+
369
+ /* Full width, and a flex column so the conversation fills the space exactly --
370
+ equal padding top and bottom, with no magic viewport arithmetic. */
371
+ .app {
372
+ padding: var(--space-4) var(--space-6);
373
+ height: 100vh;
374
+ display: flex;
375
+ flex-direction: column;
376
+ }
377
+
378
+ /* The desktop window on macOS draws its three buttons over the page
379
+ (`titleBarStyle: hiddenInset`), and they landed on top of the mark: the
380
+ buttons at y 13..25, the mark starting at y 25, measured. Three answers
381
+ were tried on 2026-09-02. The mark beside the buttons on one row (#163):
382
+ a wordmark competing with the traffic lights for the corner, not wanted.
383
+ A strip for the buttons with the mark under it (#164): 36px spent on a
384
+ band holding three buttons, then two clusters of small things stacked in
385
+ the corner -- the fix for a collision, not a layout. This is the third:
386
+ **no mark in the desktop window.** The Dock has the icon, the corner
387
+ belongs to the buttons, and the header is a Mac toolbar on one row --
388
+ title centred on the whole width, controls right -- which is what Finder,
389
+ Notes, Linear and Raycast do. The mark keeps every job it is not
390
+ redundant in: a browser tab, a Windows or Linux window, where there are
391
+ no buttons to yield to. `--traffic-lights` is the room the header leaves
392
+ for them on the left; the buttons themselves are put on this row's centre
393
+ line by `trafficLightPosition` in `desktop/main.js`.
394
+
395
+ Then a fourth answer, the one kept: the mark is in the header after all,
396
+ at the far right, on every platform (#170). No corner to fight over --
397
+ the right one has no buttons anywhere -- and the app has its name in its
398
+ own frame, which "no mark" (#168) had quietly taken away.
399
+
400
+ And with nothing on the left, nothing needs lining up with the buttons.
401
+ #170 brought them down onto the header's centre line, and that read as
402
+ air above them; they are back where macOS draws them, the page keeps its
403
+ padding, and the left of the bar is the empty half of the pair that
404
+ centres the title -- so no padding is reserved for them either, or the
405
+ title would sit 28px off centre. `html.mac-desktop` stays on the root for
406
+ whatever next needs to know. */
407
+ /* The wordmark, and the same mark the app icon carries: `Brand.tsx` draws the
408
+ path `desktop/build/icon.html` draws, and `test/brandMark.test.ts` holds
409
+ them to one string. It was "TF" set in the system font at -0.14em, matching
410
+ an icon that did the same; both are now one drawn line, and the tracking
411
+ arithmetic that used to live here went with the letters.
412
+ The height comes off the type scale rather than being a pixel: 1.5 times
413
+ `--text-md` is the line box the old wordmark occupied, so the header did not
414
+ change height when the letters became a drawing, and a theme that scales
415
+ the type scales the brand with it. `width: auto` lets the viewBox set the
416
+ aspect.
417
+ `--good` because it is the palette's one green and every theme already
418
+ overrides it for its own ground -- Paper's is the deep green that survives
419
+ a light panel. The mark is not a status pill, but it is the same green, and
420
+ a second green token would be a second decision to keep in step. */
421
+ .brand {
422
+ display: block;
423
+ height: calc(var(--text-md) * 1.5);
424
+ width: auto;
425
+ color: var(--good);
426
+ /* The cog turns about its own centre, which is the ink box's. */
427
+ transform-origin: center;
428
+ }
429
+
430
+ @keyframes brand-turn { to { transform: rotate(360deg); } }
431
+
432
+ @keyframes tf-callout-clock {
433
+ from { transform: scaleX(1); }
434
+ to { transform: scaleX(0); }
435
+ }
436
+
437
+ /* (The bordered head bar that used to live here is gone. It was a second
438
+ `.card-head` -- same name, different thing -- and the only place it was ever
439
+ used was the gallery page documenting it. A card that holds rows now takes
440
+ `title` like any other, and the header the component draws is the one
441
+ header.) */
442
+ .row { display: flex; align-items: center; gap: var(--space-3); }
443
+
444
+ .grow { flex: 1; min-width: 0; }
445
+
446
+ .mono { font-family: var(--font-mono); font-size: var(--text-sm); }
447
+
448
+ .empty { color: var(--muted); text-align: center; padding: var(--space-10) 0; }
449
+
450
+ /* A word attached to a thing.
451
+ One grey was doing three unrelated jobs. "10.62 GB" is a *measurement*, an
452
+ inert fact you look up when you want it. "reads images" is a *capability*,
453
+ one of a set you scan across. "sign-in expired" is a *state*, and the only
454
+ one of the three that should ever pull the eye. Drawn identically, the
455
+ measurement shouted as loudly as the expired sign-in and the capabilities
456
+ did not read as a set at all -- a row of five identical grey chips where
457
+ two were sizes and three were things the model can do.
458
+
459
+ So: weight follows urgency. Quiet is borderless, the chip is the default,
460
+ and only a state gets colour. */
461
+ .pill {
462
+ display: inline-block;
463
+ font-family: var(--font-mono);
464
+ font-size: var(--text-xs);
465
+ line-height: 1.5;
466
+ padding: 2px var(--space-2);
467
+ border-radius: var(--radius-lg);
468
+ /* Ink at low alpha, not a named surface -- the same lesson the skeleton
469
+ taught. `background: var(--panel-2)` and `border: var(--border)` are
470
+ invisible the moment a chip lands on a `--panel-2` surface, which is
471
+ exactly what `.modal-body .card` is: every capability tag in the download
472
+ list rendered as bare text on an identically coloured box. A translucent
473
+ fill is a step from whatever is behind it, on every surface, without the
474
+ chip knowing what that is. */
475
+ border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
476
+ background: color-mix(in srgb, currentColor 10%, transparent);
477
+ color: var(--text);
478
+ }
479
+
480
+ /* A fact, not a badge. Numbers and transports have no state to report and were
481
+ never worth a box; this leaves them legible and out of the way. */
482
+ .pill-quiet {
483
+ border-color: transparent;
484
+ background: none;
485
+ color: var(--muted);
486
+ }
487
+
488
+ /* The four states, named as `.callout` names them so one vocabulary covers the
489
+ app. Tinted rather than outlined: a border alone at this size is two pixels
490
+ of colour, which is not enough to survive being read past.
491
+
492
+ The tint mixes into `--panel`, not `--panel-2`. On a dark theme either works;
493
+ on a light one, mixing a mid-dark tone into the *darker* surface moves the
494
+ background towards the text and drops the label to 3.9:1. Ten per cent of the
495
+ tone in the lighter surface holds ≥4.65:1 in every theme, in both
496
+ schemes -- measured, not guessed. */
497
+ .pill-info { color: var(--accent); border-color: var(--accent-dim); background: color-mix(in srgb, var(--accent) 10%, var(--panel)); }
498
+
499
+ .pill-good { color: var(--good); border-color: color-mix(in srgb, var(--good) 45%, var(--border)); background: color-mix(in srgb, var(--good) 10%, var(--panel)); }
500
+
501
+ .pill-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, var(--border)); background: color-mix(in srgb, var(--warn) 10%, var(--panel)); }
502
+
503
+ .pill-bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, var(--border)); background: color-mix(in srgb, var(--bad) 10%, var(--panel)); }
504
+
505
+ /* A pill following a name needs the gap outside its own border. Leading
506
+ whitespace inside the span puts it under the pill's background instead,
507
+ which reads as the badge touching the word it qualifies. */
508
+ .row-pill { margin-left: var(--space-2); vertical-align: baseline; }
509
+
510
+ /* The tile's only control. Dim until hovered: it is chrome on someone's app. */
511
+ .icon-btn {
512
+ display: inline-flex; align-items: center; padding: var(--space-1);
513
+ background: none; border: 0; color: var(--muted); opacity: 0.6; cursor: pointer;
514
+ }
515
+
516
+ .icon-btn:hover { opacity: 1; color: var(--text); }
517
+
518
+ @keyframes tf-bar {
519
+ 0% { transform: translateX(-110%); }
520
+ 100% { transform: translateX(320%); }
521
+ }
522
+
523
+ @keyframes tf-toast-in {
524
+ from { opacity: 0; transform: translateY(8px); }
525
+ }
526
+
527
+ @keyframes tf-toast-out {
528
+ to { opacity: 0; transform: translateY(4px); }
529
+ }
530
+
531
+ /* Square, so it reads as an icon beside the picker rather than a second
532
+ button competing with it. */
533
+ .icon-btn {
534
+ display: flex;
535
+ align-items: center;
536
+ justify-content: center;
537
+ width: var(--tile-control, 34px);
538
+ height: var(--tile-control, 34px);
539
+ padding: 0;
540
+ flex: none;
541
+ color: var(--muted);
542
+ background: var(--panel);
543
+ }
544
+
545
+ .icon-btn:hover:not(:disabled) { color: var(--text); }
546
+
547
+ /* skeleton pulse ------------------------------------------------------ */
548
+ @keyframes tf-pulse {
549
+ 0%, 100% { opacity: 0.25; }
550
+ 50% { opacity: 0.65; }
551
+ }
552
+
553
+ /* pressed ---------------------------------------------------------------- */
554
+ /* Every control that answers a hover answers a press. There was one `:active`
555
+ in this sheet (the panel grip) against fifty-six `:hover`s, so a button
556
+ changed colour under the pointer and then nothing happened when it was
557
+ pushed -- the click landed, and the control did not say so. The rule is one
558
+ rule: a pressed control is its hover state with `--press-ink` of the text
559
+ colour mixed in, scaled by `--press-scale`, and it changes at once -- no
560
+ transition on the way down, because a press is an event, not a state
561
+ somebody eases into. Hover states stay where they are; these sit beside
562
+ them and derive from them, so a theme that moves a hover colour moves the
563
+ press with it. `:not(:disabled)` throughout, as the hovers are: a disabled
564
+ control does not respond, and the base rule already says so. */
565
+
566
+ /* `:where(button)` so this carries no specificity of its own: a component's
567
+ press rule below -- a card, a tab, a rail item, all of them buttons --
568
+ wins by coming later, instead of every one having to restate `button`. */
569
+ :where(button):active:not(:disabled) {
570
+ background: color-mix(in srgb, var(--text) var(--press-ink), var(--control));
571
+ border-color: var(--accent);
572
+ transform: scale(var(--press-scale));
573
+ transition: none;
574
+ }
575
+
576
+ button.primary:active:not(:disabled) {
577
+ background: color-mix(in srgb, var(--text) var(--press-ink), var(--accent));
578
+ border-color: color-mix(in srgb, var(--text) var(--press-ink), var(--accent));
579
+ }
580
+
581
+ button.ghost:active:not(:disabled) {
582
+ background: color-mix(in srgb, var(--text) var(--press-ink), var(--panel-2));
583
+ border-color: transparent;
584
+ }
585
+
586
+ button[aria-pressed='true']:active:not(:disabled) {
587
+ background: color-mix(in srgb, var(--accent) 18%, var(--panel));
588
+ }
589
+
590
+ /* Inputs are not pressed, they are focused; the base rule's `button` list
591
+ would otherwise catch the search box beside the Search button. */
592
+ input:active, textarea:active, select:active { transform: none; }
593
+
594
+ /* rendered markdown --------------------------------------------------- */
595
+ .md > *:first-child { margin-top: 0; }
596
+
597
+ .md > *:last-child { margin-bottom: 0; }
598
+
599
+ .md p { margin: 0 0 var(--space-2); }
600
+
601
+ .md h1, .md h2, .md h3, .md h4 {
602
+ margin: var(--space-4) 0 var(--space-2);
603
+ line-height: 1.3;
604
+ font-weight: 600;
605
+ }
606
+
607
+ .md h1 { font-size: var(--text-lg); }
608
+
609
+ .md h2 { font-size: var(--text-md); }
610
+
611
+ .md h3 { font-size: var(--text-base); }
612
+
613
+ .md h4 { font-size: var(--text-base); }
614
+
615
+ .md ul, .md ol { margin: 0 0 var(--space-2); padding-left: var(--space-5); }
616
+
617
+ .md li { margin: var(--space-1) 0; }
618
+
619
+ .md code {
620
+ font-family: var(--font-mono);
621
+ font-size: var(--text-sm);
622
+ background: var(--panel-2);
623
+ border: 1px solid var(--border);
624
+ border-radius: var(--radius-sm);
625
+ padding: 1px var(--space-1);
626
+ }
627
+
628
+ .md pre {
629
+ background: var(--panel-2);
630
+ border: 1px solid var(--border);
631
+ border-radius: var(--radius);
632
+ padding: var(--space-3) var(--space-3);
633
+ overflow-x: auto;
634
+ margin: 0 0 var(--space-2);
635
+ }
636
+
637
+ .md pre code { background: none; border: none; padding: 0; font-size: var(--text-sm); }
638
+
639
+ .md blockquote {
640
+ margin: 0 0 var(--space-2);
641
+ padding-left: var(--space-3);
642
+ border-left: 3px solid var(--border);
643
+ color: var(--muted);
644
+ }
645
+
646
+ .md table { border-collapse: collapse; margin: 0 0 var(--space-2); display: block; overflow-x: auto; }
647
+
648
+ .md th, .md td { border: 1px solid var(--border); padding: var(--space-1) var(--space-2); text-align: left; }
649
+
650
+ .md th { background: var(--panel-2); font-weight: 600; }
651
+
652
+ .md a { color: var(--accent); }
653
+
654
+ .md hr { border: none; border-top: 1px solid var(--border); margin: var(--space-3) 0; }
655
+
656
+ /* settings ------------------------------------------------------------ */
657
+ .set { margin-bottom: var(--space-5); }
658
+
659
+ /* `.card-title`, in a stylesheet. Small-caps muted 12px next to a card whose
660
+ title is 16px text made the same heading look like two different ranks. */
661
+ .set h4 { margin: 0; font-size: var(--text-base); color: var(--text); }
662
+
663
+ /* A second heading in the same card starts a new subject and needs the gap to
664
+ say so. Merging the model parameters into one card put "Context window"
665
+ directly under the picker it is not about, with nothing between them. */
666
+ .set h4:not(:first-child) { margin-top: var(--space-4); }
667
+
668
+ .set-hint { font-size: var(--text-xs); color: var(--muted); line-height: 1.45; }
669
+
670
+ /* The description under a section heading, spaced once here rather than at
671
+ every call site.
672
+
673
+ Seventeen of these carried `style={{ marginBottom: 'var(--space-2)' }}`,
674
+ which is the same declaration written seventeen times and seventeen chances
675
+ to write a different one -- the same element already appeared with space-1,
676
+ space-3 and space-4 elsewhere for no reason anybody recorded. A structural
677
+ relationship belongs in the stylesheet; a genuine exception can still say so
678
+ inline and now reads as the exception it is. */
679
+ /* `.card-desc`: the sentence under a title, at the size the card gives it. */
680
+ .set h4 + .set-hint {
681
+ margin-top: var(--space-1);
682
+ margin-bottom: var(--space-3);
683
+ font-size: var(--text-sm);
684
+ line-height: 1.5;
685
+ }
686
+
687
+ @keyframes tf-throb {
688
+ 0%, 100% { opacity: 0.55; }
689
+ 50% { opacity: 1; }
690
+ }
691
+
692
+ /* A copy that no longer holds the same file. Not a failure -- two copies of one
693
+ template are allowed to diverge, and this is only saying that they have. */
694
+ .set-hint.drifted { color: var(--warn); }
695
+
696
+ /* What an applet drew, read back. A tree rather than prose, so it is left as
697
+ preformatted text and given a ceiling -- a busy applet is a hundred lines and
698
+ this sits inside a settings pane. */
699
+ .drawn {
700
+ margin: var(--space-2) 0 0;
701
+ padding: var(--space-2) var(--space-2);
702
+ max-height: 220px;
703
+ overflow: auto;
704
+ font-size: var(--text-xs);
705
+ line-height: 1.45;
706
+ white-space: pre-wrap;
707
+ overflow-wrap: anywhere;
708
+ border: 1px solid var(--border);
709
+ border-radius: var(--radius);
710
+ background: var(--panel-2);
711
+ }
712
+
713
+ /* One applet, alone on the page, at `/applet/<id>`. Fills the window: there is
714
+ no grid here to decide a height, and a screenshot of an applet letterboxed
715
+ inside a page is mostly not the applet. */
716
+ .bare { height: 100vh; display: flex; flex-direction: column; background: var(--bg); }
717
+
718
+ .bare > * { flex: 1; min-height: 0; }
719
+
720
+ /* A round is in flight. On the button that asks for one, because that is where
721
+ you are looking when you have just pressed it -- and not in the foot, which
722
+ would flicker every thirty seconds on a tile that is working perfectly. */
723
+ @keyframes tf-spin { to { transform: rotate(360deg); } }
724
+
725
+ @keyframes dock-working {
726
+ 0%, 100% { opacity: .15; transform: scale(1); }
727
+ 50% { opacity: .55; transform: scale(1.1); }
728
+ }
729
+
730
+ /* Working, as one rotating mark.
731
+ It was three dots bouncing in sequence, and the sequence was the problem: the
732
+ dashboard polls `/api/models` every five seconds, React re-renders, the node
733
+ is replaced and every CSS animation on it restarts from frame zero. A bounce
734
+ caught mid-phase and thrown back to the start is visible every time -- which
735
+ is what "it resets every five and a half seconds" was.
736
+ A rotation has no perceptible beginning, so the same restart is invisible.
737
+ The animation is not made more robust; the shape is made indifferent to it. */
738
+ .spin {
739
+ display: inline-block;
740
+ animation: tf-spin 900ms linear infinite;
741
+ }
742
+
743
+ @keyframes tf-spin { to { transform: rotate(360deg); } }
744
+
745
+ @media (prefers-reduced-motion: reduce) {
746
+ /* Still says "working" -- it just says it without moving. */
747
+ .spin { animation: none; opacity: 0.6; }
748
+ }
749
+
750
+ @keyframes tf-bounce {
751
+ 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
752
+ 40% { opacity: 1; transform: translateY(-3px); }
753
+ }
754
+
755
+ /* The top bar carries the title, the views and the way into settings. Nothing
756
+ that belongs to a conversation lives here any more, so it can breathe. */
757
+ .topbar .grow { flex: 1; min-width: 0; }
758
+
759
+ /* Back on the left, Next on the right, with the gap between them doing the
760
+ separating -- so the button that goes forward is in the same place on both
761
+ pages instead of sliding when Back appears. */
762
+ .first-run-actions { align-items: center; }
763
+
764
+ @keyframes listening-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .35 } }
765
+
766
+ /* table ---------------------------------------------------------------- */
767
+
768
+ /* For the visually-hidden-but-announced case: a table's `<caption>`, a column
769
+ heading that would be noise on screen. `display: none` and `visibility:
770
+ hidden` both remove it from the accessibility tree as well, which is the
771
+ opposite of what this is for. */
772
+ .sr-only {
773
+ position: absolute;
774
+ width: 1px;
775
+ height: 1px;
776
+ margin: -1px;
777
+ padding: 0;
778
+ overflow: hidden;
779
+ clip-path: inset(50%);
780
+ white-space: nowrap;
781
+ border: 0;
782
+ }
783
+
784
+ @keyframes tf-skel {
785
+ from { opacity: 0.55; }
786
+ to { opacity: 1; }
787
+ }
788
+
789
+ /* ---- ./select.css ---- */
790
+ /* How big a control is, asked for rather than inherited.
791
+ Four sizes existed and every one of them came from a descendant selector:
792
+ `.set-row button` was 11px in 3px of padding, `.approval-actions button` was
793
+ 12px in 5px, `.mcp-tool button` repeated the first exactly. So a button's size
794
+ was a fact about where it had been put, not about what it was -- moving one
795
+ resized it, and asking for a large button inside a settings row was not
796
+ possible at all. There is already a comment further down this file complaining
797
+ about precisely that.
798
+ One scale, for buttons, inputs and selects alike, because a row holding a
799
+ button beside an input wants them the same height and that was previously
800
+ arranged by hand each time. */
801
+ /* Full width, for the one action a card or a step is about. Not a size: a
802
+ `block` button is the same height as any other, it just stops being a thing
803
+ you have to aim at. */
804
+ button.block { display: flex; width: 100%; }
805
+
806
+ /* The default is `size-md`, spelled once for every control.
807
+ The paragraph above promised one scale for buttons, inputs and selects, and
808
+ the next line broke it: inputs padded 8px top and bottom, buttons 4px, so an
809
+ input was eight pixels taller than the button beside it and no row of the two
810
+ could line up. The download modal's search box against its Search button was
811
+ the version you could see from across the room. */
812
+ button,
813
+ input,
814
+ textarea,
815
+ select,
816
+ .sel-control { padding: var(--space-2) var(--space-3); }
817
+
818
+ /* Centred on the whole block -- the caption and the sentence together -- rather
819
+ than aligned to either end of it. `.field-row` pins to the bottom, which is
820
+ right for a field beside a button and wrong beside two lines of prose. */
821
+ .card-inline > .sel { align-self: center; }
822
+
823
+ /* The custom select's control is a button and presses as one; its items are
824
+ the list's, and the active item is the keyboard's -- a click on one lands
825
+ on the item, not on a press. */
826
+ .sel-item:active { background: color-mix(in srgb, var(--text) var(--press-ink), var(--panel-2)); }
827
+
828
+ /* (the controls size themselves now; the width workaround that lived here is
829
+ gone with the base rule that replaced it) */
830
+
831
+ /* The select, drawn by us.
832
+ A native `<select>` styles down to its closed state and no further -- the
833
+ open list is the operating system's, in its font, at its size, in its
834
+ colours. That is the part you actually read: thirty model ids with their
835
+ capability tags, on a white sheet in the middle of a dark app. So the list is
836
+ ours now, and these are its clothes. The behaviour lives in `Select.tsx`,
837
+ which owes the keyboard everything the native element gave away for free. */
838
+ .sel { position: relative; display: inline-block; }
839
+
840
+ /* The subject of its pane rather than a field in it: full width and a size up.
841
+ That was `.select-field.block` on the native element, and it carried the
842
+ reason with it -- model ids are long (`qwen2.5-coder:32b-instruct-q4_K_M`) and
843
+ at the narrow size they truncated mid-name. */
844
+ .sel.block { display: block; width: 100%; }
845
+
846
+ .sel.block .sel-control { padding: var(--space-2) var(--space-3); font-weight: 500; }
847
+
848
+ /* Not full width otherwise: a select as wide as its row, holding one short
849
+ word, reads as an input somebody forgot to fill in. */
850
+ .sel:not(.block) .sel-control { width: auto; max-width: 320px; }
851
+
852
+ .sel-control {
853
+ display: flex;
854
+ align-items: center;
855
+ gap: var(--space-2);
856
+ width: 100%;
857
+ text-align: left;
858
+ /* Not `--panel`: a control sits on a panel and has to be distinguishable from
859
+ it, which is what `--control` is for. */
860
+ background: var(--control);
861
+ }
862
+
863
+ .sel-value { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
864
+
865
+ .sel-caret { flex: none; opacity: 0.6; transition: transform var(--dur-fast) var(--ease); }
866
+
867
+ .sel[data-open] .sel-caret { transform: rotate(180deg); }
868
+
869
+ .sel[data-open] .sel-control { border-color: var(--accent); }
870
+
871
+ /* State is read off React Aria's attributes: `data-open` on the root,
872
+ `data-focused` / `data-selected` / `data-disabled` on an item, where
873
+ `.open`, `[data-active]`, `.on` and `.off` used to be. */
874
+ .sel-list {
875
+ /* `fixed`, and in `document.body`: an ancestor that scrolls or hides its
876
+ overflow would otherwise clip it, which in this app means every settings
877
+ pane. Position comes from the button's measured rectangle. */
878
+ /* Positioned by React Aria -- inline `top`/`left` on a portalled popover
879
+ that flips when the edge is near. `position: fixed` and the measured
880
+ rectangle it was placed by are gone with the hand-rolled version. */
881
+ z-index: 60;
882
+ max-height: 280px;
883
+ overflow-y: auto;
884
+ margin: 0;
885
+ padding: var(--space-1);
886
+ /* The same room while the list is scrolled as it has at rest. Padding at
887
+ the top of a scroll container scrolls away with the content, so the row
888
+ `scrollIntoView({block: 'nearest'})` brings up ended flush against the
889
+ border and read as clipped rather than as one of a set -- and this list
890
+ opens scrolled to whatever is chosen, so that was the ordinary state.
891
+ `scroll-padding` insets what counts as "in view", so it stops short by
892
+ the same four the sides have. */
893
+ scroll-padding-block: var(--space-1);
894
+ list-style: none;
895
+ border: var(--border-width) solid var(--border-strong);
896
+ border-radius: var(--radius-md);
897
+ background: var(--panel);
898
+ box-shadow: var(--shadow-2);
899
+ }
900
+
901
+ .sel-item {
902
+ display: flex;
903
+ align-items: center;
904
+ gap: var(--space-2);
905
+ padding: var(--space-2) var(--space-2);
906
+ border-radius: var(--radius-sm);
907
+ cursor: pointer;
908
+ white-space: nowrap;
909
+ }
910
+
911
+ .sel-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
912
+
913
+ /* Hover and keyboard land on the same row, because they are the same idea --
914
+ two highlights for one cursor is how a list starts lying about where Enter
915
+ will go. */
916
+ .sel-item[data-focused] { background: var(--panel-2); }
917
+
918
+ .sel-item[data-selected] { color: var(--accent); }
919
+
920
+ .sel-item[data-disabled] { opacity: 0.45; cursor: not-allowed; }
921
+
922
+ /* The tick marks the chosen row. Colour alone would not: a list where the only
923
+ difference is a hue is a list you cannot read past. */
924
+ .sel-tick { flex: none; opacity: 0; }
925
+
926
+ .sel-item[data-selected] .sel-tick { opacity: 1; }
927
+
928
+ /* The icon on a speaker's row in the open list -- `Select`'s `aside`. Last in
929
+ the row and flush with its padding: it is a column of buttons down the right
930
+ of the list, which is where a list's controls belong. It sat between the
931
+ label and the tick for an afternoon, which put it a tick's width short of
932
+ the edge and reading as neither aligned nor attached to the name (William,
933
+ 2026-09-04: "why is the icon not all the way right"). 20px square, so it has
934
+ a hit area without making the row taller than the text in it.
935
+
936
+ Drawn at text weight rather than at half a muted one. It was `opacity: 0.5`
937
+ on `--muted`, on the argument that eleven visible buttons is a busy list; at
938
+ that weight it read as decoration and nobody would have pressed it, which is
939
+ worse than busy. Accent under the pointer, which is the only state it has. */
940
+ .sel-hear {
941
+ display: inline-flex;
942
+ align-items: center;
943
+ justify-content: center;
944
+ flex: none;
945
+ width: 20px;
946
+ height: 20px;
947
+ border-radius: var(--radius-sm);
948
+ color: var(--text);
949
+ }
950
+
951
+ .sel-hear:hover { color: var(--accent); }
952
+
953
+ .field-bad input,
954
+ .field-bad textarea,
955
+ .field-bad .sel-control { border-color: var(--bad); }
956
+
957
+ .field > input,
958
+ .field > textarea,
959
+ .field > .sel { width: 100%; }
960
+
961
+ /* ---- ./dangerzone.css ---- */
962
+ /* Destructive, and legible as such standing still. */
963
+ button.danger {
964
+ color: var(--bad);
965
+ border-color: color-mix(in srgb, var(--bad) 45%, var(--border-strong));
966
+ }
967
+
968
+ button.danger:hover:not(:disabled) {
969
+ background: color-mix(in srgb, var(--bad) 12%, var(--panel));
970
+ border-color: var(--bad);
971
+ color: var(--bad);
972
+ }
973
+
974
+ button.danger:active:not(:disabled) {
975
+ background: color-mix(in srgb, var(--bad) 20%, var(--panel));
976
+ border-color: var(--bad);
977
+ }
978
+
979
+ /* Three fields on one line, and their inputs on one line too.
980
+
981
+ `Field` puts the description *above* the control, which is the house rule and
982
+ right -- but only `Arguments` has one here, so as a flex row the other two
983
+ inputs floated a description's height above it and the row read as broken.
984
+ Aligned to the end instead: the labels sit where their text needs them and
985
+ the three boxes share a baseline, which is the edge the eye actually follows.
986
+
987
+ A grid rather than flex, so `Arguments` gets the room it needs by declaration
988
+ instead of by an inline pixel width on each of its neighbours. */
989
+ .mcp-local .field-row {
990
+ display: grid;
991
+ grid-template-columns: 1fr 1fr 2fr;
992
+ align-items: end;
993
+ gap: var(--space-3);
994
+ }
995
+
996
+ /* How an applet's polling is going, said the same way on every tile. Muted
997
+ when it is going fine -- this is a fact you glance at, not one you are being
998
+ told. */
999
+ /* A notch under `.set-hint`'s 11px. This is the smallest thing on a tile and
1000
+ the least often read -- at 11px it competed with the applet's own text. */
1001
+ /* The danger zone ------------------------------------------------------------
1002
+
1003
+ One bordered section with its actions divided inside it, rather than a box
1004
+ per action: three tiles of red furniture stacked down a pane read as three
1005
+ warnings, and one section with three things in it reads as a place. A place
1006
+ is somewhere you have to go, which is the whole idea.
1007
+
1008
+ Tinted rather than filled. These are still settings, and a red panel in a
1009
+ settings modal reads as an error that has already happened — the border and
1010
+ the headings are enough to say "stop and read this one".
1011
+
1012
+ The same shape as valet's, so that being careful is learned once. */
1013
+ .danger-zone {
1014
+ border: 1px solid color-mix(in srgb, var(--bad) 35%, var(--border));
1015
+ background: color-mix(in srgb, var(--bad) 5%, var(--panel));
1016
+ border-radius: var(--radius-md);
1017
+ padding: var(--space-3) var(--space-4);
1018
+ margin-top: var(--space-1);
1019
+ }
1020
+
1021
+ .danger-zone-head h3 {
1022
+ margin: 0;
1023
+ font-size: var(--text-sm);
1024
+ color: var(--bad);
1025
+ letter-spacing: 0.01em;
1026
+ }
1027
+
1028
+ .danger-zone-head .set-hint { margin-top: 2px; }
1029
+
1030
+ /* Divided by a rule rather than boxed. The first one needs no rule: it is
1031
+ already under the heading. */
1032
+ .danger-act { padding-top: var(--space-3); margin-top: var(--space-3); border-top: 1px solid color-mix(in srgb, var(--bad) 18%, var(--border)); }
1033
+
1034
+ .danger-act > h4 { margin: 0 0 2px; font-size: var(--text-sm); color: var(--bad); }
1035
+
1036
+ .danger-act.plain > h4 { color: var(--text); }
1037
+
1038
+ .danger-act > .set-hint + .set-hint { margin-top: var(--space-1); }
1039
+
1040
+ .danger-row { margin-top: var(--space-2); gap: var(--space-2); align-items: center; }
1041
+
1042
+ .danger-row > input { min-width: 0; }
1043
+
1044
+ /* The name to type, quoted rather than described -- it is a string to copy. */
1045
+ .danger-name {
1046
+ background: color-mix(in srgb, currentColor 10%, transparent);
1047
+ padding: 1px var(--space-1);
1048
+ border-radius: 4px;
1049
+ }
1050
+
1051
+ .danger-unavailable { margin-top: var(--space-2); opacity: 0.85; }
1052
+
1053
+ /* Label beside the control, for a settings pane: a column of names on the left
1054
+ and their controls on the right reads as a list of what is set, where the
1055
+ same rows stacked read as a form waiting to be filled in. The description
1056
+ and the error stay under the label, in the same column, so a long
1057
+ explanation does not push the control off its row. */
1058
+ .field-row-layout {
1059
+ grid-template-columns: 1fr auto;
1060
+ align-items: center;
1061
+ column-gap: var(--space-3);
1062
+ }
1063
+
1064
+ .field-row { align-items: flex-end; gap: var(--space-2); }
1065
+
1066
+ .field-row > button { flex: none; }
1067
+
1068
+ /* Two forms one under the other, whose buttons say different words -- "Add"
1069
+ and "Block". Sized to their text they came out different widths, and two
1070
+ controls in the same column at the same indent that do not line up read as
1071
+ an accident rather than as a pair. */
1072
+ .field-row > button { min-width: 9ch; }
1073
+
1074
+ /* ---- ./callout.css ---- */
1075
+ /* One shape for everything that tells you something.
1076
+ `.banner` and `.callout` were the same box drawn twice: a bordered, rounded,
1077
+ 12px panel with a tint. They differed in padding by a pixel in each direction
1078
+ -- 10/14 against 11/13, a distinction nobody chose and nobody could see -- and
1079
+ in how they marked tone, one tinting its whole background and the other
1080
+ growing a coloured left edge. Two answers to one question meant every new
1081
+ message was a coin toss, and the app read as though two people had built it.
1082
+ So: one `.callout`, and the tone is a modifier. `.banner` and `.callout` are
1083
+ kept as aliases below because they are spelled across a dozen files and a
1084
+ rename is a separate change from a consolidation. */
1085
+ .callout {
1086
+ display: flex;
1087
+ gap: var(--space-3);
1088
+ align-items: flex-start;
1089
+ padding: var(--space-3) var(--space-3);
1090
+ border: var(--border-width) solid var(--border);
1091
+ border-left: 2px solid var(--muted);
1092
+ border-radius: var(--radius);
1093
+ background: var(--panel-2);
1094
+ font-size: var(--text-sm);
1095
+ line-height: 1.55;
1096
+ color: var(--text);
1097
+ }
1098
+
1099
+ /* The tones. Each is the same two moves -- tint the surface, colour the edge --
1100
+ so a new one is two lines and cannot drift into a new shape. */
1101
+ .callout-info {
1102
+ border-left-color: var(--info);
1103
+ background: color-mix(in srgb, var(--info) 10%, var(--panel));
1104
+ border-color: color-mix(in srgb, var(--info) 30%, var(--border));
1105
+ }
1106
+
1107
+ .callout-bad {
1108
+ border-left-color: var(--bad);
1109
+ background: color-mix(in srgb, var(--bad) 10%, var(--panel));
1110
+ border-color: color-mix(in srgb, var(--bad) 30%, var(--border));
1111
+ }
1112
+
1113
+ .callout-good {
1114
+ border-left-color: var(--good);
1115
+ background: color-mix(in srgb, var(--good) 10%, var(--panel));
1116
+ border-color: color-mix(in srgb, var(--good) 30%, var(--border));
1117
+ }
1118
+
1119
+ .callout-warn {
1120
+ border-left-color: var(--warn);
1121
+ background: color-mix(in srgb, var(--warn) 10%, var(--panel));
1122
+ border-color: color-mix(in srgb, var(--warn) 30%, var(--border));
1123
+ }
1124
+
1125
+ /* A callout that leaves on its own.
1126
+ Room at the bottom for the bar, and `position: relative` so the bar can sit
1127
+ on the border rather than inside the text. */
1128
+ .callout-timed { position: relative; padding-bottom: var(--space-5); overflow: hidden; }
1129
+
1130
+ /* The countdown. Drawn as a shrinking bar rather than a number: nobody wants to
1131
+ read a clock, they want to know whether they have time. Animated width rather
1132
+ than a transition, so it starts the moment it is painted. */
1133
+ .callout-clock {
1134
+ position: absolute;
1135
+ left: 0;
1136
+ bottom: 0;
1137
+ height: 3px;
1138
+ width: 100%;
1139
+ transform-origin: left;
1140
+ background: currentColor;
1141
+ opacity: 0.5;
1142
+ animation: tf-callout-clock linear forwards;
1143
+ }
1144
+
1145
+ /* Held while the pointer is on it. A message that expires while you are reading
1146
+ it was never shown. */
1147
+ .callout-clock.is-held { animation-play-state: paused; }
1148
+
1149
+ @media (prefers-reduced-motion: reduce) {
1150
+ /* The timer still runs -- this is about motion, not about the message
1151
+ staying. A static bar says the same thing without sliding. */
1152
+ .callout-clock { animation: none; opacity: 0.25; }
1153
+ }
1154
+
1155
+ /* A callout arriving is a change in the page, not a thing that was always
1156
+ there. It grows into its own height so the rows under it are pushed down
1157
+ rather than replaced -- the difference between "something appeared" and
1158
+ "the page you were reading is now a different page". `grid-template-rows`
1159
+ from 0fr to 1fr is what animates a height nobody measured.
1160
+
1161
+ The margin is on the wrapper, so a callout that has not arrived takes no
1162
+ space at all: `margin-bottom` on the callout itself would leave a gap where
1163
+ an absent message would have been. */
1164
+ .callout-enter {
1165
+ display: grid;
1166
+ grid-template-rows: 0fr;
1167
+ transition: grid-template-rows var(--dur-md) var(--ease),
1168
+ margin-bottom var(--dur-md) var(--ease),
1169
+ opacity var(--dur-md) var(--ease);
1170
+ opacity: 0;
1171
+ margin-bottom: 0;
1172
+ }
1173
+
1174
+ .callout-enter > * { overflow: hidden; min-height: 0; }
1175
+
1176
+ .callout-enter.is-in { grid-template-rows: 1fr; opacity: 1; margin-bottom: var(--space-4); }
1177
+
1178
+ @media (prefers-reduced-motion: reduce) {
1179
+ /* Still arrives, just without the push. */
1180
+ .callout-enter { transition: none; }
1181
+ }
1182
+
1183
+ /* The mark, when there is one. Optional on purpose: most callouts are a sentence
1184
+ and an icon beside every sentence is noise.
1185
+
1186
+ It used to be a letter somebody typed into a ring -- `i`, `!`, `✓` -- which
1187
+ is the operating system's font drawing a glyph beside an app that has an icon
1188
+ set for exactly this. The ring went with it: a circle around a warning
1189
+ triangle is two shapes saying one thing. */
1190
+ .callout-mark {
1191
+ flex: none;
1192
+ display: grid;
1193
+ place-items: center;
1194
+ margin-top: 1px;
1195
+ opacity: 0.9;
1196
+ }
1197
+
1198
+ /* Held-back history when a local conversation would cross to a hosted model.
1199
+ Deliberately loud: the cost of missing it is uploading work that was kept
1200
+ local on purpose. */
1201
+ .callout.carried { border-color: var(--bad); }
1202
+
1203
+ /* A save that worked but is worth a word. Red would say it did not happen. */
1204
+ .callout:not([class*='callout-']) {
1205
+ border-color: var(--border);
1206
+ color: var(--muted);
1207
+ display: flex;
1208
+ align-items: center;
1209
+ gap: var(--space-2);
1210
+ }
1211
+
1212
+ .callout button { flex: none; }
1213
+
1214
+ /* ---- ./card.css ---- */
1215
+ .card {
1216
+ background: var(--panel);
1217
+ border: var(--border-width) solid var(--border);
1218
+ border-radius: var(--radius-md);
1219
+ padding: var(--space-3) var(--space-3);
1220
+ margin-bottom: var(--space-3);
1221
+ }
1222
+
1223
+ /* Header, when there is one. The title and the line under it were retyped at
1224
+ every call site and no two agreed on the gap between them. */
1225
+ .card-head { display: flex; align-items: flex-start; gap: var(--space-3); }
1226
+
1227
+ .card-head:not(:last-child) { margin-bottom: var(--space-3); }
1228
+
1229
+ .card-headings { min-width: 0; flex: 1; }
1230
+
1231
+ /* The card owns the space between the things it holds. Without this a callout
1232
+ and the button under it sit edge to edge, and every caller has to remember a
1233
+ margin on whichever child happens to be second. */
1234
+ .card-body { display: grid; gap: var(--space-3); }
1235
+
1236
+ /* The card's own control, on the card's padding rather than indented into its
1237
+ body. Centred against the whole heading block, not against the title -- a
1238
+ switch aligned to the first line drifts upward the moment the description
1239
+ runs to two. */
1240
+ .card-action { flex: none; align-self: center; display: flex; align-items: center; }
1241
+
1242
+ /* A row inside a card that lines up with the header rather than indenting past
1243
+ it: same left edge as the title, same right edge as the action. Its label is
1244
+ a quiet caption, not a second heading -- the card already has one, and two
1245
+ things in bold on one card read as two cards. */
1246
+ /* Further off the description than the card's own gap. A row that continues
1247
+ the header's column needs to read as a separate statement, and at the body
1248
+ gap it looked like a fourth line of the paragraph above it. */
1249
+ .card-inline { align-items: center; margin-top: var(--space-3); }
1250
+
1251
+ /* Indented to the title's left edge, past the icon. Starting at the card's
1252
+ padding put it under the icon instead of under the words it belongs with,
1253
+ which reads as a different column of content. */
1254
+ .card:has(.card-icon) .card-inline { padding-left: calc(var(--card-icon) + var(--space-3)); }
1255
+
1256
+ /* Its own line, above the sentence it introduces. */
1257
+ .card-inline-label {
1258
+ display: block;
1259
+ font-size: var(--text-xs);
1260
+ text-transform: uppercase;
1261
+ letter-spacing: 0.04em;
1262
+ color: var(--muted);
1263
+ margin-bottom: 2px;
1264
+ }
1265
+
1266
+ .card-title { display: block; font-size: var(--text-base); }
1267
+
1268
+ .card-desc { margin-top: var(--space-1); color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
1269
+
1270
+ /* Decoration, so it is quieter than the words it sits beside and never the
1271
+ thing the eye lands on first. */
1272
+ /* One number, because the header's indent is derived from it: a row that lines
1273
+ up with the title has to know how wide the icon column is, and two copies of
1274
+ 34px drift the moment one changes. */
1275
+ .card { --card-icon: 34px; }
1276
+
1277
+ .card-icon {
1278
+ flex: none;
1279
+ display: grid;
1280
+ place-items: center;
1281
+ width: var(--card-icon);
1282
+ height: var(--card-icon);
1283
+ border-radius: var(--radius);
1284
+ background: var(--panel-2);
1285
+ color: var(--muted);
1286
+ }
1287
+
1288
+ /* A card that is itself the choice. Reset first, because it is a `<button>`
1289
+ now and inherits a control's padding and centring. */
1290
+ button.card {
1291
+ display: block;
1292
+ width: 100%;
1293
+ text-align: left;
1294
+ font: inherit;
1295
+ color: inherit;
1296
+ padding: var(--space-3);
1297
+ }
1298
+
1299
+ /* Variants, because a card is doing four jobs and looked like one.
1300
+ A search result you can act on, a thing already installed, something that
1301
+ went wrong, and a heading with rows under it are not the same object, and
1302
+ drawing them identically means the eye has to read every one to sort them. */
1303
+
1304
+ /* Selectable: it responds before you commit, the way the choice row does. */
1305
+ .card-pick { cursor: pointer; transition: border-color var(--dur-fast) var(--ease),
1306
+ background var(--dur-fast) var(--ease); }
1307
+
1308
+ .card-pick:hover { border-color: var(--accent); background: var(--panel-2); }
1309
+
1310
+ /* Current. The left edge rather than a fill, so a list of twenty with one
1311
+ marked does not become a list with one shouting. */
1312
+ .card-on {
1313
+ border-color: var(--accent);
1314
+ box-shadow: inset 3px 0 0 var(--accent);
1315
+ }
1316
+
1317
+ /* Something went wrong with this one specifically -- a failed download, a model
1318
+ that would not load -- as against a callout about the page as a whole. */
1319
+ .card-bad {
1320
+ border-color: color-mix(in srgb, var(--bad) 35%, var(--border));
1321
+ background: color-mix(in srgb, var(--bad) 6%, var(--panel));
1322
+ }
1323
+
1324
+ /* `.callout-warn`'s mix, on a card. A warning callout that grew rows became a
1325
+ card so the rows sit inside the colour rather than under it; the numbers
1326
+ are the callout's so the two read as one tone. */
1327
+ .card-warn {
1328
+ border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
1329
+ /* More of the colour than the callout carries: a card is mostly ground,
1330
+ and at the callout's 10% the tint read as a border with nothing behind
1331
+ it. */
1332
+ background: color-mix(in srgb, var(--warn) 18%, var(--panel));
1333
+ }
1334
+
1335
+ /* Quiet: for a card that is a container rather than a thing, where the border
1336
+ is doing grouping and should not compete with what it groups. */
1337
+ .card-flat { background: transparent; border-color: var(--border); }
1338
+
1339
+ .modal-body .card { background: var(--panel-2); }
1340
+
1341
+ /* ---- ./toggle.css ---- */
1342
+ .card-action .switch-row { padding: 0; gap: var(--space-2); }
1343
+
1344
+ .card-action .switch-row .switch-body { flex: none; }
1345
+
1346
+ /* model handover marker ----------------------------------------------- */
1347
+ .switch {
1348
+ display: flex;
1349
+ align-items: center;
1350
+ gap: var(--space-2);
1351
+ margin: var(--space-4) 0;
1352
+ font-size: var(--text-xs);
1353
+ color: var(--muted);
1354
+ }
1355
+
1356
+ .switch::before, .switch::after {
1357
+ content: '';
1358
+ flex: 1;
1359
+ height: 1px;
1360
+ background: var(--border);
1361
+ }
1362
+
1363
+ /* A switch, because it takes effect as it moves. A tick box is the shape of an
1364
+ answer you are going to submit, and there is nothing here to submit.
1365
+ Its own width, which is also what keeps it clear of the trap `.set-row`,
1366
+ `.mcp-tool` and `.applet-review-row` each document: the controls block sizes
1367
+ every `input` to `width: 100%`, and a checkbox left to inherit that stretches
1368
+ across the row, squeezes its label to nothing, and draws itself at the left
1369
+ of its own oversized rectangle. */
1370
+ /* The row a switch lives in. The whole row is the label, so the words are the
1371
+ hit area too -- a 30x18 target on its own is under every minimum there is,
1372
+ and reaching for the text is what people do regardless. */
1373
+ .switch-row {
1374
+ display: flex;
1375
+ align-items: center;
1376
+ gap: var(--space-3);
1377
+ padding: var(--space-2) 0;
1378
+ cursor: pointer;
1379
+ }
1380
+
1381
+ .switch-row[data-disabled] { cursor: default; opacity: 0.55; }
1382
+
1383
+ .switch-body { flex: 1; min-width: 0; display: grid; gap: 1px; }
1384
+
1385
+ /* Sized to its words, for a switch that sits in a row of other controls rather
1386
+ than owning a row of its own. `flex: 1` there would push the label away from
1387
+ the switch by the whole width of the bar. */
1388
+ .row > .switch-row { flex: none; padding-block: 0; }
1389
+
1390
+ .row > .switch-row .switch-body { flex: none; }
1391
+
1392
+ /* The opposite case: a switch that *is* the row, with something small beside it
1393
+ -- a tooltip, a state word. `.set-row` wraps, so a switch that does not grow
1394
+ pushes whatever follows onto a line of its own, which is how the file-access
1395
+ tooltip ended up orphaned under it. */
1396
+ .set-row > .switch-row { flex: 1; min-width: 0; padding-block: 0; }
1397
+
1398
+ .switch-label { color: var(--text); }
1399
+
1400
+ .switch-hint { font-size: var(--text-sm); color: var(--muted); }
1401
+
1402
+ .switch-said { flex: none; font-size: var(--text-xs); color: var(--muted); }
1403
+
1404
+ /* Three sizes, the same names every other control uses. The switch scales with
1405
+ the row: a 30x18 track beside 12px text is a different control from the same
1406
+ track beside 16px text. */
1407
+ .switch-sm { gap: var(--space-2); padding: var(--space-1) 0; font-size: var(--text-sm); }
1408
+
1409
+ .switch-sm .switch-hint { font-size: var(--text-xs); }
1410
+
1411
+ .switch-sm .toggle { width: 26px; height: 16px; }
1412
+
1413
+ .switch-sm .toggle::after { width: 10px; height: 10px; }
1414
+
1415
+ .switch-row.switch-sm[data-selected] .toggle::after { left: calc(100% - 12px); }
1416
+
1417
+ .switch-lg { gap: var(--space-4); padding: var(--space-3) 0; font-size: var(--text-md); }
1418
+
1419
+ .switch-lg .toggle { width: 38px; height: 22px; }
1420
+
1421
+ .switch-lg .toggle::after { width: 16px; height: 16px; }
1422
+
1423
+ .switch-row.switch-lg[data-selected] .toggle::after { left: calc(100% - 18px); }
1424
+
1425
+ /* `input[type='checkbox']` is an attribute selector plus a type -- 0-1-1 --
1426
+ and `.toggle` is a class, 0-1-0. So the tick-box base outranked this no
1427
+ matter which came last in the file, and the switch rendered as a 17px
1428
+ rounded square with a checkmark in it. Matching the base's shape is the fix;
1429
+ `!important` would have been the other one, and the wrong one. */
1430
+ /* State is read off the row, not off the input.
1431
+ React Aria's `Switch` is the `<label>`; it stamps `data-selected`,
1432
+ `data-disabled`, `data-focus-visible` and `data-pressed` there and keeps the
1433
+ real input visually hidden. So the track is a span, and every rule that was
1434
+ `input.toggle:checked` is now `.switch-row[data-selected] .toggle`. The size
1435
+ variants gain `.switch-row` in their selector to keep outranking the generic
1436
+ state rule the way `input[type='checkbox']` used to. */
1437
+ .toggle {
1438
+ appearance: none; -webkit-appearance: none;
1439
+ position: relative; flex: none; cursor: pointer;
1440
+ display: block;
1441
+ width: 30px; height: 18px; margin: 0; padding: 0;
1442
+ border-radius: 999px;
1443
+ background: var(--toggle-off); border: 1px solid var(--toggle-off);
1444
+ --toggle-off: var(--border);
1445
+ transition: background var(--dur-fast), border-color var(--dur-fast);
1446
+ }
1447
+
1448
+ /* The card version. On a `--panel-2` card -- the permissions cards in
1449
+ onboarding -- the `--border` track is five points from the ground and the
1450
+ switch vanished, knob and all. So a toggle on a card is drawn for that
1451
+ surface: a track with half of `--muted` in it, and a knob with an edge so
1452
+ it reads as a knob on the darker track. By placement rather than a prop,
1453
+ so a toggle put on a card cannot be left in the wrong version. */
1454
+ .card .toggle {
1455
+ --toggle-off: color-mix(in srgb, var(--muted) 50%, var(--border));
1456
+ }
1457
+
1458
+ .card .toggle::after {
1459
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--muted) 30%, transparent), 0 1px 2px rgba(0, 0, 0, 0.18);
1460
+ }
1461
+
1462
+ .card .switch-row[data-selected] .toggle::after { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); }
1463
+
1464
+ /* The knob, not the tick. The base draws a checkmark through `::before`, which
1465
+ a switch has no use for. */
1466
+ .toggle::before { content: none; }
1467
+
1468
+ .toggle::after {
1469
+ content: ''; position: absolute; top: 50%; left: 2px;
1470
+ transform: translateY(-50%);
1471
+ width: 12px; height: 12px; border-radius: 50%;
1472
+ background: var(--panel);
1473
+ /* A spring, over the same `--dur-fast` the track recolours in. The curve is
1474
+ chef-monorepo's `spring/gentle` (`--ease-spring`), copied 2026-09-05 with
1475
+ the switch's async state; chef plays it over 200ms, and so did this for a
1476
+ day, but chef's track is 48px wide and this one is 30 -- the knob was
1477
+ still travelling 80ms after the track had finished changing colour, and
1478
+ that gap read as lag. Under reduced motion the duration is zero and the
1479
+ curve is moot. */
1480
+ transition: left var(--dur-fast) var(--ease-spring);
1481
+ }
1482
+
1483
+ /* Accent rather than green. Green reads as "this is the safe one", and the
1484
+ safe one here is off -- the switch should not editorialise about which way
1485
+ you ought to have it. */
1486
+ .switch-row[data-selected] .toggle { background: var(--accent); border-color: var(--accent); }
1487
+
1488
+ .switch-row[data-selected] .toggle::after { left: calc(100% - 14px); }
1489
+
1490
+ .switch-row[data-disabled] .toggle { opacity: .5; cursor: default; background: var(--toggle-off); }
1491
+
1492
+ .switch-row[data-selected][data-disabled] .toggle { background: var(--accent); border-color: var(--accent); }
1493
+
1494
+ .switch-row[data-focus-visible] .toggle { outline: 2px solid var(--accent); outline-offset: 2px; }
1495
+
1496
+ /* A toggle's knob widens while held, the way a switch does under a thumb,
1497
+ and snaps to its side on release. A tick box and a radio darken. */
1498
+ .switch-row[data-pressed]:not([data-disabled]) .toggle::after { width: 16px; transition: width var(--dur-fast); }
1499
+
1500
+ .switch-row[data-selected][data-pressed]:not([data-disabled]) .toggle::after { left: calc(100% - 18px); }
1501
+
1502
+ input[type='checkbox']:active:not(:disabled):not(.toggle),
1503
+ input[type='radio']:active:not(:disabled) {
1504
+ background: color-mix(in srgb, var(--text) var(--press-ink), var(--control));
1505
+ transform: none;
1506
+ }
1507
+
1508
+ /* The knob under a pointer. `Toggle.tsx` sets `--knob-x` (0..1) on the track
1509
+ while it is dragged and `data-dragging` with it; the knob's `left` follows,
1510
+ with no transition, because a knob that eases towards the thumb is a knob
1511
+ that lags it. `--knob-travel` is the distance it may move: the inner width
1512
+ less the knob and its 2px margin at each end, which is the same arithmetic
1513
+ the `[data-selected]` positions above encode as `100% - 14px` and friends.
1514
+ `pan-y`, so a thumb across the track is a drag and a thumb down the page is
1515
+ still a scroll. The second selector is there to outrank the sized selected
1516
+ positions, which carry one class more than the plain one. */
1517
+ .toggle { touch-action: pan-y; --knob-travel: calc(100% - 16px); }
1518
+
1519
+ .switch-sm .toggle { --knob-travel: calc(100% - 14px); }
1520
+
1521
+ .switch-lg .toggle { --knob-travel: calc(100% - 20px); }
1522
+
1523
+ .switch-row .toggle[data-dragging]::after,
1524
+ .switch-row[data-selected] .toggle[data-dragging]::after {
1525
+ left: calc(2px + var(--knob-x, 0) * var(--knob-travel));
1526
+ transition: none;
1527
+ }
1528
+
1529
+ /* Held on the knob itself. React Aria's `data-pressed` never arrives for a
1530
+ press the track kept to itself, so the track says so with `data-held` and the
1531
+ knob widens the same 16px it does under the label's press. */
1532
+ .switch-row .toggle[data-held]:not([data-dragging])::after { width: 16px; transition: width var(--dur-fast); }
1533
+
1534
+ .switch-row[data-selected] .toggle[data-held]:not([data-dragging])::after { left: calc(100% - 18px); }
1535
+
1536
+ /* Pending: chef-monorepo's sweep, in this vocabulary.
1537
+ `PendingSweep` from chef's `Toggle.tsx`, copied 2026-09-05 at William's
1538
+ request after two of this file's own designs failed on the same track: a
1539
+ ring 2px outside the track (`inset: -2px`, a transparent 2px border, a mask
1540
+ that keeps only the rim -- the border box less the padding box), and inside
1541
+ it a square twice the size painted as a conic gradient: transparent for the
1542
+ first sixth of the turn, the accent at 70% by halfway, transparent again by
1543
+ five sixths. It spins once a second. One tint in both states, as chef ships
1544
+ it: the arc reads against the page outside the track, and the accent is the
1545
+ colour every theme puts against its page. Under reduced motion the ring
1546
+ stands still as a steady faint band; `aria-busy` says what it means. */
1547
+ .toggle-sweep {
1548
+ position: absolute; inset: -2px;
1549
+ border-radius: 999px;
1550
+ border: 2px solid transparent;
1551
+ pointer-events: none;
1552
+ -webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
1553
+ -webkit-mask-composite: xor;
1554
+ mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
1555
+ mask-composite: exclude;
1556
+ }
1557
+
1558
+ .toggle-sweep::before {
1559
+ content: '';
1560
+ position: absolute; top: 50%; left: 50%;
1561
+ width: 220%; height: 220%;
1562
+ translate: -50% -50%;
1563
+ background: conic-gradient(from 0deg, transparent 0deg, transparent 60deg, color-mix(in oklab, var(--accent) 70%, transparent) 180deg, transparent 300deg, transparent 360deg);
1564
+ animation: tf-spin 1s linear infinite;
1565
+ }
1566
+
1567
+ @keyframes tf-spin { to { rotate: 1turn; } }
1568
+
1569
+ @media (prefers-reduced-motion: reduce) {
1570
+ .toggle-sweep::before { animation: none; background: color-mix(in oklab, var(--accent) 45%, transparent); }
1571
+ }
1572
+
1573
+ /* ---- ./skeleton.css ---- */
1574
+ .muted { color: var(--muted); }
1575
+
1576
+ .pulse-block {
1577
+ display: inline-block;
1578
+ height: 8px;
1579
+ border-radius: var(--radius-sm);
1580
+ background: var(--muted);
1581
+ animation: tf-pulse 1.4s ease-in-out infinite;
1582
+ vertical-align: middle;
1583
+ }
1584
+
1585
+ /* skeletons ------------------------------------------------------------ */
1586
+
1587
+ /* Ink at low alpha, not a named surface.
1588
+ The port kept chef's `surface` prop and its `--panel-2` default, and the
1589
+ first place it went was a card inside a modal -- where `.modal-body .card` is
1590
+ *also* `--panel-2`. Six invisible skeletons on an identical background. Any
1591
+ scheme that names the colour has that failure in it: the caller has to know
1592
+ what it is being drawn on, and gets it wrong the first time a surface moves.
1593
+
1594
+ `currentColor` at low alpha cannot be wrong. It is the text colour of
1595
+ wherever it lands, so it is dark on a light theme and light on a dark one,
1596
+ and it is a step from its background on every surface in the app without
1597
+ anybody choosing. `surface` survives as a strength, which is what callers
1598
+ actually wanted from it. */
1599
+ .skel {
1600
+ display: block;
1601
+ background: color-mix(in srgb, currentColor 13%, transparent);
1602
+ /* Never collapses to nothing: a zero-height skeleton is an invisible promise
1603
+ about layout, which is the one thing it cannot be. */
1604
+ min-height: 2px;
1605
+ }
1606
+
1607
+ /* On the page behind a panel, where there is less contrast to work with. */
1608
+ .skel-on-elevated { background: color-mix(in srgb, currentColor 20%, transparent); }
1609
+
1610
+ .skel-rectangular { border-radius: 0; }
1611
+
1612
+ .skel-rounded { border-radius: var(--radius); }
1613
+
1614
+ .skel-circular { border-radius: var(--radius-pill); flex: none; }
1615
+
1616
+ /* A paragraph, not a bar. Real text ends mid-line, so the last one is short --
1617
+ a stack of equal-length bars reads as a table waiting to load. */
1618
+ .skel-lines { display: grid; gap: var(--space-2); }
1619
+
1620
+ /* Breathing rather than sliding. A sweep travelling across a skeleton implies
1621
+ progress from left to right, and nothing here knows how far along it is. */
1622
+ .skel-pulse { animation: tf-skel calc(var(--dur-slow) * 3) var(--ease) infinite alternate; }
1623
+
1624
+ @media (prefers-reduced-motion: reduce) {
1625
+ /* Still distinguishable from a real block of colour, and still still. */
1626
+ .skel-pulse { animation: none; opacity: 0.8; }
1627
+ }
1628
+
1629
+ /* ---- ./rows.css ---- */
1630
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1631
+
1632
+ /* A name and the pills that qualify it, on one line, where the name is the only
1633
+ part allowed to run out of room.
1634
+ `truncate` on the container ellipsised the *pills* off the end instead --
1635
+ a 62-character model name took the row and the tags saying what it can do
1636
+ vanished, which is the one thing on the row that cannot be guessed from the
1637
+ rest. So the name truncates and the pills keep their width: `min-width: 0`
1638
+ because a flex item will not shrink below its content otherwise, and
1639
+ `flex: none` on the pills because shrinking a badge only clips its text. */
1640
+ .named { display: flex; align-items: center; min-width: 0; }
1641
+
1642
+ .named > .truncate { min-width: 0; flex: 0 1 auto; }
1643
+
1644
+ .named > .pill { flex: none; }
1645
+
1646
+ /* The row's hit target is a button and would press on its own inside the
1647
+ row that is already pressing; it keeps the row's colour and does nothing. */
1648
+ .row-hit:active:not(:disabled) { background: none; transform: none; border-color: transparent; }
1649
+
1650
+ .set-row {
1651
+ display: flex;
1652
+ flex-wrap: wrap;
1653
+ align-items: center;
1654
+ gap: var(--space-3);
1655
+ /* 7px top and bottom put the text almost on the rule beneath it -- a divider
1656
+ is meant to separate two rows, and at that spacing it reads as underlining
1657
+ the one above. */
1658
+ padding: var(--space-3) 0;
1659
+ border-bottom: var(--border-width) solid var(--border);
1660
+ }
1661
+
1662
+ /* The last *row*, not the last div.
1663
+ `:last-of-type` matches by element type, so a `.set-actions` div after the
1664
+ rows meant the final row was no longer last-of-type: it kept its bottom
1665
+ border, the actions bar drew its own top border, and every settings pane
1666
+ ended in two parallel lines a few pixels apart. */
1667
+ .set-row:not(:has(~ .set-row)) { border-bottom: none; }
1668
+
1669
+ /* rows ----------------------------------------------------------------- */
1670
+
1671
+ /* The list of things. `.set-row` still draws one row -- thirty-eight callers
1672
+ build that by hand and will for a while -- and these are what `Rows` adds on
1673
+ top: a container that knows what a row is, and named slots so the layout is
1674
+ not retyped per caller. */
1675
+ .rows { display: block; }
1676
+
1677
+ /* The divider between rows and none after the last. Asking the container
1678
+ rather than the DOM: `:last-of-type` matched by element type, so anything
1679
+ rendered after the list left the final row with a border and the pane ended
1680
+ in two parallel lines.
1681
+
1682
+ Excluded from the `pick` look, where the border is the box rather than a
1683
+ divider: dropping the bottom edge there left the last row open along the
1684
+ foot, which reads as a rendering fault rather than as a list ending. */
1685
+ .rows:not(.rows-pick) > .rows-row:last-child { border-bottom: none; }
1686
+
1687
+ /* A list you pick from, rather than a list you read.
1688
+
1689
+ The ruled version reads as a table: five records with hairlines between them,
1690
+ which is right for downloads and wrong for "which of these am I talking to".
1691
+ Boxes with air between them read as alternatives, and the one that is chosen
1692
+ can then say so with a border rather than only with a pill.
1693
+
1694
+ The divider goes with it — two ways of separating rows at once is one too
1695
+ many, and the border already does it. */
1696
+ .rows-pick { display: grid; gap: var(--space-2); }
1697
+
1698
+ .rows-pick > .rows-row {
1699
+ /* The containing block for the stretched hit area below, and for anything
1700
+ else a row draws in its own corners. Without it `inset: 0` resolves against
1701
+ whatever is positioned further up -- the pane -- so every row's hit area
1702
+ covered the whole list and the last one drawn won every click. Clicking
1703
+ one card selected a different model. */
1704
+ position: relative;
1705
+ border: var(--border-width) solid var(--border-strong);
1706
+ border-radius: var(--radius-md);
1707
+ /* Roomier than a list row. These are cards you choose between, and the
1708
+ chosen one carries a second line of controls under it -- at `--space-3`
1709
+ the whole block read as tight the moment anything appeared in it. */
1710
+ padding: var(--space-4);
1711
+ background: var(--panel);
1712
+ transition: border-color var(--dur-fast) var(--ease),
1713
+ background var(--dur-fast) var(--ease);
1714
+ }
1715
+
1716
+ /* Only where there is something to pick. A row without `row-hit` is not a
1717
+ choice, and lighting it up on hover promises something that does not happen. */
1718
+ .rows-pick > .rows-row:has(.row-hit):hover {
1719
+ border-color: var(--accent);
1720
+ background: var(--panel-2);
1721
+ }
1722
+
1723
+ .rows-pick > .rows-row:has(.row-hit:focus-visible) {
1724
+ border-color: var(--accent);
1725
+ box-shadow: var(--focus-ring);
1726
+ }
1727
+
1728
+ /* The whole card is the hit area, not just the words in it.
1729
+
1730
+ `.row-hit` is a button wrapping the name and the meta line, so the rest of
1731
+ the card -- the empty middle, the space around the controls -- did nothing
1732
+ when clicked. A card that looks like one target and behaves like two is a
1733
+ card people click twice.
1734
+
1735
+ Stretched from the button rather than by wrapping the row in one: a button
1736
+ containing a `<select>` and a gear is nested interactive content, which is
1737
+ invalid and which keyboards and screen readers handle badly. The pseudo
1738
+ element covers the card; everything that is itself interactive is lifted
1739
+ above it and keeps its own clicks. */
1740
+ .rows-pick > .rows-row > .row-hit::after {
1741
+ content: '';
1742
+ position: absolute;
1743
+ inset: 0;
1744
+ border-radius: inherit;
1745
+ }
1746
+
1747
+ .rows-pick > .rows-row > *:not(.row-hit) { position: relative; z-index: 1; }
1748
+
1749
+ /* And the button's own contents sit above its overlay.
1750
+
1751
+ Without this the sheet covered the row's own name and tags, so the capability
1752
+ tooltips stopped opening: the pointer landed on the overlay and `:hover`
1753
+ never reached the mark. Clicking still works from up here -- these are inside
1754
+ the button, so a click on them is a click on it. */
1755
+ .rows-pick > .rows-row > .row-hit > * { position: relative; z-index: 1; }
1756
+
1757
+ /* Loading, said by the card rather than by a word in the corner.
1758
+
1759
+ The state used to be ` · loading…` appended to the size line, which is the
1760
+ quietest place on the row and reads as a fact about the file rather than as
1761
+ something happening now. */
1762
+ .rows-pick > .rows-row.is-loading {
1763
+ border-color: var(--accent);
1764
+ animation: tf-pulse 1.4s ease-in-out infinite;
1765
+ }
1766
+
1767
+ /* And nothing else can be chosen while it happens: picking a second model
1768
+ mid-load queues a second load, which is how a machine ends up swapping two
1769
+ sets of weights it cannot hold at once. */
1770
+ .rows-pick > .rows-row.is-waiting { opacity: 0.5; }
1771
+
1772
+ .rows-pick > .rows-row.is-waiting .row-hit { cursor: not-allowed; }
1773
+
1774
+ @media (prefers-reduced-motion: reduce) {
1775
+ /* The border still says which one, without the breathing. */
1776
+ .rows-pick > .rows-row.is-loading { animation: none; }
1777
+ }
1778
+
1779
+ /* The chosen one, drawn exactly as a chosen `.choice` is -- same tint, same
1780
+ border, same ring and tick in the corner.
1781
+
1782
+ It carried an "in use" pill instead, which said the same thing a second time
1783
+ in a list where every other row's pills describe what the model *can do*. One
1784
+ pill among them meaning something else entirely is the odd one out.
1785
+
1786
+ No tick. `colour is never the only signal` still holds, and what holds it is
1787
+ the row's content rather than a mark: the chosen model is the only one
1788
+ showing its options, so it differs in what it contains and not merely in
1789
+ what colour it is. A checkbox on top of that would be a third way of saying
1790
+ the same thing, and this list is a single choice rather than a set of them
1791
+ anyway. */
1792
+ .rows-pick > .rows-row.is-picked {
1793
+ border-color: var(--accent);
1794
+ background: color-mix(in srgb, var(--accent) 8%, var(--panel));
1795
+ }
1796
+
1797
+ /* Nothing squeezes the name any more: the controls are on their own line.
1798
+
1799
+ Worth recording what was tried first, because it looked reasonable and was
1800
+ not. Two selects took 385px of a 514px row, leaving the name 40px -- so the
1801
+ fix was a `min-width` floor on the subject, then flex rules on the name
1802
+ inside it. Each one moved the overflow somewhere else, and the last pushed
1803
+ the name clean outside the card. The row was simply carrying more than a row
1804
+ holds; a second line is the answer, not better arithmetic. */
1805
+ .rows-pick .row-subject { min-width: 0; }
1806
+
1807
+ /* A reading that belongs to the row above it, under rather than beside.
1808
+
1809
+ `Row`'s `below` is a sibling of the row, so on its own it would sit outside
1810
+ the picked row's box and read as belonging to the next one. Pulled up into
1811
+ it: same tint, same border, and the row's own bottom corners squared off so
1812
+ the two read as one block. */
1813
+ .rows-pick > .rows-row.is-picked:has(+ .row-readout) {
1814
+ border-bottom-left-radius: 0;
1815
+ border-bottom-right-radius: 0;
1816
+ border-bottom: none;
1817
+ /* Closes the grid gap. `.rows-pick` puts `--space-2` between every child, and
1818
+ the readout is a child -- so eight pixels of card showed through between
1819
+ the row and its own reading, drawn edge to edge like a divider somebody had
1820
+ left in. The two are one block; the gap belongs between blocks. */
1821
+ margin-bottom: calc(var(--space-2) * -1);
1822
+ }
1823
+
1824
+ .rows-pick > .rows-row.is-picked:has(.row-hit):hover {
1825
+ background: color-mix(in srgb, var(--accent) 13%, var(--panel));
1826
+ }
1827
+
1828
+ /* A label over a run of rows, when one list holds two kinds of thing.
1829
+
1830
+ Not an `h4`: those are card titles here, and a sub-heading at card-title
1831
+ weight competes with the card it is inside. */
1832
+ .rows-split {
1833
+ margin: var(--space-4) 0 var(--space-2);
1834
+ font-size: var(--text-xs);
1835
+ font-weight: var(--weight-strong);
1836
+ color: var(--muted);
1837
+ }
1838
+
1839
+ .rows-split:first-of-type { margin-top: var(--space-2); }
1840
+
1841
+ .rows-empty { padding: var(--space-3) 0; }
1842
+
1843
+ /* The name, the pills and the line underneath. `min-width: 0` is the whole
1844
+ reason this is a class: a flex item will not shrink below its content, so
1845
+ without it the name pushes the pills off the end -- and the pills are the
1846
+ half you cannot guess. */
1847
+ .row-subject { flex: 1; min-width: 0; }
1848
+
1849
+ /* The line under the name needs air between them.
1850
+
1851
+ It had none -- measured at 0.0px, name baseline to hint cap -- so a model row
1852
+ read as one four-line paragraph rather than a name with a footnote, and a
1853
+ list of them read as a wall. Four pixels is enough: they are still one row,
1854
+ which is why this is a nudge off the scale's bottom step and not a gap. */
1855
+ .row-subject > .set-hint { margin-top: var(--space-1); }
1856
+
1857
+ /* The aligned column. `margin-left: auto` rather than a grid, because the rows
1858
+ are flex and a size that varies by two characters does not need a track. */
1859
+ .row-trail { flex: none; margin-left: auto; white-space: nowrap; }
1860
+
1861
+ .rows-row.rows-top { align-items: flex-start; }
1862
+
1863
+ .rows-row.rows-bad .truncate { color: var(--bad); }
1864
+
1865
+ /* A row that does something. A button, not a div with a handler: the keyboard
1866
+ and the focus ring come free and cannot be forgotten. It is unstyled because
1867
+ it is not a button to look at -- it is the row's hit area. */
1868
+ .row-hit {
1869
+ flex: 1;
1870
+ min-width: 0;
1871
+ /* Row, not column. The button wraps the subject *and* the trailing column,
1872
+ and stacking them put the timestamp on its own line under the name --
1873
+ which is the one thing `trail` exists to stop. */
1874
+ display: flex;
1875
+ align-items: center;
1876
+ gap: var(--space-3);
1877
+ padding: 0;
1878
+ border: 0;
1879
+ background: none;
1880
+ color: inherit;
1881
+ font: inherit;
1882
+ text-align: left;
1883
+ cursor: pointer;
1884
+ border-radius: var(--radius-sm);
1885
+ }
1886
+
1887
+ .row-hit:hover .truncate { color: var(--accent); }
1888
+
1889
+ .row-hit:focus-visible { outline: none; box-shadow: var(--focus-ring); }
1890
+
1891
+ /* ---- ./empty.css ---- */
1892
+ /* `Empty`: a list with nothing in it.
1893
+
1894
+ Quieter than `.empty`, which is a whole-screen state at `--space-10`. These
1895
+ sit inside a card that already has a heading and a description, so the same
1896
+ padding would leave a card that is mostly air about nothing.
1897
+
1898
+ Dashed rather than solid: a solid border reads as a thing, and the point of
1899
+ this box is that there is no thing. It marks out where the list will be. */
1900
+ .nothing {
1901
+ display: grid;
1902
+ justify-items: center;
1903
+ gap: var(--space-2);
1904
+ padding: var(--space-5) var(--space-4);
1905
+ border: var(--border-width) dashed var(--border-strong);
1906
+ border-radius: var(--radius-md);
1907
+ text-align: center;
1908
+ }
1909
+
1910
+ .nothing-mark { color: var(--muted); line-height: 0; }
1911
+
1912
+ /* The same box with a figure in it, for a whole surface rather than a slot in
1913
+ a card. Two things change and both follow from the size: the dashed outline
1914
+ goes, because an outline marks out where a list will be and a window-sized
1915
+ dashed rectangle reads as a layout that failed rather than a view with
1916
+ nothing on it; and the box centres itself in whatever it was dropped into,
1917
+ which is what makes it land in the middle of an empty dashboard rather than
1918
+ under the header. */
1919
+ .nothing-surface {
1920
+ border: none;
1921
+ padding: var(--space-10) var(--space-4);
1922
+ gap: var(--space-3);
1923
+ margin: auto;
1924
+ }
1925
+
1926
+ /* `currentColor` reaches the figure through the two rules the illustrations
1927
+ ship with, so the colour is set here and the drawing follows it -- the same
1928
+ grey as the sentence under it, in every theme.
1929
+ Two classes, not one: `.illo` sets `color: inherit` further down this file,
1930
+ which beats a single-class rule written earlier and drew the figure in full
1931
+ text black beside a muted sentence. */
1932
+ .nothing-surface .nothing-figure { color: var(--muted); }
1933
+
1934
+ /* `--text-sm`, not the `--text-xs` these lines used to be: it is a sentence
1935
+ somebody is meant to read, not a caption qualifying something else. */
1936
+ .nothing-said { margin: 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; max-width: 46ch; }
1937
+
1938
+ .nothing-act { margin-top: var(--space-1); }
1939
+
1940
+ /* ---- ./progress.css ---- */
1941
+ /* progress ----------------------------------------------------------- */
1942
+
1943
+ /* The bar sat 4px under its own label and flush against whatever was above
1944
+ the whole block, so a download row read as four lines of text with a rule
1945
+ drawn through the last one. The label is a caption for the bar and stays
1946
+ close to it; the block as a whole is a separate statement from the row it
1947
+ follows. */
1948
+ .progress { display: grid; gap: var(--space-1); }
1949
+
1950
+ .card > .progress { margin-top: var(--space-3); }
1951
+
1952
+ .progress-head { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-sm); }
1953
+
1954
+ .progress-detail { color: var(--muted); font-size: var(--text-xs); }
1955
+
1956
+ .bar { height: 6px; background: var(--panel-2); border-radius: var(--radius-pill); overflow: hidden; }
1957
+
1958
+ .bar > i {
1959
+ display: block;
1960
+ height: 100%;
1961
+ background: var(--accent);
1962
+ border-radius: inherit;
1963
+ transition: width var(--dur-md) var(--ease-out);
1964
+ }
1965
+
1966
+ .bar-good > i { background: var(--good); }
1967
+
1968
+ .bar-bad > i { background: var(--bad); }
1969
+
1970
+ /* Working, size unknown.
1971
+ A fetch that has not read `content-length` yet drew an empty track, which is
1972
+ the same picture as stalled. This travels instead: it says "something is
1973
+ happening" without claiming to know how much of it is left. */
1974
+ .bar-indeterminate > i {
1975
+ width: 35%;
1976
+ animation: tf-bar 1.2s var(--ease) infinite;
1977
+ }
1978
+
1979
+ @media (prefers-reduced-motion: reduce) {
1980
+ /* Still distinguishable from empty, and from full. */
1981
+ .bar-indeterminate > i { animation: none; width: 100%; opacity: 0.35; }
1982
+ }
1983
+
1984
+ /* ---- ./tabs.css ---- */
1985
+ /* ---------------------------------------------------------------- tabs */
1986
+ .tabs { position: relative; display: flex; gap: var(--space-1); }
1987
+
1988
+ .tab {
1989
+ position: relative;
1990
+ z-index: 1;
1991
+ background: transparent;
1992
+ border: none;
1993
+ border-radius: var(--radius) var(--radius) 0 0;
1994
+ color: var(--muted);
1995
+ padding: var(--space-2) var(--space-3);
1996
+ /* The strip's own line is drawn once, below; this is the piece of it each tab
1997
+ paints over when it is the current one. */
1998
+ box-shadow: inset 0 -2px 0 transparent;
1999
+ }
2000
+
2001
+ .tab:hover:not(:disabled) { color: var(--text); background: var(--panel-2); border-color: transparent; }
2002
+
2003
+ .tab.on { color: var(--accent); box-shadow: inset 0 -2px 0 var(--accent); }
2004
+
2005
+ .tab-badge { margin-left: var(--space-2); color: var(--muted); font-size: var(--text-xs); }
2006
+
2007
+ .tab.on .tab-badge { color: var(--accent); opacity: 0.75; }
2008
+
2009
+ /* A rail down the side of a pane rather than a strip across the top. Same
2010
+ component, same keyboard; the current one is marked by a filled row instead
2011
+ of a line under it, because there is no bottom edge to draw on. */
2012
+ .tabs-vertical {
2013
+ flex-direction: column;
2014
+ gap: 2px;
2015
+ align-items: stretch;
2016
+ padding: var(--space-3) var(--space-2) var(--space-3) var(--space-3);
2017
+ border-right: var(--border-width) solid var(--border);
2018
+ overflow-y: auto;
2019
+ }
2020
+
2021
+ .tabs-vertical .tab {
2022
+ display: block;
2023
+ width: 100%;
2024
+ text-align: left;
2025
+ padding: var(--space-2);
2026
+ border-radius: var(--radius);
2027
+ box-shadow: none;
2028
+ border: var(--border-width) solid transparent;
2029
+ }
2030
+
2031
+ .tabs-vertical .tab:hover:not(:disabled) { background: var(--panel-2); }
2032
+
2033
+ .tabs-vertical .tab.on {
2034
+ color: var(--text);
2035
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
2036
+ border-color: var(--accent-dim);
2037
+ box-shadow: none;
2038
+ }
2039
+
2040
+ .tabs-vertical .tab-label { display: block; font-size: var(--text-sm); color: var(--text); }
2041
+
2042
+ .tabs-vertical .tab.on .tab-label { color: var(--accent); }
2043
+
2044
+ .tab-hint {
2045
+ display: block;
2046
+ margin-top: 1px;
2047
+ font-size: var(--text-xs);
2048
+ color: var(--muted);
2049
+ line-height: 1.35;
2050
+ }
2051
+
2052
+ /* A heading above a run of tabs. Set apart from the run above by space, not
2053
+ a rule -- the rail already has one edge -- and the tabs under it step in
2054
+ by the label's own padding so the label reads as theirs. */
2055
+ .tabs-vertical .tab-group {
2056
+ display: block;
2057
+ padding: var(--space-4) var(--space-2) var(--space-1);
2058
+ }
2059
+
2060
+ .tabs-vertical .tab-group:first-child { padding-top: var(--space-2); }
2061
+
2062
+ .tab-group-label { display: block; font-size: var(--text-sm); font-weight: 600; color: var(--text); }
2063
+
2064
+ .tabs-vertical .tab.in-group { padding-left: var(--space-5); }
2065
+
2066
+ /* No line under a vertical strip -- its edge is the border on the right. */
2067
+ .tabs-vertical .tabs-rule { display: none; }
2068
+
2069
+ .tabs-rule {
2070
+ position: absolute;
2071
+ left: 0; right: 0; bottom: 0;
2072
+ height: var(--border-width);
2073
+ background: var(--border);
2074
+ }
2075
+
2076
+ .tabs-vertical .tab:active:not(:disabled) { border-color: transparent; }
2077
+
2078
+ /* ---- ./toast.css ---- */
2079
+ /* ---------------------------------------------------------------- toasts */
2080
+ .toasts {
2081
+ position: fixed;
2082
+ z-index: 90;
2083
+ bottom: var(--space-4);
2084
+ left: 50%;
2085
+ transform: translateX(-50%);
2086
+ display: grid;
2087
+ gap: var(--space-2);
2088
+ /* The stack does not take the pointer; each toast does. Otherwise an
2089
+ invisible column down the middle of the app eats clicks. */
2090
+ pointer-events: none;
2091
+ }
2092
+
2093
+ .toast {
2094
+ pointer-events: auto;
2095
+ display: flex;
2096
+ align-items: center;
2097
+ gap: var(--space-2);
2098
+ min-width: 260px;
2099
+ max-width: min(460px, 90vw);
2100
+ padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
2101
+ border: var(--border-width) solid var(--border-strong);
2102
+ border-radius: var(--radius-md);
2103
+ background: var(--panel);
2104
+ box-shadow: var(--shadow-2);
2105
+ font-size: var(--text-sm);
2106
+ animation: tf-toast-in var(--dur-md) var(--ease);
2107
+ }
2108
+
2109
+ /* The tone is the edge and the mark, in the tone's own colour. It used to be the
2110
+ edge alone, and `info` drew it in `--accent` -- a teal that sits a step from
2111
+ the green `good` uses, so the two toasts read as one tone with two icons.
2112
+ `--info` is the blue every other tone-bearing thing in the system means by
2113
+ the word, and the mark now carries the colour too: three signals -- edge,
2114
+ glyph shape, glyph colour -- and none of them alone. */
2115
+ .toast-info { border-left: 2px solid var(--info); }
2116
+
2117
+ .toast-good { border-left: 2px solid var(--good); }
2118
+
2119
+ .toast-warn { border-left: 2px solid var(--warn); }
2120
+
2121
+ .toast-bad { border-left: 2px solid var(--bad); }
2122
+
2123
+ .toast-mark { flex: none; }
2124
+
2125
+ .toast-info .toast-mark { color: var(--info); }
2126
+
2127
+ .toast-good .toast-mark { color: var(--good); }
2128
+
2129
+ .toast-warn .toast-mark { color: var(--warn); }
2130
+
2131
+ .toast-bad .toast-mark { color: var(--bad); }
2132
+
2133
+ /* `data-exiting` is React Aria's: it keeps a toast in the DOM for the length
2134
+ of this animation after it is closed, then removes it, which is what the
2135
+ `.leaving` class and a second timer used to do by hand. */
2136
+ .toast[data-exiting] { animation: tf-toast-out var(--dur-fast) var(--ease) forwards; }
2137
+
2138
+ @media (prefers-reduced-motion: reduce) {
2139
+ .toast, /* `data-exiting` is React Aria's: it keeps a toast in the DOM for the length
2140
+ of this animation after it is closed, then removes it, which is what the
2141
+ `.leaving` class and a second timer used to do by hand. */
2142
+ .toast[data-exiting] { animation: none; }
2143
+ }
2144
+
2145
+ /* ---- ./modal.css ---- */
2146
+ /* modal --------------------------------------------------------------- */
2147
+
2148
+ /* `hidden` needs saying out loud here. The browser's own `[hidden]` rule is a
2149
+ `display: none` that any `display` in a stylesheet outranks -- so a hidden
2150
+ backdrop with `display: flex` on it stays exactly where it was, over the top
2151
+ of everything. Minimising the studio depends on this line. */
2152
+ .backdrop[hidden] { display: none; }
2153
+
2154
+ .backdrop {
2155
+ position: fixed;
2156
+ inset: 0;
2157
+ background: var(--scrim);
2158
+ display: flex;
2159
+ align-items: flex-start;
2160
+ justify-content: center;
2161
+ padding: var(--space-15) var(--space-5);
2162
+ z-index: 50;
2163
+ }
2164
+
2165
+ /* The box is where focus lands when the window opens, not something you tab
2166
+ to -- it is `tabIndex={-1}` and exists so a screen reader starts inside the
2167
+ dialog rather than behind it. Chrome draws its own `:focus-visible` ring on
2168
+ it anyway, which puts a bright outline around the entire window and offers to
2169
+ activate something that does not do anything. The trap and `aria-modal` are
2170
+ what communicate here; the ring belongs on the controls inside. */
2171
+ .modal:focus,
2172
+ .modal:focus-visible,
2173
+ .modal-dialog:focus,
2174
+ .modal-dialog:focus-visible { outline: none; }
2175
+
2176
+ /* The dialog is a `<section>` React Aria renders inside the box, and it is
2177
+ where focus lands on open. It sits between `.modal` and the head, body and
2178
+ actions, so it has to be the column the box used to be: `.modal-body`
2179
+ scrolls only while it is a flex item of something with a bounded height,
2180
+ and a plain block in between would let long content overflow instead.
2181
+
2182
+ Not `display: contents`, which was the first attempt. An element with no box
2183
+ cannot take focus, so React Aria's initial focus fell through to the first
2184
+ input and Escape -- handled on the section -- stopped closing the window. */
2185
+ .modal-dialog {
2186
+ display: flex;
2187
+ flex-direction: column;
2188
+ min-height: 0;
2189
+ flex: 1;
2190
+ }
2191
+
2192
+ .modal {
2193
+ background: var(--panel);
2194
+ border: 1px solid var(--border);
2195
+ border-radius: var(--radius-lg);
2196
+ width: min(760px, 100%);
2197
+ }
2198
+
2199
+ /* Settings is wider than the others: the rail carries group headings with a
2200
+ hint each and indented tabs under them, and a pane beside it that draws
2201
+ cards with a control on the right -- 760 gave one or the other room. */
2202
+ .modal.modal-settings { width: min(880px, 100%); }
2203
+
2204
+ .modal {
2205
+ /* The window, less the backdrop's padding at each end -- `--space-15` is 60px
2206
+ and there are two of them, above and below.
2207
+
2208
+ It was a flat `120px`, which was that sum written out by hand, and
2209
+ `.first-run` subtracted `56px` for the same gap. Three numbers for one
2210
+ measurement: onboarding sat 60 from the top and four pixels *past* the
2211
+ bottom, and nothing would have moved them together.
2212
+
2213
+ `dvh`, not `vh`: on a phone `100vh` is the window with the browser chrome
2214
+ retracted, which is not the height the page has when it opens. */
2215
+ max-height: calc(100dvh - var(--space-15) - var(--space-15));
2216
+ display: flex;
2217
+ flex-direction: column;
2218
+ overflow: hidden;
2219
+ }
2220
+
2221
+ /* Head, body and actions, separated by space rather than by rules.
2222
+ A line above the title and another above the buttons boxed the content into a
2223
+ compartment inside a box that already had a border -- three horizontal rules
2224
+ in a panel eight lines tall. The head and the foot are still their own
2225
+ sections; they are just spaced apart instead of ruled off.
2226
+ The body keeps a rule *while it scrolls*, because there the line is doing
2227
+ something a gap cannot: saying content continues past the edge. */
2228
+ .modal-head {
2229
+ display: flex;
2230
+ align-items: center;
2231
+ gap: var(--space-3);
2232
+ padding: var(--space-3) var(--space-4) var(--space-2);
2233
+ }
2234
+
2235
+ .modal-head strong { font-size: var(--text-base); }
2236
+
2237
+ /* The `<h2>` React Aria puts around the title, reduced to a wrapper. A heading
2238
+ element carries the browser's margins and type size, and this header was
2239
+ laid out for the bare `<strong>` it used to hold -- 86px tall with them,
2240
+ 41px without, on a head whose padding is 12 over 8. `font: inherit` so the
2241
+ `<strong>` rule above is the one that sets the size. */
2242
+ .modal-title { margin: 0; font: inherit; }
2243
+
2244
+ /* Under the title, on the header's padding rather than the body's, so it reads
2245
+ as part of the heading rather than as the first line of content. */
2246
+ .modal-desc {
2247
+ margin: 0;
2248
+ padding: 0 var(--space-4) var(--space-3);
2249
+ color: var(--muted);
2250
+ font-size: var(--text-sm);
2251
+ line-height: 1.5;
2252
+ max-width: 62ch;
2253
+ }
2254
+
2255
+ .modal-body { padding: var(--space-2) var(--space-4) var(--space-3); overflow-y: auto; }
2256
+
2257
+ /* The close cross. Its rule stayed behind in tf's stylesheet when the split
2258
+ ran -- a one-letter class name, and the scan that finds classes in source
2259
+ dropped it -- so in the package the cross had only `.icon-btn`, which is the
2260
+ picker's 34px square tile with a panel behind it. The head is laid out for
2261
+ a 16px glyph with 4px of padding, and grew from 44 to 54. Later in the
2262
+ cascade than `.icon-btn`, so these win; the width and height are said out
2263
+ loud because the tile rule sets them, and `opacity` because the tile dims
2264
+ itself until hovered and the way out of a window should not be faint. */
2265
+ .x {
2266
+ width: auto; height: auto;
2267
+ background: none; border: none; color: var(--muted); opacity: 1;
2268
+ display: inline-flex; align-items: center; justify-content: center;
2269
+ line-height: 1; padding: var(--space-1);
2270
+ border-radius: var(--radius-sm);
2271
+ }
2272
+
2273
+ .x:hover { color: var(--text); border-color: transparent; background: var(--panel-2); }
2274
+
2275
+ /* Always the top right corner, whatever else is in the header.
2276
+ Nothing used to push it there: `.modal-head` is a flex row and every caller
2277
+ happened to put `grow` on its own title. `Modal` renders the title, so a
2278
+ window that passes only a title -- the download one -- had its close button
2279
+ sitting against the last letter of the heading, which read as part of the
2280
+ words rather than as the way out. */
2281
+ .modal-head > .x { margin-left: auto; }
2282
+
2283
+ /* Actions pinned under the pane rather than after the last section, so they do
2284
+ not move as sections of different heights are chosen. */
2285
+ .set-actions {
2286
+ display: flex;
2287
+ align-items: center;
2288
+ gap: var(--space-2);
2289
+ justify-content: flex-end;
2290
+ margin-top: var(--space-3);
2291
+ padding-top: var(--space-3);
2292
+ border-top: var(--border-width) solid var(--border);
2293
+ }
2294
+
2295
+ /* Inside the modal, not floating under it.
2296
+ As a direct child of `.modal` this had the rows' margin and none of the
2297
+ modal's padding, so the buttons sat in a strip of panel with a rule above
2298
+ them and nothing around them -- they read as being outside the thing they
2299
+ belong to. */
2300
+ .modal > .set-actions,
2301
+ .modal-dialog > .set-actions {
2302
+ margin-top: 0;
2303
+ padding: var(--space-2) var(--space-4) var(--space-3);
2304
+ border-top: none;
2305
+ }
2306
+
2307
+ /* Moved here from `base.css` on 2026-09-05, and the position is the point.
2308
+ The extractor filed `.dialog` under base because more than one component
2309
+ named the class, and base is concatenated first -- so this single-class rule
2310
+ came BEFORE `.modal { width: min(760px, 100%) }` instead of after it, as it
2311
+ had for as long as the two existed. Equal specificity, later wins: every
2312
+ dialog opened 760 wide, the exact "wide grey band" the comment below was
2313
+ written to end. No baseline caught it because the suite photographed the
2314
+ button that opens a dialog and never the dialog. `windows.spec.ts` does now. */
2315
+ /* A question is not a workspace.
2316
+ `.modal` is sized for Settings -- 760px wide and pinned near the top of the
2317
+ screen, because eleven panes of preferences need the room and want to start
2318
+ where the eye already is. A two-button question inherited all of that and
2319
+ became a wide grey band across the top of the page: the shape said "here is
2320
+ somewhere to work", the content said "answer this and go". */
2321
+ .dialog {
2322
+ width: min(440px, 100%);
2323
+ /* `auto` on the block axis beats the backdrop's `flex-start`, so the short
2324
+ one centres and the tall one still starts at the top. */
2325
+ margin-block: auto;
2326
+ box-shadow: var(--shadow-3);
2327
+ }
2328
+
2329
+ .dialog .modal-body { color: var(--muted); }
2330
+
2331
+ /* The tone is on the box, not only on the button. A destructive question that
2332
+ looks like every other question is one the reader skims.
2333
+ `Dialog` writes this as `dialog-${tone}`, which a literal scan of the source
2334
+ never sees -- so the rule was left behind in tf's stylesheet by the split and
2335
+ the package shipped a `tone="bad"` prop that changed nothing. */
2336
+ .dialog-bad { border-color: color-mix(in oklab, var(--bad) 45%, var(--border)); }
2337
+
2338
+ /* ---- ./slider.css ---- */
2339
+ /* slider ----------------------------------------------------------------- */
2340
+ /* A number from a range -- `Slider.tsx` says when one is the right control.
2341
+ The row is laid out like a switch row: words on the left, the control on the
2342
+ right, the hint under the label so a long one does not push the knob off its
2343
+ line.
2344
+
2345
+ The control is drawn as the other controls are: the box is a select's box
2346
+ -- `--control` on `--border-strong`, `--radius` corners, the same height as
2347
+ a button beside it -- filled with accent up to the value, with the value
2348
+ printed inside at the right where a select prints its word and a slim bar
2349
+ for the knob. A hairline with a dot on it was a different family from
2350
+ everything else on the row. The native range is invisible and laid over the
2351
+ box, so the keyboard, the focus and the announcement are still its own. */
2352
+ .slider-row {
2353
+ display: flex;
2354
+ align-items: center;
2355
+ /* Wraps: the control is a fixed ~330px and the words beside it deserve a
2356
+ column, not a sliver. Under about 600px the knob goes onto its own line
2357
+ under the words rather than squeezing them to four per line. */
2358
+ flex-wrap: wrap;
2359
+ gap: var(--space-2) var(--space-3);
2360
+ padding: var(--space-2) 0;
2361
+ }
2362
+
2363
+ .slider-row.is-disabled { opacity: 0.55; }
2364
+
2365
+ .slider-body { flex: 1 1 260px; min-width: 0; display: grid; gap: 1px; }
2366
+
2367
+ .set-row > .slider-row { flex: 1; min-width: 0; padding-block: 0; }
2368
+
2369
+ .slider-label { color: var(--text); }
2370
+
2371
+ .slider-hint { font-size: var(--text-sm); color: var(--muted); }
2372
+
2373
+ /* The box, with the value printed inside it at the right where a select
2374
+ prints its word, and under the box the two ends of the range in its
2375
+ corners, so they read as its scale rather than as two more numbers on
2376
+ the row. */
2377
+ .slider-control {
2378
+ display: grid;
2379
+ grid-template-columns: 1fr 1fr;
2380
+ grid-template-areas: "box box" "min max";
2381
+ row-gap: var(--space-1);
2382
+ width: min(var(--slider-w, 150px), 100%);
2383
+ flex: none;
2384
+ margin-left: auto;
2385
+ }
2386
+
2387
+ /* The three sizes every control takes, and the same three boxes: `sm` is
2388
+ `.size-sm`'s padding and type, `md` `.size-md`'s, `lg` `.size-lg`'s -- so
2389
+ a slider beside a button or a select of the same size is the same height.
2390
+ The width scales with them, since that is what a slider mostly is. */
2391
+ .slider-sm { --slider-w: 120px; }
2392
+
2393
+ .slider-sm .slider-box {
2394
+ --slider-h: calc(1em * var(--line-height, 1.5) + var(--space-1) * 2 + 2px);
2395
+ font-size: var(--text-xs);
2396
+ }
2397
+
2398
+ .slider-sm .slider-value { font-size: var(--text-xs); padding: 0 var(--space-3); }
2399
+
2400
+ .slider-sm .slider-hint { font-size: var(--text-xs); }
2401
+
2402
+ .slider-md { --slider-w: 150px; }
2403
+
2404
+ .slider-lg { --slider-w: 180px; }
2405
+
2406
+ .slider-lg .slider-box {
2407
+ --slider-h: calc(1em * var(--line-height, 1.5) + var(--space-3) * 2 + 2px);
2408
+ font-size: var(--text-md);
2409
+ }
2410
+
2411
+ .slider-end { font-size: var(--text-xs); color: var(--muted); }
2412
+
2413
+ .slider-end:first-child { grid-area: min; justify-self: start; }
2414
+
2415
+ .slider-end:last-child { grid-area: max; justify-self: end; }
2416
+
2417
+ /* The box: the select's box, and the fill *is* the reading. The track runs
2418
+ the whole width, so at the maximum the box is full and at the minimum it
2419
+ is empty -- a fill that stopped short of either end read as a control
2420
+ that would not go all the way. The readout sits over the right end where
2421
+ a select prints its word and never moves. The knob is there for the hand:
2422
+ hidden until the pointer is over the box or the keyboard has it, because
2423
+ a white block parked in the fill was a second thing to read when the fill
2424
+ already said it. Sizes are one variable, so the knob is the box's height
2425
+ by arithmetic rather than by eye. */
2426
+ .slider-box {
2427
+ grid-area: box;
2428
+ display: flex;
2429
+ position: relative;
2430
+ /* The size scale's `md`: the same padding and type as a button or a
2431
+ select with no size asked for. `sm` and `lg` below are the scale's too. */
2432
+ --slider-h: calc(1em * var(--line-height, 1.5) + var(--space-2) * 2 + 2px);
2433
+ --slider-knob-w: 12px;
2434
+ font-size: var(--text-base);
2435
+ /* The box's border colour, which the knob borrows so the two agree on
2436
+ hover and on focus rather than one lighting up beside the other. */
2437
+ --slider-edge: var(--border-strong);
2438
+ height: var(--slider-h);
2439
+ border: 1px solid var(--border-strong);
2440
+ border-radius: var(--radius);
2441
+ background: var(--control);
2442
+ overflow: hidden;
2443
+ transition: border-color var(--dur-fast);
2444
+ }
2445
+
2446
+ /* The ring is for the keyboard. `:focus-within` lit it on a click too,
2447
+ because the click focuses the input -- and a ring left behind a click is
2448
+ what the buttons' `:focus-visible` rule exists to avoid. `:has()` reaches
2449
+ the input's own `:focus-visible`, which the browser only sets for keyboard
2450
+ focus. */
2451
+ .slider-box:hover, .slider-box:has(.slider:focus-visible) { --slider-edge: var(--accent); border-color: var(--slider-edge); }
2452
+
2453
+ .slider-box:has(.slider:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
2454
+
2455
+ .slider-row.is-disabled .slider-box:hover { --slider-edge: var(--border-strong); border-color: var(--slider-edge); }
2456
+
2457
+ /* Filled to the fraction of the whole width. The knob travels the width
2458
+ minus itself, so its centre and the fill's edge part by up to half a knob
2459
+ -- always inside the knob, so the edge is never seen beside it -- and in
2460
+ return the fill reaches both ends of the box. `--slider-f` is 0..1, set
2461
+ per render, because CSS cannot read a range's value. */
2462
+ /* The fill's ink is the accent, mixed towards `--bad` by however far the
2463
+ value is into the range the caller marked as a risk (`--slider-risk`,
2464
+ 0% to 100%). Set per render, so the colour moves with the knob rather
2465
+ than flipping at a line. */
2466
+ .slider-track {
2467
+ flex: 1; min-width: 0; position: relative;
2468
+ --slider-f: 0;
2469
+ --slider-risk: 0%;
2470
+ --slider-ink: color-mix(in srgb, var(--bad) var(--slider-risk), var(--accent));
2471
+ background: linear-gradient(to right,
2472
+ color-mix(in srgb, var(--slider-ink) 28%, var(--control)) calc(100% * var(--slider-f)),
2473
+ transparent 0);
2474
+ }
2475
+
2476
+ /* Over the right end of the track, above the knob: when the knob is shown
2477
+ and the value is high the two meet, and the number is read off the knob's
2478
+ face rather than the knob hiding it. No pointer, so the input beneath
2479
+ takes the drag. A step further in from the edge than a select's word,
2480
+ for the same reason. */
2481
+ .slider-value {
2482
+ position: absolute; right: 0; top: 0; bottom: 0; z-index: 1;
2483
+ display: flex; align-items: center; padding: 0 var(--space-4);
2484
+ font-size: var(--text-sm); color: var(--text); pointer-events: none;
2485
+ }
2486
+
2487
+ /* The native input is stretched over the track and drawn nowhere but the
2488
+ knob, so the keyboard, the focus and the announcement are still its own.
2489
+ Written with the type as well as the class to outrank the generic `input`
2490
+ rule, which outlines every field and recolours it on hover and focus. */
2491
+ input[type='range'].slider,
2492
+ input[type='range'].slider:hover,
2493
+ input[type='range'].slider:focus,
2494
+ input[type='range'].slider:focus-visible {
2495
+ position: absolute; inset: 0;
2496
+ -webkit-appearance: none; appearance: none;
2497
+ width: 100%; height: 100%; margin: 0; padding: 0;
2498
+ border: 0; border-radius: 0; box-shadow: none; outline: none;
2499
+ background: transparent; cursor: pointer;
2500
+ }
2501
+
2502
+ /* Disabled: no hand, no knob, and no second dimming -- the row is already
2503
+ at 0.55, and the generic `input:disabled` would stack 0.45 on top of it.
2504
+ Written with the type and the class to outrank both that rule and the
2505
+ hover rules below. */
2506
+ input[type='range'].slider:disabled { cursor: not-allowed; opacity: 1; }
2507
+
2508
+ input[type='range'].slider:disabled::-webkit-slider-thumb { opacity: 0; }
2509
+
2510
+ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
2511
+
2512
+ /* The knob is a handle the full height of the box's inside: a button's face
2513
+ on a button's border with a hair of shadow. Invisible until the pointer is
2514
+ over the box, the drag is under way, or the keyboard has the input --
2515
+ the fill is the reading; the knob is where to grab it. */
2516
+ .slider::-webkit-slider-runnable-track {
2517
+ height: calc(var(--slider-h) - 2px); background: transparent;
2518
+ }
2519
+
2520
+ .slider::-webkit-slider-thumb {
2521
+ -webkit-appearance: none; appearance: none;
2522
+ width: var(--slider-knob-w); height: calc(var(--slider-h) - 2px); margin-top: 0;
2523
+ /* The box's inner radius, so the knob's corners sit inside the box's
2524
+ corners at either end instead of poking a square edge past a round one. */
2525
+ border-radius: calc(var(--radius) - 1px);
2526
+ background: var(--panel); border: 1px solid var(--slider-edge);
2527
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
2528
+ opacity: 0;
2529
+ transition: opacity var(--dur-fast), border-color var(--dur-fast);
2530
+ }
2531
+
2532
+ .slider-box:hover .slider::-webkit-slider-thumb,
2533
+ .slider:active::-webkit-slider-thumb,
2534
+ .slider:focus-visible::-webkit-slider-thumb { opacity: 1; }
2535
+
2536
+ .slider::-moz-range-track { height: calc(var(--slider-h) - 2px); background: transparent; }
2537
+
2538
+ .slider::-moz-range-thumb {
2539
+ width: var(--slider-knob-w); height: calc(var(--slider-h) - 2px);
2540
+ border-radius: calc(var(--radius) - 1px);
2541
+ background: var(--panel); border: 1px solid var(--slider-edge);
2542
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
2543
+ opacity: 0;
2544
+ transition: opacity var(--dur-fast), border-color var(--dur-fast);
2545
+ }
2546
+
2547
+ .slider-box:hover .slider::-moz-range-thumb,
2548
+ .slider:active::-moz-range-thumb,
2549
+ .slider:focus-visible::-moz-range-thumb { opacity: 1; }
2550
+
2551
+ .slider:focus-visible::-webkit-slider-thumb, .slider:focus-visible::-moz-range-thumb { outline: none; }
2552
+
2553
+ /* A settings row sets its controls in the small type and nothing else: the
2554
+ padding is the size scale's. This used to shrink the padding as well, for
2555
+ buttons only, so a button in a row stood 25px beside a 35px select and no
2556
+ size class could ask for the select's height. Type from the row, box from
2557
+ the scale, and the two line up; an explicit `size-*` still wins. */
2558
+ :where(.set-row) button, :where(.set-row) .slider-box { font-size: var(--text-xs); }
2559
+
2560
+ /* ---- ./checkbox.css ---- */
2561
+ /* A row you choose. There is no checkbox in it.
2562
+ Every one of the fourteen in this app sits beside a name and a line of
2563
+ explanation, so the row was always the thing being designed and the 17px
2564
+ square was a control borrowed to say one bit. The square is gone: the box
2565
+ itself is what you press and what shows the answer.
2566
+
2567
+ The input stays in the markup and stays focusable -- it is what a screen
2568
+ reader announces, what a keyboard toggles with Space, and what a form would
2569
+ submit. It is moved out of sight rather than `display: none`, which would
2570
+ take it out of the tab order and off the accessibility tree with it. */
2571
+ .choice {
2572
+ position: relative;
2573
+ display: flex;
2574
+ align-items: flex-start;
2575
+ gap: var(--space-3);
2576
+ padding: var(--space-3) var(--space-3);
2577
+ border: var(--border-width) solid var(--border-strong);
2578
+ border-radius: var(--radius-md);
2579
+ background: var(--panel);
2580
+ cursor: pointer;
2581
+ transition: border-color var(--dur-fast) var(--ease),
2582
+ background var(--dur-fast) var(--ease);
2583
+ }
2584
+
2585
+ /* Spacing between checkboxes belongs to the group that holds them, not to the
2586
+ checkbox. This adjacent-sibling margin stacked on top of `.choice-group`'s
2587
+ gap -- 8 plus 4 -- so a group asking for four pixels got twelve. Left for a
2588
+ loose pair outside a group, of which there are none, so it is gone. */
2589
+
2590
+ /* A run of checkboxes is one choice, not several settings that happen to be
2591
+ adjacent. Tight enough that the group reads as a block -- at the spacing a
2592
+ form field wants, four tools look like four unrelated questions. */
2593
+ .choice-group { display: grid; gap: var(--space-1); }
2594
+
2595
+ /* The same choice, laid out as tiles instead of a column.
2596
+
2597
+ Four short names down the full width of a card is four wide bands for four
2598
+ words -- the row is sized by the card, not by anything in it, so `Read` gets
2599
+ the same 1000px as a sentence would. As tiles they are sized by their
2600
+ content and the group reads as one set of four rather than a stack of four
2601
+ settings.
2602
+
2603
+ `auto-fit` with a floor rather than a fixed four: the pane is a modal that
2604
+ narrows with the window, and four columns of 40px is worse than two of 160.
2605
+ The floor is the width at which a two-word name still fits on one line. */
2606
+ .choice-tiles {
2607
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
2608
+ gap: var(--space-2);
2609
+ }
2610
+
2611
+ /* Room for the tick, which is absolutely positioned at the top right. In a
2612
+ full-width row the text never reached it; in a tile it would run underneath. */
2613
+ .choice-tiles .choice-body { padding-right: var(--space-6); }
2614
+
2615
+ /* The floor is 220px and not 150px because of what four items do at the widths
2616
+ in between. The settings pane gives this group 514px: at a 150px floor that
2617
+ is three columns, so four tools laid out three-and-one -- which is the one
2618
+ arrangement that reads as a mistake. 220px makes it two-by-two here and four
2619
+ across only when there is genuinely room for four. Measured, not guessed:
2620
+ `grid-template-columns` computed to `166px 166px 166px` before this. */
2621
+ .choice input {
2622
+ position: absolute;
2623
+ width: 1px;
2624
+ height: 1px;
2625
+ margin: 0;
2626
+ padding: 0;
2627
+ border: 0;
2628
+ opacity: 0;
2629
+ pointer-events: none;
2630
+ }
2631
+
2632
+ /* Suggestible: it lifts toward the accent before you commit, so the row reads
2633
+ as something that responds rather than something that is written on. */
2634
+ /* State is read off the row. React Aria's `Checkbox` is the `<label>` and
2635
+ stamps `data-selected`, `data-focus-visible` and `data-disabled` on it, so
2636
+ every `:has(input:checked)` below became `[data-selected]`. Same specificity
2637
+ as `:hover` (0,2,0), so the selected rule sits after it and wins by order,
2638
+ which is what the old 0,2,1 did by weight. */
2639
+ .choice:hover { border-color: var(--accent); background: var(--panel-2); }
2640
+
2641
+ /* Chosen. Quiet, because several of these are ticked at once in the accounts
2642
+ list and a strong fill on each would read as an alarm. */
2643
+ .choice[data-selected] {
2644
+ border-color: var(--accent);
2645
+ background: color-mix(in srgb, var(--accent) 8%, var(--panel));
2646
+ }
2647
+
2648
+ .choice[data-selected]:hover {
2649
+ background: color-mix(in srgb, var(--accent) 13%, var(--panel));
2650
+ }
2651
+
2652
+ /* The mark, drawn by the row rather than by a control.
2653
+ It exists because colour cannot be the only thing carrying the state -- the
2654
+ row would otherwise say "chosen" in a hue and nothing else, which is
2655
+ unreadable to anyone who cannot separate the two backgrounds.
2656
+
2657
+ Both layers share one box and one position, and the tick is centred inside it
2658
+ by `center` rather than by arithmetic. The first version offset the tick from
2659
+ the ring by hand -- `top: calc(var(--space-3) + 4px)` and so on -- which
2660
+ centred the tick's *bounding box* and not the tick: the checkmark shape fills
2661
+ 8%-100% of its own height, so its visual middle is four per cent below the
2662
+ box's, and it sat low. */
2663
+ .choice::after,
2664
+ .choice[data-selected]::before {
2665
+ content: '';
2666
+ position: absolute;
2667
+ top: var(--space-3);
2668
+ right: var(--space-3);
2669
+ width: 16px;
2670
+ height: 16px;
2671
+ border-radius: var(--radius-pill);
2672
+ }
2673
+
2674
+ .choice::after {
2675
+ border: var(--border-width) solid var(--border-strong);
2676
+ transition: background var(--dur-fast) var(--ease),
2677
+ border-color var(--dur-fast) var(--ease);
2678
+ }
2679
+
2680
+ .choice[data-selected]::after {
2681
+ border-color: var(--accent);
2682
+ background: var(--accent);
2683
+ }
2684
+
2685
+ /* Masked rather than clipped, so the shape is centred by the browser and takes
2686
+ the accent's contrast partner as its colour.
2687
+
2688
+ **10px, not 9.** `center` in a 16px box leaves (16 - 9) / 2 = 3.5px on each
2689
+ side, and a mask origin cannot land on half a pixel: Chrome snapped it to 4
2690
+ on one axis and 3 on the other, so the tick sat half a pixel right and half a
2691
+ pixel high inside a ring that was otherwise exactly concentric with it. Every
2692
+ even glyph size divides cleanly -- this one is 3px a side -- and it fills the
2693
+ ring better besides. Measured at 20x: offset 0.000px both axes. */
2694
+ .choice[data-selected]::before {
2695
+ z-index: 1;
2696
+ background: var(--on-accent);
2697
+ -webkit-mask: var(--tick-mask) center / 10px 10px no-repeat;
2698
+ mask: var(--tick-mask) center / 10px 10px no-repeat;
2699
+ }
2700
+
2701
+ /* The ring goes on the row, because the row is what is focused: the input it
2702
+ contains is one pixel square and off in a corner. */
2703
+ .choice[data-focus-visible] {
2704
+ outline: none;
2705
+ box-shadow: var(--focus-ring);
2706
+ border-color: var(--accent);
2707
+ }
2708
+
2709
+ /* Room for the mark, so a long name does not run under it. */
2710
+ .choice-body { min-width: 0; flex: 1; padding-right: var(--space-3); }
2711
+
2712
+ .choice-name { display: block; font-weight: var(--weight-strong); }
2713
+
2714
+ /* Explanation, not decoration: every one of these rows is a permission, and the
2715
+ second line is what the first line is actually asking for. */
2716
+ .choice-why { display: block; color: var(--muted); font-size: var(--text-sm); margin-top: 1px; }
2717
+
2718
+ .choice-meta { color: var(--muted); font-size: var(--text-xs); font-family: var(--font-mono); }
2719
+
2720
+ .choice[data-disabled] {
2721
+ cursor: not-allowed;
2722
+ border-color: var(--border);
2723
+ background: var(--panel);
2724
+ }
2725
+
2726
+ .choice[data-disabled] .choice-name,
2727
+ .choice[data-disabled] .choice-why { opacity: 0.5; }
2728
+
2729
+ .choice[data-disabled]::after { opacity: 0.4; }
2730
+
2731
+ /* ---- ./table.css ---- */
2732
+ /* A second line under a row's name, inside the `<th scope="row">` that makes it
2733
+ one. The cell is bold because it is a heading; what hangs under it is not,
2734
+ and inherited the weight -- so a pinned version read as loud as the thing it
2735
+ versions. */
2736
+ .table th .set-hint { font-weight: 400; }
2737
+
2738
+ .table-scroll { overflow-x: auto; }
2739
+
2740
+ .table {
2741
+ width: 100%;
2742
+ border-collapse: collapse;
2743
+ font-size: var(--text-sm);
2744
+ }
2745
+
2746
+ .table th,
2747
+ .table td {
2748
+ text-align: left;
2749
+ padding: var(--space-2) var(--space-3);
2750
+ border-bottom: var(--border-width) solid var(--border);
2751
+ }
2752
+
2753
+ .table th:first-child,
2754
+ .table td:first-child { padding-left: 0; }
2755
+
2756
+ .table th:last-child,
2757
+ .table td:last-child { padding-right: 0; }
2758
+
2759
+ .table thead th {
2760
+ font-size: var(--text-xs);
2761
+ font-weight: var(--weight-strong);
2762
+ text-transform: uppercase;
2763
+ letter-spacing: 0.04em;
2764
+ color: var(--muted);
2765
+ white-space: nowrap;
2766
+ }
2767
+
2768
+ /* The row's own name: a `<th scope="row">`, so it carries weight rather than
2769
+ reading as one more cell. */
2770
+ .table tbody th { font-weight: var(--weight-strong); }
2771
+
2772
+ .table tbody tr:last-child th,
2773
+ .table tbody tr:last-child td { border-bottom: none; }
2774
+
2775
+ /* Numbers compare by column, so they align right and hold their tracks --
2776
+ proportional digits shift the alignment by a pixel per glyph, which is
2777
+ exactly the comparison the column was for. */
2778
+ .table .num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
2779
+
2780
+ .table thead th[style*='right'] { text-align: right; }
2781
+
2782
+ /* ---- ./brand.css ---- */
2783
+ /* Once the morph has finished (`Brand.tsx` adds the class at t = 1), the cog
2784
+ turns -- a revolution in 5 s, a tooth every 0.8 s: the pace of a
2785
+ mechanism, not a spinner. It was 2.4 s, which was too quick to William's
2786
+ eye once the teeth were six and fat. Off under reduced motion, where the
2787
+ mark never becomes a cog in the first place.
2788
+
2789
+ Not applied since 2026-09-03 -- William asked to try it without, and
2790
+ `Brand.tsx` says so where the class would go. The rule stays so that putting
2791
+ the class back is the whole change; it stays HERE, rather than in the app
2792
+ that first held it, because the component that would add the class is this
2793
+ package's. The keyframes are in `base.css` beside `.brand`. */
2794
+ .brand-turning { animation: brand-turn 5s linear infinite; }
2795
+
2796
+ @media (prefers-reduced-motion: reduce) {
2797
+ .brand-turning { animation: none; }
2798
+ }
2799
+
2800
+ .tf-dots { display: inline-flex; gap: var(--space-1); }
2801
+
2802
+ .tf-dots i {
2803
+ width: 5px; height: 5px; border-radius: 50%; background: currentColor;
2804
+ animation: tf-bounce 1.1s ease-in-out infinite;
2805
+ }
2806
+
2807
+ .tf-dots i:nth-child(2) { animation-delay: 0.15s; }
2808
+
2809
+ .tf-dots i:nth-child(3) { animation-delay: 0.3s; }
2810
+
2811
+ @media (prefers-reduced-motion: reduce) {
2812
+ .tf-dots i { animation: none; opacity: 0.6; }
2813
+ }
2814
+
2815
+ /* ---- ./sizegrid.css ---- */
2816
+ /* Tile size, picked as a rectangle rather than read as a notation. */
2817
+ .size-grid-wrap { display: flex; align-items: center; gap: var(--space-2); }
2818
+
2819
+ .size-grid { display: grid; gap: var(--space-1); }
2820
+
2821
+ .size-cell {
2822
+ width: 18px; height: 14px; padding: 0;
2823
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
2824
+ background: var(--panel-2); cursor: pointer;
2825
+ }
2826
+
2827
+ /* `on` follows the pointer; `chosen` is what is actually saved, shown only
2828
+ when nothing is hovered so the two can never contradict each other. */
2829
+ .size-cell.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 45%, transparent); }
2830
+
2831
+ .size-cell.chosen { border-color: var(--accent); }
2832
+
2833
+ .size-cell:disabled { cursor: default; opacity: 0.5; }
2834
+
2835
+ .size-ceiling { opacity: 0.55; }
2836
+
2837
+ /* ---- ./tooltip.css ---- */
2838
+ /* A callout: something worth knowing, with nothing to answer. Distinct from a
2839
+ `?`, which is an offer to read more, and from a banner, which is a problem.
2840
+ The mark is a round `i` rather than an emoji so it takes the theme's colours
2841
+ and stays the same size as the text beside it. */
2842
+ /* An info-toned `.callout`, spelled the old way. Identical to it now rather than
2843
+ nearly identical to it, which is the whole point of the consolidation: the
2844
+
2845
+ /* The `?` beside a setting, and the box it shows on hover. Built like the
2846
+ status dot's tooltip and for the same reasons: `title` waits a second and a
2847
+ half, renders newlines however it likes, and cannot be styled. */
2848
+ .explain {
2849
+ position: relative;
2850
+ flex: none;
2851
+ display: inline-flex;
2852
+ align-items: center;
2853
+ /* The heading it follows is tracked out, which leaves the last letter's
2854
+ spacing sitting between them and reads as none at all. */
2855
+ margin-left: var(--space-2);
2856
+ cursor: help;
2857
+ /* Headings here are uppercase and tracked out, and one of these sits inside
2858
+ a heading. */
2859
+ text-transform: none;
2860
+ letter-spacing: normal;
2861
+ font-weight: 400;
2862
+ }
2863
+
2864
+ .explain-mark {
2865
+ width: 18px;
2866
+ height: 18px;
2867
+ border-radius: 50%;
2868
+ border: 1px solid var(--border);
2869
+ color: var(--muted);
2870
+ font-size: var(--text-xs);
2871
+ line-height: 16px;
2872
+ text-align: center;
2873
+ }
2874
+
2875
+ .explain:hover .explain-mark,
2876
+ .explain:focus-visible .explain-mark { color: var(--text); border-color: var(--accent); }
2877
+
2878
+ .explain-tip {
2879
+ /* No `position`/`top`/`left` here any more: the tip is portalled to
2880
+ `document.body` and React Aria positions it inline, flipping to the other
2881
+ side when the edge is near. `placement` and `offset` on the component
2882
+ carry what `top: calc(100% + 8px)` and `left: -6px` used to. */
2883
+ /* Sized by what is in it, capped where a line stops being readable.
2884
+
2885
+ It was a flat `width: 300px`, which is right for the paragraph a `?` mark
2886
+ opens and absurd for the two words a capability mark opens -- "reads
2887
+ images" got the same 300px box as a four-line explanation, so the tip
2888
+ looked like a panel that had failed to fill. `max-content` hugs the short
2889
+ ones; the cap still wraps the long ones at a readable measure. */
2890
+ width: max-content;
2891
+ max-width: min(300px, 60vw);
2892
+ padding: var(--space-2) var(--space-3);
2893
+ border: 1px solid var(--border);
2894
+ border-radius: var(--radius);
2895
+ background: var(--panel);
2896
+ box-shadow: var(--shadow-2);
2897
+ color: var(--muted);
2898
+ font-size: var(--text-xs);
2899
+ line-height: 1.5;
2900
+ text-align: left;
2901
+ opacity: 0;
2902
+ visibility: hidden;
2903
+ transform: translateY(-3px);
2904
+ transition: opacity var(--dur-fast), transform var(--dur-fast), visibility var(--dur-fast);
2905
+ z-index: 45;
2906
+ }
2907
+
2908
+ /* Which way it grows is `placement` on the component now -- `bottom start`
2909
+ or `bottom end` -- and React Aria flips it when there is no room, which the
2910
+ fixed `left: -6px` / `right: -6px` could not: a mark beside a heading got
2911
+ its first words cut off by the settings rail before `align` existed. */
2912
+
2913
+ /* Out of layout entirely while closed, not merely invisible.
2914
+
2915
+ It was `visibility: hidden`, which hides a box and keeps it in the scrollable
2916
+ overflow. Four 300px tips anchored near the right edge of the file-access
2917
+ tiles therefore gave the Permissions pane a horizontal scrollbar for content
2918
+ nobody could see -- found by hiding one class at a time until the 69px went
2919
+ away, having first blamed the `.sr-only` labels, which were innocent.
2920
+
2921
+ `allow-discrete` keeps the fade: `display` is a discrete property, and
2922
+ without this the tip would appear and vanish with no transition at all. */
2923
+ .explain-tip {
2924
+ display: none;
2925
+ transition-behavior: allow-discrete;
2926
+ }
2927
+
2928
+ /* Open while the pointer is inside the box as well as on the mark -- one that
2929
+ vanishes as you move towards it cannot be read. React Aria keeps it open
2930
+ through a `closeDelay` for that reason, and marks the box `data-entering`
2931
+ and `data-exiting` around the transition. The box is only in the DOM while
2932
+ open, so `display` is React Aria's decision; the fade is ours. */
2933
+ .explain-tip {
2934
+ display: block;
2935
+ opacity: 1;
2936
+ visibility: visible;
2937
+ transform: translateY(0);
2938
+ }
2939
+
2940
+ .explain-tip[data-entering],
2941
+ .explain-tip[data-exiting] {
2942
+ opacity: 0;
2943
+ transform: translateY(-3px);
2944
+ }
2945
+
2946
+ .explain-tip strong { color: var(--text); font-weight: 600; }
2947
+
2948
+ /* ---- ./illustration.css ---- */
2949
+ /* illustrations -------------------------------------------------------- */
2950
+
2951
+ /* Open Peeps, two colours, wearing whatever the theme is.
2952
+ The drawings are black line on white fill and nothing else, which is the only
2953
+ reason they can live in an app with four palettes: `ink` becomes the current
2954
+ text colour and `paper` becomes the surface behind it, so one file covers
2955
+ Night, Paper and both halves of System. Give the wrapper a `color` and
2956
+ the figure follows it -- `.muted` for a quiet empty state, `--accent` for the
2957
+ one thing on screen worth looking at. */
2958
+ .illo {
2959
+ display: inline-block;
2960
+ /* Height is the given dimension and the width comes from the drawing, so a
2961
+ row of figures lines up on the floor rather than on a box. */
2962
+ line-height: 0;
2963
+ color: inherit;
2964
+ }
2965
+
2966
+ .illo svg { height: 100%; width: auto; display: block; }
2967
+
2968
+ .illo .ink { fill: currentColor; }
2969
+
2970
+ /* The surface the figure is drawn on, which is the panel it sits in unless a
2971
+ caller says otherwise. Not `transparent`: these have overlapping shapes, and
2972
+ a shirt that lets the arm behind it show through reads as a hole. */
2973
+ .illo .paper { fill: var(--illo-paper); }
2974
+
2975
+ /* ---- ./field.css ---- */
2976
+ /* fields --------------------------------------------------------------- */
2977
+
2978
+ /* Label, description, input, error -- in the order they are wanted. The
2979
+ description is read before typing, so it goes above; the error is the reply
2980
+ to what was typed, so it goes after. */
2981
+ .field { display: grid; gap: var(--space-1); }
2982
+
2983
+ .field-stack-layout { /* the default: label, description, control, error */ }
2984
+
2985
+ .field-row-layout > .field-label { grid-column: 1; }
2986
+
2987
+ .field-row-layout > .field-hint,
2988
+ .field-row-layout > .field-error { grid-column: 1; }
2989
+
2990
+ .field-row-layout > :not(.field-label):not(.field-hint):not(.field-error) {
2991
+ grid-column: 2;
2992
+ grid-row: 1 / span 2;
2993
+ justify-self: end;
2994
+ }
2995
+
2996
+ .field-label {
2997
+ font-size: var(--text-sm);
2998
+ font-weight: var(--weight-strong);
2999
+ color: var(--text);
3000
+ }
3001
+
3002
+ /* Not red. The asterisk marks which fields are required; colouring it the same
3003
+ as a failure says something has already gone wrong with every one of them. */
3004
+ .field-required { color: var(--muted); }
3005
+
3006
+ .field-hint,
3007
+ .field-error { margin: 0; font-size: var(--text-sm); line-height: 1.45; }
3008
+
3009
+ .field-hint { color: var(--muted); }
3010
+
3011
+ /* Never colour alone: the field is outlined, the input is `aria-invalid`, and
3012
+ the message says what happened in words. Red is the third signal, not the
3013
+ only one. */
3014
+ .field-error { color: var(--bad); }
3015
+
3016
+ .field-bad input:focus,
3017
+ .field-bad textarea:focus { box-shadow: 0 0 0 3px color-mix(in srgb, var(--bad) 30%, transparent); }
3018
+
3019
+ /* The field owns its width; the control fills it. Otherwise a label sits over
3020
+ an input half its length and the pair reads as two unrelated things. */
3021
+ /* A field beside a button. The field takes the space and the button keeps its
3022
+ size, and they line up on the input rather than on the box -- the label and
3023
+ description above it would otherwise push the button down with them. */
3024
+ /* Fields stacked down a form. Wider than the gap inside one field, so the
3025
+ label of the next belongs to the input under it rather than to the error
3026
+ above it. */
3027
+ .field-stack { display: grid; gap: var(--space-4); }
3028
+
3029
+ .field-row > .field { flex: 1; min-width: 0; }
3030
+
3031
+ /* A password with an eye -- `Input type="password"`.
3032
+ A grid rather than `position: absolute`: both children take the one cell, the
3033
+ button is pushed to the end and centred on the cross axis, so it sits inside
3034
+ the input's right edge at every `size` without anybody knowing the input's
3035
+ height. The eye is an icon-only button, and `.icon-btn` would make it the
3036
+ picker's 34px tile -- taller than a small input -- so it is sized here from
3037
+ its glyph instead: 14, 16 or 18px of icon (set in `Input.tsx` from `size`)
3038
+ plus square padding, 22 / 24 / 34 tall against boxes of 27 / 36 / 50. The
3039
+ input keeps room for it on the right: the eye's width plus the gap to the
3040
+ edge and the same again before the text, on the scale so a density change
3041
+ moves the room with the button. */
3042
+ .secret { display: grid; align-items: center; }
3043
+
3044
+ .secret > input,
3045
+ .secret > .secret-eye { grid-area: 1 / 1; }
3046
+
3047
+ .secret > input { padding-right: var(--space-8); }
3048
+
3049
+ /* No `--space-12` on the scale; two steps that are, added. */
3050
+ .secret-lg > input { padding-right: calc(var(--space-10) + var(--space-1)); }
3051
+
3052
+ .secret > .secret-eye {
3053
+ justify-self: end;
3054
+ margin-right: var(--space-1);
3055
+ width: auto; height: auto;
3056
+ padding: var(--space-1);
3057
+ opacity: 1;
3058
+ }
3059
+
3060
+ .secret-lg > .secret-eye { padding: var(--space-2); }
3061
+
3062
+ /* ---- ./tour.css ---- */
3063
+ /* tour ----------------------------------------------------------------- */
3064
+
3065
+ /* The layer sits over everything and catches nothing: the scrim is painted by
3066
+ the hole's shadow, so there is no full-screen element to swallow a click. */
3067
+ .tour { position: fixed; inset: 0; z-index: 60; pointer-events: none; }
3068
+
3069
+ /* The control the tour is currently pointing at.
3070
+
3071
+ Much of this chrome is invisible until you touch it -- `.view-add` lives at
3072
+ `opacity: 0` until `.topbar:hover` -- so the spotlight cut its hole round a
3073
+ button nobody could see and the card explained a blank space.
3074
+
3075
+ `!important` on purpose, and this is the case it is for: the tour cannot know
3076
+ what every control does at rest, and the alternative is naming each one here
3077
+ and watching that list drift from the rules that hide them. One declaration
3078
+ that outranks whatever the resting state is beats an enumeration that goes
3079
+ stale. It is scoped to a class only ever present on one element at a time. */
3080
+ .tour-target {
3081
+ opacity: 1 !important;
3082
+ color: var(--text) !important;
3083
+ }
3084
+
3085
+ /* The spotlight. An enormous spread shadow dims the whole window except the
3086
+ rectangle it is drawn on, which needs no clip path and no arithmetic. */
3087
+ .tour-hole {
3088
+ position: fixed;
3089
+ border-radius: var(--radius-md);
3090
+ box-shadow: 0 0 0 9999px var(--scrim), 0 0 0 2px var(--accent);
3091
+ transition: top var(--dur-md) var(--ease), left var(--dur-md) var(--ease),
3092
+ width var(--dur-md) var(--ease), height var(--dur-md) var(--ease);
3093
+ }
3094
+
3095
+ @media (prefers-reduced-motion: reduce) {
3096
+ /* It still moves between stops -- it has to, that is the point -- it just
3097
+ arrives rather than travels. */
3098
+ .tour-hole { transition: none; }
3099
+ }
3100
+
3101
+ .tour-card {
3102
+ position: fixed;
3103
+ width: 340px;
3104
+ max-width: calc(100vw - var(--space-8));
3105
+ pointer-events: auto;
3106
+ background: var(--panel);
3107
+ border: var(--border-width) solid var(--border-strong);
3108
+ border-radius: var(--radius-lg);
3109
+ box-shadow: var(--shadow-3);
3110
+ padding: var(--space-4);
3111
+ display: grid;
3112
+ gap: var(--space-2);
3113
+ }
3114
+
3115
+ .tour-card:focus { outline: none; }
3116
+
3117
+ .tour-title { font-size: var(--text-base); }
3118
+
3119
+ .tour-body { margin: 0; color: var(--muted); font-size: var(--text-sm); line-height: 1.5; }
3120
+
3121
+ .tour-actions { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
3122
+
3123
+ .tour-count { color: var(--muted); font-size: var(--text-xs); }
3124
+