@cplieger/web-terminal-ui 3.4.5 → 4.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.
Files changed (38) hide show
  1. package/README.md +53 -23
  2. package/css/00-tokens.css +8 -2
  3. package/css/01-scope.css +56 -0
  4. package/css/{02-app.css → 02-terminal.css} +64 -239
  5. package/css/{03-primitives.css → 10-primitives.css} +21 -19
  6. package/css/20-connection-banner.css +42 -0
  7. package/css/21-scroll-to-bottom.css +65 -0
  8. package/css/22-context-menu.css +45 -0
  9. package/css/23-toolbar.css +181 -0
  10. package/css/{05-tabs.css → 30-tabs.css} +96 -57
  11. package/css/{06-mobile.css → 31-switcher.css} +66 -81
  12. package/css/{07-animations.css → 40-animations.css} +15 -15
  13. package/css/MANIFEST +19 -11
  14. package/css/MANIFEST.single +12 -0
  15. package/css/MANIFEST.tabbed +16 -0
  16. package/css/MANIFEST.touch +12 -0
  17. package/css/{01-reset.css → page.css} +18 -19
  18. package/package.json +21 -5
  19. package/scaffold/index.html +5 -0
  20. package/src/features/connection-banner.ts +3 -0
  21. package/src/features/context-menu.ts +6 -33
  22. package/src/features/menu-position.ts +50 -0
  23. package/src/features/{tabs.ts → tabs/index.ts} +207 -636
  24. package/src/features/tabs/model.ts +334 -0
  25. package/src/features/tabs/strip.ts +101 -0
  26. package/src/features/tabs/switcher.ts +129 -0
  27. package/src/index.ts +1 -0
  28. package/src/kernel/a11y.ts +30 -5
  29. package/src/kernel/conn-state.ts +20 -3
  30. package/src/kernel/kernel.ts +162 -59
  31. package/src/kernel/types.ts +65 -15
  32. package/src/presets/agent-tabbed.ts +15 -0
  33. package/src/presets/single.ts +27 -0
  34. package/src/presets/tabbed.ts +42 -0
  35. package/src/presets/touch.ts +13 -0
  36. package/src/presets.ts +12 -76
  37. package/src/viewport.ts +28 -15
  38. package/css/04-feedback.css +0 -180
