@cplieger/web-terminal-ui 5.0.0 → 5.0.2

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.
@@ -42,6 +42,33 @@
42
42
  background: var(--text-active);
43
43
  }
44
44
 
45
+ /* ===== Hover gating: any-hover, never hover =====
46
+ Every gated hover rule in this package uses @media (any-hover: hover). Do not
47
+ write @media (hover: hover) here.
48
+
49
+ What the gate is for: a bare touchscreen fires no pointer-leave, so a tapped
50
+ control keeps its :hover wash until the next tap somewhere else. What went
51
+ wrong: `hover` and `pointer` describe only the PRIMARY input, and iPadOS
52
+ reports hover: none / pointer: coarse whether or not a Magic Keyboard trackpad
53
+ is attached. So (hover: hover) is false on every touch-primary device,
54
+ INCLUDING the ones driving a real pointer.
55
+
56
+ For the coarse-only controls that made the gate not a tradeoff but a deletion,
57
+ and it shipped that way: .wt-tab-kb renders only under @media (pointer: coarse)
58
+ (30-tabs.css) and the whole .wt-switcher subtree only under (pointer: coarse)
59
+ plus .wt-narrow (31-switcher.css). Pairing either with (hover: hover) makes the
60
+ two conditions complementary, so the hover rule was unreachable on every device
61
+ that exists. Measured against the deployed bundle at iPad width: the keyboard
62
+ button is a visible 44x44 while a forced :hover leaves its background at
63
+ rgba(0, 0, 0, 0), where the ungated .wt-tab-new beside it resolves to
64
+ --tab-active-bg.
65
+
66
+ `any-hover` asks the question the gate meant to ask — can ANY attached device
67
+ hover — so a bare iPhone/iPad still answers none (no wash, nothing to stick)
68
+ while a trackpad or Pencil answers hover and brings a real pointer-leave with
69
+ it. Same defect class as Tailwind's hoverOnlyWhenSupported
70
+ (tailwindlabs/tailwindcss#11109). */
71
+
45
72
  /* Keyboard-focus ring for every chrome control (all interactive chrome is a
46
73
  real <button>: tabs, +, keyboard, switcher rows, menu items, the FAB).
47
74
  :focus-visible only — a tap/click never shows the ring (the UA heuristic),
@@ -147,7 +174,7 @@
147
174
  font-weight: 600;
148
175
  }
149
176
 
150
- @media (hover: hover) {
177
+ @media (any-hover: hover) {
151
178
  :where(.wt-root) .wt-fatal-reload:hover {
152
179
  background: color-mix(in oklch, var(--accent), var(--text) 12%);
153
180
  }
package/css/30-tabs.css CHANGED
@@ -75,6 +75,22 @@
75
75
  width: 300px;
76
76
  flex: 0 1 auto;
77
77
  min-width: 100px;
78
+ /* ONE height for every control in the strip: the chip, the "+" and the
79
+ keyboard button all resolve to --touch-target, so a chip and a button can
80
+ never disagree about how tall the row is. Before this the chip's height was
81
+ whatever its tallest child happened to be, and the "+" hard-coded a COPY of
82
+ that arithmetic (close button + padding + border) — two sources of truth
83
+ for one row, which drifted the moment a child's box grew: a browser without
84
+ text-box support kept the label's untrimmed line box (31px, past the 24px
85
+ close button), so the chip rendered 49px tall next to a 42px "+" and a 44px
86
+ keyboard button. --touch-target is also the tap-target floor the rest of
87
+ the chrome already respects, including this chip's mobile counterpart
88
+ (.wt-switcher-current in 31-switcher.css, same declaration).
89
+
90
+ min-height, not height: the floor governs today (the tallest child is the
91
+ 1.5rem close button, which fits with room to spare), and taller future
92
+ content grows the row rather than overflowing a fixed box. */
93
+ min-height: var(--touch-target);
78
94
  padding: var(--sp-2);
79
95
  /* A transparent border is reserved on every tab so toggling the inactive
80
96
  outline (below) never shifts the strip's layout. */
