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