devicectl-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,3034 @@
1
+ /* The design system: what a card, a row, a button and a dialog are made of.
2
+ *
3
+ * Every rule here is written against tokens and names no colour, so the
4
+ * same stylesheet dresses two programs that are meant to be told apart by
5
+ * their palette and by nothing else. A program supplies the colours in its
6
+ * own `app.css`, along with the panels that are genuinely its own.
7
+ *
8
+ * The tokens a program must define, in `:root` and again for the light
9
+ * theme -- all of them colours, and that is the point:
10
+ *
11
+ * surfaces --bg --surface --surface-2 --surface-3 --line --line-strong
12
+ * type --text --text-dim --text-faint
13
+ * colour --primary --primary-bright --primary-deep
14
+ * --live --live-deep something happening now
15
+ * --warn --warn-deep worth a look
16
+ * --ok --ok-bright --ok-deep settled, within limits
17
+ * --danger --danger-deep destructive, or a fault
18
+ *
19
+ * Two optional tokens. `--chart-alt` is the second line on a chart that
20
+ * carries two readings -- it defaults to `--primary-bright`, and exists so
21
+ * a program whose `--live` is already close to that can move it rather than
22
+ * draw two lines nobody can tell apart.
23
+ *
24
+ * And `--switch-on`: what a switch is filled with when it is on. It
25
+ * defaults to `--primary` and is a token because the two programs do not
26
+ * mean the same thing by a switch. In one they are settings -- a scan
27
+ * with the radio on, a backup that includes the certificates -- and a
28
+ * setting that is enabled is the page's own colour.
29
+ * In the other they are the pack's three main switches: charging on,
30
+ * discharging on, balancing on, which is the page's word for *within
31
+ * limits and working*, and that page paints that green wherever it says
32
+ * it. A blue switch there would be the one place on the page where "on"
33
+ * was not the colour everything else says "on" in.
34
+ *
35
+ * Every *measurement* is defined below instead -- the type scale, the
36
+ * radii, the gaps, how tight a card is, how wide a label column is, where
37
+ * the page's edges are. They were each program's own, and the result was
38
+ * two pages that shared a stylesheet and still did not line up: one had a
39
+ * 14px card padding and a 10.5rem label column, the other 18px and 9.5rem,
40
+ * so the same card was a different shape depending on which program drew
41
+ * it. A program that overrides one of these numbers now is saying
42
+ * something deliberate rather than repeating a default.
43
+ *
44
+ * The four hues used to be named for what they are -- blue, amber, green,
45
+ * rose -- which worked for one program and stopped working for two: both
46
+ * had all four and gave them different jobs, so a shared rule reading
47
+ * `var(--amber)` meant "live" in one page and "worth a look" in the other.
48
+ * Names are jobs here. `--warn` and `--live` are the same amber in both
49
+ * today, and are two tokens so that either program can part them later
50
+ * without touching this file.
51
+ */
52
+
53
+ /* --- the type scale ------------------------------------------------------- *
54
+ *
55
+ * The one thing two programs sharing a design system must not each decide
56
+ * for themselves. Both had written this block out with the same four
57
+ * numbers in it -- and then each had gone on to write a fifth somewhere
58
+ * else, a 14px table in one and a 13.5px tab strip in the other, until the
59
+ * same widget was a different size on the two pages.
60
+ *
61
+ * `--fs-base` is the body; the scale is two steps below it and one above.
62
+ * Nothing on either page is smaller than `--fs-xs`, because a device is
63
+ * read in a garage at night as often as at a desk.
64
+ */
65
+ :root {
66
+ --fs-base: 15px;
67
+ --fs-xs: 12px;
68
+ --fs-sm: 13.5px;
69
+ --fs-lg: 17px;
70
+ --body-leading: 1.55;
71
+
72
+ /* A card's heading is an eyebrow rather than a title: small, letterspaced
73
+ * and quiet -- and it is the same eyebrow on both pages. One of them set
74
+ * it at 650 and 0.07em in the faint grey and the other at 600 and 0.09em
75
+ * in the dim one, which is two different headings on two pages that are
76
+ * supposed to be the same program wearing two colours. */
77
+ --card-title-size: var(--fs-xs);
78
+ --card-title-weight: 620;
79
+ --card-title-track: 0.08em;
80
+ --card-title-color: var(--text-dim);
81
+ --card-title-gap: 12px;
82
+
83
+ /* A toast is chrome and is set at the size the rest of the chrome is. The
84
+ * fallback here used to be an ad-hoc 14px that only one of the two pages
85
+ * ever overrode, which made a toast a different size in each. */
86
+ --toast-size: var(--fs-sm);
87
+
88
+ /* The two families. Both programs had named the same two stacks; one
89
+ * with a face of its own overrides them. */
90
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
91
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
92
+
93
+ /* --- the measurements ---------------------------------------------------
94
+ *
95
+ * Corners, gaps, and how much air a card, a row and the page's edges are
96
+ * given. Every one of these was written twice, with two different
97
+ * numbers, so a card in one program was 14px tight and in the other 18px
98
+ * loose, a label column was 10.5rem here and 9.5rem there, and the two
99
+ * pages did not line up when you put them side by side.
100
+ *
101
+ * The numbers below are one set, halfway between the two: dense enough
102
+ * for a page of forty readings and loose enough to read at arm's length
103
+ * in a garage.
104
+ */
105
+ --radius: 10px;
106
+ --radius-sm: 6px;
107
+ --gap: 16px;
108
+
109
+ /* How wide the content is, and how far it stays off the window's edge.
110
+ * The header, the tab strip and the page all use these, so the wordmark,
111
+ * the first tab and the first card share one left edge. */
112
+ --page: 1600px;
113
+ --gutter: clamp(14px, 2.4vw, 40px);
114
+
115
+ /* A card: its padding, the gap between two rows in it, and how wide a
116
+ * column of the grid wants to be before another column is added. */
117
+ --card-pad: 16px 18px;
118
+ --row-gap: 8px;
119
+ --grid-track: 340px;
120
+
121
+ /* The width of a field's name, everywhere on both pages. One number is
122
+ * what makes values line up down a page instead of stepping in and out
123
+ * with whatever the longest label in each card happened to be -- and one
124
+ * number *across* the two pages is what makes them the same program. */
125
+ --label-col: 10rem;
126
+
127
+ --modal-width: 640px;
128
+ }
129
+
130
+ /* What the browser draws its own widgets in -- a number input's spinners, a
131
+ * select's arrow and popup, a scrollbar, a range slider's track. `useTheme`
132
+ * in js/shell.js always stamps `data-theme`, from the system's own
133
+ * preference unless this browser has been told otherwise, so this follows
134
+ * the page rather than the machine: a laptop set to dark was putting black
135
+ * widgets on a light page in the one program that had forgotten to say
136
+ * this. Checkboxes are no longer among them -- see `label.toggle`, which
137
+ * draws its own. */
138
+ :root {
139
+ color-scheme: dark;
140
+ }
141
+
142
+ :root[data-theme="light"] {
143
+ color-scheme: light;
144
+ }
145
+
146
+ /* --- base ---------------------------------------------------------------- */
147
+
148
+ * {
149
+ box-sizing: border-box;
150
+ }
151
+
152
+ html,
153
+ body {
154
+ margin: 0;
155
+ height: 100%;
156
+ }
157
+
158
+ body {
159
+ background: var(--bg);
160
+ color: var(--text);
161
+ font: var(--fs-base)/var(--body-leading) var(--sans);
162
+ -webkit-font-smoothing: antialiased;
163
+ }
164
+
165
+ button,
166
+ input,
167
+ select,
168
+ textarea {
169
+ font: inherit;
170
+ color: inherit;
171
+ }
172
+
173
+ a {
174
+ color: var(--primary);
175
+ }
176
+
177
+ :focus-visible {
178
+ outline: 2px solid var(--primary);
179
+ outline-offset: 2px;
180
+ }
181
+
182
+ /* Every number that changes under you keeps its columns still. */
183
+ .value,
184
+ table td,
185
+ .stat .v,
186
+ .pill-op {
187
+ font-variant-numeric: tabular-nums;
188
+ }
189
+
190
+ .muted {
191
+ color: var(--text-faint);
192
+ }
193
+
194
+ /* Something read character by character -- a device path, a register name,
195
+ * a command you are meant to type. Both pages had this helper, at the same
196
+ * two declarations, under the same name. */
197
+ .mono {
198
+ font-family: var(--mono);
199
+ font-size: var(--fs-sm);
200
+ }
201
+
202
+ .spacer {
203
+ flex: 1 1 auto;
204
+ }
205
+
206
+ .grow {
207
+ flex: 1 1 auto;
208
+ min-width: 180px;
209
+ }
210
+
211
+ .grow.narrow {
212
+ flex: 1 1 90px;
213
+ min-width: 90px;
214
+ }
215
+
216
+ .small {
217
+ font-size: var(--fs-sm);
218
+ }
219
+
220
+ .spaced {
221
+ margin-top: 12px;
222
+ }
223
+
224
+ /* Said to a screen reader and to nothing else. Not `display: none`, which
225
+ * is not read at all. */
226
+ .sr-only {
227
+ position: absolute;
228
+ width: 1px;
229
+ height: 1px;
230
+ margin: -1px;
231
+ padding: 0;
232
+ overflow: hidden;
233
+ clip-path: inset(50%);
234
+ white-space: nowrap;
235
+ border: 0;
236
+ }
237
+
238
+ /* --- the grid of cards --------------------------------------------------- */
239
+
240
+ /* One card is one column, always. auto-fill (not auto-fit) keeps the track
241
+ * width fixed by the window rather than by how many cards happen to exist,
242
+ * so every panel on the page is the same width and a page with five of them
243
+ * looks like a page with eight.
244
+ *
245
+ * The cards used to keep their own heights, which meant a row of them ended
246
+ * on three different lines and the page read as a pile rather than a grid.
247
+ * They stretch to their row now, and the card is a column so that what sits
248
+ * at the bottom of one -- the Apply row, the caveats -- sits at the bottom
249
+ * of all of them rather than wherever its own content happened to stop. */
250
+ .grid {
251
+ display: grid;
252
+ gap: var(--gap);
253
+ grid-template-columns: repeat(auto-fill, minmax(var(--grid-track, 360px), 1fr));
254
+ align-items: stretch;
255
+ }
256
+
257
+ .card {
258
+ display: flex;
259
+ flex-direction: column;
260
+ min-width: 0;
261
+ background: var(--surface);
262
+ border: 1px solid var(--line);
263
+ border-radius: var(--radius);
264
+ padding: var(--card-pad);
265
+ }
266
+
267
+ /* Every card renders this heading through the one shared `Card`, so its
268
+ * layout lives here rather than as an inline style the class checks cannot
269
+ * see: the title, then a spacer, then whatever the card hangs on the right
270
+ * of it -- a badge saying what it is, then the actions that work on it. */
271
+ .card > h2 {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 10px;
275
+ margin: 0 0 var(--card-title-gap, 14px);
276
+ font-size: var(--card-title-size);
277
+ font-weight: var(--card-title-weight, 600);
278
+ letter-spacing: var(--card-title-track);
279
+ text-transform: uppercase;
280
+ color: var(--card-title-color);
281
+ }
282
+
283
+ .card > h2 .grow {
284
+ flex: 1 1 auto;
285
+ min-width: 0;
286
+ }
287
+
288
+ /* Nothing beside the title makes the title taller. A badge, a button, the
289
+ * unsent count and its Apply are all taller than a line of small capitals,
290
+ * and each one that came and went -- the count with the first edit, the
291
+ * Apply with it -- moved everything under the title by the difference. So
292
+ * they overhang the line instead, centred on it, into the card's padding
293
+ * above and the title's gap below; every title is the height of its text,
294
+ * whatever is hung on it. */
295
+ .card > h2 > :not(.grow) {
296
+ flex: none;
297
+ margin-block: -8px;
298
+ white-space: nowrap;
299
+ }
300
+
301
+ /* And the title keeps to its one line while the count and its Apply are
302
+ * beside it: on a phone a long title would otherwise wrap under them, and
303
+ * the card would grow by a line with the first edit. It is cut short
304
+ * instead, for as long as the card holds edits -- the edge and the count
305
+ * say which card it is meanwhile. */
306
+ .card.pending > h2 .grow {
307
+ overflow: hidden;
308
+ white-space: nowrap;
309
+ text-overflow: ellipsis;
310
+ }
311
+
312
+ .card-body {
313
+ flex: 1 1 auto;
314
+ min-width: 0;
315
+ }
316
+
317
+ /* A button that is a card's own child -- "Restart the device", "Sync the
318
+ * clock" -- is as wide as its label, not as wide as the card. It used to
319
+ * be that by being inline in a block; now that the card is a flex column
320
+ * it has to be told, or a stretched item is what it becomes. */
321
+ .card-body > button.btn {
322
+ align-self: flex-start;
323
+ }
324
+
325
+ /* Two exceptions to a card per column, and they are the only two. `wide`
326
+ * is content that wants more than one track but not the row -- a list of
327
+ * releases, a four-column table; `full` is content that is genuinely a row
328
+ * wide. Anything else stays one column, which is what keeps the page a
329
+ * grid.
330
+ *
331
+ * A full-width card *ends* its row, so every card after a short one
332
+ * spanning the page starts a new line -- which is what makes a tab read as
333
+ * a stack rather than a grid. `tools/frontlint.py` (C005) holds a card
334
+ * claiming the row to holding a table, a log or a plot.
335
+ *
336
+ * What is *not* here is `grid-auto-flow: dense`. It would let a later
337
+ * one-column card backfill the gap a wide one leaves at the end of a row,
338
+ * and it would do it by decoupling visual order from DOM order -- on a
339
+ * page whose tab strip is a real `role="tablist"` with a roving tabIndex.
340
+ * Keyboard order disagreeing with reading order is a worse fault than a
341
+ * gap. Cards that really are a row wide go last within their tab instead,
342
+ * so the row they end is the last one. */
343
+ .card.wide {
344
+ grid-column: span 2;
345
+ }
346
+
347
+ .card.full {
348
+ grid-column: 1 / -1;
349
+ }
350
+
351
+ @media (max-width: 900px) {
352
+ .card.wide {
353
+ grid-column: 1 / -1;
354
+ }
355
+ }
356
+
357
+ /* --- which device this page could be about ---------------------------------
358
+ *
359
+ * A grid of tiles, one per device the server can see: the boards on a bus,
360
+ * the chargers on a network. Clicking one is how the page changes what it
361
+ * is about, and the same grid is behind the button in the header that opens
362
+ * the picker -- so the list you choose from is the list you were looking at.
363
+ *
364
+ * A tile is a button, is as wide as the grid gives it, and carries a
365
+ * coloured left edge that is the whole point of the view: four packs, four
366
+ * states of charge, and the one that is drifting visible without opening
367
+ * anything.
368
+ */
369
+ .tiles {
370
+ display: grid;
371
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
372
+ gap: var(--gap);
373
+ }
374
+
375
+ .tile {
376
+ display: block;
377
+ width: 100%;
378
+ text-align: left;
379
+ color: inherit;
380
+ background: var(--surface);
381
+ border: 1px solid var(--line);
382
+ /* Neutral until the device has said something. A green edge on a tile
383
+ * for a charger nobody has connected to yet is the page claiming to know
384
+ * it is well. */
385
+ border-left: 3px solid var(--line-strong);
386
+ border-radius: var(--radius);
387
+ padding: 13px 15px;
388
+ cursor: pointer;
389
+ }
390
+
391
+ .tile:hover {
392
+ border-color: var(--primary);
393
+ }
394
+
395
+ .tile.good {
396
+ border-left-color: var(--ok);
397
+ }
398
+
399
+ .tile.warn {
400
+ border-left-color: var(--warn);
401
+ }
402
+
403
+ .tile.bad {
404
+ border-left-color: var(--danger);
405
+ }
406
+
407
+ .tile.gone {
408
+ border-left-color: var(--line-strong);
409
+ opacity: 0.75;
410
+ }
411
+
412
+ .tile.selected {
413
+ outline: 1px solid var(--primary);
414
+ }
415
+
416
+ .tile-head {
417
+ display: flex;
418
+ align-items: baseline;
419
+ gap: 8px;
420
+ margin-bottom: 8px;
421
+ }
422
+
423
+ .tile-head .id {
424
+ font-weight: 700;
425
+ font-size: var(--fs-lg);
426
+ }
427
+
428
+ /* Nothing is written until this row says so: a card of settings is edited
429
+ * as a whole and applied once, so a stray keystroke cannot reach a device
430
+ * that is mid-charge. */
431
+ .card-foot {
432
+ display: flex;
433
+ align-items: center;
434
+ flex-wrap: wrap;
435
+ gap: 10px;
436
+ /* `auto` rather than a fixed margin: the cards in a row are one height,
437
+ * so the row that commits a card's edits belongs against its bottom
438
+ * edge, not floating wherever the fields above it happened to end. */
439
+ margin-top: auto;
440
+ padding-top: 13px;
441
+ border-top: 1px solid var(--line);
442
+ }
443
+
444
+ /* The gap the footer's own margin used to hold open, moved to the element
445
+ * above it -- an `auto` margin spends itself on the free space and has
446
+ * nothing left over for the card that is already as tall as its row.
447
+ *
448
+ * `:not(:empty)` for the same reason the rule below folds an empty foot
449
+ * away: an Apply bar that appears only once something is edited leaves the
450
+ * element behind, and a gap held open for a rule that is not being drawn
451
+ * is 16px of nothing at the bottom of every card that can be edited. */
452
+ .card-body:has(+ .card-foot:not(:empty)) {
453
+ margin-bottom: 16px;
454
+ }
455
+
456
+ /* A foot whose contents are conditional -- an Apply bar that appears only
457
+ * once something is edited -- still leaves the element behind. A rule
458
+ * ruling off nothing reads as a card that failed to finish drawing. */
459
+ .card-foot:empty {
460
+ display: none;
461
+ }
462
+
463
+ .card-foot .state {
464
+ color: var(--text-faint);
465
+ font-size: var(--fs-sm);
466
+ }
467
+
468
+ /* --- rows ---------------------------------------------------------------- */
469
+
470
+ /* Every row in a card shares one two-column grid, so all the values in a
471
+ * card start at the same place. The width of that column is the same
472
+ * number in every card: derived from the content it made "Mode" and "Clock
473
+ * difference" put their values in different places, and a page of cards had
474
+ * as many value columns as it had cards. A fixed column is one column,
475
+ * down the whole page.
476
+ *
477
+ * The label sits against the middle of its value, not against its first
478
+ * baseline. Baseline alignment is right for two pieces of text and wrong
479
+ * for everything else a value can be: a `<select>`, a range slider and a
480
+ * checkbox each put their baseline somewhere different -- the bottom of
481
+ * the box, the middle of the thumb -- so a card of mixed rows had the
482
+ * labels at four heights and none of them level with the control they
483
+ * name. Centred, every label is level with its value whatever the value
484
+ * is made of. */
485
+ .rows {
486
+ display: grid;
487
+ grid-template-columns: minmax(0, var(--label-col)) minmax(0, 1fr);
488
+ gap: var(--row-gap, 9px) 16px;
489
+ align-items: center;
490
+ }
491
+
492
+ /* A row that is not in a row list is a two-column grid of its own.
493
+ *
494
+ * It was only ever `display: contents`, which is how a pair dissolves into
495
+ * the list's shared grid -- and does nothing whatever when there is no list
496
+ * around it. Put straight into a card, which is a flex *column*, the label
497
+ * and the value became two stacked items: the name on one line and the
498
+ * value right-aligned on the next. Whole tabs were built that way while
499
+ * others wrapped their rows and got two columns, which is why the same kind
500
+ * of field looked like two different things depending on which tab you were
501
+ * on.
502
+ *
503
+ * The columns are identical either way, because the label column is a fixed
504
+ * width rather than one derived from the content -- so a row aligns with
505
+ * the rows above it whether they share one grid or have one each, and a
506
+ * card can no longer be laid out wrongly by forgetting a wrapper. */
507
+ .row {
508
+ display: grid;
509
+ grid-template-columns: minmax(0, var(--label-col)) minmax(0, 1fr);
510
+ gap: var(--row-gap, 9px) 16px;
511
+ align-items: center;
512
+ }
513
+
514
+ .row + .row {
515
+ margin-top: var(--row-gap, 9px);
516
+ }
517
+
518
+ .rows .row {
519
+ display: contents;
520
+ margin-top: 0;
521
+ }
522
+
523
+ /* On a phone, the label above its value: a key, a URL or a timestamp gets
524
+ * the whole width of the card rather than a third of it.
525
+ *
526
+ * Stacked there is no column of values for one to line up with, so the
527
+ * right alignment that makes two columns tidy is dropped along with the
528
+ * second column -- a value pushed to the far edge from the label above it
529
+ * only looks lost. Without this the second column keeps a minimum of its
530
+ * own and the inputs in it run off the side of the page. */
531
+ @media (max-width: 560px) {
532
+ .rows,
533
+ .row {
534
+ grid-template-columns: minmax(0, 1fr);
535
+ gap: 2px;
536
+ }
537
+
538
+ .rows .row + .row .k,
539
+ .row + .row {
540
+ margin-top: 9px;
541
+ }
542
+
543
+ .v {
544
+ text-align: left;
545
+ }
546
+
547
+ .v > .set,
548
+ .v > .toggle,
549
+ .v > .chips,
550
+ .v > .actions {
551
+ justify-content: flex-start;
552
+ }
553
+ }
554
+
555
+ /* A row list is a two-column grid whose pairs dissolve into it, so anything
556
+ * else put directly in one is a grid item and auto-placement drops it in
557
+ * whichever single column happened to be free -- usually the label column,
558
+ * which wraps a price list to death. Give such a block the whole width; a
559
+ * group of rows belongs flat among the rows anyway. */
560
+ .rows > :not(.row) {
561
+ grid-column: 1 / -1;
562
+ }
563
+
564
+ .row .k {
565
+ color: var(--text-dim);
566
+ font-size: var(--fs-sm);
567
+ }
568
+
569
+ /* A name with something to say about itself on hover -- an acronym spelled
570
+ * out, the register behind it. Dotted, the way an abbreviation is marked,
571
+ * so the pointer is told there is something there without the name being
572
+ * made to look like a link. */
573
+ .row .k.hinted {
574
+ cursor: help;
575
+ text-decoration: underline dotted color-mix(in srgb, var(--text-dim) 55%, transparent);
576
+ text-underline-offset: 3px;
577
+ }
578
+
579
+ /* Values end at the card's right edge rather than starting at the label's.
580
+ * Left-aligned they left a different width of nothing after every one, so a
581
+ * card of six readings had six ragged ends; against the edge they line up
582
+ * with each other, with the values in the card below, and -- the cards
583
+ * being one width -- across the page. */
584
+ .row .v {
585
+ color: var(--text);
586
+ min-width: 0;
587
+ overflow-wrap: anywhere;
588
+ text-align: right;
589
+ }
590
+
591
+ /* A value that is itself a row of things -- a slider and its reading, a
592
+ * checkbox and its label -- has to be pushed rather than aligned. */
593
+ .row .v > .set,
594
+ .row .v > .toggle,
595
+ .row .v > .chips,
596
+ .row .v > .wrap {
597
+ justify-content: flex-end;
598
+ }
599
+
600
+ /* Identifiers, keys and timestamps read character by character, so they get
601
+ * fixed-width digits. They wrap like anything else: a value that runs off
602
+ * its own scrollbar is worse than one that takes two lines. */
603
+ .row .v.data,
604
+ .row .v.token {
605
+ font-family: var(--mono);
606
+ font-size: var(--fs-sm);
607
+ }
608
+
609
+ .row .v.good {
610
+ color: var(--ok);
611
+ }
612
+
613
+ .row .v.warn {
614
+ color: var(--warn);
615
+ }
616
+
617
+ .row .v.bad {
618
+ color: var(--danger);
619
+ }
620
+
621
+ /* --- badges, stats and empties -------------------------------------------- */
622
+
623
+ .badge {
624
+ font-size: var(--fs-xs);
625
+ letter-spacing: 0.05em;
626
+ text-transform: uppercase;
627
+ padding: 2px 7px;
628
+ border-radius: 4px;
629
+ border: 1px solid var(--line-strong);
630
+ color: var(--text-dim);
631
+ }
632
+
633
+ .badge.warn {
634
+ color: var(--warn);
635
+ border-color: var(--warn-deep);
636
+ }
637
+
638
+ .badge.good {
639
+ color: var(--ok);
640
+ border-color: var(--ok-deep);
641
+ }
642
+
643
+ .badge.bad {
644
+ color: var(--danger);
645
+ border-color: var(--danger-deep);
646
+ }
647
+
648
+ .badge.info {
649
+ color: var(--primary);
650
+ border-color: var(--primary-deep);
651
+ }
652
+
653
+ /* How many edits a card is holding that have not been written. In the
654
+ * colour every unsent edit on the page wears -- the field's edge, the dot
655
+ * on a band -- so the card, the row and the control all say the same thing
656
+ * in the same colour. See `Card` in js/ui.js. */
657
+ .badge.unsent {
658
+ color: var(--live);
659
+ border-color: var(--live);
660
+ white-space: nowrap;
661
+ }
662
+
663
+ /* A card's own Apply and Discard, in its title (see `CardApply`): two
664
+ * squares the height of the badge beside them. Like everything else hung
665
+ * on a title they take none of its height (`.card > h2 > :not(.grow)`). */
666
+ .card-apply {
667
+ display: inline-flex;
668
+ gap: 4px;
669
+ }
670
+
671
+ button.btn.tick {
672
+ display: inline-flex;
673
+ align-items: center;
674
+ justify-content: center;
675
+ width: 26px;
676
+ height: 24px;
677
+ padding: 0;
678
+ line-height: 1;
679
+ }
680
+
681
+ /* A card holding unsent edits, from across the page: its edge changes
682
+ * colour. A colour on an edge the card already has, so nothing in it or
683
+ * around it moves when the first edit is made or the last one sent. */
684
+ .card.pending {
685
+ border-color: color-mix(in srgb, var(--live) 70%, var(--line));
686
+ }
687
+
688
+ /* A labelled reading, in a row of them: what it is in small caps, the
689
+ * number under it. Pack voltage, current, power; average cell, spread,
690
+ * balance current. Three or six of these under a card's title is how both
691
+ * pages say "here are the figures" before anything has to be read.
692
+ *
693
+ * It was a bordered pill here and a plain stacked pair in the other
694
+ * program, under the same class name -- so a stylesheet rule written
695
+ * against `.stat` meant two different widgets depending on which page it
696
+ * landed on. This is the stacked one: a pill of two words is a badge, and
697
+ * `.badge` is already that.
698
+ *
699
+ * `.k` and `.v` rather than `.label` and a bare div, because that is what a
700
+ * row's two halves are called everywhere else on both pages.
701
+ */
702
+ .stats {
703
+ display: flex;
704
+ flex-wrap: wrap;
705
+ gap: 6px 18px;
706
+ }
707
+
708
+ /* As wide as what it says, and no wider. A floor under this -- these were
709
+ * 90px each -- is what made a row of three readings wrap to two and one
710
+ * inside a tile, which reads as a layout that gave up rather than as three
711
+ * figures. */
712
+ .stat {
713
+ min-width: 0;
714
+ }
715
+
716
+ .stat .k {
717
+ display: block;
718
+ font-size: var(--fs-xs);
719
+ color: var(--text-faint);
720
+ text-transform: uppercase;
721
+ letter-spacing: 0.05em;
722
+ }
723
+
724
+ .stat .v {
725
+ color: var(--text);
726
+ }
727
+
728
+ .stat.warn .v {
729
+ color: var(--warn);
730
+ }
731
+
732
+ .stat.good .v {
733
+ color: var(--ok);
734
+ }
735
+
736
+ .stat.bad .v {
737
+ color: var(--danger);
738
+ }
739
+
740
+ /* Nothing here, said in the space one sentence needs. This was 30px of
741
+ * padding above and below, which on a tab whose empty state is the whole
742
+ * page until something is read is a card mostly made of nothing, above the
743
+ * one control that would fill it. */
744
+ .empty {
745
+ color: var(--text-dim);
746
+ padding: 16px 4px;
747
+ text-align: center;
748
+ }
749
+
750
+ /* Explanatory prose under a card's title -- read as text, not a caption. */
751
+ .note {
752
+ color: var(--text-dim);
753
+ font-size: var(--fs-sm);
754
+ margin: 0 0 14px;
755
+ }
756
+
757
+ /* Prose that carries its own spacing already -- inside a dialog, or beside
758
+ * a control in a toolbar. */
759
+ .note.flush {
760
+ margin: 0;
761
+ }
762
+
763
+ /* --- tables ---------------------------------------------------------------
764
+ *
765
+ * A catalogue of four hundred properties, a bank's register tables, a
766
+ * year's sessions: both pages are half made of tables, and both had drawn
767
+ * their own. One set its head in the lifted grey with 10px of padding and
768
+ * the other in the panel fill with 4px, one ruled every row in the line
769
+ * colour and the other at 55% of it -- so the same table was two tables.
770
+ *
771
+ * `.table-wrap` is the scroller a wide table goes in; a table that is
772
+ * taller than it is wide goes in a `.tall` instead, whose head stays put.
773
+ */
774
+ .table-wrap {
775
+ overflow-x: auto;
776
+ border: 1px solid var(--line);
777
+ border-radius: var(--radius);
778
+ }
779
+
780
+ .tall {
781
+ max-height: 62vh;
782
+ overflow: auto;
783
+ }
784
+
785
+ table {
786
+ width: 100%;
787
+ border-collapse: collapse;
788
+ font-size: var(--fs-sm);
789
+ }
790
+
791
+ thead th {
792
+ position: sticky;
793
+ top: 0;
794
+ background: var(--surface-2);
795
+ text-align: left;
796
+ font-weight: 560;
797
+ font-size: var(--fs-xs);
798
+ letter-spacing: 0.06em;
799
+ text-transform: uppercase;
800
+ color: var(--text-dim);
801
+ padding: 9px 10px;
802
+ border-bottom: 1px solid var(--line);
803
+ white-space: nowrap;
804
+ }
805
+
806
+ tbody td {
807
+ padding: 7px 10px;
808
+ border-bottom: 1px solid var(--line);
809
+ vertical-align: top;
810
+ }
811
+
812
+ tbody tr:last-child td {
813
+ border-bottom: 0;
814
+ }
815
+
816
+ tbody tr:hover {
817
+ background: var(--surface);
818
+ }
819
+
820
+ /* A column of figures: fixed-width digits, one line, and capped rather than
821
+ * allowed to set the table's width. One long value -- thirty-two cell
822
+ * voltages on one line -- would otherwise take a fifth of the page and
823
+ * squeeze the column that says what the row is down to one word per line.
824
+ * The cap is wide enough for this column's own editor, an input and its
825
+ * unit, and no wider. */
826
+ th.num,
827
+ td.num,
828
+ td.val.num {
829
+ text-align: right;
830
+ font-variant-numeric: tabular-nums;
831
+ white-space: nowrap;
832
+ max-width: 14rem;
833
+ overflow: hidden;
834
+ text-overflow: ellipsis;
835
+ }
836
+
837
+ /* The action column's buttons end where the table does. */
838
+ td.right,
839
+ th.right {
840
+ text-align: right;
841
+ }
842
+
843
+ /* Two buttons in one action cell -- "show" then "apply" -- are two
844
+ * controls, not one word. */
845
+ td.right .btn + .btn {
846
+ margin-left: 7px;
847
+ }
848
+
849
+ /* The columns that hold four characters and a button: `width: 1%` with
850
+ * nothing to wrap is the shrink-to-fit idiom, so the thing the table is
851
+ * about gets the width instead. */
852
+ td.type,
853
+ th.type,
854
+ td.right,
855
+ th.right {
856
+ width: 1%;
857
+ white-space: nowrap;
858
+ }
859
+
860
+ /* --- what folds ----------------------------------------------------------- */
861
+
862
+ /* A card's one line, with the paragraph behind it: what is worth reading
863
+ * every time stays out, what is worth reading once folds away. */
864
+ .help {
865
+ margin: 0 0 14px;
866
+ font-size: var(--fs-sm);
867
+ }
868
+
869
+ .help > summary {
870
+ display: flex;
871
+ align-items: baseline;
872
+ gap: 8px;
873
+ cursor: pointer;
874
+ color: var(--text-dim);
875
+ list-style: none;
876
+ }
877
+
878
+ .help > summary::-webkit-details-marker {
879
+ display: none;
880
+ }
881
+
882
+ .help > summary:hover {
883
+ color: var(--text);
884
+ }
885
+
886
+ .help .short {
887
+ flex: 1 1 auto;
888
+ }
889
+
890
+ .help > p {
891
+ margin: 7px 0 0;
892
+ color: var(--text-faint);
893
+ }
894
+
895
+ /* Something legal that will not do what it looks like. The card has one
896
+ * line for it; the whole explanation is the hover title and opens in place
897
+ * on a click, for touch and for keyboards. */
898
+ /* One line of warning at the foot of a card: a mark and a sentence. */
899
+ .caveat {
900
+ font-size: var(--fs-sm);
901
+ color: var(--warn);
902
+ margin-top: 8px;
903
+ display: flex;
904
+ gap: 7px;
905
+ }
906
+
907
+ /* A list of short lines -- findings, a plan's changes -- ruled between. */
908
+ .list {
909
+ margin: 0;
910
+ padding: 0;
911
+ list-style: none;
912
+ }
913
+
914
+ .list > li {
915
+ padding: 5px 0;
916
+ border-bottom: 1px solid color-mix(in srgb, var(--line) 55%, transparent);
917
+ display: flex;
918
+ flex-wrap: wrap;
919
+ gap: 8px;
920
+ align-items: baseline;
921
+ }
922
+
923
+ .list > li:last-child {
924
+ border-bottom: 0;
925
+ }
926
+
927
+ /* A plan covering several cards names each before its lines. */
928
+ .plan-head {
929
+ margin: 12px 0 2px;
930
+ font-size: var(--fs-xs);
931
+ font-weight: 600;
932
+ text-transform: uppercase;
933
+ letter-spacing: var(--card-title-track);
934
+ color: var(--text-dim);
935
+ }
936
+
937
+ .caveats {
938
+ display: grid;
939
+ gap: 6px;
940
+ margin-top: 12px;
941
+ }
942
+
943
+ .why {
944
+ font-size: var(--fs-sm);
945
+ }
946
+
947
+ .why > summary {
948
+ display: flex;
949
+ align-items: baseline;
950
+ gap: 7px;
951
+ cursor: help;
952
+ color: var(--warn);
953
+ list-style: none;
954
+ }
955
+
956
+ .why > summary::-webkit-details-marker {
957
+ display: none;
958
+ }
959
+
960
+ .why .mark {
961
+ flex: none;
962
+ width: 15px;
963
+ height: 15px;
964
+ border: 1px solid var(--warn);
965
+ border-radius: 50%;
966
+ font-size: 10px;
967
+ line-height: 13px;
968
+ text-align: center;
969
+ align-self: center;
970
+ }
971
+
972
+ .why .short {
973
+ flex: 1 1 auto;
974
+ }
975
+
976
+ .why > p {
977
+ margin: 6px 0 0 22px;
978
+ color: var(--text-dim);
979
+ }
980
+
981
+ /* What says a line opens, wherever one does.
982
+ *
983
+ * This was the word "more" on a card's summary and the word "why?" on a
984
+ * caveat, set small and faint at the right-hand end -- two different labels
985
+ * for one gesture, neither of which looks like anything until it is read,
986
+ * and both of which vanished once the line was open so that the way to
987
+ * close it again was to guess. A chevron is the disclosure control every
988
+ * reader already knows, and it turns rather than disappearing -- so the
989
+ * shape says which way the line is now. */
990
+ .chev {
991
+ flex: none;
992
+ /* A rotation needs a box to turn, and the summaries these sit in align
993
+ * on the baseline -- which is not where the middle of a chevron is. */
994
+ display: inline-block;
995
+ align-self: center;
996
+ /* At the size of the letters next to it. It was `--fs-xs` on a Unicode
997
+ * "small triangle", which is a small shape inside a small em: about
998
+ * four pixels of mark, which is a dot. A drawn chevron holds its shape
999
+ * at any size, so it is set in the surrounding text's own size and the
1000
+ * stroke keeps it one weight lighter than the words. */
1001
+ width: 1em;
1002
+ height: 1em;
1003
+ /* Where one of these sits in running text rather than in a flex row --
1004
+ * in front of a key in a table of rows that open -- an inline-block sits
1005
+ * on the baseline, which puts a symmetrical mark well above the middle
1006
+ * of the letters it is in front of. */
1007
+ vertical-align: -0.15em;
1008
+ color: var(--text-faint);
1009
+ transition: transform 0.15s ease;
1010
+ }
1011
+
1012
+ /* Turned a quarter turn, for a control that opens downwards. */
1013
+ .chev.down {
1014
+ transform: rotate(90deg);
1015
+ }
1016
+
1017
+ summary:hover > .chev {
1018
+ color: var(--text-dim);
1019
+ }
1020
+
1021
+ details[open] > summary > .chev {
1022
+ transform: rotate(90deg);
1023
+ }
1024
+
1025
+ @media (prefers-reduced-motion: reduce) {
1026
+ .chev {
1027
+ transition: none;
1028
+ }
1029
+ }
1030
+
1031
+ /* --- controls -------------------------------------------------------------- */
1032
+
1033
+ /* Buttons carry the primary colour on their edge and a little of it in
1034
+ * their fill. Set in the same face and weight as a field value -- which is
1035
+ * what they sit next to -- they read as another line of data until you try
1036
+ * to press one. */
1037
+ button.btn {
1038
+ background: color-mix(in srgb, var(--primary) 9%, var(--surface-2));
1039
+ color: var(--text);
1040
+ border: 1px solid color-mix(in srgb, var(--primary) 55%, var(--line-strong));
1041
+ border-radius: var(--radius-sm);
1042
+ padding: 6px 13px;
1043
+ font-weight: 560;
1044
+ cursor: pointer;
1045
+ transition:
1046
+ background 0.14s ease,
1047
+ border-color 0.14s ease,
1048
+ color 0.14s ease;
1049
+ }
1050
+
1051
+ button.btn:hover:not(:disabled) {
1052
+ background: color-mix(in srgb, var(--primary) 18%, var(--surface-2));
1053
+ border-color: var(--primary);
1054
+ color: var(--primary-bright);
1055
+ }
1056
+
1057
+ button.btn:disabled {
1058
+ opacity: 0.45;
1059
+ cursor: not-allowed;
1060
+ }
1061
+
1062
+ button.btn.primary {
1063
+ background: var(--primary-deep);
1064
+ border-color: var(--primary);
1065
+ color: var(--primary-bright);
1066
+ }
1067
+
1068
+ button.btn.primary:hover:not(:disabled) {
1069
+ background: color-mix(in srgb, var(--primary-deep) 60%, var(--primary));
1070
+ color: var(--primary-bright);
1071
+ }
1072
+
1073
+ button.btn.danger {
1074
+ color: var(--danger);
1075
+ background: color-mix(in srgb, var(--danger) 8%, var(--surface-2));
1076
+ border-color: color-mix(in srgb, var(--danger) 55%, var(--line-strong));
1077
+ }
1078
+
1079
+ button.btn.danger:hover:not(:disabled) {
1080
+ background: var(--danger-deep);
1081
+ border-color: var(--danger);
1082
+ }
1083
+
1084
+ /* The quiet ones: cancels, and the per-row Edit in a table of hundreds. */
1085
+ button.btn.ghost {
1086
+ background: none;
1087
+ border-color: transparent;
1088
+ color: var(--text-dim);
1089
+ font-weight: 500;
1090
+ }
1091
+
1092
+ button.btn.ghost:hover:not(:disabled) {
1093
+ color: var(--primary-bright);
1094
+ background: var(--surface-2);
1095
+ border-color: var(--line-strong);
1096
+ }
1097
+
1098
+ button.btn.small {
1099
+ padding: 4px 10px;
1100
+ font-size: var(--fs-sm);
1101
+ }
1102
+
1103
+ /* A button's label is one line. In a row that also holds something with
1104
+ * `.grow` in it -- a file picker, a search box, a URL field -- the button
1105
+ * is the item with slack to give, so a two-word label was broken across two
1106
+ * lines and the button came out twice the height of the field beside it.
1107
+ * These rows all wrap between their controls, so a label that really has
1108
+ * nowhere to go takes the next line whole. */
1109
+ .card-foot > button.btn,
1110
+ .actions > button.btn,
1111
+ .toolbar > button.btn {
1112
+ white-space: nowrap;
1113
+ }
1114
+
1115
+ /* A button whose whole label is one glyph -- a "?", a notification bell,
1116
+ * the live-updates switch. A square target rather than a wide one, so a
1117
+ * lone character does not sit in a button shaped for a sentence. Every one
1118
+ * of these carries its own aria-label: the glyph is the picture, never the
1119
+ * name. */
1120
+ /* One choice in a picker: a device named in the two lines it is named in
1121
+ * everywhere else, read left to right rather than centred like a button
1122
+ * that does something. Taller than a plain button, because a name over a
1123
+ * line about it is two lines; `align-items` keeps whatever the program
1124
+ * files it under, and the badge on the one you are already on, beside the
1125
+ * name rather than beside the second line. */
1126
+ button.btn.pick {
1127
+ display: flex;
1128
+ align-items: center;
1129
+ gap: 10px;
1130
+ width: 100%;
1131
+ padding-top: 7px;
1132
+ padding-bottom: 7px;
1133
+ text-align: left;
1134
+ }
1135
+
1136
+ /* The name takes the row's slack, so that everything after it ends at the
1137
+ * same place down a list of them and a long model name gives way with an
1138
+ * ellipsis rather than pushing the badge out of the dialog. */
1139
+ button.btn.pick .lines {
1140
+ flex: 1;
1141
+ }
1142
+
1143
+ /* What a program files a device under that is neither of the two lines --
1144
+ * an address on a bus, the fact that mDNS is what found it. */
1145
+ button.btn.pick .aside {
1146
+ color: var(--text-faint);
1147
+ font-size: var(--fs-xs);
1148
+ font-variant-numeric: tabular-nums;
1149
+ white-space: nowrap;
1150
+ }
1151
+
1152
+ /* A device that is on the list and did not answer. A dashed edge and dimmed
1153
+ * type, the same way anything absent is drawn on these pages: it is still
1154
+ * there to be picked -- picking it is how you find out what is wrong with it
1155
+ * -- but it is not the same as the ones that answered. */
1156
+ button.btn.pick.gone {
1157
+ border-style: dashed;
1158
+ color: var(--text-dim);
1159
+ }
1160
+
1161
+ button.btn.icon {
1162
+ display: inline-flex;
1163
+ align-items: center;
1164
+ justify-content: center;
1165
+ width: 30px;
1166
+ padding: 0;
1167
+ flex: none;
1168
+ aspect-ratio: 1;
1169
+ }
1170
+
1171
+ button.btn.icon.small {
1172
+ width: 26px;
1173
+ }
1174
+
1175
+ /* Live updates. The mark itself changes with the state rather than only
1176
+ * its colour: a flat line while the page is holding still, a pulse while it
1177
+ * is reading. A screen with no colour to read still says which. */
1178
+ button.btn.icon.live {
1179
+ color: var(--text-faint);
1180
+ }
1181
+
1182
+ button.btn.icon.live .beat {
1183
+ display: none;
1184
+ }
1185
+
1186
+ button.btn.icon.live.on {
1187
+ color: var(--live);
1188
+ border-color: var(--live-deep);
1189
+ }
1190
+
1191
+ button.btn.icon.live.on .flat {
1192
+ display: none;
1193
+ }
1194
+
1195
+ button.btn.icon.live.on .beat {
1196
+ display: inline;
1197
+ }
1198
+
1199
+ button.btn.icon.live:hover:not(:disabled) {
1200
+ color: var(--live);
1201
+ }
1202
+
1203
+ input[type="text"],
1204
+ input[type="search"],
1205
+ input[type="number"],
1206
+ input[type="password"],
1207
+ select,
1208
+ textarea {
1209
+ background: var(--surface-2);
1210
+ border: 1px solid var(--line-strong);
1211
+ border-radius: var(--radius-sm);
1212
+ padding: 6px 9px;
1213
+ /* A text input's default width is its `size` -- about 260px -- and that
1214
+ * is an intrinsic size, not a maximum: in a column narrower than that it
1215
+ * is drawn over the card's border and out the other side, and takes the
1216
+ * page's horizontal scrollbar with it. `min-width: 0` lets it shrink
1217
+ * inside a flex or grid track; this is for everywhere else. */
1218
+ max-width: 100%;
1219
+ min-width: 0;
1220
+ transition: border-color 0.14s ease;
1221
+ }
1222
+
1223
+ input:focus,
1224
+ select:focus,
1225
+ textarea:focus {
1226
+ border-color: var(--primary);
1227
+ outline: none;
1228
+ }
1229
+
1230
+ input::placeholder {
1231
+ color: var(--text-faint);
1232
+ }
1233
+
1234
+ /* A field holding a draft nobody has applied yet, in the same colour every
1235
+ * other unsent edit on the page wears. */
1236
+ input.pending,
1237
+ select.pending {
1238
+ border-color: var(--live);
1239
+ }
1240
+
1241
+ /* A row holding an unsent edit says so in its name as well as in its
1242
+ * control. The control's edge is a hairline, and on a switch or a band
1243
+ * there is no edge to colour at all; the name is the part of the row that
1244
+ * is always text, and text in the unsent colour reads at a glance down a
1245
+ * column of twenty. */
1246
+ .row.pending > .k {
1247
+ color: var(--live);
1248
+ }
1249
+
1250
+ /* A table row holding an unsent edit, the same way: its first cell -- the
1251
+ * name the row is read by -- in the unsent colour. */
1252
+ tr.pending > td:first-child {
1253
+ color: var(--live);
1254
+ }
1255
+
1256
+ /* A switch that has been flipped and not sent: a ring in the same colour
1257
+ * round its track, the way a dragged setpoint on a band gets one. */
1258
+ label.toggle.pending input[type="checkbox"] {
1259
+ box-shadow: 0 0 0 2px var(--live);
1260
+ }
1261
+
1262
+ /* A `<select>` sizes itself to its longest *option*, not to its value, and
1263
+ * several of these hold whole sentences. In a card's value column that is
1264
+ * a control wider than the card, drawn straight over the border and out the
1265
+ * other side, and it takes the rows beside it with it. Capped at the
1266
+ * column, the closed box fits and the option that will not is cut with an
1267
+ * ellipsis; the popup a browser opens is not the column's business and is
1268
+ * as wide as it needs to be. */
1269
+ select {
1270
+ max-width: 100%;
1271
+ text-overflow: ellipsis;
1272
+ }
1273
+
1274
+ /* A file input says two things, and only one of them is ever news.
1275
+ *
1276
+ * "No file selected" is the browser telling you what you already know -- you
1277
+ * have not picked one yet -- in the same colour as every reading on the
1278
+ * page. It is a placeholder, so it is a placeholder's grey; a filename,
1279
+ * once there is one, is the answer to the question the card is asking and
1280
+ * gets the text colour back. */
1281
+ input[type="file"] {
1282
+ color: var(--text-faint);
1283
+ }
1284
+
1285
+ input[type="file"].chosen {
1286
+ color: var(--text);
1287
+ }
1288
+
1289
+ /* The button inside it is a control, not a label, so it is not dimmed with
1290
+ * the sentence beside it -- and while we are in here, it may as well be
1291
+ * this page's button rather than the browser's. */
1292
+ input[type="file"]::file-selector-button {
1293
+ background: color-mix(in srgb, var(--primary) 9%, var(--surface-2));
1294
+ color: var(--text);
1295
+ border: 1px solid color-mix(in srgb, var(--primary) 55%, var(--line-strong));
1296
+ border-radius: var(--radius-sm);
1297
+ padding: 4px 10px;
1298
+ margin-right: 10px;
1299
+ font: inherit;
1300
+ font-size: var(--fs-sm);
1301
+ font-weight: 560;
1302
+ cursor: pointer;
1303
+ }
1304
+
1305
+ input[type="file"]:disabled::file-selector-button {
1306
+ opacity: 0.45;
1307
+ cursor: not-allowed;
1308
+ }
1309
+
1310
+ input[type="file"]:not(:disabled)::file-selector-button:hover {
1311
+ background: color-mix(in srgb, var(--primary) 18%, var(--surface-2));
1312
+ border-color: var(--primary);
1313
+ color: var(--primary-bright);
1314
+ }
1315
+
1316
+ label.toggle {
1317
+ display: inline-flex;
1318
+ align-items: center;
1319
+ gap: 7px;
1320
+ cursor: pointer;
1321
+ user-select: none;
1322
+ font-size: var(--fs-sm);
1323
+ }
1324
+
1325
+ /* A boolean is a switch, not a tick box.
1326
+ *
1327
+ * These were the browser's own checkboxes, tinted with `accent-color`. A
1328
+ * tick box says "this is one of a list of things you may choose"; every
1329
+ * one of these is a single thing that is either on or off -- live updates,
1330
+ * the balancer, following the log -- and half of them sit in a row of
1331
+ * labels where a small square reads as a bullet. A switch says which of
1332
+ * two states a thing is in from across the room, and says it by position
1333
+ * as well as by colour, which is what a reader who cannot tell the tinted
1334
+ * square from the untinted one needs.
1335
+ *
1336
+ * It is still a checkbox underneath: the same element, the same events,
1337
+ * the same keyboard, the same `:checked`. Nothing that draws one of these
1338
+ * had to change to get it.
1339
+ */
1340
+ label.toggle input[type="checkbox"] {
1341
+ appearance: none;
1342
+ -webkit-appearance: none;
1343
+ flex: none;
1344
+ margin: 0;
1345
+ /* The track. Sized off the text it sits beside rather than in pixels,
1346
+ * so it is the same height as the label whatever the label is set in. */
1347
+ width: 2.25em;
1348
+ height: 1.25em;
1349
+ border: 1px solid var(--line-strong);
1350
+ border-radius: 999px;
1351
+ background: var(--surface-2);
1352
+ cursor: pointer;
1353
+ position: relative;
1354
+ transition:
1355
+ background 0.16s ease,
1356
+ border-color 0.16s ease;
1357
+ }
1358
+
1359
+ /* The knob. It travels the width of the track less its own width and the
1360
+ * air either side, which is what makes on and off tell each other apart
1361
+ * without any colour at all.
1362
+ *
1363
+ * It is drawn the way `.band .grip` is -- a ring of the card's own colour
1364
+ * around a grey core, over a hairline that keeps the ring off a background
1365
+ * of the same colour. The two are the same gesture, "take hold of this
1366
+ * and move it", and were drawn two different ways: a plain white disc
1367
+ * here, a ringed one on every band on the page. `box-sizing` is set
1368
+ * rather than inherited, because the `*` rule at the top of this file does
1369
+ * not match pseudo-elements and a border would otherwise grow the knob
1370
+ * past the track.
1371
+ */
1372
+ label.toggle input[type="checkbox"]::before {
1373
+ content: '';
1374
+ box-sizing: border-box;
1375
+ position: absolute;
1376
+ top: 50%;
1377
+ left: 2px;
1378
+ width: 0.875em;
1379
+ height: 0.875em;
1380
+ margin-top: -0.4375em;
1381
+ border: 2px solid var(--surface);
1382
+ border-radius: 50%;
1383
+ background: var(--text-dim);
1384
+ box-shadow: 0 0 0 1px var(--line-strong);
1385
+ transition:
1386
+ transform 0.16s ease,
1387
+ background 0.16s ease,
1388
+ box-shadow 0.16s ease;
1389
+ }
1390
+
1391
+ label.toggle input[type="checkbox"]:checked {
1392
+ background: var(--switch-on, var(--primary));
1393
+ border-color: var(--switch-on, var(--primary));
1394
+ }
1395
+
1396
+ /* On, the knob is the same knob: the ring is what tells it from the fill
1397
+ * under it, so it does not have to change colour to stay visible, and the
1398
+ * hairline goes -- against a filled track there is nothing for the ring to
1399
+ * be lost in.
1400
+ *
1401
+ * Which means on and off differ by position and by the colour of the
1402
+ * track, and not by the knob. That is the cost of drawing it like a grip,
1403
+ * and it is why this was a white disc for a while; see the changelog. */
1404
+ label.toggle input[type="checkbox"]:checked::before {
1405
+ box-shadow: none;
1406
+ transform: translateX(1em);
1407
+ }
1408
+
1409
+ label.toggle input[type="checkbox"]:hover:not(:disabled) {
1410
+ border-color: var(--switch-on, var(--primary));
1411
+ }
1412
+
1413
+ /* Dimmed as a whole -- the switch and the word naming it are one control,
1414
+ * and dimming only the switch reads as a switch that is off. */
1415
+ label.toggle:has(input:disabled) {
1416
+ opacity: 0.45;
1417
+ cursor: not-allowed;
1418
+ }
1419
+
1420
+ label.toggle input[type="checkbox"]:disabled {
1421
+ cursor: not-allowed;
1422
+ }
1423
+
1424
+ @media (prefers-reduced-motion: reduce) {
1425
+ label.toggle input[type="checkbox"],
1426
+ label.toggle input[type="checkbox"]::before {
1427
+ transition: none;
1428
+ }
1429
+ }
1430
+
1431
+ /* Something is happening and there is no honest percentage to put on it.
1432
+ * The page's other answer to that is `.bar.unknown`, which is for a job
1433
+ * with a start and an end; this is for a wait small enough to sit inside a
1434
+ * control.
1435
+ *
1436
+ * Whatever draws one says in words what it is waiting for, so nothing is
1437
+ * carried by the motion alone -- which is what makes the reduced-motion
1438
+ * fallback a ring that has simply stopped, the same answer `.bar.unknown`
1439
+ * gives. */
1440
+ .spinner {
1441
+ display: inline-block;
1442
+ flex: none;
1443
+ box-sizing: border-box;
1444
+ width: 1em;
1445
+ height: 1em;
1446
+ border: 2px solid var(--line-strong);
1447
+ border-top-color: var(--primary);
1448
+ border-radius: 50%;
1449
+ animation: turn 0.7s linear infinite;
1450
+ }
1451
+
1452
+ @keyframes turn {
1453
+ to {
1454
+ transform: rotate(360deg);
1455
+ }
1456
+ }
1457
+
1458
+ @media (prefers-reduced-motion: reduce) {
1459
+ .spinner {
1460
+ animation: none;
1461
+ opacity: 0.55;
1462
+ }
1463
+ }
1464
+
1465
+ /* See `--slider-lift`: the light theme's own widget shadow, and a faint
1466
+ * light rim in its place on a page too dark for a shadow to show on. */
1467
+ input[type="range"] {
1468
+ accent-color: var(--primary);
1469
+ filter: var(--slider-lift);
1470
+ }
1471
+
1472
+ /* One shape wherever a control and its reading appear together. */
1473
+ .set {
1474
+ display: flex;
1475
+ align-items: center;
1476
+ flex-wrap: wrap;
1477
+ gap: 10px;
1478
+ }
1479
+
1480
+ .set input[type="range"] {
1481
+ flex: 1 1 110px;
1482
+ min-width: 110px;
1483
+ }
1484
+
1485
+ .val {
1486
+ font-variant-numeric: tabular-nums;
1487
+ font-weight: 560;
1488
+ flex: none;
1489
+ min-width: 4.6em;
1490
+ text-align: right;
1491
+ }
1492
+
1493
+ .val.pending {
1494
+ color: var(--live);
1495
+ }
1496
+
1497
+ /* A typed number and the unit it is in.
1498
+ *
1499
+ * A unit is part of the number, so it sits against it; a column is kept, at
1500
+ * the width of a unit rather than of a reading, so every input on a card
1501
+ * still ends at the same place. And it is read from its right-hand edge,
1502
+ * like every other value in the card. */
1503
+ .set.num {
1504
+ gap: 7px;
1505
+ }
1506
+
1507
+ .set.num > input[type="number"] {
1508
+ width: 90px;
1509
+ }
1510
+
1511
+ .unit {
1512
+ flex: none;
1513
+ min-width: 2.2em;
1514
+ text-align: right;
1515
+ color: var(--text-dim);
1516
+ font-size: var(--fs-sm);
1517
+ }
1518
+
1519
+ .unit.pending {
1520
+ color: var(--live);
1521
+ }
1522
+
1523
+ /* The shapes a panel arranges controls in, so they are one shape rather
1524
+ * than forty inline `style="display:flex;gap:10px"` attributes that each
1525
+ * drifted a pixel or two from the others. */
1526
+ .actions {
1527
+ display: flex;
1528
+ gap: 10px;
1529
+ align-items: center;
1530
+ flex-wrap: wrap;
1531
+ }
1532
+
1533
+ .actions.centred {
1534
+ justify-content: center;
1535
+ margin-top: 12px;
1536
+ }
1537
+
1538
+ /* A row of controls that follows something else in a card keeps its
1539
+ * distance from it, so the gap is the card's and not each caller's guess. */
1540
+ .card-body > .actions:not(:first-child) {
1541
+ margin-top: 12px;
1542
+ }
1543
+
1544
+ .toolbar {
1545
+ display: flex;
1546
+ gap: 10px;
1547
+ align-items: center;
1548
+ flex-wrap: wrap;
1549
+ margin-bottom: 14px;
1550
+ }
1551
+
1552
+ .stack {
1553
+ display: grid;
1554
+ gap: 6px;
1555
+ }
1556
+
1557
+ .wrap {
1558
+ display: flex;
1559
+ align-items: center;
1560
+ gap: 8px;
1561
+ flex-wrap: wrap;
1562
+ }
1563
+
1564
+ /* --- a list of things that arrived from outside ---------------------------
1565
+ *
1566
+ * Rows of one identifier and a line about it: the browsers watching this
1567
+ * server, the firmware images on the vendor's, the networks a radio found.
1568
+ * One shape, because they are one kind of list -- something out there, and
1569
+ * what is known about it.
1570
+ *
1571
+ * `you` is the row that is this browser, in the watcher list `Watchers`
1572
+ * draws: the whole question that list answers is whether the other watcher
1573
+ * is me, so the answer is on the row rather than only in the words. */
1574
+ .entry {
1575
+ display: grid;
1576
+ gap: 3px;
1577
+ padding: 11px 13px;
1578
+ border: 1px solid var(--line);
1579
+ border-radius: var(--radius-sm);
1580
+ background: var(--surface-2);
1581
+ }
1582
+
1583
+ .entry + .entry { margin-top: 8px; }
1584
+ .entry .head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
1585
+ .entry .name { font-weight: 560; }
1586
+ .entry .name.data {
1587
+ font-family: var(--mono);
1588
+ font-size: var(--fs-sm);
1589
+ font-weight: 500;
1590
+ overflow-wrap: anywhere;
1591
+ }
1592
+ .entry .meta { color: var(--text-dim); font-size: var(--fs-sm); }
1593
+ .entry .act { margin-left: auto; }
1594
+ .entry.you { border-color: var(--primary-deep); }
1595
+
1596
+ .scroller {
1597
+ max-height: min(52vh, 460px);
1598
+ overflow-y: auto;
1599
+ padding-right: 2px;
1600
+ }
1601
+
1602
+ /* --- progress -------------------------------------------------------------- */
1603
+
1604
+ /* A read that is taking long enough to be worth a bar. Sits between a
1605
+ * panel's toolbar and its content, so the thing you are waiting for is
1606
+ * where the answer will appear. */
1607
+ .reading {
1608
+ margin-bottom: 14px;
1609
+ }
1610
+
1611
+ .reading .what {
1612
+ display: flex;
1613
+ align-items: baseline;
1614
+ gap: 12px;
1615
+ font-size: var(--fs-sm);
1616
+ color: var(--text-dim);
1617
+ margin-bottom: 6px;
1618
+ }
1619
+
1620
+ .reading .what .muted {
1621
+ margin-left: auto;
1622
+ font-variant-numeric: tabular-nums;
1623
+ }
1624
+
1625
+ .bar {
1626
+ height: 6px;
1627
+ border-radius: 999px;
1628
+ background: var(--surface-3);
1629
+ overflow: hidden;
1630
+ }
1631
+
1632
+ .bar > span {
1633
+ display: block;
1634
+ height: 100%;
1635
+ border-radius: 999px;
1636
+ background: linear-gradient(90deg, var(--bar-from), var(--bar-to));
1637
+ transition: width 0.2s ease;
1638
+ }
1639
+
1640
+ .bar-note {
1641
+ font-size: var(--fs-xs);
1642
+ margin-top: 4px;
1643
+ }
1644
+
1645
+ /* Nothing knows how many pages a device's log has, so a bar that claimed a
1646
+ * percentage would be making one up. It sweeps instead: motion says "still
1647
+ * going", which is the whole question being asked. */
1648
+ .bar.unknown > span {
1649
+ width: 35%;
1650
+ animation: sweep 1.4s ease-in-out infinite;
1651
+ }
1652
+
1653
+ @keyframes sweep {
1654
+ 0% {
1655
+ margin-left: 0;
1656
+ }
1657
+ 50% {
1658
+ margin-left: 65%;
1659
+ }
1660
+ 100% {
1661
+ margin-left: 0;
1662
+ }
1663
+ }
1664
+
1665
+ @media (prefers-reduced-motion: reduce) {
1666
+ .bar.unknown > span {
1667
+ animation: none;
1668
+ width: 100%;
1669
+ opacity: 0.55;
1670
+ }
1671
+ }
1672
+
1673
+ /* What a card looks like on its way in: the shape of the rows that are
1674
+ * coming, so the grid is already the right size when they land. */
1675
+ .skeleton {
1676
+ display: grid;
1677
+ gap: 12px;
1678
+ padding: 4px 0;
1679
+ }
1680
+
1681
+ .skeleton .bone {
1682
+ height: 11px;
1683
+ border-radius: 999px;
1684
+ background: linear-gradient(
1685
+ 90deg,
1686
+ var(--surface-2),
1687
+ var(--surface-3),
1688
+ var(--surface-2)
1689
+ );
1690
+ background-size: 300% 100%;
1691
+ animation: shimmer 1.6s ease-in-out infinite;
1692
+ }
1693
+
1694
+ .skeleton .bone:nth-child(2n) {
1695
+ width: 82%;
1696
+ }
1697
+
1698
+ .skeleton .bone:nth-child(3n) {
1699
+ width: 64%;
1700
+ }
1701
+
1702
+ @keyframes shimmer {
1703
+ 0% {
1704
+ background-position: 100% 0;
1705
+ }
1706
+ 100% {
1707
+ background-position: -100% 0;
1708
+ }
1709
+ }
1710
+
1711
+ @media (prefers-reduced-motion: reduce) {
1712
+ .skeleton .bone {
1713
+ animation: none;
1714
+ }
1715
+ }
1716
+
1717
+ /* --- the server has gone ---------------------------------------------------- */
1718
+
1719
+ /* This was a bar of its own between the header and the tabs, which meant
1720
+ * every blip of the server moved the whole page down and back. It rides in
1721
+ * the header's slack instead: `min-width: 0` and an ellipsis so it gives the
1722
+ * space back rather than pushing the pill along, and no height of its own --
1723
+ * the header is already taller than one line of this text. See
1724
+ * `OfflineNotice` in js/ui.js for why the sentence is hidden from the reader
1725
+ * here and repeated in `.sr-only`. */
1726
+ .offline-note {
1727
+ display: flex;
1728
+ align-items: center;
1729
+ gap: 8px;
1730
+ min-width: 0;
1731
+ font-size: var(--fs-sm);
1732
+ color: var(--warn);
1733
+ }
1734
+
1735
+ .offline-note.gone {
1736
+ color: var(--danger);
1737
+ }
1738
+
1739
+ .offline-note .dot {
1740
+ width: 7px;
1741
+ height: 7px;
1742
+ border-radius: 50%;
1743
+ background: currentColor;
1744
+ flex: none;
1745
+ }
1746
+
1747
+ .offline-note .what {
1748
+ min-width: 0;
1749
+ overflow: hidden;
1750
+ text-overflow: ellipsis;
1751
+ white-space: nowrap;
1752
+ color: var(--text);
1753
+ }
1754
+
1755
+ /* The sentence is the only part that gives: the way out of this is two
1756
+ * words on a button, and "Try / now" on two lines in a header that is
1757
+ * already the wrong shape is not a way out anybody reads. */
1758
+ .offline-note .btn {
1759
+ flex: none;
1760
+ white-space: nowrap;
1761
+ }
1762
+
1763
+ /* On a phone there is no slack to ride in: the dot and the button stay and
1764
+ * the sentence goes, which costs nothing, because the whole of it is in the
1765
+ * `.sr-only` copy beside it either way. Eliding text for the eye must not
1766
+ * elide it for a reader. */
1767
+ @media (max-width: 760px) {
1768
+ header.top .offline-note .what {
1769
+ display: none;
1770
+ }
1771
+ }
1772
+
1773
+ /* --- dialogs and toasts ------------------------------------------------------ */
1774
+
1775
+ .backdrop {
1776
+ position: fixed;
1777
+ inset: 0;
1778
+ z-index: 60;
1779
+ background: rgb(0 0 0 / 72%);
1780
+ display: grid;
1781
+ place-items: center;
1782
+ padding: 20px;
1783
+ }
1784
+
1785
+ .modal {
1786
+ background: var(--surface);
1787
+ border: 1px solid var(--line-strong);
1788
+ border-radius: var(--radius);
1789
+ padding: 20px 22px;
1790
+ width: min(var(--modal-width, 460px), 100%);
1791
+ display: grid;
1792
+ gap: 14px;
1793
+ }
1794
+
1795
+ .modal h3 {
1796
+ margin: 0;
1797
+ font-size: var(--fs-lg);
1798
+ }
1799
+
1800
+ .modal p {
1801
+ margin: 0;
1802
+ color: var(--text-dim);
1803
+ }
1804
+
1805
+ .modal .buttons {
1806
+ display: flex;
1807
+ justify-content: flex-end;
1808
+ gap: 8px;
1809
+ }
1810
+
1811
+ .modal .buttons:empty {
1812
+ display: none;
1813
+ }
1814
+
1815
+ /* The word that has to be typed before the button will do anything. */
1816
+ .modal input.typed {
1817
+ margin-left: 8px;
1818
+ width: 8rem;
1819
+ }
1820
+
1821
+ .toasts {
1822
+ position: fixed;
1823
+ right: 18px;
1824
+ bottom: 18px;
1825
+ z-index: 80;
1826
+ display: grid;
1827
+ gap: 8px;
1828
+ width: min(400px, calc(100vw - 36px));
1829
+ }
1830
+
1831
+ .toast {
1832
+ border: 1px solid var(--line-strong);
1833
+ border-left-width: 3px;
1834
+ border-radius: var(--radius-sm);
1835
+ background: var(--surface-2);
1836
+ padding: 11px 13px;
1837
+ font-size: var(--toast-size);
1838
+ display: flex;
1839
+ gap: 10px;
1840
+ align-items: flex-start;
1841
+ animation: slide-in 0.16s ease-out;
1842
+ }
1843
+
1844
+ .toast.error {
1845
+ border-left-color: var(--danger);
1846
+ }
1847
+
1848
+ .toast.ok {
1849
+ border-left-color: var(--ok);
1850
+ }
1851
+
1852
+ .toast.info {
1853
+ border-left-color: var(--primary);
1854
+ }
1855
+
1856
+ /* The sentence, and under it whatever can be done about it right there --
1857
+ * fetching the recording a failure started, stopping it. */
1858
+ .toast .said {
1859
+ display: grid;
1860
+ gap: 9px;
1861
+ min-width: 0;
1862
+ }
1863
+
1864
+ .toast-actions {
1865
+ display: flex;
1866
+ flex-wrap: wrap;
1867
+ gap: 6px;
1868
+ }
1869
+
1870
+ .toast .x {
1871
+ margin-left: auto;
1872
+ cursor: pointer;
1873
+ color: var(--text-faint);
1874
+ background: none;
1875
+ border: 0;
1876
+ }
1877
+
1878
+ @keyframes slide-in {
1879
+ from {
1880
+ opacity: 0;
1881
+ transform: translateY(6px);
1882
+ }
1883
+ to {
1884
+ opacity: 1;
1885
+ transform: none;
1886
+ }
1887
+ }
1888
+
1889
+ @media (prefers-reduced-motion: reduce) {
1890
+ .toast {
1891
+ animation: none;
1892
+ }
1893
+ }
1894
+
1895
+
1896
+ /* --- the page's own frame -------------------------------------------------
1897
+ *
1898
+ * The header, the tab strip, the pane that holds a tab, and the licence
1899
+ * line under all of it. These are drawn by `js/shell.js`, which every
1900
+ * program of this shape had re-typed for itself, so the rules move with
1901
+ * it. What stays behind is what a program puts *in* the header -- its
1902
+ * own wordmark, and whatever it calls the thing it is talking to.
1903
+ */
1904
+
1905
+ /* A column: the header, whatever is warning you, and the page.
1906
+ *
1907
+ * This was `grid-template-rows: auto 1fr`, which was right for exactly two
1908
+ * children and wrong the moment the offline banner became a third -- the
1909
+ * banner would have taken the `1fr` and the page the leftover `auto`, so
1910
+ * the one row that says the server has gone would have been the tallest
1911
+ * thing on the screen. */
1912
+ .app {
1913
+ display: flex;
1914
+ flex-direction: column;
1915
+ min-height: 100vh;
1916
+ }
1917
+
1918
+ /* And a column again inside, so the licence line below the tabs is a
1919
+ * *footer*: on a short tab -- a picker, an empty state, one card -- it was
1920
+ * drawn immediately under the content and left half the window blank below
1921
+ * it, which reads as the page having stopped rather than as a footer. The
1922
+ * main region takes the slack instead and the line lands on the bottom
1923
+ * edge, wherever the content ends. */
1924
+ .app > .body {
1925
+ flex: 1 1 auto;
1926
+ display: flex;
1927
+ flex-direction: column;
1928
+ min-height: 0;
1929
+ }
1930
+ .app > .body > main { flex: 1 1 auto; }
1931
+ .app > .body > .license { margin-top: auto; }
1932
+
1933
+ /* The header is two rows -- what the page is about, and the tabs -- and
1934
+ * neither of them is the header itself: both are centred on the content
1935
+ * column inside it, so the wordmark, the first tab and the first card share
1936
+ * one left edge however wide the window is.
1937
+ *
1938
+ * One program had this and the other did not, which on a wide screen meant
1939
+ * a wordmark against the far left of the window with its own page floating
1940
+ * in the middle. `Header` in js/shell.js draws both rows now, so neither
1941
+ * program decides this for itself. */
1942
+ header.top {
1943
+ position: sticky;
1944
+ top: 0;
1945
+ z-index: 20;
1946
+ display: block;
1947
+ padding: 0;
1948
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
1949
+ backdrop-filter: blur(8px);
1950
+ border-bottom: 1px solid var(--line);
1951
+ }
1952
+
1953
+ /* One row, and it shrinks rather than wraps.
1954
+ *
1955
+ * The offline line rides in the slack here and gives the space back with an
1956
+ * ellipsis when there is none -- which only works if the row refuses to
1957
+ * wrap, or the widest thing on it pushes the theme switch onto a second row
1958
+ * and the whole page steps down every time the server blinks. Below the
1959
+ * phone breakpoint there is no slack to ride in, so it wraps there and the
1960
+ * sentence goes (it stays in full in the `.sr-only` copy beside it). */
1961
+ .top-inner {
1962
+ max-width: var(--page);
1963
+ margin: 0 auto;
1964
+ padding: 10px var(--gutter);
1965
+ display: flex;
1966
+ align-items: center;
1967
+ gap: 12px;
1968
+ flex-wrap: nowrap;
1969
+ }
1970
+
1971
+ @media (max-width: 760px) {
1972
+ .top-inner {
1973
+ flex-wrap: wrap;
1974
+ row-gap: 10px;
1975
+ }
1976
+
1977
+ .top-inner .device-id {
1978
+ order: 3;
1979
+ flex: 1 1 100%;
1980
+ }
1981
+ }
1982
+
1983
+ /* --- a page's unsent edits, where they can always be seen -----------------
1984
+ *
1985
+ * `PageApply` in js/panels.js. A page that is one draft across a dozen
1986
+ * cards had its Apply above the first of them, which scrolls away long
1987
+ * before the setting at the bottom is reached; the header is pinned, so it
1988
+ * is the one place on the page that is always in view.
1989
+ *
1990
+ * It rides in the header's slack, the way the offline line does. It goes
1991
+ * after the spacer, so everything to its right -- the pill, the switches --
1992
+ * stays exactly where it was, and everything to its left is not reached
1993
+ * until the spacer has given up all of its width. It is no taller than
1994
+ * the two lines naming the device, which set the header's height, so the
1995
+ * header does not grow by a pixel when it appears. And the sentence is the
1996
+ * part that gives way when the row runs short, not the buttons: "Review 3
1997
+ * changes" cut to "Rev..." is not a way to send anything.
1998
+ */
1999
+ .page-apply {
2000
+ display: flex;
2001
+ align-items: center;
2002
+ gap: 8px;
2003
+ flex: 0 1 auto;
2004
+ min-width: 0;
2005
+ /* No edge and no padding above or below: the buttons are the height, and
2006
+ * a small button is shorter than the link pill beside it, which is
2007
+ * shorter than the name. A border round it made it three pixels taller
2008
+ * than the name and the whole header grew by three when it appeared. */
2009
+ padding: 0 0 0 10px;
2010
+ border-radius: var(--radius-sm);
2011
+ background: color-mix(in srgb, var(--live) 14%, transparent);
2012
+ box-shadow: inset 3px 0 0 var(--live);
2013
+ font-size: var(--fs-sm);
2014
+ }
2015
+
2016
+ .page-apply .dot {
2017
+ flex: none;
2018
+ width: 7px;
2019
+ height: 7px;
2020
+ border-radius: 50%;
2021
+ background: var(--live);
2022
+ }
2023
+
2024
+ .page-apply .state {
2025
+ min-width: 0;
2026
+ overflow: hidden;
2027
+ text-overflow: ellipsis;
2028
+ white-space: nowrap;
2029
+ }
2030
+
2031
+ .page-apply .btn {
2032
+ flex: none;
2033
+ white-space: nowrap;
2034
+ }
2035
+
2036
+ /* On a phone the header is two rows -- the wordmark and the switches, then
2037
+ * the device's name across the whole width -- and there is no slack in the
2038
+ * first. So the group is laid over the right-hand end of the second, and
2039
+ * the name gives up that much width and ellipsises, as it does anyway when
2040
+ * it is long. Out of the flow, so the rows are exactly as tall as they
2041
+ * were. The sentence goes -- the group is labelled with it in full for a
2042
+ * reader -- and the dot and the two buttons stay. */
2043
+ @media (max-width: 760px) {
2044
+ .top-inner {
2045
+ position: relative;
2046
+ }
2047
+
2048
+ .page-apply {
2049
+ position: absolute;
2050
+ right: var(--gutter);
2051
+ bottom: 12px;
2052
+ }
2053
+
2054
+ /* Padded rather than narrowed: the name keeps its whole row, so nothing
2055
+ * from the row above can wrap down beside it when the group appears. A
2056
+ * narrower name let a switch that had wrapped onto a row of its own move
2057
+ * up next to it, and the header lost a row -- and the page jumped up by
2058
+ * one -- with the first edit. */
2059
+ .top-inner:has(.page-apply) .device-id {
2060
+ box-sizing: border-box;
2061
+ padding-right: 270px;
2062
+ }
2063
+
2064
+ .page-apply .state {
2065
+ display: none;
2066
+ }
2067
+ }
2068
+
2069
+ /* The one edge the page already draws, tinted, rather than a new one:
2070
+ * a colour change on an existing hairline reflows nothing. */
2071
+ header.top.lost { border-bottom-color: var(--live-deep); }
2072
+ header.top.gone { border-bottom-color: var(--danger-deep); }
2073
+
2074
+ /* --- the wordmark ----------------------------------------------------------
2075
+ *
2076
+ * Two links in one mark: the name goes to the project's page, the version
2077
+ * beside it to the release notes for exactly this build. Neither URL is
2078
+ * written into the page -- both come from the program's own packaging
2079
+ * metadata by way of `GET /api/about`, which is the only place either is
2080
+ * written down at all. See `Brand` in js/shell.js.
2081
+ *
2082
+ * The glyph in front of the name is the one part that is a program's own,
2083
+ * and it is passed in rather than styled here.
2084
+ */
2085
+ /* Centred, not on a baseline.
2086
+ *
2087
+ * Baseline was right while every program's mark was a character -- a bolt,
2088
+ * a plug -- because a character sits on the same baseline as the word after
2089
+ * it. One of these marks is *drawn*: a battery, two divs and a border, with
2090
+ * no baseline of its own. A flex container takes its baseline from its
2091
+ * first item, so `.home` handed the row the bottom edge of that battery,
2092
+ * the row put that edge on the baseline, and the program's name was lifted
2093
+ * several pixels above the version sitting next to it.
2094
+ *
2095
+ * Centring asks nothing of what the mark is made of, which is the only
2096
+ * thing a shared header can safely assume about it. */
2097
+ .brand {
2098
+ display: flex;
2099
+ align-items: center;
2100
+ gap: 8px;
2101
+ flex: none;
2102
+ }
2103
+
2104
+ .brand .home {
2105
+ display: inline-flex;
2106
+ align-items: center;
2107
+ gap: 8px;
2108
+ text-decoration: none;
2109
+ color: inherit;
2110
+ line-height: 1.2;
2111
+ }
2112
+
2113
+ a.home { cursor: pointer; }
2114
+
2115
+ .brand .name {
2116
+ font-weight: 620;
2117
+ letter-spacing: -0.01em;
2118
+ }
2119
+
2120
+ /* The version reads as an annotation on the name rather than a link of
2121
+ * equal standing, so it is faint and lighter -- but it still answers the
2122
+ * pointer, and says where it goes. */
2123
+ .brand .ver {
2124
+ color: var(--text-faint);
2125
+ font-weight: 500;
2126
+ font-size: var(--fs-sm);
2127
+ text-decoration: none;
2128
+ }
2129
+
2130
+ .brand .home:hover .name,
2131
+ .brand .ver:hover {
2132
+ color: var(--brand-hover, var(--primary-bright));
2133
+ }
2134
+
2135
+ /* --- which device this page is about ---------------------------------------
2136
+ *
2137
+ * Two lines, in the header, on every tab: what this one is called -- a name
2138
+ * it was given, or a serial, something no other device on the bench shares
2139
+ * -- and under it what it is, which is the model and the versions of the
2140
+ * hardware and the software answering.
2141
+ *
2142
+ * Both halves are why: a reading is about *that* device, and what a device
2143
+ * will answer at all depends on which firmware is on it. A page that made
2144
+ * you open a tab to find either is a page that lets you read the right
2145
+ * number off the wrong box.
2146
+ */
2147
+ .device-id {
2148
+ display: flex;
2149
+ align-items: center;
2150
+ gap: 6px;
2151
+ min-width: 0;
2152
+ }
2153
+
2154
+ /* The two lines a device is named by, wherever it is named: in the header,
2155
+ * and in the dialog that changes which device the page is about. See
2156
+ * `Lines` in js/ui.js. */
2157
+ .lines {
2158
+ display: flex;
2159
+ flex-direction: column;
2160
+ line-height: 1.25;
2161
+ min-width: 0;
2162
+ }
2163
+
2164
+ .lines .primary {
2165
+ font-weight: 560;
2166
+ }
2167
+
2168
+ .lines .secondary {
2169
+ color: var(--text-dim);
2170
+ font-size: var(--fs-sm);
2171
+ }
2172
+
2173
+ /* Neither line wraps to a second one: in the header this block is what sets
2174
+ * the height, and a long model name pushing it to four lines moves the
2175
+ * whole page down. `min-width: 0` above is what lets them give way
2176
+ * instead. */
2177
+ .lines .primary,
2178
+ .lines .secondary {
2179
+ overflow: hidden;
2180
+ text-overflow: ellipsis;
2181
+ white-space: nowrap;
2182
+ }
2183
+
2184
+ /* The name as the way to change device.
2185
+ *
2186
+ * There was a ghost button reading "change" beside it, which is a second
2187
+ * control for an action whose subject is the thing next to it; the name is
2188
+ * what you read to realise you are on the wrong device, so the name is what
2189
+ * you go to click.
2190
+ *
2191
+ * Under the pointer it becomes a button, in the same fill, edge and colour
2192
+ * as every other button on the page: it had only a faint grey wash, which
2193
+ * on a header that is already a panel is barely a change at all, so the one
2194
+ * control on the page that is not shaped like a button also did not behave
2195
+ * like one on hover. The border is there the whole time and transparent
2196
+ * until then, and the padding is a pixel smaller than the border is wide
2197
+ * -- 2px + 1px against the 3px this used to have -- so nothing under the
2198
+ * header moves when the edge appears.
2199
+ *
2200
+ * The padding is taken straight back out again as a negative margin. The
2201
+ * header is exactly as tall as this block, and a control needs room around
2202
+ * its text for a hover to look like anything; without the negative margin
2203
+ * that room would be six more pixels of header on every page, and the tab
2204
+ * strip and everything under it would sit six pixels lower than it does in
2205
+ * the program next to it. Margin does not change how tall a flex item's
2206
+ * outer box is, so this one is still the height of two lines of text.
2207
+ */
2208
+ .device-id.pick {
2209
+ padding: 2px 5px;
2210
+ margin: -3px -6px;
2211
+ border: 1px solid transparent;
2212
+ border-radius: var(--radius-sm);
2213
+ background: none;
2214
+ color: inherit;
2215
+ font: inherit;
2216
+ text-align: left;
2217
+ cursor: pointer;
2218
+ transition:
2219
+ background 0.14s ease,
2220
+ border-color 0.14s ease,
2221
+ color 0.14s ease;
2222
+ }
2223
+
2224
+ /* The same fill and edge `button.btn:hover` wears. Only the first line
2225
+ * takes the button's text colour: the second is the model and the firmware
2226
+ * versions, which is the caption to the name and stays the caption. */
2227
+ .device-id.pick:hover,
2228
+ .device-id.pick:focus-visible {
2229
+ background: color-mix(in srgb, var(--primary) 18%, var(--surface-2));
2230
+ border-color: var(--primary);
2231
+ }
2232
+
2233
+ .device-id.pick:hover .primary,
2234
+ .device-id.pick:focus-visible .primary {
2235
+ color: var(--primary-bright);
2236
+ }
2237
+
2238
+ @media (prefers-reduced-motion: reduce) {
2239
+ .device-id.pick {
2240
+ transition: none;
2241
+ }
2242
+ }
2243
+
2244
+ /* The program's own mark, in front of its name. Its size is set where it
2245
+ * is drawn; what is here is only that it may not be squeezed by a long
2246
+ * device name sharing the row. */
2247
+ .brand .glyph {
2248
+ flex: none;
2249
+ }
2250
+
2251
+ /* --- what the link is doing, as one control --------------------------------
2252
+ *
2253
+ * Both programs hang the same thing off the right of the header: a pill
2254
+ * saying what the connection to the device is doing, which opens a menu
2255
+ * holding everything else about it. They had drawn it twice -- one a
2256
+ * bordered capsule that tints, the other a capsule that recolours its own
2257
+ * text -- so the two headers read as two different programs at the one
2258
+ * glance a header is for. What is left to each is the words and what goes
2259
+ * in the menu.
2260
+ */
2261
+ .pill {
2262
+ display: inline-flex;
2263
+ align-items: center;
2264
+ gap: 8px;
2265
+ padding: 5px 11px 5px 9px;
2266
+ border: 1px solid var(--line-strong);
2267
+ border-radius: 999px;
2268
+ background: var(--surface);
2269
+ font-size: var(--fs-sm);
2270
+ /* Two or three words, on one line. This sits at the tight end of a
2271
+ * header that shrinks, and a pill reading "port / held" is the header
2272
+ * having run out of room rather than the port having done anything. */
2273
+ flex: none;
2274
+ white-space: nowrap;
2275
+ }
2276
+
2277
+ button.pill {
2278
+ cursor: pointer;
2279
+ text-align: left;
2280
+ }
2281
+
2282
+ button.pill:hover,
2283
+ .anchor.open > .pill {
2284
+ border-color: var(--line-strong);
2285
+ background: var(--surface-2);
2286
+ }
2287
+
2288
+ .pill .dot {
2289
+ width: 7px;
2290
+ height: 7px;
2291
+ border-radius: 50%;
2292
+ background: var(--text-faint);
2293
+ flex: none;
2294
+ }
2295
+
2296
+ /* The label is the state's name and never abbreviates; what gives when the
2297
+ * pill runs out of room is the operation text beside it. */
2298
+ .pill .label {
2299
+ color: var(--text-dim);
2300
+ flex: none;
2301
+ }
2302
+
2303
+ .pill-op {
2304
+ color: var(--text);
2305
+ overflow: hidden;
2306
+ text-overflow: ellipsis;
2307
+ white-space: nowrap;
2308
+ }
2309
+
2310
+ .pill .chev {
2311
+ color: var(--text-faint);
2312
+ }
2313
+
2314
+ .pill .queued {
2315
+ font-size: var(--fs-xs);
2316
+ color: var(--live);
2317
+ border-left: 1px solid var(--line-strong);
2318
+ padding-left: 8px;
2319
+ }
2320
+
2321
+ .pill.released {
2322
+ border-color: var(--line);
2323
+ }
2324
+
2325
+ .pill.idle {
2326
+ border-color: var(--primary-deep);
2327
+ }
2328
+
2329
+ .pill.idle .dot {
2330
+ background: var(--primary);
2331
+ }
2332
+
2333
+ .pill.opening {
2334
+ border-color: var(--live-deep);
2335
+ }
2336
+
2337
+ .pill.busy {
2338
+ border-color: var(--live-deep);
2339
+ background: color-mix(in srgb, var(--live-deep) 30%, var(--surface));
2340
+ }
2341
+
2342
+ .pill.busy .dot,
2343
+ .pill.opening .dot,
2344
+ .pill.flashing .dot {
2345
+ background: var(--live);
2346
+ animation: throb 1.1s ease-in-out infinite;
2347
+ }
2348
+
2349
+ .pill.busy .label,
2350
+ .pill.opening .label {
2351
+ color: var(--live);
2352
+ }
2353
+
2354
+ /* The live refresh is the link being busy, and the pill says so -- but not
2355
+ * in the colours of an alarm. Something that lights up every three seconds
2356
+ * whether or not anything is wrong is a light nobody reads twice. */
2357
+ .pill.busy.quiet {
2358
+ border-color: var(--primary-deep);
2359
+ background: var(--surface);
2360
+ }
2361
+
2362
+ .pill.busy.quiet .label {
2363
+ color: var(--text-dim);
2364
+ }
2365
+
2366
+ .pill.busy.quiet .dot {
2367
+ background: var(--primary);
2368
+ animation: none;
2369
+ }
2370
+
2371
+ .pill.flashing,
2372
+ .pill.error {
2373
+ border-color: var(--danger-deep);
2374
+ background: color-mix(in srgb, var(--danger-deep) 30%, var(--surface));
2375
+ }
2376
+
2377
+ .pill.error .dot {
2378
+ background: var(--danger);
2379
+ }
2380
+
2381
+ .pill.error .label {
2382
+ color: var(--danger);
2383
+ }
2384
+
2385
+ /* The server itself is gone, so the pill is not describing a device at all.
2386
+ * Reddened, and that is all: it was struck through in one of the two, on
2387
+ * the reasoning that this is the page saying it cannot see rather than an
2388
+ * alarm about the device -- but a line through five small words is a line
2389
+ * through the one message on the page that has to be read at a glance. The
2390
+ * colour already says it. */
2391
+ .pill.gone {
2392
+ border-color: var(--danger-deep);
2393
+ }
2394
+
2395
+ .pill.gone .dot {
2396
+ background: var(--danger);
2397
+ }
2398
+
2399
+ .pill.gone .label {
2400
+ color: var(--danger);
2401
+ }
2402
+
2403
+ @keyframes throb {
2404
+ 0%,
2405
+ 100% {
2406
+ opacity: 1;
2407
+ transform: scale(1);
2408
+ }
2409
+ 50% {
2410
+ opacity: 0.45;
2411
+ transform: scale(0.82);
2412
+ }
2413
+ }
2414
+
2415
+ @media (prefers-reduced-motion: reduce) {
2416
+ /* Same specificity as the rule that starts it, and later in the file, so
2417
+ * the animation is cancelled without !important. */
2418
+ .pill.busy .dot,
2419
+ .pill.opening .dot,
2420
+ .pill.flashing .dot {
2421
+ animation: none;
2422
+ }
2423
+ }
2424
+
2425
+ /* A popover hung off something in the header. The trigger is the anchor's
2426
+ * own child, so the menu opens under it rather than under the header. */
2427
+ .anchor {
2428
+ position: relative;
2429
+ display: flex;
2430
+ }
2431
+
2432
+ .menu {
2433
+ position: absolute;
2434
+ z-index: 30;
2435
+ top: calc(100% + 8px);
2436
+ right: 0;
2437
+ width: min(430px, calc(100vw - 2 * var(--gutter)));
2438
+ padding: 13px 15px;
2439
+ border: 1px solid var(--line-strong);
2440
+ border-radius: var(--radius);
2441
+ background: var(--surface-2);
2442
+ box-shadow: 0 12px 30px rgb(0 0 0 / 45%);
2443
+ }
2444
+
2445
+ /* The tabs, on the header's own bottom rule and on the content column's
2446
+ * own left edge. Inside the header rather than under it: two hairlines a
2447
+ * row apart is a box drawn around the chrome, and the chrome is not a box. */
2448
+ nav.tabs {
2449
+ max-width: var(--page);
2450
+ margin: 0 auto;
2451
+ display: flex;
2452
+ gap: 2px;
2453
+ padding: 0 var(--gutter);
2454
+ overflow-x: auto;
2455
+ scrollbar-width: none;
2456
+ }
2457
+ nav.tabs::-webkit-scrollbar { display: none; }
2458
+ nav.tabs button {
2459
+ background: none;
2460
+ border: 0;
2461
+ border-bottom: 2px solid transparent;
2462
+ color: var(--text-dim);
2463
+ padding: 10px 16px;
2464
+ cursor: pointer;
2465
+ border-radius: 0;
2466
+ white-space: nowrap;
2467
+ /* The size the rest of the chrome is -- the pill, the badges, the offline
2468
+ * line. Written here rather than left to the body, because one page had
2469
+ * overridden it and the two tab strips were different sizes. */
2470
+ font-size: var(--fs-sm);
2471
+ font-weight: 550;
2472
+ }
2473
+ /* A tab you are not on has to look like a tab. Colour alone was the only
2474
+ * thing that moved on hover, which on a strip of nine words reads as text
2475
+ * that happens to brighten rather than as somewhere you can go -- so the
2476
+ * underline the selected tab wears appears in a quieter grey under the one
2477
+ * the pointer is on, and the label lifts off the canvas. */
2478
+ nav.tabs button:hover {
2479
+ color: var(--text);
2480
+ border-bottom-color: var(--line-strong);
2481
+ background: color-mix(in srgb, var(--surface) 65%, transparent);
2482
+ }
2483
+ nav.tabs button[aria-selected="true"] { color: var(--primary-bright); border-bottom-color: var(--primary); }
2484
+ nav.tabs button[aria-selected="true"]:hover { border-bottom-color: var(--primary); }
2485
+
2486
+ /* Nine tabs do not fit a phone, and a strip that is cut off dead at the
2487
+ * edge does not look scrollable. A fade at each end says there is more. */
2488
+ nav.tabs {
2489
+ mask-image: linear-gradient(90deg, transparent 0, #000 14px, #000 calc(100% - 14px), transparent 100%);
2490
+ }
2491
+
2492
+ /* A tab that has been opened and is not the one being looked at. It keeps
2493
+ * everything -- what it read, what is half-typed in it, where it was
2494
+ * scrolled to -- and comes back instantly. */
2495
+ .pane[hidden] { display: none; }
2496
+
2497
+ main {
2498
+ padding: 24px var(--gutter) 40px;
2499
+ max-width: var(--page);
2500
+ width: 100%;
2501
+ margin: 0 auto;
2502
+ }
2503
+
2504
+ /* Narrower screens: the same page, not a cut-down one. The columns get
2505
+ * closer together and the label column gives up some of its width, and
2506
+ * nothing here shrinks the type -- a phone in a garage is the worst place
2507
+ * to read 11px labels. */
2508
+ @media (max-width: 900px) {
2509
+ :root {
2510
+ --grid-track: 300px;
2511
+ --label-col: 8rem;
2512
+ }
2513
+ }
2514
+
2515
+ @media (max-width: 760px) {
2516
+ main {
2517
+ padding: 18px var(--gutter) 56px;
2518
+ }
2519
+ }
2520
+
2521
+ /* The licence line: below all content, quiet, one line. The EU flag next
2522
+ * to it is the cue for what "EUPL" means even if the acronym says nothing
2523
+ * to you. The flag itself is an inline SVG in the official geometry; what
2524
+ * the stylesheet adds is the presentation. */
2525
+ .license {
2526
+ display: flex;
2527
+ align-items: center;
2528
+ justify-content: center;
2529
+ gap: 8px;
2530
+ padding: 0 var(--gutter) 28px;
2531
+ font-size: var(--fs-sm);
2532
+ color: var(--text-faint);
2533
+ }
2534
+ .license a { color: var(--text-faint); }
2535
+ .license a:hover { color: var(--primary-bright); }
2536
+ .license svg { flex: none; display: block; }
2537
+
2538
+ /* The flag is a dark blue rectangle, and this page paints its canvas
2539
+ * either near-black or near-white -- on both, a flat dark rectangle at
2540
+ * 30x20 sinks into the page. A hairline in the panel-line grey was not
2541
+ * enough to pick it out of the black, so the dark theme frames it in the
2542
+ * faint text grey instead: a light steel edge that reads as a sticker,
2543
+ * not a sunk rectangle. The drop shadow gives it a lift in both
2544
+ * themes. */
2545
+ .eu-flag {
2546
+ border: 1px solid var(--text-faint);
2547
+ border-radius: 3px;
2548
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4));
2549
+ }
2550
+
2551
+ /* Light theme: the flag's blue is dark enough to hold its own edge
2552
+ * against the light-grey page, so drop the border; the drop shadow
2553
+ * stays as the cue. */
2554
+ [data-theme="light"] .eu-flag { border: 0; }
2555
+
2556
+ /* --- what the pointer is over, on a plot ----------------------------------
2557
+ *
2558
+ * The plot itself is a program's own -- `.chart` is sized and coloured
2559
+ * beside the panel that draws it -- but the label that follows the
2560
+ * pointer across it is `js/chart.js`, and so is this.
2561
+ */
2562
+
2563
+ /* The plot's own rectangle: whatever is drawn, and everything laid over
2564
+ * it. Everything below is positioned in percentages of this box, so this
2565
+ * is the box those percentages are of.
2566
+ *
2567
+ * It lived in one program's own stylesheet while the label that needs it
2568
+ * lived here, which held together for exactly as long as one program drew
2569
+ * plots: the first bar chart in the other one had a label that positioned
2570
+ * itself against the page. The rule belongs with the component that
2571
+ * depends on it. */
2572
+ .chart .plot { position: relative; }
2573
+
2574
+ /* What the pointer is over. Not interactive itself -- it follows the
2575
+ * pointer, so anything it caught would be caught from the plot underneath
2576
+ * it, and the reading would flicker as the label chased the cursor.
2577
+ *
2578
+ * Lifted above the page's own content, because the label leaves the plot:
2579
+ * it stands a few pixels clear of it, above a chart and below a band, and
2580
+ * whatever it lands on is a sibling further down the card. With no layer
2581
+ * of its own it was painted in source order against those siblings -- and
2582
+ * a range input carries `--slider-lift`, a drop-shadow, which is enough to
2583
+ * put it in front of anything drawn before it. A reading that a slider
2584
+ * further down the card is drawn on top of is a reading nobody can read.
2585
+ *
2586
+ * 5 is above everything in the page body and below all of the chrome: the
2587
+ * header is 20, a menu inside it 30, a dialog 60, the toasts 80. */
2588
+ .chart .hover { position: absolute; inset: 0; z-index: 5; pointer-events: none; }
2589
+ .chart .hover .rule {
2590
+ position: absolute;
2591
+ top: 1px;
2592
+ bottom: 1px;
2593
+ width: 1px;
2594
+ margin-left: -0.5px;
2595
+ background: var(--line-strong);
2596
+ }
2597
+ .chart .hover .spot {
2598
+ position: absolute;
2599
+ width: 7px;
2600
+ height: 7px;
2601
+ margin: -3.5px 0 0 -3.5px;
2602
+ border-radius: 50%;
2603
+ background: var(--live);
2604
+ box-shadow: 0 0 0 2px var(--surface-2);
2605
+ }
2606
+ /* The reading itself, above the plot rather than on it: over the line it
2607
+ * would cover the very shape it is explaining. */
2608
+ .chart .hover .tip {
2609
+ position: absolute;
2610
+ bottom: calc(100% + 6px);
2611
+ transform: translateX(-50%);
2612
+ display: grid;
2613
+ gap: 1px;
2614
+ padding: 5px 9px;
2615
+ border: 1px solid var(--line-strong);
2616
+ border-radius: var(--radius-sm);
2617
+ background: var(--surface);
2618
+ box-shadow: 0 6px 16px rgb(0 0 0 / 35%);
2619
+ white-space: nowrap;
2620
+ font-size: var(--fs-xs);
2621
+ font-variant-numeric: tabular-nums;
2622
+ line-height: 1.35;
2623
+ }
2624
+ /* Under the plot instead of over it, for something that is a strip rather
2625
+ * than a rectangle: see `below` in js/chart.js. */
2626
+ .chart .hover .tip.below {
2627
+ top: calc(100% + 6px);
2628
+ bottom: auto;
2629
+ }
2630
+
2631
+ .chart .hover .tip .when { color: var(--text-faint); }
2632
+ .chart .hover .tip .what { color: var(--text); font-weight: 560; }
2633
+
2634
+ /* --- a reading over time --------------------------------------------------
2635
+ *
2636
+ * `Series` in js/chart.js. The line, the fill under it, the marks where
2637
+ * something was written, and the row of ends under the lot. These lived in
2638
+ * one program's own stylesheet beside the component, and moved here with it
2639
+ * when the other program's copy of the same chart turned out to be drawing
2640
+ * in colours that no longer existed.
2641
+ *
2642
+ * `--live` is what both pages mean by something happening right now, and
2643
+ * the line is the live number's own history rather than a second
2644
+ * measurement -- so it is drawn in the same colour as the number above it.
2645
+ */
2646
+ .chart {
2647
+ display: block;
2648
+ width: 100%;
2649
+ }
2650
+
2651
+ .chart svg {
2652
+ display: block;
2653
+ width: 100%;
2654
+ height: 108px;
2655
+ border: 1px solid var(--line);
2656
+ border-radius: var(--radius-sm);
2657
+ background: var(--surface-2);
2658
+ }
2659
+
2660
+ .chart .line {
2661
+ fill: none;
2662
+ stroke: var(--live);
2663
+ stroke-width: 1.75;
2664
+ stroke-linejoin: round;
2665
+ }
2666
+
2667
+ /* A second reading on the same plot, on a scale of its own. `--chart-alt`
2668
+ * so a program can part it from the page's own accent: it has to be told
2669
+ * apart from the first line at a glance and from the write marks as well,
2670
+ * and which colour manages that depends on what `--live` already is. It is
2671
+ * drawn thinner than the first, because it is the answer to a question
2672
+ * about the first rather than a second subject. */
2673
+ .chart .line.alt {
2674
+ stroke: var(--chart-alt, var(--primary-bright));
2675
+ stroke-width: 1.4;
2676
+ }
2677
+
2678
+ .chart .area {
2679
+ fill: color-mix(in srgb, var(--live) 16%, transparent);
2680
+ stroke: none;
2681
+ }
2682
+
2683
+ /* Zero, where the reading goes both ways. A pack that charges and
2684
+ * discharges is drawn either side of this; one that only ever draws power
2685
+ * has its baseline along the bottom and no rule at all, because a rule
2686
+ * along the frame is a second frame. */
2687
+ .chart .axis {
2688
+ stroke: var(--line);
2689
+ fill: none;
2690
+ }
2691
+
2692
+ /* Where a setting was written. Faint, and behind nothing: the question it
2693
+ * answers is "did that do anything", so it has to be next to the line
2694
+ * without competing with it. */
2695
+ .chart .mark {
2696
+ stroke: var(--primary);
2697
+ stroke-width: 1;
2698
+ stroke-dasharray: 3 3;
2699
+ opacity: 0.75;
2700
+ }
2701
+
2702
+ /* Under the plot: how far back it goes, and what is on it.
2703
+ *
2704
+ * Two shapes, because there are two things to lay out. A chart of one
2705
+ * reading has two facts -- the window and the scale -- and they go to the
2706
+ * two ends, which is where a reader looks for the ends of an axis. A chart
2707
+ * of two has a key per line as well, and pushed apart those read as three
2708
+ * unrelated remarks across a line that is mostly gap; `keyed` packs them
2709
+ * into a list instead, wrapping onto a second line on a narrow card.
2710
+ */
2711
+ .chart .ends {
2712
+ display: flex;
2713
+ flex-wrap: wrap;
2714
+ justify-content: space-between;
2715
+ gap: 2px 12px;
2716
+ margin-top: 5px;
2717
+ color: var(--text-faint);
2718
+ font-size: var(--fs-xs);
2719
+ font-variant-numeric: tabular-nums;
2720
+ }
2721
+
2722
+ .chart .ends.keyed {
2723
+ justify-content: flex-start;
2724
+ }
2725
+
2726
+ .chart .ends .key {
2727
+ display: inline-flex;
2728
+ align-items: center;
2729
+ gap: 5px;
2730
+ }
2731
+
2732
+ /* The key's own sample of the mark it is naming, drawn the same way: a
2733
+ * dashed upright for a write, a length of the line itself for a reading. */
2734
+ .chart .ends .key i.tick {
2735
+ display: inline-block;
2736
+ width: 0;
2737
+ height: 10px;
2738
+ border-left: 1px dashed var(--primary);
2739
+ }
2740
+
2741
+ .chart .ends .key i.swatch {
2742
+ display: inline-block;
2743
+ width: 14px;
2744
+ height: 0;
2745
+ border-top: 2px solid var(--live);
2746
+ }
2747
+
2748
+ .chart .ends .key i.swatch.alt {
2749
+ border-top-color: var(--chart-alt, var(--primary-bright));
2750
+ }
2751
+
2752
+ /* A chart with not enough readings to be a chart yet. Dashed, and the size
2753
+ * of the plot it will become, so the card does not change shape when the
2754
+ * second sample lands. */
2755
+ .chart.empty-chart {
2756
+ color: var(--text-faint);
2757
+ font-size: var(--fs-sm);
2758
+ border: 1px dashed var(--line);
2759
+ border-radius: var(--radius-sm);
2760
+ padding: 24px 14px;
2761
+ text-align: center;
2762
+ }
2763
+
2764
+ /* --- a reading against the limits it has to stay inside -------------------
2765
+ *
2766
+ * `js/band.js`. One picture with the reading and every setpoint that
2767
+ * bears on it, because none of them says anything on its own: 42 °C is
2768
+ * fine under a 60 °C protection and a fault under a 40 °C one.
2769
+ *
2770
+ * It is laid out over `.chart .plot`, like the plots are, so that the
2771
+ * label that follows the pointer is the same label -- the same box, in the
2772
+ * same place, appearing at the same moment -- as the one on a chart. Two
2773
+ * different tooltips on one page is two things to learn.
2774
+ */
2775
+ .band { margin: 12px 0 2px; }
2776
+
2777
+ /* What this band is about, where a card draws more than one of them. A
2778
+ * temperature card with the cells' band over the MOSFETs' is two strips
2779
+ * that look alike and mean different things, and a scale that reads -2 to
2780
+ * 32 over one that reads 27 to 52 is not the difference between them. */
2781
+ .band .cap {
2782
+ margin-bottom: 5px;
2783
+ color: var(--text-dim);
2784
+ font-size: var(--fs-xs);
2785
+ }
2786
+
2787
+ /* --- every number on the band, written above it --------------------------
2788
+ *
2789
+ * The reading and each setpoint, printed where it sits. A hover label is
2790
+ * an answer to a question somebody already knew to ask; these are what
2791
+ * make the picture answer "so what is it, and what are the limits" without
2792
+ * being asked, on a phone, and in a screenshot pasted into a message.
2793
+ *
2794
+ * Laid out in `js/band.js`, which knows how wide the track is: the rows,
2795
+ * and how far along each label goes, come in as inline positions.
2796
+ */
2797
+ .band .marks {
2798
+ position: relative;
2799
+ margin-bottom: 4px;
2800
+ font-size: var(--fs-xs);
2801
+ font-variant-numeric: tabular-nums;
2802
+ line-height: 1;
2803
+ }
2804
+
2805
+ .band .tag {
2806
+ position: absolute;
2807
+ display: inline-flex;
2808
+ align-items: center;
2809
+ gap: 4px;
2810
+ height: 14px;
2811
+ padding: 0 3px;
2812
+ transform: translateX(-50%);
2813
+ border: 1px solid transparent;
2814
+ border-radius: 4px;
2815
+ /* The card's own colour rather than nothing, so that the hairline from a
2816
+ * number higher up passes behind the numbers below it instead of through
2817
+ * a digit. */
2818
+ background: var(--surface);
2819
+ color: var(--text-dim);
2820
+ white-space: nowrap;
2821
+ }
2822
+
2823
+ /* The reading is the one number on the strip somebody came to read; the
2824
+ * setpoints are what it is being read against. */
2825
+ .band .tag.read {
2826
+ color: var(--text);
2827
+ font-weight: 600;
2828
+ }
2829
+
2830
+ /* Held but not written, in the colour the row beside it goes -- see
2831
+ * `input.pending`. */
2832
+ .band .tag.pending { color: var(--live); }
2833
+
2834
+ /* Two things on one value: the release still sitting on the protection it
2835
+ * releases, or the pack sitting exactly on a limit. One number, a mark
2836
+ * for each of them, and a box around the lot so that it reads as several
2837
+ * things agreeing rather than as one. */
2838
+ .band .tag.many {
2839
+ border-color: var(--line-strong);
2840
+ background: var(--surface-2);
2841
+ }
2842
+
2843
+ /* A number lifted off the track because the one beside it was already
2844
+ * there, joined back to its own place on the scale. */
2845
+ .band .stem {
2846
+ position: absolute;
2847
+ bottom: 0;
2848
+ width: 1px;
2849
+ margin-left: -0.5px;
2850
+ background: var(--line);
2851
+ }
2852
+
2853
+ /* The same four marks as on the track, small enough to sit in a label:
2854
+ * a protection, the release beside it, a setpoint that protects against
2855
+ * nothing, and the reading.
2856
+ *
2857
+ * Square, where the setpoint on the track is round. They used to be the
2858
+ * same disc in both places, which said the two were the same thing when
2859
+ * only one of them is: the dot on the track is a handle and can be
2860
+ * dragged, the dot in the label is a key to what the number above it
2861
+ * belongs to and does nothing at all. A reader who tried to drag one
2862
+ * found that out by trying. Round is now the whole vocabulary for "take
2863
+ * hold of this"; the label's marks keep the colour and the hollowness --
2864
+ * which is what says *which* setpoint -- and give up the shape.
2865
+ *
2866
+ * The reading's mark is left alone: it is already a bar rather than a dot,
2867
+ * because the reading on the track is a needle rather than a grip. */
2868
+ .band .pip {
2869
+ flex: none;
2870
+ width: 7px;
2871
+ height: 7px;
2872
+ border-radius: 1.5px;
2873
+ background: var(--text-dim);
2874
+ }
2875
+
2876
+ .band .pip.low,
2877
+ .band .pip.high { background: var(--danger); }
2878
+
2879
+ .band .pip.soft {
2880
+ background: var(--surface);
2881
+ box-shadow: inset 0 0 0 2px var(--text-dim);
2882
+ }
2883
+
2884
+ .band .pip.low.soft,
2885
+ .band .pip.high.soft { box-shadow: inset 0 0 0 2px var(--danger); }
2886
+
2887
+ .band .pip.read {
2888
+ width: 3px;
2889
+ height: 9px;
2890
+ border-radius: 2px;
2891
+ background: var(--text);
2892
+ }
2893
+
2894
+ /* Room above and below the track for the setpoints, which stand taller
2895
+ * than it: the plot is the box the hover label measures itself against, so
2896
+ * anything drawn outside it would be a hit area the label disagrees with. */
2897
+ .band .plot {
2898
+ height: 22px;
2899
+ display: flex;
2900
+ align-items: center;
2901
+ }
2902
+
2903
+ /* Horizontal drags belong to the setpoints; vertical ones are the page
2904
+ * scrolling past, which a band across a phone must not swallow. */
2905
+ .band .plot.settable {
2906
+ touch-action: pan-y;
2907
+ cursor: pointer;
2908
+ }
2909
+
2910
+ .band .track {
2911
+ position: relative;
2912
+ width: 100%;
2913
+ height: 8px;
2914
+ border: 1px solid var(--line);
2915
+ border-radius: 999px;
2916
+ background: var(--surface-3);
2917
+ }
2918
+
2919
+ /* Where nothing trips: from the highest floor to the lowest ceiling. */
2920
+ .band .safe {
2921
+ position: absolute;
2922
+ top: 0;
2923
+ bottom: 0;
2924
+ border-radius: 999px;
2925
+ background: color-mix(in srgb, var(--ok) 45%, var(--surface-3));
2926
+ }
2927
+
2928
+ /* A reading that is a range rather than a point -- sixteen cells are not
2929
+ * one voltage -- drawn as the stretch the readings actually cover. On a
2930
+ * balanced pack that stretch is a few pixels wide, which is the answer. */
2931
+ .band .spread {
2932
+ position: absolute;
2933
+ top: -2px;
2934
+ bottom: -2px;
2935
+ min-width: 2px;
2936
+ border-radius: 999px;
2937
+ background: var(--text);
2938
+ opacity: 0.85;
2939
+ }
2940
+
2941
+ .band .needle {
2942
+ position: absolute;
2943
+ top: -4px;
2944
+ width: 3px;
2945
+ height: 14px;
2946
+ margin-left: -1.5px;
2947
+ border-radius: 2px;
2948
+ background: var(--text);
2949
+ }
2950
+
2951
+ .band .needle.bad { background: var(--danger); }
2952
+
2953
+ /* A setpoint. Round, so that it reads as something to take hold of rather
2954
+ * than as another mark on the scale. A protection is drawn in the colour
2955
+ * of a fault whichever end of the band it is on -- which side it is is
2956
+ * already said by which side of the calm stretch it sits on -- and a
2957
+ * setpoint that is not a protection at all is grey. */
2958
+ .band .grip {
2959
+ position: absolute;
2960
+ top: 50%;
2961
+ width: 11px;
2962
+ height: 11px;
2963
+ margin: -5.5px 0 0 -5.5px;
2964
+ padding: 0;
2965
+ border: 2px solid var(--surface);
2966
+ border-radius: 50%;
2967
+ background: var(--text-dim);
2968
+ box-shadow: 0 0 0 1px var(--line-strong);
2969
+ cursor: grab;
2970
+ }
2971
+
2972
+ .band .grip.low,
2973
+ .band .grip.high { background: var(--danger); }
2974
+ .band .grip.mark { background: var(--text-dim); }
2975
+
2976
+ /* The release point beside a protection: the temperature the device lets
2977
+ * the pack work again at. Smaller and hollow, because it is not a limit
2978
+ * -- the calm band reaches past it to the protection itself -- and a
2979
+ * reader who cannot tell the two apart will set the wrong one. */
2980
+ .band .grip.soft {
2981
+ width: 9px;
2982
+ height: 9px;
2983
+ margin: -4.5px 0 0 -4.5px;
2984
+ background: var(--surface);
2985
+ box-shadow: 0 0 0 2px var(--text-dim);
2986
+ border-width: 2px;
2987
+ }
2988
+
2989
+ .band .grip.low.soft,
2990
+ .band .grip.high.soft { box-shadow: 0 0 0 2px var(--danger); }
2991
+
2992
+ /* A value the card is holding but has not written yet, in the same colour
2993
+ * the number in the row beside it goes -- see `input.pending`. A picture
2994
+ * and a table editing one draft have to agree about which of them is
2995
+ * unsent. */
2996
+ .band .grip.pending { box-shadow: 0 0 0 2px var(--live); }
2997
+
2998
+ .band .grip.fixed { cursor: default; }
2999
+
3000
+ /* A grip that has the keyboard is drawn as having it, however it got it.
3001
+ *
3002
+ * `:focus-visible` is the usual rule and is the wrong one here: it is false
3003
+ * for a button focused by a pointer, which is exactly the grip somebody has
3004
+ * just dragged and is about to nudge with the arrow keys. A ring that
3005
+ * appeared only after a Tab would mean the one affordance that says "this
3006
+ * still answers to the keyboard" is hidden from everybody who used the
3007
+ * mouse. So the ring is on `:focus`, and it is the page's own ring from
3008
+ * the top of this file, drawn here because the rule up there is
3009
+ * `:focus-visible`. */
3010
+ .band button.grip:hover,
3011
+ .band button.grip:focus { transform: scale(1.25); }
3012
+
3013
+ .band button.grip:focus {
3014
+ outline: 2px solid var(--primary);
3015
+ outline-offset: 2px;
3016
+ }
3017
+
3018
+ .band button.grip:active { cursor: grabbing; }
3019
+
3020
+ .band .scale {
3021
+ display: flex;
3022
+ justify-content: space-between;
3023
+ gap: 8px;
3024
+ margin-top: 5px;
3025
+ color: var(--text-faint);
3026
+ font-size: var(--fs-xs);
3027
+ font-variant-numeric: tabular-nums;
3028
+ }
3029
+
3030
+ .band .scale .mid {
3031
+ flex: 1;
3032
+ text-align: center;
3033
+ font-variant-numeric: normal;
3034
+ }