@@ -112,9 +128,8 @@
112
128
 
113
129
  /* Press (mousedown / touch-hold): one rung past the hover lift — same recipe,
114
130
  deeper mix — so hover -> press reads as a single dimension. After the hover
115
- rule so it wins while both apply. Scoped like hover: clicking the active tab
116
- selects nothing (it is already active), so it presses like the static surface
117
- it is. */
131
+ rule so it wins while both apply. The active chip has its own pair (above),
132
+ mixed against the active fill instead of the inactive one. */
118
133
  :where(.wt-root) .wt-tab:not(.wt-tab-active):active {
119
134
  background: color-mix(in oklch, var(--tab-bg), var(--text) 25%);
120
135
  color: var(--tab-active-fg);
@@ -129,6 +144,35 @@
129
144
  color: var(--tab-active-fg);
130
145
  }
131
146
 
147
+ /* The active tab answers a press too. Clicking it selects nothing, which is why
148
+ it was originally left inert — but selection is not the only thing a chip does:
149
+ it is a drag handle for reordering, a right-click target for the tab menu, a
150
+ double-tap target for rename, and it carries a close button. A control with
151
+ four affordances and no pointer feedback reads as decoration, and it was the
152
+ only chip in the strip that gave none (the same gap that got the keyboard
153
+ button its hover wash).
154
+
155
+ The lift is mixed with --tab-active-fg rather than --text: this is a lighter
156
+ press over an already-filled control (the .wt-switcher-current / .wt-tab-new
157
+ recipe), not a wash over a transparent one, so it follows the filled-surface
158
+ vocabulary and stays themeable through one token. It is a rung subtler than the
159
+ inactive chip's 15%/25% because it starts from an already-lifted fill.
160
+
161
+ Hover is gated (see the any-hover note in 10-primitives.css). Tapping an
162
+ INACTIVE chip makes it active, so a touchscreen's sticky :hover leaves with the
163
+ class; tapping the ACTIVE chip changes nothing, so an ungated hover would stay
164
+ lit until the next tap somewhere else. Touch feedback there is :active, which is
165
+ correct. */
166
+ @media (any-hover: hover) {
167
+ :where(.wt-root) .wt-tab-active:hover {
168
+ background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 12%);
169
+ }
170
+ }
171
+
172
+ :where(.wt-root) .wt-tab-active:active {
173
+ background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 22%);
174
+ }
175
+
132
176
  /* The label flexes to fill the fixed-width tab and truncates with an ellipsis
133
177
  (min-width: 0 lets a flex item shrink below its content width). */
