@cplieger/web-terminal-ui 1.0.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/LICENSE +692 -0
- package/README.md +76 -0
- package/css/00-tokens.css +33 -0
- package/css/01-reset.css +73 -0
- package/css/02-app.css +730 -0
- package/css/MANIFEST +5 -0
- package/package.json +58 -0
- package/scaffold/index.html +52 -0
- package/src/composition.ts +171 -0
- package/src/index.ts +10 -0
- package/src/mount.ts +798 -0
- package/src/predict.ts +183 -0
- package/src/status.ts +173 -0
- package/src/viewport.ts +98 -0
package/css/02-app.css
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/* ===== Terminal ===== */
|
|
2
|
+
.term {
|
|
3
|
+
position: fixed;
|
|
4
|
+
inset: 0;
|
|
5
|
+
overflow: hidden auto;
|
|
6
|
+
/* The scroll controller (vterm scroll.ts) is the single owner of
|
|
7
|
+
scrollTop: it pins to the bottom only while the user is following,
|
|
8
|
+
and does nothing while they are scrolled up reading. New output
|
|
9
|
+
always appends at the tail (the absolute-index model never inserts
|
|
10
|
+
above the viewport), so native scroll anchoring is safe to leave on
|
|
11
|
+
and keeps the read position steady on the rare insert-above. */
|
|
12
|
+
overflow-anchor: auto;
|
|
13
|
+
font-family: var(--font-mono);
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
line-height: 17px;
|
|
16
|
+
padding: 4px;
|
|
17
|
+
background: var(--bg);
|
|
18
|
+
color: var(--text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.term-row {
|
|
22
|
+
white-space: pre;
|
|
23
|
+
min-height: 17px;
|
|
24
|
+
font-variant-ligatures: none;
|
|
25
|
+
font-feature-settings:
|
|
26
|
+
"liga" 0,
|
|
27
|
+
"calt" 0;
|
|
28
|
+
font-kerning: none;
|
|
29
|
+
|
|
30
|
+
/* Note: do NOT use `contain: layout style` here. iOS Safari has
|
|
31
|
+
paint-suppression bugs in WebKit where layout-contained elements
|
|
32
|
+
receiving frequent replaceChildren() updates can drop paint
|
|
33
|
+
invalidations for inline children whose styling-only changes
|
|
34
|
+
don't trigger layout — most visibly the inverse-styled "cursor"
|
|
35
|
+
span Ink draws inside our rows, which would visually stick at
|
|
36
|
+
its old column on space/arrow key presses until a content-
|
|
37
|
+
changing keystroke forced a repaint. Without `contain`, every
|
|
38
|
+
row update participates in normal paint flow and the cursor
|
|
39
|
+
cell tracks the column on every flush. */
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.term-row:empty::after {
|
|
43
|
+
content: "\00a0";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* "earlier output trimmed" marker (vterm renderer inserts .term-trim-marker
|
|
47
|
+
as the first row when history older than the buffer holds was evicted —
|
|
48
|
+
client-side cap or a server-reported resume gap). A quiet, centered,
|
|
49
|
+
non-selectable divider so the user knows scrollback doesn't go further up. */
|
|
50
|
+
.term-trim-marker {
|
|
51
|
+
color: color-mix(in oklch, var(--text) 45%, transparent);
|
|
52
|
+
font-size: 0.6875rem;
|
|
53
|
+
text-align: center;
|
|
54
|
+
padding: 2px 0;
|
|
55
|
+
opacity: 0.7;
|
|
56
|
+
user-select: none;
|
|
57
|
+
font-family: var(--font-ui);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.term-input {
|
|
61
|
+
/* Keep the input INSIDE the viewport (not at left:-9999px) — iOS
|
|
62
|
+
* Safari is increasingly strict about not opening the virtual
|
|
63
|
+
* keyboard for off-screen inputs, especially during heavy DOM
|
|
64
|
+
* mutations like a streaming agent turn. A 1×1 fully-transparent
|
|
65
|
+
* pinned-bottom input is "visible enough" for iOS without being
|
|
66
|
+
* tappable or perceptible to the user. opacity:0 + size:1px hides
|
|
67
|
+
* it visually; pointer-events:none makes sure stray taps don't
|
|
68
|
+
* land on it (programmatic focus() still works). */
|
|
69
|
+
position: fixed;
|
|
70
|
+
inset-block-end: 0;
|
|
71
|
+
inset-inline-start: 0;
|
|
72
|
+
width: 1px;
|
|
73
|
+
height: 1px;
|
|
74
|
+
opacity: 0;
|
|
75
|
+
pointer-events: none;
|
|
76
|
+
border: 0;
|
|
77
|
+
resize: none;
|
|
78
|
+
outline: none;
|
|
79
|
+
/* iOS auto-zooms on input focus when the input's font-size < 16px.
|
|
80
|
+
* Lock to 16px so the keyboard pop doesn't trigger a viewport zoom
|
|
81
|
+
* (which would also defer the keyboard show animation). */
|
|
82
|
+
font-size: 16px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.term-output {
|
|
86
|
+
position: relative;
|
|
87
|
+
outline: none;
|
|
88
|
+
|
|
89
|
+
/* Display-only surface: native text selection works (the hterm
|
|
90
|
+
pattern), but it is NOT contenteditable and is never focused — the
|
|
91
|
+
textarea owns input, the keyboard, and IME. Keeping the editable
|
|
92
|
+
element off the scroll content is what lets the first touch-drag
|
|
93
|
+
scroll instead of placing a caret (bug 6), and not re-rendering it
|
|
94
|
+
as an editable keeps a selection alive across redraws (bug 1). */
|
|
95
|
+
user-select: text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* IME composition view: floating overlay positioned at the cursor
|
|
99
|
+
while the user is composing via an IME / autocorrect / dictation.
|
|
100
|
+
composition.ts mirrors the in-progress text here. CSS direction:rtl
|
|
101
|
+
keeps the visual focus on the trailing edge for long compositions
|
|
102
|
+
so the candidate popup tracks what the user is typing.
|
|
103
|
+
Visibility is gated by the `.active` class so empty state doesn't
|
|
104
|
+
render a phantom box on top of the cursor. */
|
|
105
|
+
.composition-view {
|
|
106
|
+
position: absolute;
|
|
107
|
+
pointer-events: none;
|
|
108
|
+
background: var(--surface);
|
|
109
|
+
color: var(--text);
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
font-family: var(--font-mono);
|
|
112
|
+
font-size: 14px;
|
|
113
|
+
padding: 0 2px;
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
direction: rtl;
|
|
116
|
+
z-index: 5;
|
|
117
|
+
display: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.composition-view.active {
|
|
121
|
+
display: block;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Predicted-cursor overlay (INPUT-01): rendered at the location the
|
|
125
|
+
cursor will be after the in-flight keystrokes are echoed back by
|
|
126
|
+
the server. Hidden when the prediction matches the server's actual
|
|
127
|
+
cursor (predict.ts deactivates on bail and the renderer hides on
|
|
128
|
+
coincidence). Dashed outline + reduced opacity reads as "tentative"
|
|
129
|
+
— distinct from the solid block of the real cursor. */
|
|
130
|
+
.pred-cursor {
|
|
131
|
+
position: absolute;
|
|
132
|
+
pointer-events: none;
|
|
133
|
+
border: 1px dashed var(--text);
|
|
134
|
+
opacity: 0.55;
|
|
135
|
+
display: none;
|
|
136
|
+
z-index: 4;
|
|
137
|
+
box-sizing: border-box;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.pred-cursor.visible {
|
|
141
|
+
display: block;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Connection-state banner: floats top-center with a subtle backdrop
|
|
145
|
+
blur. Hidden until status.ts sets `.visible`. Distinct from the
|
|
146
|
+
`.pill` family because it wants to span across the full top center
|
|
147
|
+
visually rather than dock to a corner. */
|
|
148
|
+
.conn-banner {
|
|
149
|
+
position: fixed;
|
|
150
|
+
bottom: var(--sp-3);
|
|
151
|
+
left: 50%;
|
|
152
|
+
transform: translateX(-50%);
|
|
153
|
+
max-width: calc(100% - 2 * var(--sp-3));
|
|
154
|
+
padding: var(--sp-1) var(--sp-3);
|
|
155
|
+
background: var(--surface);
|
|
156
|
+
backdrop-filter: var(--blur);
|
|
157
|
+
border: 1px solid var(--border);
|
|
158
|
+
border-radius: var(--radius);
|
|
159
|
+
color: var(--text);
|
|
160
|
+
font-family: var(--font-ui);
|
|
161
|
+
font-size: 0.8125rem;
|
|
162
|
+
font-weight: 500;
|
|
163
|
+
text-align: center;
|
|
164
|
+
z-index: 20;
|
|
165
|
+
display: none;
|
|
166
|
+
pointer-events: auto;
|
|
167
|
+
opacity: 0;
|
|
168
|
+
transition:
|
|
169
|
+
opacity var(--dur-standard) var(--ease-standard),
|
|
170
|
+
display var(--dur-standard) allow-discrete;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.conn-banner.visible {
|
|
174
|
+
display: block;
|
|
175
|
+
opacity: 1;
|
|
176
|
+
|
|
177
|
+
@starting-style {
|
|
178
|
+
opacity: 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Color coding by notification type */
|
|
183
|
+
.conn-banner[data-state="reconnecting"] {
|
|
184
|
+
border-color: oklch(70% 0.15 60deg / 40%);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.conn-banner[data-state="offline"] {
|
|
188
|
+
color: #f55;
|
|
189
|
+
border-color: oklch(60% 0.2 25deg / 50%);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.conn-banner[data-state="restarted"] {
|
|
193
|
+
color: #fa5;
|
|
194
|
+
border-color: oklch(70% 0.15 60deg / 40%);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.conn-banner[data-state="toast"] {
|
|
198
|
+
border-color: oklch(70% 0.1 160deg / 30%);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Virtual keyboard awareness: when the keyboard is open the
|
|
202
|
+
visualViewport height shrinks. viewport.ts sets --kb-inset on
|
|
203
|
+
termWrap; we use it here to lift the banner above the keyboard.
|
|
204
|
+
Falls back to 0px when the variable isn't set (desktop). */
|
|
205
|
+
@media (pointer: coarse) {
|
|
206
|
+
.conn-banner {
|
|
207
|
+
bottom: calc(var(--sp-3) + var(--kb-inset, 0px));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Clickable URLs */
|
|
212
|
+
.term-link {
|
|
213
|
+
color: inherit;
|
|
214
|
+
text-decoration: underline dotted;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.term-link:hover {
|
|
219
|
+
text-decoration: underline solid;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Inline cursor cell (xterm.js convention): the character at the cursor
|
|
223
|
+
position gets the .term-cursor class. CSS gives it inverted colors;
|
|
224
|
+
the blink is driven by a JS interval that toggles .cursor-blink-off on
|
|
225
|
+
the viewport. JS-driven instead of CSS animation because the cursor
|
|
226
|
+
span is recreated every row update — a CSS animation would restart on
|
|
227
|
+
each recreation and never reach the "off" frame during streaming. */
|
|
228
|
+
.term-cursor,
|
|
229
|
+
.term-cursor-underline,
|
|
230
|
+
.term-cursor-bar {
|
|
231
|
+
box-sizing: border-box;
|
|
232
|
+
display: inline-block;
|
|
233
|
+
height: 17px;
|
|
234
|
+
line-height: 17px;
|
|
235
|
+
vertical-align: top;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.term-cursor {
|
|
239
|
+
background: var(--text);
|
|
240
|
+
color: var(--bg);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* Underline cursor (DECSCUSR 3/4) */
|
|
244
|
+
.term-cursor-underline {
|
|
245
|
+
background: transparent;
|
|
246
|
+
color: var(--text);
|
|
247
|
+
border-bottom: 2px solid var(--text);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* Bar cursor (DECSCUSR 5/6) */
|
|
251
|
+
.term-cursor-bar {
|
|
252
|
+
background: transparent;
|
|
253
|
+
color: var(--text);
|
|
254
|
+
border-left: 2px solid var(--text);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.cursor-blink-off .term-cursor {
|
|
258
|
+
background: transparent;
|
|
259
|
+
color: var(--text);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.term-blink {
|
|
263
|
+
animation: term-blink-anim 1s step-end infinite;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@keyframes term-blink-anim {
|
|
267
|
+
50% {
|
|
268
|
+
opacity: 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* When the terminal isn't focused, render the cursor as an outline
|
|
273
|
+
rather than a filled block (xterm.js convention). */
|
|
274
|
+
.term:not(.focus) .term-cursor,
|
|
275
|
+
.term:not(.focus) .term-cursor-underline,
|
|
276
|
+
.term:not(.focus) .term-cursor-bar {
|
|
277
|
+
background: transparent;
|
|
278
|
+
color: var(--text);
|
|
279
|
+
border: none;
|
|
280
|
+
|
|
281
|
+
/* Inset box-shadow stays strictly inside the cell box without
|
|
282
|
+
affecting layout or clipping at the row boundary. */
|
|
283
|
+
box-shadow: inset 0 0 0 1px var(--text);
|
|
284
|
+
animation: none;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ===== Terminal color classes (basic 16 — themeable) ===== */
|
|
288
|
+
.term-fg-0 {
|
|
289
|
+
color: #000;
|
|
290
|
+
}
|
|
291
|
+
.term-fg-1 {
|
|
292
|
+
color: #a00;
|
|
293
|
+
}
|
|
294
|
+
.term-fg-2 {
|
|
295
|
+
color: #0a0;
|
|
296
|
+
}
|
|
297
|
+
.term-fg-3 {
|
|
298
|
+
color: #a50;
|
|
299
|
+
}
|
|
300
|
+
.term-fg-4 {
|
|
301
|
+
color: #00a;
|
|
302
|
+
}
|
|
303
|
+
.term-fg-5 {
|
|
304
|
+
color: #a0a;
|
|
305
|
+
}
|
|
306
|
+
.term-fg-6 {
|
|
307
|
+
color: #0aa;
|
|
308
|
+
}
|
|
309
|
+
.term-fg-7 {
|
|
310
|
+
color: #aaa;
|
|
311
|
+
}
|
|
312
|
+
.term-fg-8 {
|
|
313
|
+
color: #555;
|
|
314
|
+
}
|
|
315
|
+
.term-fg-9 {
|
|
316
|
+
color: #f55;
|
|
317
|
+
}
|
|
318
|
+
.term-fg-10 {
|
|
319
|
+
color: #5f5;
|
|
320
|
+
}
|
|
321
|
+
.term-fg-11 {
|
|
322
|
+
color: #ff5;
|
|
323
|
+
}
|
|
324
|
+
.term-fg-12 {
|
|
325
|
+
color: #55f;
|
|
326
|
+
}
|
|
327
|
+
.term-fg-13 {
|
|
328
|
+
color: #f5f;
|
|
329
|
+
}
|
|
330
|
+
.term-fg-14 {
|
|
331
|
+
color: #5ff;
|
|
332
|
+
}
|
|
333
|
+
.term-fg-15 {
|
|
334
|
+
color: #fff;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.term-bg-0 {
|
|
338
|
+
background: #000;
|
|
339
|
+
}
|
|
340
|
+
.term-bg-1 {
|
|
341
|
+
background: #a00;
|
|
342
|
+
}
|
|
343
|
+
.term-bg-2 {
|
|
344
|
+
background: #0a0;
|
|
345
|
+
}
|
|
346
|
+
.term-bg-3 {
|
|
347
|
+
background: #a50;
|
|
348
|
+
}
|
|
349
|
+
.term-bg-4 {
|
|
350
|
+
background: #00a;
|
|
351
|
+
}
|
|
352
|
+
.term-bg-5 {
|
|
353
|
+
background: #a0a;
|
|
354
|
+
}
|
|
355
|
+
.term-bg-6 {
|
|
356
|
+
background: #0aa;
|
|
357
|
+
}
|
|
358
|
+
.term-bg-7 {
|
|
359
|
+
background: #aaa;
|
|
360
|
+
}
|
|
361
|
+
.term-bg-8 {
|
|
362
|
+
background: #555;
|
|
363
|
+
}
|
|
364
|
+
.term-bg-9 {
|
|
365
|
+
background: #f55;
|
|
366
|
+
}
|
|
367
|
+
.term-bg-10 {
|
|
368
|
+
background: #5f5;
|
|
369
|
+
}
|
|
370
|
+
.term-bg-11 {
|
|
371
|
+
background: #ff5;
|
|
372
|
+
}
|
|
373
|
+
.term-bg-12 {
|
|
374
|
+
background: #55f;
|
|
375
|
+
}
|
|
376
|
+
.term-bg-13 {
|
|
377
|
+
background: #f5f;
|
|
378
|
+
}
|
|
379
|
+
.term-bg-14 {
|
|
380
|
+
background: #5ff;
|
|
381
|
+
}
|
|
382
|
+
.term-bg-15 {
|
|
383
|
+
background: #fff;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ===== Key toolbar (touch devices only) =====
|
|
387
|
+
Uses interpolate-size: allow-keywords (on :root) to animate
|
|
388
|
+
width/height between a fixed collapsed size and auto (intrinsic).
|
|
389
|
+
Toggle is first in DOM so it's at top-left of the grid — visible
|
|
390
|
+
when the container clips to 1-cell size. Container is right-anchored
|
|
391
|
+
so it grows leftward on open. */
|
|
392
|
+
.key-toolbar {
|
|
393
|
+
display: contents;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* Touch-only toolbar children: the on-screen Esc/Tab/arrows/hamburger
|
|
397
|
+
are useless on desktop where the user has a real keyboard. Hidden by
|
|
398
|
+
default; the @media (pointer: coarse) block below restores them. */
|
|
399
|
+
.kb-key,
|
|
400
|
+
.kb-toggle {
|
|
401
|
+
display: none;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* Desktop: scroll-to-bottom is still genuinely useful for jumping back
|
|
405
|
+
after scrolling up to read history, so it surfaces here as a
|
|
406
|
+
bottom-center pill with a label. .key-toolbar.scrolled-up is
|
|
407
|
+
toggled by app.ts, same as on touch. The display+opacity transition
|
|
408
|
+
uses allow-discrete so the fade-out plays cleanly when the user
|
|
409
|
+
clicks (scroll-to-bottom clears .scrolled-up, reverting display:none
|
|
410
|
+
after the opacity has gone to 0). */
|
|
411
|
+
.kb-scroll-bottom {
|
|
412
|
+
position: fixed;
|
|
413
|
+
left: 50%;
|
|
414
|
+
bottom: calc(var(--safe-bottom, 0px) + var(--sp-3));
|
|
415
|
+
transform: translateX(-50%);
|
|
416
|
+
display: none;
|
|
417
|
+
opacity: 0;
|
|
418
|
+
align-items: center;
|
|
419
|
+
gap: var(--sp-1);
|
|
420
|
+
padding: var(--sp-2) var(--sp-3);
|
|
421
|
+
background: var(--surface);
|
|
422
|
+
backdrop-filter: var(--blur);
|
|
423
|
+
border: 1px solid var(--border);
|
|
424
|
+
border-radius: var(--radius);
|
|
425
|
+
color: var(--text);
|
|
426
|
+
font: inherit;
|
|
427
|
+
font-size: 0.875rem;
|
|
428
|
+
font-weight: 500;
|
|
429
|
+
cursor: pointer;
|
|
430
|
+
z-index: 10;
|
|
431
|
+
transition:
|
|
432
|
+
opacity var(--dur-standard) var(--ease-standard),
|
|
433
|
+
background-color 150ms var(--ease-standard),
|
|
434
|
+
display var(--dur-standard) allow-discrete;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.kb-scroll-bottom::after {
|
|
438
|
+
content: "Scroll to bottom";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.kb-scroll-bottom svg {
|
|
442
|
+
width: var(--icon);
|
|
443
|
+
height: var(--icon);
|
|
444
|
+
stroke: currentcolor;
|
|
445
|
+
fill: none;
|
|
446
|
+
stroke-width: 2;
|
|
447
|
+
stroke-linecap: round;
|
|
448
|
+
stroke-linejoin: round;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.kb-scroll-bottom:hover {
|
|
452
|
+
background: color-mix(in oklch, var(--text) 8%, var(--surface));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.kb-scroll-bottom:active {
|
|
456
|
+
background: var(--text-active);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.key-toolbar.scrolled-up .kb-scroll-bottom {
|
|
460
|
+
display: inline-flex;
|
|
461
|
+
opacity: 1;
|
|
462
|
+
|
|
463
|
+
@starting-style {
|
|
464
|
+
opacity: 0;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@media (pointer: coarse) {
|
|
469
|
+
.key-toolbar {
|
|
470
|
+
position: fixed;
|
|
471
|
+
top: calc(var(--safe-top) + var(--sp-2) + var(--vv-top, 0px));
|
|
472
|
+
right: var(--sp-2);
|
|
473
|
+
z-index: 10;
|
|
474
|
+
display: grid;
|
|
475
|
+
grid-template-columns: repeat(4, var(--touch-target)) var(--touch-target);
|
|
476
|
+
grid-template-rows: var(--touch-target) var(--touch-target);
|
|
477
|
+
place-content: start end;
|
|
478
|
+
gap: var(--sp-1);
|
|
479
|
+
padding: var(--sp-1);
|
|
480
|
+
background: var(--surface);
|
|
481
|
+
backdrop-filter: var(--blur);
|
|
482
|
+
border: 1px solid var(--border);
|
|
483
|
+
border-radius: var(--radius);
|
|
484
|
+
overflow: hidden;
|
|
485
|
+
width: auto;
|
|
486
|
+
height: auto;
|
|
487
|
+
transition:
|
|
488
|
+
width var(--dur-enter) var(--ease-standard),
|
|
489
|
+
height var(--dur-enter) var(--ease-standard);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* Collapsed: fixed size = 1 button + padding + border. Content is
|
|
493
|
+
right-aligned (justify-content:end) so the toggle (col 5) stays at
|
|
494
|
+
the right edge, visible within the clipped area. --pill-outer
|
|
495
|
+
includes the 2px border (box-sizing:border-box); omitting it left
|
|
496
|
+
the content box 2px short, pinning the button top-right with
|
|
497
|
+
unequal bottom/left gaps. */
|
|
498
|
+
.key-toolbar.collapsed {
|
|
499
|
+
width: var(--pill-outer);
|
|
500
|
+
height: var(--pill-outer);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* Collapsed + scrolled up: 2 buttons tall (+2px border). */
|
|
504
|
+
.key-toolbar.collapsed.scrolled-up {
|
|
505
|
+
height: calc(2 * var(--touch-target) + var(--sp-1) + 2 * var(--sp-1) + 2px);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/* Grid placement: toggle at col 5 row 1 (top-right). */
|
|
509
|
+
.kb-toggle {
|
|
510
|
+
grid-area: 1 / 5;
|
|
511
|
+
position: relative;
|
|
512
|
+
}
|
|
513
|
+
.kb-r1c1 {
|
|
514
|
+
grid-area: 1 / 1;
|
|
515
|
+
}
|
|
516
|
+
.kb-r1c2 {
|
|
517
|
+
grid-area: 1 / 2;
|
|
518
|
+
}
|
|
519
|
+
.kb-r1c3 {
|
|
520
|
+
grid-area: 1 / 3;
|
|
521
|
+
}
|
|
522
|
+
.kb-r1c4 {
|
|
523
|
+
grid-area: 1 / 4;
|
|
524
|
+
}
|
|
525
|
+
.kb-r2c1 {
|
|
526
|
+
grid-area: 2 / 1;
|
|
527
|
+
}
|
|
528
|
+
.kb-r2c2 {
|
|
529
|
+
grid-area: 2 / 2;
|
|
530
|
+
}
|
|
531
|
+
.kb-r2c3 {
|
|
532
|
+
grid-area: 2 / 3;
|
|
533
|
+
}
|
|
534
|
+
.kb-r2c4 {
|
|
535
|
+
grid-area: 2 / 4;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* Scroll-bottom: col 5 row 2 (below toggle). Resets the desktop
|
|
539
|
+
FAB-specific properties (fixed position, pill padding, surface
|
|
540
|
+
background, label) so the button collapses back to a grid-area
|
|
541
|
+
icon-only key. */
|
|
542
|
+
.key-toolbar .kb-scroll-bottom {
|
|
543
|
+
position: static;
|
|
544
|
+
transform: none;
|
|
545
|
+
bottom: auto;
|
|
546
|
+
left: auto;
|
|
547
|
+
grid-area: 2 / 5;
|
|
548
|
+
display: none;
|
|
549
|
+
opacity: 0;
|
|
550
|
+
padding: 0;
|
|
551
|
+
background: transparent;
|
|
552
|
+
backdrop-filter: none;
|
|
553
|
+
border: none;
|
|
554
|
+
border-radius: var(--radius-inner);
|
|
555
|
+
font-size: inherit;
|
|
556
|
+
/* Touch override drops the desktop label (::after content: "")
|
|
557
|
+
so only the SVG remains. The base rule sets align-items:center
|
|
558
|
+
but inherits justify-content:flex-start, which would push the
|
|
559
|
+
lone icon to the left edge of the touch-target-sized cell —
|
|
560
|
+
same unequal-padding issue as the toolbar toggle. Center it. */
|
|
561
|
+
justify-content: center;
|
|
562
|
+
transition:
|
|
563
|
+
opacity var(--dur-standard) var(--ease-standard),
|
|
564
|
+
display var(--dur-standard) allow-discrete;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.kb-scroll-bottom::after {
|
|
568
|
+
/* No label on touch. Remove the box entirely (not just its text) so
|
|
569
|
+
it isn't an empty flex item — combined with the base rule's
|
|
570
|
+
gap:var(--sp-1) it would shove the lone icon half a gap off-centre. */
|
|
571
|
+
content: "";
|
|
572
|
+
display: none;
|
|
573
|
+
}
|
|
574
|
+
.kb-scroll-bottom:hover {
|
|
575
|
+
background: transparent;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.key-toolbar.scrolled-up .kb-scroll-bottom {
|
|
579
|
+
display: flex;
|
|
580
|
+
opacity: 1;
|
|
581
|
+
|
|
582
|
+
@starting-style {
|
|
583
|
+
opacity: 0;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* Keys fade in/out during open/close. The container's width/height
|
|
588
|
+
transition handles the background slide; opacity handles the
|
|
589
|
+
individual button appearance. Stagger the fade slightly after
|
|
590
|
+
the container starts growing (delay on open) and before it
|
|
591
|
+
finishes shrinking (no delay on close). */
|
|
592
|
+
.kb-key {
|
|
593
|
+
opacity: 1;
|
|
594
|
+
transition: opacity var(--dur-standard) var(--ease-enter) 0.05s;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.key-toolbar.collapsed .kb-key {
|
|
598
|
+
opacity: 0;
|
|
599
|
+
transition: opacity var(--dur-exit) var(--ease-exit);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/* Shared button */
|
|
603
|
+
.key-toolbar button {
|
|
604
|
+
width: var(--touch-target);
|
|
605
|
+
height: var(--touch-target);
|
|
606
|
+
background: transparent;
|
|
607
|
+
color: var(--text);
|
|
608
|
+
border: none;
|
|
609
|
+
border-radius: var(--radius-inner);
|
|
610
|
+
font-size: 0.9375rem;
|
|
611
|
+
font-family: var(--font-ui);
|
|
612
|
+
font-weight: 500;
|
|
613
|
+
cursor: pointer;
|
|
614
|
+
touch-action: manipulation;
|
|
615
|
+
user-select: none;
|
|
616
|
+
display: flex;
|
|
617
|
+
align-items: center;
|
|
618
|
+
justify-content: center;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.key-toolbar button:active {
|
|
622
|
+
background: var(--text-active);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* Sticky Ctrl, armed: inverted fill so the toggle state reads clearly
|
|
626
|
+
in the monochrome palette (stays filled while pressed). */
|
|
627
|
+
.kb-key.armed,
|
|
628
|
+
.kb-key.armed:active {
|
|
629
|
+
background: var(--text);
|
|
630
|
+
color: var(--bg);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.key-toolbar button svg {
|
|
634
|
+
width: var(--icon);
|
|
635
|
+
height: var(--icon);
|
|
636
|
+
stroke: currentcolor;
|
|
637
|
+
fill: none;
|
|
638
|
+
stroke-width: 2;
|
|
639
|
+
stroke-linecap: round;
|
|
640
|
+
stroke-linejoin: round;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/* Toggle icon crossfade. Both icons are stacked absolute so they
|
|
644
|
+
can independently fade + rotate during the open<->close
|
|
645
|
+
transition. inset:0 + margin:auto centers them within the
|
|
646
|
+
button (works because the SVGs carry explicit width/height); a
|
|
647
|
+
bare `position: absolute` would pin them at the top-left, giving
|
|
648
|
+
the toggle visibly unequal left/right padding when collapsed. */
|
|
649
|
+
.kb-toggle .icon-hamburger,
|
|
650
|
+
.kb-toggle .icon-close {
|
|
651
|
+
position: absolute;
|
|
652
|
+
inset: 0;
|
|
653
|
+
margin: auto;
|
|
654
|
+
transition:
|
|
655
|
+
opacity var(--dur-standard) linear,
|
|
656
|
+
transform var(--dur-standard) linear;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.kb-toggle .icon-close {
|
|
660
|
+
opacity: 1;
|
|
661
|
+
transform: rotate(0deg);
|
|
662
|
+
}
|
|
663
|
+
.kb-toggle .icon-hamburger {
|
|
664
|
+
opacity: 0;
|
|
665
|
+
transform: rotate(-90deg);
|
|
666
|
+
}
|
|
667
|
+
.key-toolbar.collapsed .kb-toggle .icon-close {
|
|
668
|
+
opacity: 0;
|
|
669
|
+
transform: rotate(90deg);
|
|
670
|
+
}
|
|
671
|
+
.key-toolbar.collapsed .kb-toggle .icon-hamburger {
|
|
672
|
+
opacity: 1;
|
|
673
|
+
transform: rotate(0deg);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/* No-transition on first render */
|
|
677
|
+
.key-toolbar.no-transition,
|
|
678
|
+
.key-toolbar.no-transition .kb-scroll-bottom,
|
|
679
|
+
.key-toolbar.no-transition .kb-toggle .icon-hamburger,
|
|
680
|
+
.key-toolbar.no-transition .kb-toggle .icon-close {
|
|
681
|
+
transition: none;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/* ===== Context menu ===== */
|
|
686
|
+
.ctx-menu {
|
|
687
|
+
position: fixed;
|
|
688
|
+
display: none;
|
|
689
|
+
flex-direction: column;
|
|
690
|
+
background: var(--surface);
|
|
691
|
+
backdrop-filter: var(--blur);
|
|
692
|
+
border: 1px solid var(--border);
|
|
693
|
+
border-radius: var(--radius);
|
|
694
|
+
padding: var(--sp-1);
|
|
695
|
+
z-index: 30;
|
|
696
|
+
opacity: 0;
|
|
697
|
+
scale: 0.95;
|
|
698
|
+
transition:
|
|
699
|
+
opacity 0.15s ease,
|
|
700
|
+
scale 0.15s ease,
|
|
701
|
+
display 0.15s allow-discrete;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.ctx-menu.visible {
|
|
705
|
+
display: flex;
|
|
706
|
+
opacity: 1;
|
|
707
|
+
scale: 1;
|
|
708
|
+
|
|
709
|
+
@starting-style {
|
|
710
|
+
opacity: 0;
|
|
711
|
+
scale: 0.95;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.ctx-menu button {
|
|
716
|
+
background: transparent;
|
|
717
|
+
color: var(--text);
|
|
718
|
+
border: none;
|
|
719
|
+
border-radius: var(--radius-inner);
|
|
720
|
+
font-family: var(--font-ui);
|
|
721
|
+
font-size: 0.8125rem;
|
|
722
|
+
font-weight: 500;
|
|
723
|
+
padding: var(--sp-1) var(--sp-3);
|
|
724
|
+
cursor: pointer;
|
|
725
|
+
text-align: left;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.ctx-menu button:active {
|
|
729
|
+
background: var(--text-active);
|
|
730
|
+
}
|