package/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/@cplieger/web-terminal-ui)](https://www.npmjs.com/package/@cplieger/web-terminal-ui)
4
4
  [![JSR](https://jsr.io/badges/@cplieger/web-terminal-ui)](https://jsr.io/@cplieger/web-terminal-ui)
5
5
  [![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/coverage.json)](https://github.com/cplieger/web-terminal-ui/actions/workflows/coverage.yml)
6
+ [![Mutation (TS)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/web-terminal-ui/badges/mutation-ts.json)](https://github.com/cplieger/web-terminal-ui/issues?q=label%3Astryker-tracker)
6
7
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13431/badge)](https://www.bestpractices.dev/projects/13431)
7
8
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/web-terminal-ui/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/web-terminal-ui)
8
9
 
@@ -48,11 +49,26 @@ pins the engine version explicitly.
48
49
 
49
50
  ## Usage
50
51
 
51
- Serve the bundled CSS (`css/` concatenated per `css/MANIFEST` into the
52
- `style.css` your page links) plus a minimal HTML page that has one empty
53
- container element for the terminal, then call `createTerminal(root, { features })`
54
- from your entry module. Feature bundles (presets) live at the `./presets`
55
- sub-path:
52
+ Serve a CSS bundle matching how you embed the terminal, plus a minimal HTML
53
+ page that has one empty container element, then call
54
+ `createTerminal(root, { features })` from your entry module.
55
+
56
+ **Full-page host** (the terminal IS the page — `web-terminal-server`,
57
+ `web-terminal-kiro`): concatenate `css/MANIFEST` into the `style.css` your page
58
+ links. That reference bundle is `css/page.css` (the page kit: `html/body`
59
+ reset + the terminal web font's `@font-face`, expecting the font files at
60
+ `/vendor/fonts/`) plus the complete component set.
61
+
62
+ **Embedder** (the terminal lives inside your app's layout — a panel, a pane):
63
+ concatenate the per-preset manifest matching your composition instead —
64
+ `css/MANIFEST.single`, `css/MANIFEST.touch`, or `css/MANIFEST.tabbed`. These
65
+ contain ONLY root-scoped component styles: no page reset, no fonts, no
66
+ document-level rules, nothing to quarantine. Pass `layout: "container"` so the
67
+ terminal fills (and positions its chrome against) your container element
68
+ instead of the viewport.
69
+
70
+ Feature bundles (presets) live at the `./presets` sub-path, with per-preset
71
+ entry modules beside it:
56
72
 
57
73
  ```html
58
74
  <div id="terminal"></div>
@@ -80,14 +96,22 @@ sub-path:
80
96
 
81
97
  `createTerminal(root, opts?)` builds the entire terminal subtree (the kernel
82
98
  plus every feature's chrome) inside `root` itself — there is no element-id
83
- contract for the host page to reproduce. Call it exactly once; the engine's
99
+ contract for the host page to reproduce, and every style and CSS custom
100
+ property is scoped to the `wt-root` class it stamps on your element (removed
101
+ again by `destroy()`). Call it exactly once; the engine's
84
102
  render/connection/scroll modules are single-instance per page (tabs multiplex
85
103
  sessions over the one kernel). `scaffold/index.html` is a complete reference page
86
104
  to copy and adapt.
87
105
 
88
- Four presets are provided (import from `@cplieger/web-terminal-ui/presets`); each
89
- is a plain feature-array factory, so you can spread and edit it, or hand-pick
90
- individual features instead:
106
+ Four presets are provided; each is a plain feature-array factory, so you can
107
+ spread and edit it. Import the barrel (`@cplieger/web-terminal-ui/presets`) for
108
+ convenience, or a per-preset entry module for the minimal delivered import
109
+ graph — `…/presets/single`, `…/presets/touch`, `…/presets/tabbed`,
110
+ `…/presets/agent-tabbed` (the barrel statically reaches every feature; the
111
+ touch entry, for example, never imports the tabs module). Individual features
112
+ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
113
+ `scroll-to-bottom`, `predictive-echo`, `connection-banner`, `mobile-toolbar`,
114
+ `tabs`, `activity-monitor`, `animations`) for hand-picked compositions:
91
115
 
92
116
  - `presetSingle()` — single-pane desktop UI (context menu, clipboard,
93
117
  scroll-to-bottom, predictive echo, connection banner).
@@ -109,25 +133,31 @@ individual features instead:
109
133
 
110
134
  ### Options
111
135
 
112
- | Option | Default | Purpose |
113
- | ----------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
114
- | `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
115
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
116
- | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
117
- | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
118
- | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`. The library ships neutral defaults. |
136
+ | Option | Default | Purpose |
137
+ | ----------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
138
+ | `features` | _(none — bare kernel)_ | The feature list. Omitted or empty builds only the terminal (no chrome). Use a preset from `./presets` or a hand-picked array. |
139
+ | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
140
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
141
+ | `fontReady` | `'14px "MonaspiceNe NFM"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
142
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
143
+ | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`. The library ships neutral defaults. |
119
144
 
120
145
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
121
- (and opens the soft keyboard on touch), and `destroy()` tears every feature down
122
- and releases the kernel.
146
+ (and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
147
+ session through the kernel's sanitizing input funnel (the supported host path
148
+ for a "type this command" affordance); `reset()` drops the local scrollback and
149
+ screen without injecting keystrokes (send a redraw keystroke yourself if you
150
+ want one — e.g. Ctrl+L); and `destroy()` tears every feature down and releases
151
+ the kernel.
123
152
 
124
153
  ## What ships
125
154
 
126
- | Path | Purpose |
127
- | ------------------------ | --------------------------------------------------------------------------------------------------------------- |
128
- | `src/**/*.ts` | The UI modules: the kernel (`kernel/`), opt-in features (`features/`), presets, IME, predictive echo, viewport. |
129
- | `css/*.css` + `MANIFEST` | The default theme + layout; concatenate in MANIFEST order. |
130
- | `scaffold/index.html` | A reference HTML page: `<head>` + one empty root element + importmap. |
155
+ | Path | Purpose |
156
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | `src/**/*.ts` | The UI modules: the kernel (`kernel/`), opt-in features (`features/`), per-preset entries (`presets/`), IME, predictive echo, viewport. |
158
+ | `css/*.css` + manifests | Root-scoped component styles. `MANIFEST` = the reference full-page bundle (`page.css` + the tabbed set); `MANIFEST.single/touch/tabbed` = component-only per-preset bundles for embedders. Concatenate in manifest order. |
159
+ | `css/page.css` | The page kit (full-page hosts only): `html/body` reset + the terminal web font's `@font-face`. |
160
+ | `scaffold/index.html` | A reference full-page HTML host: `<head>` + one empty root element + importmap. |
131
161
 
132
162
  ## Related projects
133
163
 
package/css/00-tokens.css CHANGED
@@ -1,5 +1,11 @@
1
- /* ===== Tokens ===== */
2
- :root {
1
+ /* ===== Tokens =====
2
+ Every token defaults on .wt-root (the class createTerminal stamps on the
3
+ consumer's root element), never on the document :root: the terminal subtree
4
+ inherits them, the host page never does, and a host page's identically-named
5
+ custom properties never reach the terminal (the class definition wins for the
6
+ subtree). The `theme` option overrides them as inline style on the same
7
+ element — the consumer contract is unchanged. */
8
+ .wt-root {
3
9
  interpolate-size: allow-keywords;
4
10
 
5
11
  --bg: oklch(0% 0 0deg);
@@ -0,0 +1,56 @@
1
+ /* ===== Component scope + layout modes =====
2
+ Everything here is scoped under .wt-root — the class createTerminal stamps on
3
+ the consumer's root element. The library never styles the host document: page
4
+ concerns (html/body reset, fonts, viewport meta behavior) live in page.css,
5
+ served only by full-page hosts. This file owns the boundary itself: the
6
+ scoped box-sizing + font-smoothing baseline, the two layout modes, and the
7
+ scoped reduced-motion kill-switch. */
8
+
9
+ /* Layout mode: how the root claims space (kernel stamps exactly one).
10
+ wt-viewport — the root IS the page surface: a fixed full-viewport box (the
11
+ full-page product; geometrically identical to the pre-v4 fixed-inset .term).
12
+ wt-container — the root fills its parent, which becomes the boundary: the
13
+ embedded case. overflow: clip keeps absolute chrome (menus, banners, the
14
+ switcher) inside the box, so an embedder needs no containment workarounds. */
15
+ .wt-root.wt-viewport {
16
+ position: fixed;
17
+ inset: 0;
18
+ }
19
+
20
+ .wt-root.wt-container {
21
+ position: relative;
22
+ inline-size: 100%;
23
+ block-size: 100%;
24
+ overflow: clip;
25
+ }
26
+
27
+ .wt-root,
28
+ .wt-root *,
29
+ .wt-root *::before,
30
+ .wt-root *::after {
31
+ box-sizing: border-box;
32
+ }
33
+
34
+ .wt-root {
35
+ -webkit-font-smoothing: antialiased;
36
+ }
37
+
38
+ /* stylelint-disable declaration-no-important, time-min-milliseconds --
39
+ Canonical prefers-reduced-motion override. !important and near-zero
40
+ durations are required to forcibly defeat any animation/transition rule
41
+ regardless of specificity, which is the whole point of this a11y reset.
42
+ Scoped to the terminal subtree: the host page's own motion policy is the
43
+ host's business (pre-v4 this hit `*` and silently flattened the embedding
44
+ app's animations). */
45
+ @media (prefers-reduced-motion: reduce) {
46
+ .wt-root,
47
+ .wt-root *,
48
+ .wt-root *::before,
49
+ .wt-root *::after {
50
+ animation-duration: 0.01ms !important;
51
+ animation-iteration-count: 1 !important;
52
+ transition-duration: 0.01ms !important;
53
+ scroll-behavior: auto !important;
54
+ }
55
+ }
56
+ /* stylelint-enable declaration-no-important, time-min-milliseconds */
@@ -1,6 +1,11 @@
1
1
  /* ===== Terminal ===== */
2
- .term {
3
- position: fixed;
2
+ :where(.wt-root) .term {
3
+ /* Absolute against .wt-root (the positioning boundary in BOTH layout modes):
4
+ in wt-viewport the root is a fixed full-viewport box, so this is
5
+ geometrically identical to the pre-v4 `position: fixed`; in wt-container it
6
+ fills the embedder's panel. viewport.ts still pins top/bottom inline to
7
+ track the visual viewport around the soft keyboard. */
8
+ position: absolute;
4
9
  inset: 0;
5
10
  overflow: hidden auto;
6
11
  /* The scroll controller (vterm scroll.ts) is the single owner of
@@ -27,7 +32,7 @@
27
32
  color: var(--text);
28
33
  }
29
34
 
30
- .term-row {
35
+ :where(.wt-root) .term-row {
31
36
  white-space: pre;
32
37
  min-height: 17px;
33
38
  font-variant-ligatures: none;
@@ -48,7 +53,7 @@
48
53
  cell tracks the column on every flush. */
49
54
  }
50
55
 
51
- .term-row:empty::after {
56
+ :where(.wt-root) .term-row:empty::after {
52
57
  content: "\00a0";
53
58
  }
54
59
 
@@ -56,7 +61,7 @@
56
61
  as the first row when history older than the buffer holds was evicted —
57
62
  client-side cap or a server-reported resume gap). A quiet, centered,
58
63
  non-selectable divider so the user knows scrollback doesn't go further up. */
59
- .term-trim-marker {
64
+ :where(.wt-root) .term-trim-marker {
60
65
  color: color-mix(in oklch, var(--text) 60%, transparent);
61
66
  font-size: 0.6875rem;
62
67
  text-align: center;
@@ -65,7 +70,7 @@
65
70
  font-family: var(--font-ui);
66
71
  }
67
72
 
68
- .term-input {
73
+ :where(.wt-root) .term-input {
69
74
  /* The hidden keyboard target. position:absolute (NOT fixed) so it lives in
70
75
  the terminal's scroll/content coordinate space alongside .composition-view
71
76
  and .pred-cursor: composition.positionCompositionView() moves it to the
@@ -94,7 +99,7 @@
94
99
  font-size: 16px;
95
100
  }
96
101
 
97
- .term-output {
102
+ :where(.wt-root) .term-output {
98
103
  position: relative;
99
104
  outline: none;
100
105
 
@@ -128,7 +133,7 @@
128
133
  so the candidate popup tracks what the user is typing.
129
134
  Visibility is gated by the `.active` class so empty state doesn't
130
135
  render a phantom box on top of the cursor. */
131
- .composition-view {
136
+ :where(.wt-root) .composition-view {
132
137
  position: absolute;
133
138
  pointer-events: none;
134
139
  background: var(--surface);
@@ -143,7 +148,7 @@
143
148
  display: none;
144
149
  }
145
150
 
146
- .composition-view.active {
151
+ :where(.wt-root) .composition-view.active {
147
152
  display: block;
148
153
  }
149
154
 
@@ -153,7 +158,7 @@
153
158
  cursor (predict.ts deactivates on bail and the renderer hides on
154
159
  coincidence). Dashed outline + reduced opacity reads as "tentative"
155
160
  — distinct from the solid block of the real cursor. */
156
- .pred-cursor {
161
+ :where(.wt-root) .pred-cursor {
157
162
  position: absolute;
158
163
  pointer-events: none;
159
164
  border: 1px dashed var(--text);
@@ -163,7 +168,7 @@
163
168
  box-sizing: border-box;
164
169
  }
165
170
 
166
- .pred-cursor.visible {
171
+ :where(.wt-root) .pred-cursor.visible {
167
172
  display: block;
168
173
  }
169
174
 
@@ -177,17 +182,17 @@
177
182
  Auto-detected bare URLs (.term-autolink) are tightly scoped to the URL text and
178
183
  never bleed, so they keep a subtle persistent underline. (The engine also only
179
184
  anchors runs with real link text, so the underline hugs the text either way.) */
180
- .term-link {
185
+ :where(.wt-root) .term-link {
181
186
  color: inherit;
182
187
  text-decoration: none;
183
188
  cursor: pointer;
184
189
  }
185
190
 
186
- .term-autolink {
191
+ :where(.wt-root) .term-autolink {
187
192
  text-decoration: underline dotted;
188
193
  }
189
194
 
190
- .term-link:hover {
195
+ :where(.wt-root) .term-link:hover {
191
196
  text-decoration: underline solid;
192
197
  }
193
198
 
@@ -197,9 +202,9 @@
197
202
  the viewport. JS-driven instead of CSS animation because the cursor
198
203
  span is recreated every row update — a CSS animation would restart on
199
204
  each recreation and never reach the "off" frame during streaming. */
200
- .term-cursor,
201
- .term-cursor-underline,
202
- .term-cursor-bar {
205
+ :where(.wt-root) .term-cursor,
206
+ :where(.wt-root) .term-cursor-underline,
207
+ :where(.wt-root) .term-cursor-bar {
203
208
  box-sizing: border-box;
204
209
  display: inline-block;
205
210
  height: 17px;
@@ -207,35 +212,35 @@
207
212
  vertical-align: top;
208
213
  }
209
214
 
210
- .term-cursor {
215
+ :where(.wt-root) .term-cursor {
211
216
  background: var(--text);
212
217
  color: var(--bg);
213
218
  }
214
219
 
215
220
  /* Underline cursor (DECSCUSR 3/4) */
216
- .term-cursor-underline {
221
+ :where(.wt-root) .term-cursor-underline {
217
222
  background: transparent;
218
223
  color: var(--text);
219
224
  border-bottom: 2px solid var(--text);
220
225
  }
221
226
 
222
227
  /* Bar cursor (DECSCUSR 5/6) */
223
- .term-cursor-bar {
228
+ :where(.wt-root) .term-cursor-bar {
224
229
  background: transparent;
225
230
  color: var(--text);
226
231
  border-left: 2px solid var(--text);
227
232
  }
228
233
 
229
- .cursor-blink-off .term-cursor {
234
+ :where(.wt-root) .cursor-blink-off .term-cursor {
230
235
  background: transparent;
231
236
  color: var(--text);
232
237
  }
233
238
 
234
- .term-blink {
235
- animation: term-blink-anim 1s step-end infinite;
239
+ :where(.wt-root) .term-blink {
240
+ animation: wt-blink-anim 1s step-end infinite;
236
241
  }
237
242
 
238
- @keyframes term-blink-anim {
243
+ @keyframes wt-blink-anim {
239
244
  50% {
240
245
  opacity: 0;
241
246
  }
@@ -243,9 +248,9 @@
243
248
 
244
249
  /* When the terminal isn't focused, render the cursor as an outline
245
250
  rather than a filled block (xterm.js convention). */
246
- .term:not(.focus) .term-cursor,
247
- .term:not(.focus) .term-cursor-underline,
248
- .term:not(.focus) .term-cursor-bar {
251
+ :where(.wt-root) .term:not(.focus) .term-cursor,
252
+ :where(.wt-root) .term:not(.focus) .term-cursor-underline,
253
+ :where(.wt-root) .term:not(.focus) .term-cursor-bar {
249
254
  background: transparent;
250
255
  color: var(--text);
251
256
  border: none;
@@ -257,280 +262,100 @@
257
262
  }
258
263
 
259
264
  /* ===== Terminal color classes (basic 16 — themeable) ===== */
260
- .term-fg-0 {
265
+ :where(.wt-root) .term-fg-0 {
261
266
  color: #000;
262
267
  }
263
- .term-fg-1 {
268
+ :where(.wt-root) .term-fg-1 {
264
269
  color: #a00;
265
270
  }
266
- .term-fg-2 {
271
+ :where(.wt-root) .term-fg-2 {
267
272
  color: #0a0;
268
273
  }
269
- .term-fg-3 {
274
+ :where(.wt-root) .term-fg-3 {
270
275
  color: #a50;
271
276
  }
272
- .term-fg-4 {
277
+ :where(.wt-root) .term-fg-4 {
273
278
  color: #00a;
274
279
  }
275
- .term-fg-5 {
280
+ :where(.wt-root) .term-fg-5 {
276
281
  color: #a0a;
277
282
  }
278
- .term-fg-6 {
283
+ :where(.wt-root) .term-fg-6 {
279
284
  color: #0aa;
280
285
  }
281
- .term-fg-7 {
286
+ :where(.wt-root) .term-fg-7 {
282
287
  color: #aaa;
283
288
  }
284
- .term-fg-8 {
289
+ :where(.wt-root) .term-fg-8 {
285
290
  color: #555;
286
291
  }
287
- .term-fg-9 {
292
+ :where(.wt-root) .term-fg-9 {
288
293
  color: #f55;
289
294
  }
290
- .term-fg-10 {
295
+ :where(.wt-root) .term-fg-10 {
291
296
  color: #5f5;
292
297
  }
293
- .term-fg-11 {
298
+ :where(.wt-root) .term-fg-11 {
294
299
  color: #ff5;
295
300
  }
296
- .term-fg-12 {
301
+ :where(.wt-root) .term-fg-12 {
297
302
  color: #55f;
298
303
  }
299
- .term-fg-13 {
304
+ :where(.wt-root) .term-fg-13 {
300
305
  color: #f5f;
301
306
  }
302
- .term-fg-14 {
307
+ :where(.wt-root) .term-fg-14 {
303
308
  color: #5ff;
304
309
  }
305
- .term-fg-15 {
310
+ :where(.wt-root) .term-fg-15 {
306
311
  color: #fff;
307
312
  }
308
313
 
309
- .term-bg-0 {
314
+ :where(.wt-root) .term-bg-0 {
310
315
  background: #000;
311
316
  }
312
- .term-bg-1 {
317
+ :where(.wt-root) .term-bg-1 {
313
318
  background: #a00;
314
319
  }
315
- .term-bg-2 {
320
+ :where(.wt-root) .term-bg-2 {
316
321
  background: #0a0;
317
322
  }
318
- .term-bg-3 {
323
+ :where(.wt-root) .term-bg-3 {
319
324
  background: #a50;
320
325
  }
321
- .term-bg-4 {
326
+ :where(.wt-root) .term-bg-4 {
322
327
  background: #00a;
323
328
  }
324
- .term-bg-5 {
329
+ :where(.wt-root) .term-bg-5 {
325
330
  background: #a0a;
326
331
  }
327
- .term-bg-6 {
332
+ :where(.wt-root) .term-bg-6 {
328
333
  background: #0aa;
329
334
  }
330
- .term-bg-7 {
335
+ :where(.wt-root) .term-bg-7 {
331
336
  background: #aaa;
332
337
  }
333
- .term-bg-8 {
338
+ :where(.wt-root) .term-bg-8 {
334
339
  background: #555;
335
340
  }
336
- .term-bg-9 {
341
+ :where(.wt-root) .term-bg-9 {
337
342
  background: #f55;
338
343
  }
339
- .term-bg-10 {
344
+ :where(.wt-root) .term-bg-10 {
340
345
  background: #5f5;
341
346
  }
342
- .term-bg-11 {
347
+ :where(.wt-root) .term-bg-11 {
343
348
  background: #ff5;
344
349
  }
345
- .term-bg-12 {
350
+ :where(.wt-root) .term-bg-12 {
346
351
  background: #55f;
347
352
  }
348
- .term-bg-13 {
353
+ :where(.wt-root) .term-bg-13 {
349
354
  background: #f5f;
350
355
  }
351
- .term-bg-14 {
356
+ :where(.wt-root) .term-bg-14 {
352
357
  background: #5ff;
353
358
  }
354
- .term-bg-15 {
359
+ :where(.wt-root) .term-bg-15 {
355
360
  background: #fff;
356
361
  }
357
-
358
- /* ===== Key toolbar (touch devices only) =====
359
- Uses interpolate-size: allow-keywords (on :root) to animate
360
- width/height between a fixed collapsed size and auto (intrinsic).
361
- Toggle is first in DOM so it's at top-left of the grid — visible
362
- when the container clips to 1-cell size. Container is right-anchored
363
- so it grows leftward on open. */
364
- .key-toolbar {
365
- display: contents;
366
- }
367
-
368
- /* Touch-only toolbar children: the on-screen Esc/Tab/arrows/hamburger
369
- are useless on desktop where the user has a real keyboard. Hidden by
370
- default; the @media (pointer: coarse) block below restores them. */
371
- .kb-key,
372
- .kb-toggle {
373
- display: none;
374
- }
375
-
376
- @media (pointer: coarse) {
377
- .key-toolbar {
378
- position: fixed;
379
- top: calc(var(--safe-top) + var(--sp-2) + var(--vv-top, 0px));
380
- right: var(--sp-2);
381
- z-index: 10;
382
- display: grid;
383
- grid-template-columns: repeat(4, var(--touch-target)) var(--touch-target);
384
- grid-template-rows: var(--touch-target) var(--touch-target);
385
- place-content: start end;
386
- gap: var(--sp-1);
387
- padding: var(--sp-1);
388
- background: var(--surface);
389
- backdrop-filter: var(--blur);
390
- border: 1px solid var(--border);
391
- border-radius: var(--radius);
392
- overflow: hidden;
393
- width: auto;
394
- height: auto;
395
- transition:
396
- width var(--dur-enter) var(--ease-standard),
397
- height var(--dur-enter) var(--ease-standard);
398
- }
399
-
400
- /* Collapsed: fixed size = 1 button + padding + border. Content is
401
- right-aligned (justify-content:end) so the toggle (col 5) stays at
402
- the right edge, visible within the clipped area. --pill-outer
403
- includes the 2px border (box-sizing:border-box); omitting it left
404
- the content box 2px short, pinning the button top-right with
405
- unequal bottom/left gaps. */
406
- .key-toolbar.collapsed {
407
- width: var(--pill-outer);
408
- height: var(--pill-outer);
409
- }
410
-
411
- /* Grid placement: toggle at col 5 row 1 (top-right). */
412
- .kb-toggle {
413
- grid-area: 1 / 5;
414
- position: relative;
415
- }
416
- .kb-r1c1 {
417
- grid-area: 1 / 1;
418
- }
419
- .kb-r1c2 {
420
- grid-area: 1 / 2;
421
- }
422
- .kb-r1c3 {
423
- grid-area: 1 / 3;
424
- }
425
- .kb-r1c4 {
426
- grid-area: 1 / 4;
427
- }
428
- .kb-r2c1 {
429
- grid-area: 2 / 1;
430
- }
431
- .kb-r2c2 {
432
- grid-area: 2 / 2;
433
- }
434
- .kb-r2c3 {
435
- grid-area: 2 / 3;
436
- }
437
- .kb-r2c4 {
438
- grid-area: 2 / 4;
439
- }
440
-
441
- /* Keys fade in/out during open/close. The container's width/height
442
- transition handles the background slide; opacity handles the
443
- individual button appearance. Stagger the fade slightly after
444
- the container starts growing (delay on open) and before it
445
- finishes shrinking (no delay on close). */
446
- .kb-key {
447
- opacity: 1;
448
- transition: opacity var(--dur-standard) var(--ease-enter) 0.05s;
449
- }
450
-
451
- .key-toolbar.collapsed .kb-key {
452
- opacity: 0;
453
- transition: opacity var(--dur-exit) var(--ease-exit);
454
- }
455
-
456
- /* Shared button */
457
- .key-toolbar button {
458
- width: var(--touch-target);
459
- height: var(--touch-target);
460
- background: transparent;
461
- color: var(--text);
462
- border: none;
463
- border-radius: var(--radius-inner);
464
- font-size: 0.9375rem;
465
- font-family: var(--font-ui);
466
- font-weight: 500;
467
- cursor: pointer;
468
- touch-action: manipulation;
469
- user-select: none;
470
- display: flex;
471
- align-items: center;
472
- justify-content: center;
473
- }
474
-
475
- .key-toolbar button:active {
476
- background: var(--text-active);
477
- }
478
-
479
- /* Sticky Ctrl, armed: inverted fill so the toggle state reads clearly
480
- in the monochrome palette (stays filled while pressed). */
481
- .kb-key.armed,
482
- .kb-key.armed:active {
483
- background: var(--text);
484
- color: var(--bg);
485
- }
486
-
487
- .key-toolbar button svg {
488
- width: var(--icon);
489
- height: var(--icon);
490
- stroke: currentcolor;
491
- fill: none;
492
- stroke-width: 2;
493
- stroke-linecap: round;
494
- stroke-linejoin: round;
495
- }
496
-
497
- /* Toggle icon crossfade. Both icons are stacked absolute so they
498
- can independently fade + rotate during the open<->close
499
- transition. inset:0 + margin:auto centers them within the
500
- button (works because the SVGs carry explicit width/height); a
501
- bare `position: absolute` would pin them at the top-left, giving
502
- the toggle visibly unequal left/right padding when collapsed. */
503
- .kb-toggle .icon-hamburger,
504
- .kb-toggle .icon-close {
505
- position: absolute;
506
- inset: 0;
507
- margin: auto;
508
- transition:
509
- opacity var(--dur-standard) linear,
510
- transform var(--dur-standard) linear;
511
- }
512
-
513
- .kb-toggle .icon-close {
514
- opacity: 1;
515
- transform: rotate(0deg);
516
- }
517
- .kb-toggle .icon-hamburger {
518
- opacity: 0;
519
- transform: rotate(-90deg);
520
- }
521
- .key-toolbar.collapsed .kb-toggle .icon-close {
522
- opacity: 0;
523
- transform: rotate(90deg);
524
- }
525
- .key-toolbar.collapsed .kb-toggle .icon-hamburger {
526
- opacity: 1;
527
- transform: rotate(0deg);
528
- }
529
-
530
- /* No-transition on first render */
531
- .key-toolbar.no-transition,
532
- .key-toolbar.no-transition .kb-toggle .icon-hamburger,
533
- .key-toolbar.no-transition .kb-toggle .icon-close {
534
- transition: none;
535
- }
536
- }