134
178
  :where(.wt-root) .wt-tab-label {
@@ -137,7 +181,9 @@
137
181
  overflow: hidden;
138
182
  white-space: nowrap;
139
183
  text-overflow: ellipsis;
140
- /* Center the INK, not the line box: UI fonts carry far more ascent than
184
+ }
185
+
186
+ /* Center the INK, not the line box: UI fonts carry far more ascent than
141
187
  descent (the -apple-system/Noto Sans stack at 13px: 14px over / 4px under
142
188
  the baseline), so the flex-centered line box leaves the visible text ~1.5px
143
189
  low — reading as baseline-aligned with the activity dot's bottom instead of
@@ -159,15 +205,26 @@
159
205
  measured: 3.3px of p/y/g/j tail and 2.3px of an acute accent, i.e. "pygmy
160
206
  jaguar Ápple" rendered as "pyqmy iaquar Apple"). 0.5em restores the ink
161
207
  overhang the trim removes from the box; being symmetric it does not move
162
- the centered band, and being em-relative it tracks the font size. The
163
- grown box (~20px) still sits inside the close button's 1.5rem, so the tab
164
- height is unchanged. A browser without text-box support keeps the untrimmed
165
- box, where the padding is harmless slack.
208
+ the centered band, and being em-relative it tracks the font size.
209
+
210
+ The pair is @supports-GATED because the padding is the trim's allowance and
211
+ nothing else: an untrimmed line box already contains that overhang, so
212
+ adding the padding there is not "harmless slack" (this comment used to
213
+ claim it was) — it grew the label's box to 31px, past the 24px close button,
214
+ and a browser without text-box support rendered 49px tab chips beside a
215
+ 42px "+" and a 44px keyboard button. The chip's own --touch-target floor
216
+ pins the row height either way; the gate is what keeps the label from
217
+ pushing PAST it. Gating the shorthand (not the longhands) is deliberate: an
218
+ engine that ships text-box-trim without the shorthand does not apply the
219
+ declaration we actually write, so it must take the untrimmed branch too.
166
220
 
167
221
  Shared by all three chip labels (this one + the two switcher labels in
168
222
  31-switcher.css). */
169
- text-box: trim-both ex alphabetic;
170
- padding-block: 0.5em;
223
+ @supports (text-box: trim-both ex alphabetic) {
224
+ :where(.wt-root) .wt-tab-label {
225
+ text-box: trim-both ex alphabetic;
226
+ padding-block: 0.5em;
227
+ }
171
228
  }
172
229
 
173
230
  /* A little breathing room after the activity dot, before the label. */
@@ -241,12 +298,16 @@
241
298
  align-items: center;
242
299
  justify-content: center;
243
300
  flex: none;
244
- /* Square box that matches the tab height (a stretched flex item ignores
245
- aspect-ratio against its SVG's intrinsic width, so size it explicitly). The
246
- tab height is the close-button (1.5rem) + vertical padding (2×sp-2) +
247
- border. */
248
- width: calc(1.5rem + 2 * var(--sp-2) + 2px);
249
- height: calc(1.5rem + 2 * var(--sp-2) + 2px);
301
+ /* A SQUARE box of exactly the strip's control height, so it lines up with the
302
+ tab chips beside it and with the keyboard button after it (the .wt-btn
303
+ primitive's 44px box, same token). Sized explicitly on both axes rather
304
+ than by aspect-ratio: a stretched flex item ignores aspect-ratio against
305
+ its SVG's intrinsic width. It used to restate the chip's height arithmetic
306
+ (close button + padding + border) as a calc(), which is how it ended up
307
+ 7px short of a chip whose content grew — see the min-height note on
308
+ .wt-tab. */
309
+ width: var(--touch-target);
310
+ height: var(--touch-target);
250
311
  padding: 0;
251
312
  background: transparent;
252
313
  border: none;
@@ -322,13 +383,15 @@
322
383
 
323
384
  /* Hover wash, matching every other bar control (.wt-tab-new, the switcher's
324
385
  buttons) — without it this was the one control in the chrome that gave no
325
- pointer feedback at all. Gated to real hover pointers because the button only
326
- SHOWS on a coarse pointer (the iPad strip): a plain touchscreen has no
327
- pointer-leave, so :hover would stick after a tap and the wash would linger;
328
- the feedback there is :active, from .wt-btn. Before .wt-active / .wt-armed so
329
- those toggled fills win on a hovered button (equal specificity, source order
330
- decides). */
331
- @media (hover: hover) {
386
+ pointer feedback at all. Gated on any-hover, NOT hover: this button renders only
387
+ under (pointer: coarse) below, and a touch-primary device reports hover: none
388
+ even with a trackpad attached, so a (hover: hover) gate here made the wash
389
+ unreachable on every device rather than restricting it to hovering ones (the
390
+ note in 10-primitives.css has the measurement). On a bare touchscreen any-hover
391
+ is none, so the feedback there is still :active, from .wt-btn. Before
392
+ .wt-active / .wt-armed so those toggled fills win on a hovered button (equal
393
+ specificity, source order decides). */
394
+ @media (any-hover: hover) {
332
395
  :where(.wt-root) .wt-tab-kb:hover {
333
396
  background: var(--tab-hover-bg);
334
397
  }
@@ -148,7 +148,7 @@
148
148
  color: color-mix(in oklch, var(--text) 55%, transparent);
149
149
  }
150
150
 
151
- @media (hover: hover) {
151
+ @media (any-hover: hover) {
152
152
  :where(.wt-root) .wt-switcher-current-close:hover {
153
153
  color: var(--text);
154
154
  }
@@ -178,11 +178,16 @@
178
178
  text-overflow: ellipsis;
179
179
  white-space: nowrap;
180
180
  text-align: left;
181
- /* Ink-centering trim + its mandatory descender allowance, same as
182
- .wt-tab-label (see 30-tabs.css for why `ex` and why the padding is not
183
- optional). */
184
- text-box: trim-both ex alphabetic;
185
- padding-block: 0.5em;
181
+ }
182
+
183
+ /* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
184
+ (see 30-tabs.css for why `ex`, why the padding is not optional, and why the
185
+ pair is @supports-gated rather than left to fall back). */
186
+ @supports (text-box: trim-both ex alphabetic) {
187
+ :where(.wt-root) .wt-switcher-label {
188
+ text-box: trim-both ex alphabetic;
189
+ padding-block: 0.5em;
190
+ }
186
191
  }
187
192
 
188
193
  /* Keyboard button: hidden until a keyboardToggle is wired; filled while the key
@@ -211,10 +216,11 @@
211
216
  overflow: hidden;
212
217
  }
213
218
 
214
- /* Hover wash like the switch and "+" buttons beside it; gated to real hover
215
- pointers for the same reason they are (a sticky :hover after a tap). Before
216
- .wt-active / .wt-armed so those fills win on a hovered button. */
217
- @media (hover: hover) {
219
+ /* Hover wash like the switch and "+" buttons beside it; gated on any-hover for
220
+ the same reason they are (see the note in 10-primitives.css — this whole
221
+ subtree renders only on a coarse pointer, where hover: hover is always false).
222
+ Before .wt-active / .wt-armed so those fills win on a hovered button. */
223
+ @media (any-hover: hover) {
218
224
  :where(.wt-root) .wt-switcher-kb:hover {
219
225
  background: var(--tab-hover-bg);
220
226
  }
@@ -287,7 +293,7 @@
287
293
  pointer-events: none;
288
294
  }
289
295
 
290
- @media (hover: hover) {
296
+ @media (any-hover: hover) {
291
297
  :where(.wt-root) .wt-switcher-switch:hover {
292
298
  background: var(--tab-hover-bg);
293
299
  }
@@ -315,15 +321,17 @@
315
321
  }
316
322
 
317
323
  /* Mobile "+": the primary action, carried in the accent colour so it reads as
318
- permanent and prominent. Hover fill is gated to real hover pointers: on touch,
319
- :hover sticks after a tap (no pointer-leave), so the purple would linger — the
320
- press feedback there comes from :active, which clears on release. */
324
+ permanent and prominent. Hover fill is gated on any-hover (see the note in
325
+ 10-primitives.css): on a bare touchscreen any-hover is none, so the purple
326
+ cannot linger after a tap and the press feedback comes from :active; a trackpad
327
+ or Pencil reports hover and gets the wash, which a (hover: hover) gate denied it.
328
+ */
321
329
  :where(.wt-root) .wt-switcher-new {
322
330
  flex: none;
323
331
  color: var(--accent);
324
332
  }
325
333
 
326
- @media (hover: hover) {
334
+ @media (any-hover: hover) {
327
335
  :where(.wt-root) .wt-switcher-new:hover {
328
336
  background: var(--tab-hover-bg);
329
337
  }
@@ -413,7 +421,7 @@
413
421
  overflow: hidden;
414
422
  }
415
423
 
416
- @media (hover: hover) {
424
+ @media (any-hover: hover) {
417
425
  :where(.wt-root) .wt-switcher-row-select:hover {
418
426
  background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
419
427
  }
@@ -432,11 +440,16 @@
432
440
  overflow: hidden;
433
441
  text-overflow: ellipsis;
434
442
  white-space: nowrap;
435
- /* Ink-centering trim + its mandatory descender allowance, same as
436
- .wt-tab-label (see 30-tabs.css for why `ex` and why the padding is not
437
- optional). */
438
- text-box: trim-both ex alphabetic;
439
- padding-block: 0.5em;
443
+ }
444
+
445
+ /* Ink-centering trim + its mandatory descender allowance, same as .wt-tab-label
446
+ (see 30-tabs.css for why `ex`, why the padding is not optional, and why the
447
+ pair is @supports-gated rather than left to fall back). */
448
+ @supports (text-box: trim-both ex alphabetic) {
449
+ :where(.wt-root) .wt-switcher-row-label {
450
+ text-box: trim-both ex alphabetic;
451
+ padding-block: 0.5em;
452
+ }
440
453
  }
441
454
 
442
455
  /* The close sits flush at the row's right edge (no gap), vertically centered. */
@@ -448,10 +461,13 @@
448
461
  color: color-mix(in oklch, var(--text) 55%, transparent);
449
462
  }
450
463
 
451
- /* Hover only on real hover pointers: on touch, :hover sticks after a tap, so
452
- when closing a tab shifts the list under the finger the newly-under close
453
- would latch white. Touch feedback comes from the surrounding row's :active. */
454
- @media (hover: hover) {
464
+ /* Hover gated on any-hover (see the note in 10-primitives.css): on a bare
465
+ touchscreen any-hover is none, so when closing a tab shifts the list under the
466
+ finger the newly-under close cannot latch white, and the feedback comes from the
467
+ surrounding row's :active. A trackpad or Pencil reports hover and gets the
468
+ brighten, which a (hover: hover) gate denied it — this subtree only ever renders
469
+ on a coarse-primary device. */
470
+ @media (any-hover: hover) {
455
471
  :where(.wt-root) .wt-switcher-row-close:hover {
456
472
  color: var(--text);
457
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -2445,8 +2445,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2445
2445
  void close_(activeId);
2446
2446
  }
2447
2447
  });
2448
- // Dismiss the tab context menu on an outside click, on Escape, and on
2449
- // scroll (it is anchored to a viewport point, so a scroll would detach it).
2448
+ // Dismiss the tab context menu on an outside click, on Escape, and on a
2449
+ // scroll of the strip itself (which moves the chip the menu is anchored to).
2450
2450
  const onDocClickMenu = (): void => {
2451
2451
  // The trailing click of the long-press that just opened the menu is that
2452
2452
  // gesture's own release, not a click-away.
@@ -2466,8 +2466,23 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2466
2466
  }
2467
2467
  };
2468
2468
  document.addEventListener("contextmenu", onDocContextMenu);
2469
- const onScrollMenu = (): void => {
2470
- hideTabMenu();
2469
+ // Only a scroll that moves the menu's ANCHOR dismisses it, which is the
2470
+ // strip's own horizontal scroller and nothing else: the menu is placed
2471
+ // root-relative (menu-position.ts rebases onto the offsetParent), so any
2472
+ // scroll that moves .wt-root moves the menu with it, and the terminal's
2473
+ // scroller does not move the chip at all.
2474
+ //
2475
+ // The target check is load-bearing, not a micro-optimization. This listener
2476
+ // is capture-phase on window, so it sees EVERY scroll in the document —
2477
+ // including the terminal surface auto-scrolling to the bottom on each chunk
2478
+ // of output. Without the check, an agent printing into the TUI closed the
2479
+ // tab menu within a frame of it opening, which is unusable on exactly the
2480
+ // screen the menu exists for (a busy multi-agent strip).
2481
+ const onScrollMenu = (e: Event): void => {
2482
+ const target = e.target;
2483
+ if (target instanceof Element && target.closest(".wt-tab-scroll")) {
2484
+ hideTabMenu();
2485
+ }
2471
2486
  };
2472
2487
  window.addEventListener("scroll", onScrollMenu, true);
2473
2488
  const offMenuKey = ctx.registerKeydown((ev) => {