@cplieger/web-terminal-ui 3.5.0 → 4.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.
Files changed (41) hide show
  1. package/README.md +60 -27
  2. package/css/00-tokens.css +18 -2
  3. package/css/01-scope.css +76 -0
  4. package/css/{02-app.css → 02-terminal.css} +113 -239
  5. package/css/10-primitives.css +98 -0
  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} +127 -66
  11. package/css/{06-mobile.css → 31-switcher.css} +85 -80
  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 +19 -6
  19. package/scaffold/index.html +5 -0
  20. package/src/features/activity-monitor.ts +32 -2
  21. package/src/features/connection-banner.ts +1 -0
  22. package/src/features/context-menu.ts +6 -33
  23. package/src/features/menu-position.ts +50 -0
  24. package/src/features/mobile-toolbar.ts +5 -4
  25. package/src/features/{tabs.ts → tabs/index.ts} +310 -721
  26. package/src/features/tabs/model.ts +342 -0
  27. package/src/features/tabs/strip.ts +101 -0
  28. package/src/features/tabs/switcher.ts +129 -0
  29. package/src/index.ts +1 -0
  30. package/src/kernel/a11y.ts +30 -5
  31. package/src/kernel/conn-state.ts +16 -6
  32. package/src/kernel/kernel.ts +170 -59
  33. package/src/kernel/types.ts +68 -15
  34. package/src/presets/agent-tabbed.ts +15 -0
  35. package/src/presets/single.ts +27 -0
  36. package/src/presets/tabbed.ts +42 -0
  37. package/src/presets/touch.ts +13 -0
  38. package/src/presets.ts +12 -76
  39. package/src/viewport.ts +28 -15
  40. package/css/03-primitives.css +0 -83
  41. package/css/04-feedback.css +0 -180
@@ -1,18 +1,23 @@
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). The bar itself never scrolls: the
17
+ tabs + "+" scroll inside .wt-tab-scroll (below), so the keyboard button —
18
+ the bar's last flex item, outside the scroller — stays pinned at the right
19
+ edge however many tabs are open. */
20
+ position: absolute;
16
21
  bottom: var(--safe-bottom);
17
22
  left: 0;
18
23
  right: 0;
@@ -24,17 +29,34 @@
24
29
  background: var(--surface);
25
30
  backdrop-filter: var(--blur);
26
31
  border-top: 1px solid var(--border);
27
- overflow-x: auto;
28
32
  z-index: 15;
29
33
  }
30
34
 
