@tn3270/gui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/ui.css ADDED
@@ -0,0 +1,843 @@
1
+ /*
2
+ Shared chrome for every HTML surface in this project: the GUI's transfer window, the GUI's
3
+ keypad window, and the web gateway's keypad overlay.
4
+
5
+ ## ONE FILE, THREE DOCUMENTS, AND THAT IS A REQUIREMENT RATHER THAN TIDINESS
6
+
7
+ The user's call, 2026-10-06: the keypad and the transfer window must be stylistically similar.
8
+ `transfer.html` inlined these rules, so a second HTML surface meant a second copy and a third
9
+ meant a third -- and three copies of one `font:` declaration diverge the first time anybody
10
+ touches one of them.
11
+
12
+ ## "NATIVE, NOT X-ISH", CONCRETELY
13
+
14
+ The user asked for something that "looks like a native app, not an X-windows app", with a
15
+ pleasant font. That means, specifically:
16
+
17
+ - `system-ui`, so each platform supplies its own face rather than us shipping one;
18
+ - `color-scheme: light dark`, so the OS theme is honored instead of a hardcoded palette;
19
+ - system color KEYWORDS (`GrayText`, `Canvas`, `CanvasText`) rather than hex values, so
20
+ high-contrast and dark modes keep working without a second stylesheet.
21
+
22
+ NOTHING HERE USES THE 3270 ATLAS. That is the entire point of moving the keypad off the canvas:
23
+ the old one drew its buttons as 3270 glyphs through x3270's `3270.bdf`, which is why the user
24
+ called it ugly.
25
+
26
+ ## THE 3270 SCREEN ITSELF IS NOT STYLED BY THIS FILE
27
+
28
+ The canvas is pixels blitted from an atlas and must stay that way, so `gui/index.html` and
29
+ `web/static/index.html` keep their own two-line style blocks. Those two also encode a
30
+ LOAD-BEARING difference from each other -- `overflow:hidden` against `overflow:auto`, measured
31
+ against a 43-row model 4 -- and merging them into here would lose it.
32
+
33
+ A SECOND REASON NOT TO TOUCH THOSE PAGES: `gui/src/main.ts:1234` records that the click path's
34
+ `offsetX` arithmetic depends on the canvas sitting at the viewport origin -- "give the page a
35
+ body margin and every click here misses by it". The `body { padding }` below applies to the two
36
+ HTML surfaces, never to a canvas page.
37
+ */
38
+
39
+ :root { color-scheme: light dark; }
40
+
41
+ /*
42
+ SCOPED TO `.ui-surface`, NOT APPLIED TO `body`, AND THAT WAS MEASURED RATHER THAN FORESEEN.
43
+
44
+ The first version styled `body` directly, which was fine while only the transfer and keypad
45
+ WINDOWS loaded this file -- in those, the HTML form IS the document. The web gateway loads it
46
+ into the page that also holds the 3270 CANVAS, and `body { padding: 12px 14px }` displaced that
47
+ canvas: `browser-shot.mjs` reported both goldens differing, and the measurement was content
48
+ moving from x=1 to x=16, a 15px shift of the whole screen.
49
+
50
+ That is worse than a cosmetic regression. `gui/src/main.ts` records that the click path's
51
+ `offsetX` arithmetic assumes the canvas sits at the viewport origin -- "give the page a body
52
+ margin and every click here misses by it" -- so padding on `body` would have put every keypad
53
+ and selection click 15px off target in the browser, silently, with the goldens as the only
54
+ witness. The warning was already written down in this file's own header; scoping is what makes
55
+ it structural instead of advisory.
56
+
57
+ So every HTML surface carries `class="ui-surface"` on its `body`, and the canvas pages simply do
58
+ not. The keypad OVERLAY is styled through `#keypad-overlay` below, which is its own element and
59
+ never the page.
60
+ */
61
+ body.ui-surface {
62
+ font: 13px system-ui, sans-serif;
63
+ margin: 0;
64
+ padding: 12px 14px;
65
+ }
66
+
67
+ .ui-surface h1 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
68
+
69
+ /* The overlay is a surface too, but it is an element inside a canvas page rather than a body. */
70
+ #keypad-overlay h1 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
71
+
72
+ /* ---- The transfer form ---- */
73
+
74
+ .row {
75
+ display: grid;
76
+ grid-template-columns: 90px 1fr auto;
77
+ gap: 6px 8px;
78
+ align-items: center;
79
+ margin-bottom: 6px;
80
+ }
81
+
82
+ .ui-surface label { text-align: right; color: GrayText; }
83
+ .ui-surface input, .ui-surface select { font: inherit; padding: 2px 4px; min-width: 0; }
84
+ .ui-surface input[disabled], .ui-surface select[disabled] { opacity: 0.5; }
85
+
86
+ /*
87
+ NO `#status.error` RULE, deliberately -- moved here from `transfer.html` with its reasoning
88
+ intact, because the reasoning is what stops it being re-added. `UiDeps.setStatus` takes a BARE
89
+ STRING: a help line, a progress report and a validator complaint are indistinguishable by the
90
+ time they reach the DOM, so nothing in `transferBoot.ts` could ever add that class and the rule
91
+ would be dead CSS promising a color it never paints. Styling an error needs the distinction
92
+ carried across the boundary -- a second argument on `setStatus`, or a separate `setError` --
93
+ which is a change to `transferUi.ts`'s interface and its tests, not to markup.
94
+ */
95
+ #status { margin: 10px 0 12px; min-height: 2.4em; white-space: pre-wrap; color: GrayText; }
96
+
97
+ /*
98
+ THE CONNECT DIALOG'S ERROR LINE, and unlike `#status` above it IS only ever an error: the boot
99
+ file writes nothing else to it, so a colour here cannot lie. WRAPPED, never clipped -- a usage
100
+ line is long, and the remedy in a TLS message must not be cut off. `min-height` reserves two lines
101
+ so the buttons do not jump when an error appears; a longer message grows the box and scrolls.
102
+ */
103
+ #connect-error {
104
+ /* Whole lines, so the box never cuts one in half: two reserved, five before it scrolls. */
105
+ line-height: 1.3; min-height: calc(2 * 1.3em); max-height: calc(5 * 1.3em);
106
+ margin: 8px 0 10px; overflow-y: auto;
107
+ white-space: pre-wrap; overflow-wrap: anywhere; color: #c62828;
108
+ }
109
+ /* A checkbox in the row's wide middle column would otherwise sit centred, away from its label. */
110
+ #connect-form input[type="checkbox"] { justify-self: start; margin-left: 0; }
111
+ @media (prefers-color-scheme: dark) { #connect-error { color: #ff6e6e; } }
112
+
113
+ #buttons { display: flex; gap: 8px; justify-content: flex-end; }
114
+ .ui-surface button, #keypad-overlay button, #transfer-overlay button {
115
+ font: inherit;
116
+ padding: 3px 12px;
117
+ }
118
+
119
+ /*
120
+ THE FOCUS RING, AND IT IS AN ACCESSIBILITY FIX RATHER THAN A FLOURISH.
121
+
122
+ Before this rule there was NO `:focus` and NO `outline` anywhere in this file, so every button in
123
+ the keypad and in both transfer surfaces relied on the browser's default ring -- which differs
124
+ between Firefox and Chromium and can read poorly against the `Canvas` background these overlays
125
+ set deliberately. The transfer form is built to be keyboard-navigable (`transferBoot.ts`'s focus
126
+ memo, `transferUi.ts`'s `caretAfterEdit`, and the tab order `TRANSFER_FIELDS` fixes), so having no
127
+ visible focus indicator was a real defect and not a cosmetic one.
128
+
129
+ `:focus-visible` AND NOT `:focus`. The latter leaves a ring behind after a mouse click, which is
130
+ the reason the pseudo-class was split in the first place. That is the right rule for every button
131
+ an operator TABS to.
132
+
133
+ **BUT IT IS NOT ENOUGH FOR A PROGRAMMATIC FOCUS, which this comment originally claimed it was.**
134
+ MEASURED the same day: `:focus-visible` is decided by the UA's heuristic about the user's LAST
135
+ INPUT MODALITY, not by how the focus was set -- so the Save button that `transferBoot.ts` focuses
136
+ on a completed receive did NOT match it, because the operator got there by CLICKING Start. See
137
+ the `#transfer-save` rule below, which is why it exists.
138
+
139
+ THIS REACHES THE ELECTRON GUI TOO, deliberately. This file lives in `packages/gui` and is served
140
+ to the browser as a third asset root (`web/src/httpstatic.ts` sets out why at length); the GUI's
141
+ keypad and transfer windows had no focus indicator either, and the user requirement that the two
142
+ look alike is what made the stylesheet shared at all.
143
+
144
+ SYSTEM COLOUR, like `GrayText` on the status lines and `Canvas` on the surfaces, so the ring
145
+ follows the operator's own theme rather than hard-coding a colour that might vanish into it.
146
+ `outline-offset` keeps it clear of the glyphs on the keypad's 3.4em buttons.
147
+ */
148
+ .ui-surface button:focus-visible,
149
+ #keypad-overlay button:focus-visible,
150
+ #transfer-overlay button:focus-visible {
151
+ outline: 2px solid Highlight;
152
+ outline-offset: 2px;
153
+ }
154
+
155
+ /*
156
+ AND `#transfer-save` NEEDS PLAIN `:focus` AS WELL -- MEASURED, NOT ASSUMED.
157
+
158
+ `transferBoot.ts` focuses this button when a receive completes, and the rule above DID NOT PAINT:
159
+ `browser-clicks.mjs` reported `:focus-visible matched: false` with the focus correctly on
160
+ `#transfer-save`. `:focus-visible` is decided by the UA's heuristic about the user's LAST INPUT
161
+ MODALITY, not by how the focus was set -- and the operator reaches a completed transfer by
162
+ CLICKING Start, so the modality is pointer and the ring is suppressed. Confirmed against MDN
163
+ 2026-10-22. The autofocus would have landed invisibly, which is the whole defect it exists to fix.
164
+
165
+ SO THIS ONE BUTTON OPTS INTO `:focus` TOO, and the usual objection does not apply to it. `:focus`
166
+ is avoided generally because it leaves a ring on a button the operator merely clicked -- but
167
+ clicking THIS button saves the file and `transferBoot.ts` then DISABLES it (`hasUnsaved()` goes
168
+ false), so the lingering-ring case cannot arise: there is no state where Save is both clicked and
169
+ still focusable. The narrow selector is deliberate; widening it to every button would
170
+ reintroduce exactly what `:focus-visible` is for.
171
+ */
172
+ #transfer-overlay #transfer-save:focus {
173
+ outline: 2px solid Highlight;
174
+ outline-offset: 2px;
175
+ }
176
+
177
+ /* ---- The keypad ---- */
178
+
179
+ /*
180
+ One block per semantic group from `KEYPAD_BLOCKS`, each a wrapping flex row of buttons.
181
+
182
+ NO FIXED PIXEL GRID AND NO PER-KEY POSITIONING, which is the substantive difference from the
183
+ canvas keypad: that one computed a left edge in CHARACTER CELLS for every key (`KeypadKey.col`,
184
+ six cells per key, sized to fit inside 80 columns). Here the browser lays the buttons out, so a
185
+ longer label grows its own button instead of overflowing into the next one -- the 5-character
186
+ limit on `KeypadKey.label` exists for the canvas, and is why labels read `FldMk` and `BkSp`.
187
+ Those abbreviations stay for now: they are shared with the TUI overlay, and widening them is a
188
+ separate change to a table two front ends read.
189
+ */
190
+ .keypad-block { margin: 0 0 10px; }
191
+
192
+ .keypad-block > h2 {
193
+ font-size: 11px;
194
+ font-weight: 600;
195
+ color: GrayText;
196
+ margin: 0 0 4px;
197
+ text-transform: uppercase;
198
+ letter-spacing: 0.04em;
199
+ }
200
+
201
+ .keypad-keys { display: flex; flex-wrap: wrap; gap: 4px; }
202
+ .keypad-keys > button { min-width: 3.4em; padding: 3px 6px; }
203
+
204
+ /*
205
+ THE WEB OVERLAY, which the GUI does not use: in a window the keypad IS the document, but a
206
+ browser tab has only the one pane, so the web gets an overlay over its canvas.
207
+
208
+ OPAQUE, NOT TRANSLUCENT -- the user's call, and there is a measured reason to respect it. The
209
+ TUI's overlay once had host text leak through into its chord column, and opacity was the
210
+ hard-won fix. An overlay that lets the screen show through is a legibility bug waiting to be
211
+ re-found, so `background: Canvas` is a rule rather than a default.
212
+
213
+ `position: fixed` AND NOT A SIBLING IN FLOW, for the reason the header gives: the canvas must
214
+ stay at the viewport origin or every keypad click in the OTHER front end misses by the offset.
215
+ Fixed positioning paints over it without displacing it.
216
+
217
+ `overflow: auto` because the keypad is taller than some viewports and a browser page cannot
218
+ resize its own window -- the same argument that made `web/static/index.html` scroll where the
219
+ Electron page does not.
220
+ */
221
+ #keypad-overlay {
222
+ position: fixed;
223
+ inset: 0;
224
+ overflow: auto;
225
+ background: Canvas;
226
+ color: CanvasText;
227
+ padding: 12px 14px;
228
+ }
229
+
230
+ #keypad-overlay[hidden] { display: none; }
231
+ #keypad-overlay > .keypad-close { position: absolute; top: 8px; right: 10px; }
232
+
233
+ /*
234
+ THE WEB TRANSFER OVERLAY, 2026-10-07, which the GUI does not use either: in Electron the transfer
235
+ form IS a window (`transfer.html`, styled through `body.ui-surface` above), and a browser tab has
236
+ only the one pane.
237
+
238
+ THE TWO RULES ARE THE KEYPAD'S, and both are load-bearing -- but `position: fixed` earns its
239
+ keep HERE for a different measured reason than the one the keypad's comment gives, and the
240
+ difference is worth writing down because the obvious claim is FALSE for this element:
241
+
242
+ - `position: fixed`, so the form appears IN THE VIEWPORT. It does NOT exist to stop the canvas
243
+ being displaced: this overlay follows `<canvas id="screen">` in document order, so a static
244
+ one lands BELOW it and cannot push it anywhere. MEASURED 2026-10-07 under real Chromium with
245
+ `position: static`, the form shown: the canvas rect was byte-identical (`x:0 y:0` either
246
+ way), `browser-shot.mjs` still scored 1/1, and what actually broke was the FORM -- its top
247
+ edge sat at y=773 in a 773px viewport, i.e. entirely below the fold, `body.scrollHeight`
248
+ grew from 773 to 1105, and `elementFromPoint` over the Start button returned `null` instead
249
+ of `transfer-start`. So the honest statement is that a static overlay is an INVISIBLE,
250
+ UNCLICKABLE form the operator must scroll to find, not a displaced canvas.
251
+ The displacement hazard is real and is the keypad's: `gui/src/main.ts` records what it costs
252
+ -- "give the page a body margin and every click here misses by it" -- and
253
+ `browser-clicks.mjs` measured 15px of it once, from this file styling `body`. That is why
254
+ the shared rules above are scoped to `.ui-surface`. Fixed positioning keeps this element
255
+ structurally incapable of contributing to it, which is worth having; it is just not what a
256
+ mutation of this line would demonstrate.
257
+ - `background: Canvas`, opaque rather than translucent. The TUI's overlay once let host text
258
+ leak through into its chord column and opacity was the hard-won fix; here the surface
259
+ contains EDITABLE FIELDS over a logged-on session, which is strictly worse to read through.
260
+
261
+ `inset: 0` IS DELIBERATELY NOT COPIED FROM THE KEYPAD. That one fills the viewport because 48
262
+ buttons need the room; this form is ten rows, and covering the whole screen would hide the host
263
+ output the operator is naming files from. So it is a panel in the top-left with a `max-height`
264
+ and its own scrollbar, for the same reason the keypad has one: a browser page cannot resize its
265
+ own window.
266
+
267
+ VISIBILITY IS DRIVEN BY THE `hidden` ATTRIBUTE, matching `#keypad-overlay` above and
268
+ `transferOverlay.ts`, which sets `element.hidden` and never `style.display` -- an inline style
269
+ would beat this rule by specificity and silently diverge the two overlays' mechanisms.
270
+ */
271
+ #transfer-overlay {
272
+ position: fixed;
273
+ top: 0;
274
+ left: 0;
275
+ max-height: 100%;
276
+ overflow: auto;
277
+ background: Canvas;
278
+ color: CanvasText;
279
+ padding: 12px 14px;
280
+ border: 1px solid GrayText;
281
+ }
282
+
283
+ #transfer-overlay[hidden] { display: none; }
284
+
285
+ #transfer-overlay h1 { font-size: 13px; font-weight: 600; margin: 0 0 10px; }
286
+
287
+ /*
288
+ TWO COLUMNS, LABEL AND CONTROL, matching the transfer WINDOW's `.row` grid in spirit without
289
+ reusing it: that one is a three-column grid per row because the window draws a Browse button
290
+ beside the `Local file` input. Here Browse lives in the static button row below the fields, so
291
+ the rows need no third column -- and one grid over all the rows keeps the labels aligned without
292
+ a fixed pixel width.
293
+ */
294
+ #transfer-fields {
295
+ display: grid;
296
+ grid-template-columns: auto 1fr;
297
+ gap: 6px 8px;
298
+ align-items: center;
299
+ }
300
+
301
+ #transfer-overlay label { text-align: right; color: GrayText; }
302
+ #transfer-overlay input, #transfer-overlay select { font: inherit; padding: 2px 4px; min-width: 0; }
303
+ #transfer-overlay input[disabled], #transfer-overlay select[disabled] { opacity: 0.5; }
304
+ #transfer-overlay button[disabled] { opacity: 0.5; }
305
+
306
+ /* Mirrors `#status` above, including the `min-height` that stops the panel resizing per message. */
307
+ #transfer-status { margin: 10px 0 12px; min-height: 2.4em; white-space: pre-wrap; color: GrayText; }
308
+
309
+ #transfer-buttons { display: flex; gap: 8px; }
310
+
311
+ /*
312
+ THE WEB MODEL/OVERSIZE PICKER, Alt-M, 2026-11-04 -- the GUI's Model menu as an overlay, since a
313
+ browser tab has no menu bar. The transfer overlay's rules, for that overlay's measured reasons:
314
+ `position: fixed` so it is in the viewport whatever the page has scrolled to; `background:
315
+ Canvas`, OPAQUE, so the 3270 screen does not show through the labels; a top-left panel with its
316
+ own scrollbar rather than `inset: 0`, because eleven radios do not need the whole screen; and
317
+ visibility through the `hidden` attribute, never `style.display`, which would beat this rule by
318
+ specificity. The Electron GUI does not use it: there the picker IS the Model menu.
319
+ */
320
+ #geometry-overlay {
321
+ position: fixed;
322
+ top: 0;
323
+ left: 0;
324
+ max-height: 100%;
325
+ overflow: auto;
326
+ background: Canvas;
327
+ color: CanvasText;
328
+ padding: 12px 14px;
329
+ border: 1px solid GrayText;
330
+ font: 13px system-ui, sans-serif;
331
+ }
332
+
333
+ #geometry-overlay[hidden] { display: none; }
334
+ #geometry-overlay h1 { font-size: 13px; font-weight: 600; margin: 0 4.5em 4px 0; }
335
+ #geometry-overlay > .geometry-close { position: absolute; top: 8px; right: 10px; }
336
+ #geometry-overlay button { font: inherit; padding: 3px 12px; }
337
+ #geometry-overlay button:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
338
+ #geometry-overlay .geometry-help { margin: 0 0 8px; color: GrayText; }
339
+ /* A GRID, not the flex row it was: the Disconnect button (2026-11-05) is the root's first child and
340
+ takes a whole row of its own, at its own width, above the two groups side by side. */
341
+ #geometry-choices { display: grid; grid-template-columns: auto auto; justify-content: start; gap: 12px; align-items: start; }
342
+ #geometry-choices > .geometry-disconnect { grid-column: 1 / -1; justify-self: start; }
343
+ #geometry-choices fieldset { border: 1px solid GrayText; margin: 0; padding: 4px 10px 6px; }
344
+ #geometry-choices legend { color: GrayText; }
345
+ #geometry-choices label { display: block; white-space: nowrap; padding: 1px 0; }
346
+ #geometry-choices input[disabled] + span { opacity: 0.5; }
347
+ /* Mirrors `#transfer-status`, including the `min-height` that stops the panel resizing per message. */
348
+ #geometry-status { margin: 8px 0 0; min-height: 2.4em; max-width: 40em; white-space: pre-wrap; color: GrayText; }
349
+
350
+ /* ---- The full 122-key keyboard ---- */
351
+
352
+ /*
353
+ ============================================================================================
354
+ THE FULL KEYBOARD, IN TWO HALVES: `.keyboard-surface` IS THE KEYBOARD, `#keyboard-overlay` IS
355
+ ONE PLACE TO PUT IT
356
+ ============================================================================================
357
+
358
+ ## WHY THIS IS SPLIT AT ALL, 2026-10-23
359
+
360
+ Every one of these rules was scoped to `#keyboard-overlay` when the WEB gateway was the only
361
+ front end with a keyboard. The Electron window (`keyboard.html`) is a DOCUMENT, not an overlay
362
+ over a canvas, so under the old scoping it matched NONE of them -- and the failure is not a
363
+ blank window but something worse to diagnose: `.ui-surface button { padding: 3px 12px }` on line
364
+ 98 still applies, so the caps render as ordinary buttons at the wrong size, in rows, looking
365
+ like a keyboard that has merely been styled badly. `uiAssets.test.ts` records the same shape for
366
+ a missing stylesheet: the whole thing works and looks like 1993.
367
+
368
+ ## THE SPLIT FOLLOWS THE KEYPAD'S OWN PRECEDENT
369
+
370
+ `.keypad-keys` (line 185) is the keypad's shared layout and `#keypad-overlay` (line 205) is its
371
+ web-only framing -- the same division, already in this file, for the same reason. So:
372
+
373
+ - `.keyboard-surface` CARRIES THE KEYBOARD: the pitch, the font, the regions, the rows, the cap
374
+ geometry. Both front ends put this class on whatever element holds the keyboard.
375
+ - `#keyboard-overlay` CARRIES THE OVERLAY: fixed positioning, the dock, the opaque background,
376
+ the scroll, and the `hidden` rule. The Electron window needs none of these -- a window IS
377
+ positioned and opaque by being a window.
378
+
379
+ THE PITCH AND FONT ARE ON THE CLASS, NOT THE ID, and that is the half most easily got wrong:
380
+ they look like framing and they are not. `--kb-pitch` is what every cap's width calc multiplies,
381
+ so on the id it would be absent in the window and every cap would compute from an undefined
382
+ variable.
383
+ */
384
+ .keyboard-surface {
385
+ /*
386
+ ONE PITCH VARIABLE, which is the whole reason `KEYBOARD_3279` holds KEY UNITS rather than
387
+ pixels: the table says a cap is 1 or 1.5 or 7 units wide, and this decides what a unit is. To
388
+ make the keyboard bigger or smaller, change this number and nothing else.
389
+ */
390
+ /*
391
+ THE PITCH AND THE FONT TOGETHER, AND THE FONT IS THE ONE THAT WAS WRONG.
392
+
393
+ MEASURED 2026-10-22 from the user's screenshots: `PF13` rendered as `PF1`, `PF24` as `PF2` and
394
+ `Attn` as `Attr` -- every four-character label silently clipped by the `overflow: hidden`
395
+ below, which made the whole PF strip useless since PF13-24 became indistinguishable from
396
+ PF1-12.
397
+
398
+ THE ARITHMETIC, so the next change is not another guess: a cap is `--kb-pitch` wide less 4px of
399
+ padding. At 11px, four characters of system-ui need about 24px, against 26px usable -- it
400
+ "fits" by 2px in theory and clips in practice, because the font metric is an average and
401
+ capitals are wider. At 9px the same label needs about 20px, which leaves real room.
402
+
403
+ The user's requirement, which is the right test to keep: **the common three-letter names
404
+ (`PA1`, `PF3`, `Esc`) must fit**, and the four-character ones (`PF13`, `Attn`, `Dup`) are the
405
+ cases that actually bite.
406
+
407
+ THE SHIPPED PAIR IS 28px AND 8px, ARRIVED AT BY MEASUREMENT RATHER THAN TASTE. A cap is
408
+ 28 - 4 = 24px usable, and at 8px a character of system-ui averages ~4.5px, so FIVE characters
409
+ fit per line -- which is what `Clear`, `Pause`, `Enter`, `Space` and `Field` need. Six would
410
+ require either a 6px font (unreadable) or a 34px pitch (a 816px-wide surface), so the
411
+ six-character legends are STACKED instead, which is what the hardware does anyway.
412
+
413
+ The lower band is 24 key units, so 28px gives ~672px -- inside a 24x80 screen's ~720px with
414
+ room for the gaps between regions, where the original 30px gave exactly 720px and left none.
415
+
416
+ `keyboard3279.test.ts` copies these two numbers and fails if a legend cannot fit. CHANGE IT
417
+ WHEN YOU CHANGE THESE.
418
+ */
419
+ --kb-pitch: 28px;
420
+ /*
421
+ THE FAMILY, AND ITS ABSENCE IS WHY THE KEYBOARD WENT SERIF.
422
+
423
+ `font: 13px system-ui, sans-serif` is set on `body.ui-surface` -- which is the ELECTRON pages'
424
+ body. The web gateway's page is a bare `<body>` holding a canvas, so this overlay inherited
425
+ from it, and a bare body's default family is SERIF. The `font: inherit` added to the caps in
426
+ the previous commit then faithfully inherited the wrong thing: it fixed the SIZE (they had been
427
+ stuck at the UA's 13.33px) and exposed the family at the same time.
428
+
429
+ SET HERE RATHER THAN ON THE CAPS, so one declaration covers the overlay, and `system-ui` to
430
+ match the rest of this stylesheet -- each platform supplies its own face rather than us
431
+ shipping one, which is the reasoning this file's header already gives.
432
+ */
433
+ /*
434
+ AN EXPLICIT FALLBACK CHAIN FOR THE ARROW GLYPHS, 2026-10-22.
435
+
436
+ The cursor cluster and several other caps draw a dozen glyphs from Unicode's Arrows block
437
+ (U+2190..U+21FF): two-headed arrows, arrows to bar, white arrows, an arrow with a stroke. A
438
+ font that lacks any of them renders a TOFU BOX, which looks like a defect in the keyboard
439
+ rather than in the font.
440
+
441
+ MEASURED: `browser-clicks.mjs` now compares each glyph's advance width against U+FFFD's in the
442
+ RESOLVED font and fails if any matches or is zero. On this machine `system-ui` resolves to a
443
+ face that has all twelve. **BUT `system-ui` IS A DIFFERENT FACE ON EVERY PLATFORM**, so that
444
+ result does not travel -- hence the named fallbacks after it, each chosen because it ships with
445
+ broad Arrows coverage on the platform it belongs to:
446
+
447
+ - `Segoe UI Symbol` -- Windows, the usual home for these when Segoe UI itself falls short.
448
+ - `Apple Symbols` -- macOS, which is the user's own platform.
449
+ - `Noto Sans Symbols 2` / `DejaVu Sans` -- Linux, the latter being near-universal there.
450
+
451
+ THE ORDER PUTS `system-ui` FIRST DELIBERATELY: the keyboard should look native where it can,
452
+ and a fallback only has to catch the glyphs the native face is missing -- per-character
453
+ fallback is how CSS font lists already work, so naming these costs nothing when they are
454
+ unneeded.
455
+ */
456
+ font-family: system-ui, 'Segoe UI Symbol', 'Apple Symbols',
457
+ 'Noto Sans Symbols 2', 'DejaVu Sans', sans-serif;
458
+ font-size: 8px;
459
+ }
460
+
461
+ /*
462
+ THE WEB OVERLAY'S OWN HALF: BOTTOM-DOCKED, BECAUSE THE GEOMETRY ALLOWS NOTHING ELSE.
463
+
464
+ The reference hardware is 2.6:1 and about 24 key units across, so at a readable pitch the
465
+ keyboard is as wide as a 24x80 screen itself. Corner-anchoring it -- the arrangement the user
466
+ was considering for the compact KEYPAD -- is simply not available to this surface.
467
+
468
+ `position: fixed` LIKE THE OTHER TWO OVERLAYS, and load-bearing for the reason recorded further
469
+ up this file: the click path's `offsetX` arithmetic assumes the canvas sits at the viewport
470
+ origin, and a sibling in normal flow would push it down and break the OTHER front end's keypad
471
+ and selection clicks through the shared renderer.
472
+
473
+ OPAQUE, like the keypad, and that is also load-bearing: `keypadOverlay.ts` records that the TUI's
474
+ keypad once let host text bleed into its chord column. A 3270 screen behind 117 buttons is a
475
+ denser background still.
476
+
477
+ `overflow: auto` rather than `hidden`, matching the web page's own rule: a short viewport must be
478
+ able to reach the bottom row rather than having it clipped away.
479
+
480
+ NONE OF THIS IS WANTED IN THE ELECTRON WINDOW, which is the reason the two halves are separate: a
481
+ window is already positioned, already opaque, and already scrolls. `max-height: 60vh` in
482
+ particular would be actively wrong there -- it exists to stop an overlay eating the screen it is
483
+ drawn over, and in a window there is nothing behind it to protect.
484
+ */
485
+ #keyboard-overlay {
486
+ position: fixed;
487
+ left: 0;
488
+ right: 0;
489
+ bottom: 0;
490
+ max-height: 60vh;
491
+ overflow: auto;
492
+ background: Canvas;
493
+ color: CanvasText;
494
+ padding: 8px 10px;
495
+ }
496
+
497
+ #keyboard-overlay[hidden] { display: none; }
498
+
499
+ /*
500
+ THE REGIONS SIT SIDE BY SIDE, LIKE THE REAL KEYBOARD -- MEASURED AND CORRECTED 2026-10-22.
501
+
502
+ They were plain block `div`s, so all five STACKED VERTICALLY and the surface was a tall column
503
+ nothing could display at once. That was wrong at EVERY width, not only on a narrow screen: there
504
+ was no horizontal layout at all, so nothing could have placed them.
505
+
506
+ TWO BANDS, BECAUSE THAT IS WHAT THE HARDWARE HAS. The PF strip runs across the top on its own;
507
+ the other four regions -- the left special bar, the main block, the cursor cluster and the
508
+ numeric pad -- sit in a row beneath it. Measured from the table: the lower band is 24 key units
509
+ wide and the PF strip is 12, so a single row of all five would have been 36 units and needlessly
510
+ wide.
511
+
512
+ `flex-wrap` IS DELIBERATELY ON for the lower band: on a genuinely narrow window the numeric pad
513
+ drops to a second line rather than being clipped. That is the behaviour the stacked column was
514
+ accidentally giving at all widths; here it is a fallback instead of the default.
515
+
516
+ `align-items: flex-start` so the shorter regions top-align with the main block rather than
517
+ stretching to its height.
518
+ */
519
+ .keyboard-surface .keyboard-bands { display: flex; flex-direction: column; gap: 10px; }
520
+
521
+ .keyboard-surface .keyboard-band {
522
+ display: flex;
523
+ flex-wrap: wrap;
524
+ align-items: flex-start;
525
+ gap: 14px;
526
+ }
527
+
528
+ /*
529
+ WRAP PRIORITY, SO A NARROW WINDOW DEGRADES SENSIBLY RATHER THAN ARBITRARILY.
530
+
531
+ `flex-wrap: wrap` above already reflows the regions when the window narrows -- but on its own it
532
+ wraps in DOCUMENT ORDER, which is the table's order, and that is the wrong priority: the 2-unit
533
+ left special bar would keep its slot ahead of the 15-unit main block, so the main keyboard would
534
+ be the thing pushed to a second line. `order` makes the priority deliberate.
535
+
536
+ THE MAIN BLOCK IS FIRST AND MUST NEVER WRAP: it is the keyboard. Then the cursor cluster (the
537
+ arrows and Dup/Field Mark, which an operator reaches for often), then the numeric pad, and the
538
+ left special bar LAST because it is two units wide and holds four blank caps plus four PC keys
539
+ that do nothing here -- the cheapest thing on the surface to push down.
540
+
541
+ MEASURED BREAKPOINTS at pitch 28px with 14px gaps, so the degradation is known rather than
542
+ discovered: all four side by side need ~734px; without the numpad ~608px; main plus the special
543
+ bar ~510px; the main block alone ~440px. Nothing here hard-codes those -- flexbox finds them --
544
+ but they are written down so a future pitch change can be reasoned about.
545
+ */
546
+ .keyboard-surface .keyboard-region { flex: 0 0 auto; }
547
+
548
+ /*
549
+ ORDER: THE LEFT SPECIAL BAR IS FIRST, BECAUSE IT IS ON THE FAR LEFT OF THE HARDWARE.
550
+
551
+ CORRECTED 2026-10-22: it was ordered LAST, which put the Attn/Clear cluster on the far RIGHT --
552
+ the user caught it on a screenshot. That was a wrap-priority decision (two units wide, four
553
+ blank caps, the cheapest thing to push down) applied to the wrong property: `order` governs
554
+ POSITION, and on this keyboard that cluster sits left of the main block.
555
+
556
+ WRAP PRIORITY AND POSITION ARE NOW SEPARATE CONCERNS. Position comes from `order` and matches the
557
+ hardware left to right; what drops first when the window narrows is `flex-wrap`'s business, and
558
+ the container queries below tighten the gaps before anything has to move at all.
559
+ */
560
+ .keyboard-surface #keyboard-special-left { order: 0; }
561
+ .keyboard-surface #keyboard-main { order: 1; }
562
+ .keyboard-surface #keyboard-cursor { order: 2; }
563
+ .keyboard-surface #keyboard-numpad { order: 3; }
564
+
565
+ /*
566
+ PER-REGION FONT SIZES, WHICH IS WHAT THE REAL HARDWARE DOES.
567
+
568
+ The user's observation, and it is the fix: "on my actual Model M, they're just F1-12, but the
569
+ characters are noticeably smaller than A-Z are". A 3270 keyboard does NOT letter every cap at one
570
+ size -- the PF and PA legends are visibly smaller than the alphabet.
571
+
572
+ WHY ONE GLOBAL SIZE COULD NOT WORK, measured: `PF13` is four capitals-and-digits on a one-unit
573
+ cap, and `Clear`, `Space`, `Enter`, `Shift` are five. At 24px of usable width, five capitals need
574
+ the font below 6.5px to fit -- unreadable. So the choice was never "what size fits everything",
575
+ it was "which regions need smaller type", and the hardware answers that.
576
+
577
+ THE MAIN BLOCK KEEPS THE LARGEST TYPE because its caps are mostly ONE character (`Q`, `4`, `;`)
578
+ and the one five-character legend it has -- `Shift` -- sits on a 1.25-unit cap with room.
579
+ */
580
+ /*
581
+ THE PF STRIP IS CENTRED OVER THE ALPHANUMERIC BLOCK, which is where the hardware has it.
582
+
583
+ The user's request, and the arithmetic is why it needs a rule rather than `justify-content`: the
584
+ PF strip is 12 key units and the main block is 15, sitting at an offset of its own because the
585
+ left special bar (2 units) precedes it. Centring the strip within the WHOLE band would put it
586
+ over the gap between the main block and the cursor cluster.
587
+
588
+ SO THE OFFSET IS COMPUTED FROM THE TABLE'S OWN GEOMETRY: the special bar's 2 units plus the gap,
589
+ then half the difference between the main block's 15 units and the strip's 12 -- i.e. 1.5 units.
590
+ Written as a `calc` on the pitch so it tracks a pitch change, and as `margin-left` rather than
591
+ padding so it cannot interact with the band's own gap.
592
+ */
593
+ .keyboard-surface #keyboard-pf {
594
+ margin-left: calc(2 * (var(--kb-pitch) + 2px) + 14px + 1.5 * (var(--kb-pitch) + 2px));
595
+ }
596
+
597
+ /*
598
+ THE SIZES, RAISED 2026-10-22 BECAUSE 7px WAS UNREADABLE -- the user's words, and the only
599
+ judgement in this file that a harness cannot make.
600
+
601
+ THE HEADROOM CAME FROM THE PREVIOUS FIX RATHER THAN FROM SHRINKING ANYTHING. `box-sizing:
602
+ border-box` and `font: inherit` together recovered real width inside each cap (a 28px cap now
603
+ offers 26px for text, not 24px, and the type is the size the stylesheet asks for rather than the
604
+ UA's 13.33px). So these can go UP while `browser-clicks.mjs` still reports no cap clipped --
605
+ which is the check that must stay green, and the reason these numbers are not a guess.
606
+
607
+ `PF13` is the binding constraint at 4 characters on a one-unit cap: measured per-character width
608
+ is 0.61em, so 4 chars at 9px need ~22px of the 26px available. 10px would need ~24.4px and leave
609
+ under 2px of slack, which is where the last three rounds of clipping lived.
610
+ */
611
+ .keyboard-surface #keyboard-main { font-size: 11px; }
612
+ .keyboard-surface #keyboard-pf { font-size: 9px; }
613
+ .keyboard-surface #keyboard-cursor { font-size: 9px; }
614
+ .keyboard-surface #keyboard-special-left { font-size: 9px; }
615
+ .keyboard-surface #keyboard-numpad { font-size: 10px; }
616
+
617
+ /*
618
+ AND A CONTAINER QUERY FOR THE ONE THING FLEX CANNOT DO: at the narrowest widths the gaps between
619
+ regions are wasted space, so they tighten before anything else has to move.
620
+
621
+ `@container` RATHER THAN `@media`, DELIBERATELY. The question is how wide THIS SURFACE is, not
622
+ how wide the viewport is. They were the same when the web overlay was the only surface -- it is
623
+ bottom-docked and full-width -- but a media query would be measuring the wrong box the moment
624
+ that changed, and the Electron window IS that moment: it is a window the operator can resize
625
+ independently of anything.
626
+
627
+ ON `.keyboard-surface` AND NOT ON `#keyboard-overlay`, 2026-10-23, for the reason the pitch is:
628
+ the container is the thing being queried, so naming the overlay would leave the window with no
629
+ container at all and these two blocks would silently never apply there. In the web page this
630
+ changes nothing -- the overlay element is the one carrying the class.
631
+ */
632
+ .keyboard-surface { container-type: inline-size; container-name: keyboard; }
633
+
634
+ @container keyboard (width < 760px) {
635
+ .keyboard-surface .keyboard-band { gap: 8px; }
636
+ }
637
+
638
+ @container keyboard (width < 620px) {
639
+ /*
640
+ Below the width where the numpad can sit beside the main block, the PF strip is the other
641
+ thing that benefits from tightening: 12 units plus padding is ~356px, so it still fits on one
642
+ line well past this point, and keeping it there matters more than the gap.
643
+ */
644
+ .keyboard-surface .keyboard-band { gap: 6px; }
645
+ .keyboard-surface .keyboard-bands { gap: 6px; }
646
+ }
647
+ .keyboard-surface .keyboard-row { display: flex; gap: 2px; margin-bottom: 2px; }
648
+
649
+ /*
650
+ A ROW-SPANNING CAP, WHICH IS WHAT MAKES THE MAIN BLOCK A RECTANGLE.
651
+
652
+ The Return key covers the QWERTY and home rows on the real keyboard, and without it the home row
653
+ ends 1.25 units short of the one above -- the raggedness the user asked to fix. `--kb-h` is set
654
+ by `keyboardUi.ts` only on a cap whose table entry declares a height.
655
+
656
+ `position: relative` ON THE ROW and `absolute` on the tall cap, rather than a grid: the rows are
657
+ flex containers and converting the whole block to grid would re-do a layout that is otherwise
658
+ correct. The cap is positioned from the row's RIGHT edge, because the only tall cap on this
659
+ keyboard is flush right -- stated as the limit it is, rather than pretending to be general.
660
+
661
+ AND `right: 0` MEANS THE TABLE'S `x` IS NEVER READ FOR THIS CAP, which a mutation found on
662
+ 2026-10-23: changing the Return's `x` from 13.75 to 13.5 moved nothing, because its WIDTH alone
663
+ decides its left edge. `keyboard3279.ts` carried a long comment crediting the `x` with keeping
664
+ the `{` clickable; it is the width that does. A tall cap added elsewhere would need this rule
665
+ generalised before its `x` meant anything.
666
+
667
+ THE HEIGHT IS `n` CAPS PLUS THE `n-1` GAPS BETWEEN THEIR ROWS, the same arithmetic the width
668
+ formula uses for units, with `margin-bottom: 2px` per row being the vertical gap.
669
+ */
670
+ .keyboard-surface .keyboard-row { position: relative; }
671
+
672
+ .keyboard-surface .keyboard-row > button[style*='--kb-h'] {
673
+ position: absolute;
674
+ right: 0;
675
+ top: 0;
676
+ /*
677
+ `2px` PER ROW GAP, NOT `4px` -- the cap was 2 units tall and 2px TOO TALL, so it crowded the
678
+ Shift row below it where every other key leaves a clean gap. The user spotted it.
679
+
680
+ THE VERTICAL GAP IS `margin-bottom: 2px` ON `.keyboard-row`, so an n-row cap spans n caps and
681
+ the n-1 margins between them. I had written `4px`, which is the HORIZONTAL `gap` doubled -- a
682
+ plausible-looking number from the wrong axis.
683
+ */
684
+ height: calc(var(--kb-h) * var(--kb-pitch) + (var(--kb-h) - 1) * 2px);
685
+ }
686
+
687
+ /*
688
+ THE RETURN KEY IS A PLAIN RECTANGLE, AND THE GAP BESIDE IT IS LEFT OVERSIZED ON PURPOSE.
689
+
690
+ ## THE HISTORY, BECAUSE IT IS A THREE-ROUND LESSON AND THE THIRD ROUND IS THIS ONE
691
+
692
+ The hardware's Return is an inverted L: its top half reaches further left than its bottom,
693
+ because the QWERTY row's punctuation stops 0.25 units short of the home row's. Drawing that
694
+ faithfully was attempted twice and BOTH attempts were wrong in the same way:
695
+
696
+ - A WIDER BOX. It overlapped the `{` cap below -- the home row's content really does run to
697
+ 13.75u -- and the user saw the `{` lose its border. `browser-clicks.mjs`: `OVERLAPS by 7px`.
698
+ - A `::before` PAINTING THE TAB. Geometrically honest (a pseudo-element is not in the layout
699
+ flow, so the `{` stayed clickable) and still WRONG FOR THE USER, who looked at the result and
700
+ named two faults no measurement had: the overhanging half LOOKS LIKE A KEY BUT IS DEAD, and
701
+ the join between the element and its pseudo-element draws a VERTICAL BAR through the middle
702
+ of what should be one cap.
703
+
704
+ ## SO THE KEY IS RECTANGULAR AND THE SPACE IS JUST SPACE
705
+
706
+ THE CAP KEEPS THE GEOMETRY IT ALREADY HAD -- x=13.75, 1.25 units, two rows tall -- and nothing
707
+ is painted into the 0.25 units to its left. That space becomes a gap several times wider than
708
+ every other gap in the row, which is what the user asked for and reads as deliberate rather than
709
+ as a mistake: an oversized gap is obviously a gap, where a dead half-key is not obviously dead.
710
+
711
+ THE RULE THIS SETTLES: a shape that cannot be made clickable should not be drawn as a key. The
712
+ earlier rule -- paint may extend where no other key is, a clip may not hide an overlap -- was
713
+ about honesty to the LAYOUT, and it was right as far as it went. This is about honesty to the
714
+ PERSON: a cap-shaped region that does nothing is a lie told in pixels, however correct its box.
715
+
716
+ `browser-clicks.mjs` asserts all three clauses of this: the gap below equals the ordinary row
717
+ gap, the gap to its left is at least 3x it, and `::before`'s `content` is `none`.
718
+ */
719
+
720
+ /*
721
+ THE CAP WIDTH COMES FROM THE TABLE, through the `--kb-w` custom property `keyboardUi.ts` sets
722
+ per button. `flex: 0 0 auto` stops flex from helpfully resizing caps and destroying the layout
723
+ the table describes.
724
+ */
725
+ .keyboard-surface .keyboard-row > button {
726
+ flex: 0 0 auto;
727
+ /*
728
+ `border-box`, AND ITS ABSENCE WAS THE REAL BUG BEHIND THREE ROUNDS OF CLIPPED LABELS.
729
+
730
+ A `<button>` defaults to `content-box`, so the 2px padding and the 1px border were ADDED to
731
+ the width: a 28px cap reported `clientWidth: 24` for text, and the three font reductions I
732
+ tried were all fighting 6px of chrome rather than a font that was too big. MEASURED by asking
733
+ the browser which caps overflowed (`scrollWidth > clientWidth`) instead of modelling it --
734
+ `PF13` at 7px measures 17.2px of text and still reported `37 > 24`.
735
+
736
+ THE LESSON, which is why this comment is long: I estimated a per-character ratio twice (0.56,
737
+ then 0.75) and the user caught the clipping on a screenshot both times. The browser knew the
738
+ answer all along and `browser-clicks.mjs` now asks it. The real ratio is 0.61 -- so BOTH
739
+ estimates bracketed it, and neither was the problem.
740
+ */
741
+ /*
742
+ `font: inherit` -- AND ITS ABSENCE IS WHY THREE ROUNDS OF FONT REDUCTION DID NOTHING.
743
+
744
+ A `<button>` does NOT inherit `font` from its parent: browsers apply their own UA default, and
745
+ Chromium's is 13.33px. The rule further up this file that sets `font: inherit` for buttons
746
+ lists `.ui-surface`, `#keypad-overlay` and `#transfer-overlay` -- and NOT this overlay, which
747
+ did not exist when it was written. So every cap rendered at 13.33px no matter what
748
+ `font-size` the region carried, and the per-region sizes below were dead declarations.
749
+
750
+ MEASURED, not deduced: the harness printed PF13's computed box as `font: 13.3333px` while the
751
+ stylesheet said 7px. Three screenshots, three font reductions, and the font was never the
752
+ thing being changed.
753
+ */
754
+ font: inherit;
755
+ box-sizing: border-box;
756
+ /*
757
+ A MULTI-UNIT CAP MUST ABSORB THE GAPS IT SPANS -- found 2026-10-22 on the numeric pad.
758
+
759
+ This was `--kb-w * --kb-pitch`, which is one cap's width per unit and NO allowance for the 2px
760
+ gaps BETWEEN those units. So an n-unit cap came out 2*(n-1)px short and everything after it in
761
+ its row shifted left: the user spotted it as the numpad's `.` not lining up under the `3` and
762
+ `Ent` not under the `-`, because the double-width `0` beside them was 2px narrow. The
763
+ spacebar, at 7 units, was 12px short.
764
+
765
+ `+ (w - 1) * 2px` IS THE GAP COUNT, and it is exact rather than a fudge: n caps have n-1 gaps
766
+ between them, and a cap spanning n units replaces exactly those. The `2px` matches
767
+ `.keyboard-row`'s own `gap`, which is the one duplication here -- change both together.
768
+ */
769
+ width: calc(var(--kb-w, 1) * var(--kb-pitch) + (var(--kb-w, 1) - 1) * 2px);
770
+ height: var(--kb-pitch);
771
+ padding: 0 1px;
772
+ border-width: 1px;
773
+ /*
774
+ `overflow: hidden` IS THE TRAP THAT HID THE FONT BUG, kept because a cap must not grow and
775
+ destroy the layout the table describes -- but it fails SILENTLY, turning `PF13` into `PF1`
776
+ with no visible sign anything was lost. The font size above is what stops it mattering; this
777
+ comment is here so the next person to see a clipped label looks at the font rather than at
778
+ this line.
779
+ */
780
+ overflow: hidden;
781
+ /*
782
+ TWO LINES ARE ALLOWED, BECAUSE THE HARDWARE USES THEM -- corrected 2026-10-22.
783
+
784
+ `white-space: nowrap` was here, which forced every legend onto one line and made nine caps
785
+ unfittable at ANY sane font size: `Cursr Sel`, `Print Screen`, `Scroll Lock`, `Dup / PA1`,
786
+ `Field Mark / PA2` and the rest are MULTI-WORD legends on SINGLE-UNIT caps, and the real
787
+ keyboard prints them stacked. Asking for one line was the error; shrinking the font would
788
+ never have fixed it.
789
+
790
+ `pre-line` RATHER THAN `normal`: the labels carry explicit newlines now (see
791
+ `keyboard3279.ts`), so the break lands where the hardware puts it instead of wherever the cap
792
+ happens to run out. A legend with no newline still behaves as before.
793
+ */
794
+ white-space: pre-line;
795
+ /* The glyphs are small, so centre them rather than leaving them hard against the left edge. */
796
+ text-align: center;
797
+ line-height: 1.05;
798
+ /* Two stacked lines need the room; `height` was a single line's worth. */
799
+ min-height: var(--kb-pitch);
800
+ height: auto;
801
+ }
802
+
803
+ /*
804
+ AN INERT CAP, AND IT IS DELIBERATELY NOT THE HOUSE `opacity: 0.5`.
805
+
806
+ That pattern appears three times above for DISABLED CONTROLS, and it means "not right now -- try
807
+ again when a transfer finishes". An inert 3279 key means something different: "this exists on the
808
+ hardware and will never do anything here". Same visual, two incompatible promises, and a Cancel
809
+ button at 0.5 telling you to wait would be a lie on `Scroll Lock`.
810
+
811
+ THE LABEL MUST STAY READABLE, which is the functional reason rather than an aesthetic one: half
812
+ the value of drawing `Cursr Sel` at all is seeing WHERE IT SITS on the hardware, and 0.5 opacity
813
+ on a small cap at this font size could make `Print Screen` unreadable -- which defeats the
814
+ faithfulness the whole surface exists for. Hence `opacity: 1` explicitly, in case a future rule
815
+ above starts matching these buttons.
816
+
817
+ `keyboardUi.ts` STILL SETS THE `disabled` ATTRIBUTE, so clicks do nothing and keyboard and
818
+ screen-reader behaviour are correct. Only the styling departs. DO NOT "fix" this into consistency
819
+ with the rules above -- it was the user's explicit choice, of three options offered.
820
+
821
+ SHIFT IS NOT SELECTED HERE even though it too has no action: it carries `data-shift` and no
822
+ `disabled`, because it is a live control. An inert cap and an actionless-but-live cap look
823
+ different on purpose.
824
+ */
825
+ .keyboard-surface button[disabled] {
826
+ color: GrayText;
827
+ background: color-mix(in srgb, Canvas 92%, CanvasText 8%);
828
+ border-style: inset;
829
+ opacity: 1;
830
+ }
831
+
832
+ /*
833
+ THE ARMED ONE-SHOT SHIFT, so the state is VISIBLE rather than remembered.
834
+
835
+ Without this the modifier would be invisible: press Shift, look at the keyboard, and nothing says
836
+ whether the next character will be shifted. `keyboardUi.ts` sets `data-armed` on BOTH Shift caps
837
+ together, since neither is distinguishable to us.
838
+ */
839
+ .keyboard-surface button[data-armed='true'] {
840
+ background: Highlight;
841
+ color: HighlightText;
842
+ border-style: inset;
843
+ }