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