31
- .wt-tab {
35
+ /* The scrolling tab list inside the bar: the tabs plus the "+", which floats
36
+ right of the last tab and scrolls with the list. Horizontal scrolling lives
37
+ HERE, not on the bar, so the strip-end keyboard button never scrolls away.
38
+ flex: 1 + min-width: 0 make it shrink to leave room for the keyboard button
39
+ when that is shown (a wide touchscreen) and stretch to the full bar width
40
+ when it is hidden (a fine-pointer desktop), so the tabs reclaim the space. */
41
+ :where(.wt-root) .wt-tab-scroll {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: var(--sp-1);
45
+ flex: 1 1 auto;
46
+ min-width: 0;
47
+ overflow-x: auto;
48
+ /* A wheel/trackpad/touch scroll that runs out of tabs must not chain into
49
+ an embedding page (wt-container mode). */
50
+ overscroll-behavior-x: contain;
51
+ }
52
+
53
+ :where(.wt-root) .wt-tab {
32
54
  display: inline-flex;
33
55
  align-items: center;
34
56
  gap: var(--sp-1);
35
57
  /* Browser-style tabs: up to 300px per tab when there is room, shrinking toward
36
- a 100px floor as the strip fills; past the floor the bar scrolls horizontally
37
- (.wt-tab-bar has overflow-x: auto). flex-grow is 0, so the 300px basis is a
58
+ a 100px floor as the strip fills; past the floor the list scrolls horizontally
59
+ (.wt-tab-scroll has overflow-x: auto). flex-grow is 0, so the 300px basis is a
38
60
  hard cap — every tab is the same size and never stretches past it, whatever
39
61
  its title; the label truncates instead. box-sizing keeps the basis a true
40
62
  total. */
@@ -56,12 +78,12 @@
56
78
 
57
79
  /* Inactive tabs read as outlined chips with a subtle dark fill (so adjacent tabs
58
80
  stay separated from the bar and from each other) and lift on hover. */
59
- .wt-tab:not(.wt-tab-active) {
81
+ :where(.wt-root) .wt-tab:not(.wt-tab-active) {
60
82
  border-color: var(--border);
61
83
  background: var(--tab-bg);
62
84
  }
63
85
 
64
- .wt-tab:not(.wt-tab-active):hover {
86
+ :where(.wt-root) .wt-tab:not(.wt-tab-active):hover {
65
87
  /* A solid lift over the chip. --tab-hover-bg is a translucent wash meant for a
66
88
  transparent-rest control; replacing the --tab-bg chip with it would read as
67
89
  "more transparent", not "lighter", so bake the same 15% --text lift into the
@@ -73,7 +95,7 @@
73
95
  /* The active tab reads clearly above the inactive chips: a stronger fill plus an
74
96
  accent-coloured edge (inactive chips carry a faint white --border; the active
75
97
  one gets the accent equivalent). Themeable via --accent (web-terminal-kiro -> purple). */
76
- .wt-tab-active {
98
+ :where(.wt-root) .wt-tab-active {
77
99
  background: var(--tab-active-bg);
78
100
  border-color: var(--tab-active-border);
79
101
  color: var(--tab-active-fg);
@@ -81,7 +103,7 @@
81
103
 
82
104
  /* The label flexes to fill the fixed-width tab and truncates with an ellipsis
83
105
  (min-width: 0 lets a flex item shrink below its content width). */
84
- .wt-tab-label {
106
+ :where(.wt-root) .wt-tab-label {
85
107
  flex: 1 1 auto;
86
108
  min-width: 0;
87
109
  overflow: hidden;
@@ -90,14 +112,14 @@
90
112
  }
91
113
 
92
114
  /* A little breathing room after the activity dot, before the label. */
93
- .wt-tab-dot {
115
+ :where(.wt-root) .wt-tab-dot {
94
116
  margin-right: 2px;
95
117
  }
96
118
 
97
119
  /* The tab close (x): a centered, muted square icon button with a box on hover.
98
120
  Its glyph is an inline SVG (styled below), so flex-centering the button
99
121
  centers it exactly. */
100
- .wt-tab-close {
122
+ :where(.wt-root) .wt-tab-close {
101
123
  display: inline-flex;
102
124
  align-items: center;
103
125
  justify-content: center;
@@ -116,27 +138,28 @@
116
138
  cursor: pointer;
117
139
  }
118
140
 
119
- .wt-tab-close:hover {
141
+ :where(.wt-root) .wt-tab-close:hover {
120
142
  background: var(--text-active);
121
143
  color: var(--text);
122
144
  }
123
145
 
124
146
  /* On an inactive tab the close appears only on hover, kept in the layout via
125
147
  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 {
148
+ :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
127
149
  visibility: hidden;
128
150
  }
129
151
 
130
- .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
152
+ :where(.wt-root) .wt-tab:not(.wt-tab-active):hover .wt-tab-close {
131
153
  visibility: visible;
132
154
  }
133
155
 
134
156
  /* Coarse pointer (an iPad using the strip): no hover fires, so an inactive tab's close
135
157
  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 {
158
+ matching the always-visible mobile switcher closes. No narrow gate needed: on the
159
+ narrow-coarse phone the whole strip is display:none (31-switcher.css), so this rule
160
+ is moot there. */
161
+ @media (pointer: coarse) {
162
+ :where(.wt-root) .wt-tab:not(.wt-tab-active) .wt-tab-close {
140
163
  visibility: visible;
141
164
  }
142
165
  }
@@ -144,7 +167,7 @@
144
167
  /* The add-tab (+) button: its hover box matches an active tab's fill and
145
168
  stretches to the strip height; the 2px strip gap (not a custom margin) spaces
146
169
  it from the last tab. Its glyph is an inline SVG. */
147
- .wt-tab-new {
170
+ :where(.wt-root) .wt-tab-new {
148
171
  display: inline-flex;
149
172
  align-items: center;
150
173
  justify-content: center;
@@ -166,7 +189,7 @@
166
189
  cursor: pointer;
167
190
  }
168
191
 
169
- .wt-tab-new:hover {
192
+ :where(.wt-root) .wt-tab-new:hover {
170
193
  /* Keep the accent glyph on hover (no colour override) so the "+" stays the
171
194
  accent colour like the mobile "+"; hover only adds the fill. */
172
195
  background: var(--tab-active-bg);
@@ -175,8 +198,8 @@
175
198
  /* The +/x icons are inline SVG so they center exactly in their flex-centered
176
199
  button and stay symmetric regardless of the UI font (fixes the off-center
177
200
  glyph and the lopsided "+"). The + is a touch larger and bolder than the x. */
178
- .wt-tab-close svg,
179
- .wt-tab-new svg {
201
+ :where(.wt-root) .wt-tab-close svg,
202
+ :where(.wt-root) .wt-tab-new svg {
180
203
  stroke: currentcolor;
181
204
  fill: none;
182
205
  stroke-width: 2.5;
@@ -184,12 +207,12 @@
184
207
  stroke-linejoin: round;
185
208
  }
186
209
 
187
- .wt-tab-close svg {
210
+ :where(.wt-root) .wt-tab-close svg {
188
211
  width: 13px;
189
212
  height: 13px;
190
213
  }
191
214
 
192
- .wt-tab-new svg {
215
+ :where(.wt-root) .wt-tab-new svg {
193
216
  width: 16px;
194
217
  height: 16px;
195
218
  }
@@ -199,29 +222,31 @@
199
222
  landscape), where there is no mobile switcher to carry one. Built + wired by
200
223
  the SAME factory as the mobile switcher's keyboard button (see makeKbButton
201
224
  in tabs.ts). Uses the shared .wt-btn primitive for its 44px box + centered
202
- icon, borderless like the switcher's — the old fixed-overlay styling gave it
203
- a border and pinned it right of the "+", which read as a different control
204
- the moment a phone rotated to landscape. Now an ordinary flex item inside the
205
- strip, before the "+" (kb-then-new, mirroring the mobile switcher's order).
206
- Hidden on a fine-pointer desktop (a real keyboard is present) and until a
207
- 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 {
225
+ icon, borderless like the switcher's. The bar's LAST flex item, OUTSIDE the
226
+ tab scroller: pinned at the bar's right edge in the same column as the
227
+ scroll-to-bottom button floating above it (both 44px boxes whose right edge
228
+ sits --sp-2 from the root's right edge), where an overflowing tab list can
229
+ never push or scroll it away; the "+" stays with the tabs inside the
230
+ scroller. Hidden on a fine-pointer desktop (a real keyboard is present; the
231
+ scroller then stretches to the full bar width) and until a keyboardToggle
232
+ feature is wired ([hidden]); shown only on a coarse pointer at strip widths
233
+ (the .wt-narrow complement below; the breakpoint itself is the kernel's,
234
+ mirrored as the root class). */
235
+ :where(.wt-root) .wt-tab-kb {
211
236
  display: none;
212
237
  flex: none;
213
238
  }
214
239
 
215
240
  /* Grid open: filled so the toggled state reads at a glance (mirrors the
216
241
  switcher keyboard button). */
217
- .wt-tab-kb.wt-active {
242
+ :where(.wt-root) .wt-tab-kb.wt-active {
218
243
  background: var(--tab-hover-bg);
219
244
  color: var(--tab-active-fg);
220
245
  }
221
246
 
222
247
  /* Sticky-Ctrl armed: invert (matches the switcher keyboard button + the armed
223
248
  Ctrl key) so a pending Ctrl is visible with the grid closed. */
224
- .wt-tab-kb.wt-armed {
249
+ :where(.wt-root) .wt-tab-kb.wt-armed {
225
250
  background: var(--text);
226
251
  color: var(--bg);
227
252
  }
@@ -229,14 +254,14 @@
229
254
  /* Show only on a WIDE touchscreen (where the desktop strip is used AND a
230
255
  keyboardToggle is wired, hence :not([hidden])). In-flow now, so the strip
231
256
  needs no reserved right padding for it. */
232
- @media (pointer: coarse) and (width >= 601px) {
233
- .wt-tab-kb:not([hidden]) {
257
+ @media (pointer: coarse) {
258
+ :where(.wt-root:not(.wt-narrow)) .wt-tab-kb:not([hidden]) {
234
259
  display: inline-flex;
235
260
  }
236
261
  }
237
262
 
238
263
  /* The tab being dragged (reorder) dims while it moves. */
239
- .wt-tab-dragging {
264
+ :where(.wt-root) .wt-tab-dragging {
240
265
  opacity: 0.5;
241
266
  }
242
267
 
@@ -244,8 +269,10 @@
244
269
  Close to the right / Close to the left / Close all. Replaces the old bar
245
270
  "Close all" button. Mirrors .wt-ctx-menu (a fixed surface popover, clamped
246
271
  into the viewport by the feature); disabled items are dimmed and inert. */
247
- .wt-tab-menu {
248
- position: fixed;
272
+ :where(.wt-root) .wt-tab-menu {
273
+ /* Absolute against .wt-root; placed by the shared point-anchored positioner
274
+ (menu-position.ts), which converts to root-relative offsets. */
275
+ position: absolute;
249
276
  display: none;
250
277
  flex-direction: column;
251
278
  background: var(--surface);
@@ -256,11 +283,11 @@
256
283
  z-index: 30;
257
284
  }
258
285
 
259
- .wt-tab-menu.visible {
286
+ :where(.wt-root) .wt-tab-menu.visible {
260
287
  display: flex;
261
288
  }
262
289
 
263
- .wt-tab-menu button {
290
+ :where(.wt-root) .wt-tab-menu button {
264
291
  background: transparent;
265
292
  color: var(--text);
266
293
  border: none;
@@ -274,30 +301,37 @@
274
301
  white-space: nowrap;
275
302
  }
276
303
 
277
- .wt-tab-menu button:not(:disabled):hover {
304
+ /* :enabled (not :not(:disabled)) keeps the pseudo-class count within the
305
+ stylelint budget alongside the zero-specificity scope. */
306
+ :where(.wt-root) .wt-tab-menu button:enabled:hover {
278
307
  background: var(--text-active);
279
308
  }
280
309
 
281
- .wt-tab-menu button:disabled {
310
+ :where(.wt-root) .wt-tab-menu button:disabled {
282
311
  color: color-mix(in oklch, var(--text) 35%, transparent);
283
312
  cursor: default;
284
313
  }
285
314
 
286
- /* Pull the terminal surface up off the fixed BOTTOM tab strip so it does not
315
+ /* Pull the terminal surface up off the docked BOTTOM tab strip so it does not
287
316
  overlap the last rows (a mirror of the old top offset). The tabs feature
288
317
  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 {
318
+ --wt-tabbar-h; the surface is absolute inset:0, so a bottom offset clears it.
319
+ Applies whenever the desktop strip is shown — a fine pointer OR a root that
320
+ is not narrow (an iPad) — the complement of the narrow-coarse switcher gate
321
+ in css/31-switcher.css. On the narrow-coarse phone the strip is hidden and
322
+ the mobile switcher's inset is applied through viewport.ts
323
+ (--wt-reserve-bottom) instead. Two rules because the class half cannot mix
324
+ into a media condition; they set the same declaration. */
325
+ @media (pointer: fine) {
326
+ :where(.wt-root) .term.wt-with-tabbar {
297
327
  bottom: var(--wt-tabbar-h, 44px);
298
328
  }
299
329
  }
300
330
 
331
+ :where(.wt-root:not(.wt-narrow)) .term.wt-with-tabbar {
332
+ bottom: var(--wt-tabbar-h, 44px);
333
+ }
334
+
301
335
  /* ===== Activity dots (activityMonitor via tabs) =====
302
336
  Four-state, distinguishable without color (shape + motion, not hue alone),
303
337
  per section 12 / WCAG 1.4.1. The one .wt-status-dot vocabulary is shared by
@@ -310,7 +344,7 @@
310
344
  stay clean, label-only; a progress-reporting agent (kiro-cli, Claude Code)
311
345
  lights up. The element stays in the DOM either way (JS sets data-status
312
346
  regardless), so revealing it is a pure CSS switch. */
313
- .wt-status-dot {
347
+ :where(.wt-root) .wt-status-dot {
314
348
  display: none;
315
349
  width: 9px;
316
350
  height: 9px;
@@ -327,11 +361,11 @@
327
361
  /* Reveal a dot only once its session has reported activity (the tabs feature
328
362
  adds .wt-reports per dot; see paintStatusDot). Without it the dot — and the
329
363
  whole activity vocabulary for that tab — stays invisible. */
330
- .wt-status-dot.wt-reports {
364
+ :where(.wt-root) .wt-status-dot.wt-reports {
331
365
  display: block;
332
366
  }
333
367
 
334
- .wt-status-dot[data-status="idle"] {
368
+ :where(.wt-root) .wt-status-dot[data-status="idle"] {
335
369
  background: transparent;
336
370
  border: 1.5px solid color-mix(in oklch, var(--text) 45%, transparent);
337
371
  }
@@ -341,7 +375,7 @@
341
375
  on every tab state.) Motion is the primary non-hue cue. Server-side this is
342
376
  driven by the OSC 9;4 progress signal, so it tracks agent activity, not the
343
377
  user typing at the prompt. */
344
- .wt-status-dot[data-status="working"] {
378
+ :where(.wt-root) .wt-status-dot[data-status="working"] {
345
379
  background: #fff;
346
380
  border-color: #fff;
347
381
  animation: wt-pulse 1.2s ease-in-out infinite;
@@ -349,7 +383,7 @@
349
383
 
350
384
  /* Agent done / ready: solid green, matching Kiro IDE's "Ready" (#22c55e), no
351
385
  motion. The filled dot distinguishes it from the hollow idle without hue. */
352
- .wt-status-dot[data-status="done"] {
386
+ :where(.wt-root) .wt-status-dot[data-status="done"] {
353
387
  background: #22c55e;
354
388
  border-color: #22c55e;
355
389
  }
@@ -357,14 +391,14 @@
357
391
  /* User action required (permission / approval): solid amber matching Kiro IDE's
358
392
  "waiting-approval" (#fbbf24), plus a ring so it reads as "needs you" via shape
359
393
  as well as hue. It persists until you act on or switch into the tab. */
360
- .wt-status-dot[data-status="input"] {
394
+ :where(.wt-root) .wt-status-dot[data-status="input"] {
361
395
  background: #fbbf24;
362
396
  border-color: #fbbf24;
363
397
  box-shadow: 0 0 0 2px oklch(85% 0.16 85deg / 40%);
364
398
  }
365
399
 
366
400
  /* Exited: a dim hollow dot, static; distinct from idle by its lower opacity. */
367
- .wt-status-dot[data-status="exited"] {
401
+ :where(.wt-root) .wt-status-dot[data-status="exited"] {
368
402
  background: transparent;
369
403
  border: 1.5px solid color-mix(in oklch, var(--text) 30%, transparent);
370
404
  opacity: 0.5;
@@ -378,7 +412,7 @@
378
412
 
379
413
  /* Reduced-motion (or the animations feature off): drop the pulse, keep color. */
380
414
  @media (prefers-reduced-motion: reduce) {
381
- .wt-status-dot[data-status="working"] {
415
+ :where(.wt-root) .wt-status-dot[data-status="working"] {
382
416
  animation: none;
383
417
  /* The pulse is working's ONLY non-hue cue; with motion removed,
384
418
  add a static shape cue (an inner hole -> a ring) so working differs
@@ -386,3 +420,30 @@
386
420
  box-shadow: inset 0 0 0 2px var(--bg);
387
421
  }
388
422
  }
423
+
424
+ /* ===== Catching-up cue (tabs feature, sections 12/13) =====
425
+ A switched-into tab's cached screen is stale until its resume delta lands, so
426
+ it must not read as live. This unobtrusive cue shows only if the delta has not
427
+ arrived shortly after the switch, and clears on the first screen frame.
428
+ Absolute against .wt-root, like all chrome. */
429
+ :where(.wt-root) .wt-catchup {
430
+ position: absolute;
431
+ top: calc(var(--safe-top, 0px) + var(--vv-top, 0px) + 44px);
432
+ left: 50%;
433
+ transform: translateX(-50%);
434
+ padding: 2px var(--sp-2);
435
+ background: var(--surface);
436
+ backdrop-filter: var(--blur);
437
+ border: 1px solid var(--border);
438
+ border-radius: var(--radius);
439
+ color: color-mix(in oklch, var(--text) 75%, transparent);
440
+ font-family: var(--font-ui);
441
+ font-size: 0.75rem;
442
+ z-index: 24;
443
+ pointer-events: none; /* passive status surface: never steal taps from the terminal beneath */
444
+ display: none;
445
+ }
446
+
447
+ :where(.wt-root) .wt-catchup.visible {
448
+ display: block;
449
+ }