@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
@@ -1,18 +1,20 @@
1
1
  /* ===== Tabs: desktop strip + activity vocabulary (tabs feature) =====
2
2
  The desktop top-bar tab strip, its per-tab chrome (label, close, add, the
3
- right-click menu), the surface offset that clears the fixed strip, and the
3
+ right-click menu), the surface offset that clears the docked strip, and the
4
4
  shared activity-dot status vocabulary. The mobile tab chrome (switcher bar,
5
- overview sheet, external key grid) lives in 06-mobile.css; all motion lives in
6
- 07-animations.css. */
5
+ overview sheet, external key grid) lives in 31-switcher.css; all motion lives
6
+ in 40-animations.css. */
7
7
 
8
- .wt-region-top-bar {
8
+ :where(.wt-root) .wt-region-top-bar {
9
9
  /* On desktop the tab strip docks to the top; the region is transparent, so
10
10
  the bar element below owns its own fixed placement. */
11
11
  display: contents;
12
12
  }
13
13
 
14
- .wt-tab-bar {
15
- position: fixed;
14
+ :where(.wt-root) .wt-tab-bar {
15
+ /* Absolute against .wt-root, like all chrome (identical geometry in
16
+ wt-viewport mode; see 01-scope.css). */
17
+ position: absolute;
16
18
  bottom: var(--safe-bottom);
17
19
  left: 0;
18
20
  right: 0;
@@ -28,7 +30,7 @@
28
30
  z-index: 15;
29
31
  }
30
32
 
31
- .wt-tab {
33
+ :where(.wt-root) .wt-tab {
32
34
  display: inline-flex;
33
35
  align-items: center;
34
36
  gap: var(--sp-1);
@@ -56,12 +58,12 @@
56
58
 
57
59
  /* Inactive tabs read as outlined chips with a subtle dark fill (so adjacent tabs
58
60
  stay separated from the bar and from each other) and lift on hover. */
59
- .wt-tab:not(.wt-tab-active) {
61
+ :where(.wt-root) .wt-tab:not(.wt-tab-active) {
60
62
  border-color: var(--border);
61
63
  background: var(--tab-bg);
62
64
  }
63
65
 
64
- .wt-tab:not(.wt-tab-active):hover {
66
+ :where(.wt-root) .wt-tab:not(.wt-tab-active):hover {
65
67
  /* A solid lift over the chip. --tab-hover-bg is a translucent wash meant for a
66
68
  transparent-rest control; replacing the --tab-bg chip with it would read as
67
69
  "more transparent", not "lighter", so bake the same 15% --text lift into the
@@ -73,7 +75,7 @@
73
75
  /* The active tab reads clearly above the inactive chips: a stronger fill plus an
74
76
  accent-coloured edge (inactive chips carry a faint white --border; the active
75
77
  one gets the accent equivalent). Themeable via --accent (web-terminal-kiro -> purple). */
76
- .wt-tab-active {
78
+ :where(.wt-root) .wt-tab-active {
77
79
  background: var(--tab-active-bg);
78
80
  border-color: var(--tab-active-border);
79
81
  color: var(--tab-active-fg);
@@ -81,7 +83,7 @@
81
83
 
82
84
  /* The label flexes to fill the fixed-width tab and truncates with an ellipsis
83
85
  (min-width: 0 lets a flex item shrink below its content width). */
84
- .wt-tab-label {
86
+ :where(.wt-root) .wt-tab-label {
85
87
  flex: 1 1 auto;
86
88
  min-width: 0;
87
89
  overflow: hidden;
@@ -90,14 +92,14 @@
90
92
  }
91
93
 
92
94
  /* A little breathing room after the activity dot, before the label. */
93
- .wt-tab-dot {
95
+ :where(.wt-root) .wt-tab-dot {
94
96
  margin-right: 2px;
95
97
  }
96
98
 
97
99
  /* The tab close (x): a centered, muted square icon button with a box on hover.
98
100
  Its glyph is an inline SVG (styled below), so flex-centering the button
99
101
  centers it exactly. */
100
- .wt-tab-close {
102
+ :where(.wt-root) .wt-tab-close {
101
103
  display: inline-flex;
102
104
  align-items: center;
103
105
  justify-content: center;
@@ -116,27 +118,28 @@
116
118
  cursor: pointer;
117
119
  }
118
120
 
119
- .wt-tab-close:hover {
121
+ :where(.wt-root) .wt-tab-close:hover {
120
122
  background: var(--text-active);
121
123
  color: var(--text);
122
124
  }
123
125
 
124
126
  /* On an inactive tab the close appears only on hover, kept in the layout via
125
127
  visibility so the tab width never jumps; on the active tab it is always shown. */
126
- .wt-tab:not(.wt-tab-active) .wt-tab-close {
128
+ :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
127
129
  visibility: hidden;
128
130
  }
129
131
 
130
- .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
132
+ :where(.wt-root) .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
131
133
  visibility: visible;
132
134
  }
133
135
 
134
136
  /* Coarse pointer (an iPad using the strip): no hover fires, so an inactive tab's close
135
137
  would be unreachable without first activating the tab. Reveal every tab's close there,
136
- matching the always-visible mobile switcher closes. Kept in lockstep with the strip-shown
137
- gate (pointer: coarse, width >= 601px). */
138
- @media (pointer: coarse) and (width >= 601px) {
139
- .wt-tab:not(.wt-tab-active) .wt-tab-close {
138
+ matching the always-visible mobile switcher closes. No narrow gate needed: on the
139
+ narrow-coarse phone the whole strip is display:none (31-switcher.css), so this rule
140
+ is moot there. */
141
+ @media (pointer: coarse) {
142
+ :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
140
143
  visibility: visible;
141
144
  }
142
145
  }
@@ -144,7 +147,7 @@
144
147
  /* The add-tab (+) button: its hover box matches an active tab's fill and
145
148
  stretches to the strip height; the 2px strip gap (not a custom margin) spaces
146
149
  it from the last tab. Its glyph is an inline SVG. */
147
- .wt-tab-new {
150
+ :where(.wt-root) .wt-tab-new {
148
151
  display: inline-flex;
149
152
  align-items: center;
150
153
  justify-content: center;
@@ -166,7 +169,7 @@
166
169
  cursor: pointer;
167
170
  }
168
171
 
169
- .wt-tab-new:hover {
172
+ :where(.wt-root) .wt-tab-new:hover {
170
173
  /* Keep the accent glyph on hover (no colour override) so the "+" stays the
171
174
  accent colour like the mobile "+"; hover only adds the fill. */
172
175
  background: var(--tab-active-bg);
@@ -175,8 +178,8 @@
175
178
  /* The +/x icons are inline SVG so they center exactly in their flex-centered
176
179
  button and stay symmetric regardless of the UI font (fixes the off-center
177
180
  glyph and the lopsided "+"). The + is a touch larger and bolder than the x. */
178
- .wt-tab-close svg,
179
- .wt-tab-new svg {
181
+ :where(.wt-root) .wt-tab-close svg,
182
+ :where(.wt-root) .wt-tab-new svg {
180
183
  stroke: currentcolor;
181
184
  fill: none;
182
185
  stroke-width: 2.5;
@@ -184,12 +187,12 @@
184
187
  stroke-linejoin: round;
185
188
  }
186
189
 
187
- .wt-tab-close svg {
190
+ :where(.wt-root) .wt-tab-close svg {
188
191
  width: 13px;
189
192
  height: 13px;
190
193
  }
191
194
 
192
- .wt-tab-new svg {
195
+ :where(.wt-root) .wt-tab-new svg {
193
196
  width: 16px;
194
197
  height: 16px;
195
198
  }
@@ -205,23 +208,23 @@
205
208
  strip, before the "+" (kb-then-new, mirroring the mobile switcher's order).
206
209
  Hidden on a fine-pointer desktop (a real keyboard is present) and until a
207
210
  keyboardToggle feature is wired ([hidden]); shown only on a coarse pointer at
208
- strip widths (the media block below, kept in lockstep with SWITCHER_MEDIA in
209
- src/features/tabs.ts). */
210
- .wt-tab-kb {
211
+ strip widths (the .wt-narrow complement below; the breakpoint itself is the
212
+ kernel's, mirrored as the root class). */
213
+ :where(.wt-root) .wt-tab-kb {
211
214
  display: none;
212
215
  flex: none;
213
216
  }
214
217
 
215
218
  /* Grid open: filled so the toggled state reads at a glance (mirrors the
216
219
  switcher keyboard button). */
217
- .wt-tab-kb.wt-active {
220
+ :where(.wt-root) .wt-tab-kb.wt-active {
218
221
  background: var(--tab-hover-bg);
219
222
  color: var(--tab-active-fg);
220
223
  }
221
224
 
222
225
  /* Sticky-Ctrl armed: invert (matches the switcher keyboard button + the armed
223
226
  Ctrl key) so a pending Ctrl is visible with the grid closed. */
224
- .wt-tab-kb.wt-armed {
227
+ :where(.wt-root) .wt-tab-kb.wt-armed {
225
228
  background: var(--text);
226
229
  color: var(--bg);
227
230
  }
@@ -229,14 +232,14 @@
229
232
  /* Show only on a WIDE touchscreen (where the desktop strip is used AND a
230
233
  keyboardToggle is wired, hence :not([hidden])). In-flow now, so the strip
231
234
  needs no reserved right padding for it. */
232
- @media (pointer: coarse) and (width >= 601px) {
233
- .wt-tab-kb:not([hidden]) {
235
+ @media (pointer: coarse) {
236
+ :where(.wt-root:not(.wt-narrow)) .wt-tab-kb:not([hidden]) {
234
237
  display: inline-flex;
235
238
  }
236
239
  }
237
240
 
238
241
  /* The tab being dragged (reorder) dims while it moves. */
239
- .wt-tab-dragging {
242
+ :where(.wt-root) .wt-tab-dragging {
240
243
  opacity: 0.5;
241
244
  }
242
245
 
@@ -244,8 +247,10 @@
244
247
  Close to the right / Close to the left / Close all. Replaces the old bar
245
248
  "Close all" button. Mirrors .wt-ctx-menu (a fixed surface popover, clamped
246
249
  into the viewport by the feature); disabled items are dimmed and inert. */
247
- .wt-tab-menu {
248
- position: fixed;
250
+ :where(.wt-root) .wt-tab-menu {
251
+ /* Absolute against .wt-root; placed by the shared point-anchored positioner
252
+ (menu-position.ts), which converts to root-relative offsets. */
253
+ position: absolute;
249
254
  display: none;
250
255
  flex-direction: column;
251
256
  background: var(--surface);
@@ -256,11 +261,11 @@
256
261
  z-index: 30;
257
262
  }
258
263
 
259
- .wt-tab-menu.visible {
264
+ :where(.wt-root) .wt-tab-menu.visible {
260
265
  display: flex;
261
266
  }
262
267
 
263
- .wt-tab-menu button {
268
+ :where(.wt-root) .wt-tab-menu button {
264
269
  background: transparent;
265
270
  color: var(--text);
266
271
  border: none;
@@ -274,30 +279,37 @@
274
279
  white-space: nowrap;
275
280
  }
276
281
 
277
- .wt-tab-menu button:not(:disabled):hover {
282
+ /* :enabled (not :not(:disabled)) keeps the pseudo-class count within the
283
+ stylelint budget alongside the zero-specificity scope. */
284
+ :where(.wt-root) .wt-tab-menu button:enabled:hover {
278
285
  background: var(--text-active);
279
286
  }
280
287
 
281
- .wt-tab-menu button:disabled {
288
+ :where(.wt-root) .wt-tab-menu button:disabled {
282
289
  color: color-mix(in oklch, var(--text) 35%, transparent);
283
290
  cursor: default;
284
291
  }
285
292
 
286
- /* Pull the terminal surface up off the fixed BOTTOM tab strip so it does not
293
+ /* Pull the terminal surface up off the docked BOTTOM tab strip so it does not
287
294
  overlap the last rows (a mirror of the old top offset). The tabs feature
288
295
  marks the surface (.wt-with-tabbar) and keeps the measured strip height in
289
- --wt-tabbar-h; the surface is position:fixed inset:0, so a bottom offset
290
- clears it. Applies whenever the desktop strip is shown — a fine pointer OR a
291
- wide viewport (an iPad) — the complement of the narrow-coarse switcher gate in
292
- css/06-mobile.css (kept in lockstep with SWITCHER_MEDIA in tabs.ts). On the
293
- narrow-coarse phone the strip is hidden and the mobile switcher's inset is
294
- applied through viewport.ts (--wt-reserve-bottom) instead. */
295
- @media (pointer: fine), (width >= 601px) {
296
- .term.wt-with-tabbar {
296
+ --wt-tabbar-h; the surface is absolute inset:0, so a bottom offset clears it.
297
+ Applies whenever the desktop strip is shown — a fine pointer OR a root that
298
+ is not narrow (an iPad) — the complement of the narrow-coarse switcher gate
299
+ in css/31-switcher.css. On the narrow-coarse phone the strip is hidden and
300
+ the mobile switcher's inset is applied through viewport.ts
301
+ (--wt-reserve-bottom) instead. Two rules because the class half cannot mix
302
+ into a media condition; they set the same declaration. */
303
+ @media (pointer: fine) {
304
+ :where(.wt-root) .term.wt-with-tabbar {
297
305
  bottom: var(--wt-tabbar-h, 44px);
298
306
  }
299
307
  }
300
308
 
309
+ :where(.wt-root:not(.wt-narrow)) .term.wt-with-tabbar {
310
+ bottom: var(--wt-tabbar-h, 44px);
311
+ }
312
+
301
313
  /* ===== Activity dots (activityMonitor via tabs) =====
302
314
  Four-state, distinguishable without color (shape + motion, not hue alone),
303
315
  per section 12 / WCAG 1.4.1. The one .wt-status-dot vocabulary is shared by
@@ -310,7 +322,7 @@
310
322
  stay clean, label-only; a progress-reporting agent (kiro-cli, Claude Code)
311
323
  lights up. The element stays in the DOM either way (JS sets data-status
312
324
  regardless), so revealing it is a pure CSS switch. */
313
- .wt-status-dot {
325
+ :where(.wt-root) .wt-status-dot {
314
326
  display: none;
315
327
  width: 9px;
316
328
  height: 9px;
@@ -327,11 +339,11 @@
327
339
  /* Reveal a dot only once its session has reported activity (the tabs feature
328
340
  adds .wt-reports per dot; see paintStatusDot). Without it the dot — and the
329
341
  whole activity vocabulary for that tab — stays invisible. */
330
- .wt-status-dot.wt-reports {
342
+ :where(.wt-root) .wt-status-dot.wt-reports {
331
343
  display: block;
332
344
  }
333
345
 
334
- .wt-status-dot[data-status="idle"] {
346
+ :where(.wt-root) .wt-status-dot[data-status="idle"] {
335
347
  background: transparent;
336
348
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
337
349
  }
@@ -341,7 +353,7 @@
341
353
  on every tab state.) Motion is the primary non-hue cue. Server-side this is
342
354
  driven by the OSC 9;4 progress signal, so it tracks agent activity, not the
343
355
  user typing at the prompt. */
344
- .wt-status-dot[data-status="working"] {
356
+ :where(.wt-root) .wt-status-dot[data-status="working"] {
345
357
  background: #fff;
346
358
  border-color: #fff;
347
359
  animation: wt-pulse 1.2s ease-in-out infinite;
@@ -349,7 +361,7 @@
349
361
 
350
362
  /* Agent done / ready: solid green, matching Kiro IDE's "Ready" (#22c55e), no
351
363
  motion. The filled dot distinguishes it from the hollow idle without hue. */
352
- .wt-status-dot[data-status="done"] {
364
+ :where(.wt-root) .wt-status-dot[data-status="done"] {
353
365
  background: #22c55e;
354
366
  border-color: #22c55e;
355
367
  }
@@ -357,14 +369,14 @@
357
369
  /* User action required (permission / approval): solid amber matching Kiro IDE's
358
370
  "waiting-approval" (#fbbf24), plus a ring so it reads as "needs you" via shape
359
371
  as well as hue. It persists until you act on or switch into the tab. */
360
- .wt-status-dot[data-status="input"] {
372
+ :where(.wt-root) .wt-status-dot[data-status="input"] {
361
373
  background: #fbbf24;
362
374
  border-color: #fbbf24;
363
375
  box-shadow: 0 0 0 2px oklch(85% 0.16 85deg / 40%);
364
376
  }
365
377
 
366
378
  /* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
367
- .wt-status-dot[data-status="exited"] {
379
+ :where(.wt-root) .wt-status-dot[data-status="exited"] {
368
380
  background: transparent;
369
381
  border: 1.5px solid color-mix(in oklch, var(--text) 30%, transparent);
370
382
  opacity: 0.5;
@@ -378,7 +390,7 @@
378
390
 
379
391
  /* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
380
392
  @media (prefers-reduced-motion: reduce) {
381
- .wt-status-dot[data-status="working"] {
393
+ :where(.wt-root) .wt-status-dot[data-status="working"] {
382
394
  animation: none;
383
395
  /* The pulse is working's ONLY non-hue cue; with motion removed,
384
396
  add a static shape cue (an inner hole -> a ring) so working differs
@@ -386,3 +398,30 @@
386
398
  box-shadow: inset 0 0 0 2px var(--bg);
387
399
  }
388
400
  }
401
+
402
+ /* ===== Catching-up cue (tabs feature, sections 12/13) =====
403
+ A switched-into tab's cached screen is stale until its resume delta lands, so
404
+ it must not read as live. This unobtrusive cue shows only if the delta has not
405
+ arrived shortly after the switch, and clears on the first screen frame.
406
+ Absolute against .wt-root, like all chrome. */
407
+ :where(.wt-root) .wt-catchup {
408
+ position: absolute;
409
+ top: calc(var(--safe-top, 0px) + var(--vv-top, 0px) + 44px);
410
+ left: 50%;
411
+ transform: translateX(-50%);
412
+ padding: 2px var(--sp-2);
413
+ background: var(--surface);
414
+ backdrop-filter: var(--blur);
415
+ border: 1px solid var(--border);
416
+ border-radius: var(--radius);
417
+ color: color-mix(in oklch, var(--text) 75%, transparent);
418
+ font-family: var(--font-ui);
419
+ font-size: 0.75rem;
420
+ z-index: 24;
421
+ pointer-events: none; /* passive status surface: never steal taps from the terminal beneath */
422
+ display: none;
423
+ }
424
+
425
+ :where(.wt-root) .wt-catchup.visible {
426
+ display: block;
427
+ }