@cplieger/web-terminal-ui 5.0.1 → 5.0.3

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
@@ -158,11 +158,12 @@
158
158
  vocabulary and stays themeable through one token. It is a rung subtler than the
159
159
  inactive chip's 15%/25% because it starts from an already-lifted fill.
160
160
 
161
- Hover is gated to real hover pointers. Tapping an INACTIVE chip makes it
162
- active, so a touchscreen's sticky :hover leaves with the class; tapping the
163
- ACTIVE chip changes nothing, so an ungated hover would stay lit until the next
164
- tap somewhere else. Touch feedback there is :active, which is correct. */
165
- @media (hover: hover) {
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) {
166
167
  :where(.wt-root) .wt-tab-active:hover {
167
168
  background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 12%);
168
169
  }
@@ -382,13 +383,15 @@
382
383
 
383
384
  /* Hover wash, matching every other bar control (.wt-tab-new, the switcher's
384
385
  buttons) — without it this was the one control in the chrome that gave no
385
- pointer feedback at all. Gated to real hover pointers because the button only
386
- SHOWS on a coarse pointer (the iPad strip): a plain touchscreen has no
387
- pointer-leave, so :hover would stick after a tap and the wash would linger;
388
- the feedback there is :active, from .wt-btn. Before .wt-active / .wt-armed so
389
- those toggled fills win on a hovered button (equal specificity, source order
390
- decides). */
391
- @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) {
392
395
  :where(.wt-root) .wt-tab-kb:hover {
393
396
  background: var(--tab-hover-bg);
394
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
  }
@@ -216,10 +216,11 @@
216
216
  overflow: hidden;
217
217
  }
218
218
 
219
- /* Hover wash like the switch and "+" buttons beside it; gated to real hover
220
- pointers for the same reason they are (a sticky :hover after a tap). Before
221
- .wt-active / .wt-armed so those fills win on a hovered button. */
222
- @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) {
223
224
  :where(.wt-root) .wt-switcher-kb:hover {
224
225
  background: var(--tab-hover-bg);
225
226
  }
@@ -292,7 +293,7 @@
292
293
  pointer-events: none;
293
294
  }
294
295
 
295
- @media (hover: hover) {
296
+ @media (any-hover: hover) {
296
297
  :where(.wt-root) .wt-switcher-switch:hover {
297
298
  background: var(--tab-hover-bg);
298
299
  }
@@ -320,15 +321,17 @@
320
321
  }
321
322
 
322
323
  /* Mobile "+": the primary action, carried in the accent colour so it reads as
323
- permanent and prominent. Hover fill is gated to real hover pointers: on touch,
324
- :hover sticks after a tap (no pointer-leave), so the purple would linger — the
325
- 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
+ */
326
329
  :where(.wt-root) .wt-switcher-new {
327
330
  flex: none;
328
331
  color: var(--accent);
329
332
  }
330
333
 
331
- @media (hover: hover) {
334
+ @media (any-hover: hover) {
332
335
  :where(.wt-root) .wt-switcher-new:hover {
333
336
  background: var(--tab-hover-bg);
334
337
  }
@@ -418,7 +421,7 @@
418
421
  overflow: hidden;
419
422
  }
420
423
 
421
- @media (hover: hover) {
424
+ @media (any-hover: hover) {
422
425
  :where(.wt-root) .wt-switcher-row-select:hover {
423
426
  background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
424
427
  }
@@ -458,10 +461,13 @@
458
461
  color: color-mix(in oklch, var(--text) 55%, transparent);
459
462
  }
460
463
 
461
- /* Hover only on real hover pointers: on touch, :hover sticks after a tap, so
462
- when closing a tab shifts the list under the finger the newly-under close
463
- would latch white. Touch feedback comes from the surrounding row's :active. */
464
- @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) {
465
471
  :where(.wt-root) .wt-switcher-row-close:hover {
466
472
  color: var(--text);
467
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.0.1",
3
+ "version": "5.0.3",
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",
@@ -52,7 +52,7 @@
52
52
  "@types/node": "25.9.5",
53
53
  "@typescript/native": "npm:typescript@7.0.2",
54
54
  "@vitest/coverage-v8": "4.1.10",
55
- "eslint": "10.7.0",
55
+ "eslint": "10.8.0",
56
56
  "fast-check": "4.9.0",
57
57
  "happy-dom": "20.11.1",
58
58
  "html-validate": "11.5.6",
@@ -14,9 +14,11 @@ import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
15
  import { fromHTML } from "../dom.js";
16
16
  import { createClickSwallow, placeMenuAt } from "../menu-position.js";
17
- import type { SessionInfo, Tab } from "./model.js";
17
+ import type { CueStatus, SessionInfo, Tab } from "./model.js";
18
18
  import {
19
19
  ACTIVE_TAB_KEY,
20
+ CUE_SEEN_KEY,
21
+ MAX_PERSISTED_CUE_SEEN,
20
22
  MAX_PINNED_NAME,
21
23
  STATUS_EXITED,
22
24
  SWIPE_HINT_KEY,
@@ -26,9 +28,12 @@ import {
26
28
  createSessionAPI,
27
29
  createTombstones,
28
30
  hasPinnedName,
31
+ isCueStatus,
29
32
  orderedInsertIndex,
33
+ parseCueSeen,
30
34
  parseTabOrder,
31
35
  sanitizePinnedName,
36
+ serializeCueSeen,
32
37
  serializeTabOrder,
33
38
  } from "./model.js";
34
39
  import {
@@ -186,6 +191,29 @@ function writeSavedOrder(ids: readonly string[]): void {
186
191
  }
187
192
  }
188
193
 
194
+ /** readCueSeen loads the acknowledged background-tab cues, or an empty map when
195
+ * there is none to trust. Storage itself can throw (Safari private mode, a
196
+ * disabled third-party context, an embedder's iframe) and an unreadable map is
197
+ * never fatal: the dot simply lights again, exactly as it behaved before
198
+ * acknowledgements were remembered. */
199
+ function readCueSeen(): Map<string, CueStatus> {
200
+ try {
201
+ return parseCueSeen(localStorage.getItem(CUE_SEEN_KEY));
202
+ } catch {
203
+ return new Map<string, CueStatus>();
204
+ }
205
+ }
206
+
207
+ /** writeCueSeen persists acknowledgements, best-effort: a full quota must not
208
+ * break the dismissal the user just performed on screen. */
209
+ function writeCueSeen(seen: ReadonlyMap<string, CueStatus>): void {
210
+ try {
211
+ localStorage.setItem(CUE_SEEN_KEY, serializeCueSeen(seen));
212
+ } catch {
213
+ /* storage unavailable or full — the dismissal still holds for this page */
214
+ }
215
+ }
216
+
189
217
  async function createSessionHonouringRetry(
190
218
  api: { create: () => Promise<SessionInfo> },
191
219
  ctx: TerminalContext,
@@ -468,6 +496,45 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
468
496
  // The session that raised the pending cue, so arriving on that tab (a
469
497
  // swipe or any switch) acknowledges it without opening the list.
470
498
  let switchNotifyId: string | null = null;
499
+ // Cues this viewer has already SEEN, per session (see CUE_SEEN_KEY).
500
+ // Loaded here so a reload starts from what the user already dismissed:
501
+ // input/done stay LATCHED server-side, and the status stream re-pushes the
502
+ // latch in the snapshot it sends on every open, so without this a
503
+ // dismissed dot came back on the next load — and on every SSE reconnect,
504
+ // which on a phone is just returning to a backgrounded page.
505
+ const cueSeen = readCueSeen();
506
+ // markCueSeen records that this viewer has seen `id` holding `status`:
507
+ // either because that tab is the one on screen, or because the tray listing
508
+ // every tab's dot was opened. A non-cue status is not an acknowledgeable
509
+ // event, so it is ignored rather than stored.
510
+ function markCueSeen(id: string, status: string): void {
511
+ if (!isCueStatus(status) || cueSeen.get(id) === status) {
512
+ return;
513
+ }
514
+ cueSeen.set(id, status);
515
+ // Evict the oldest entries so the live map obeys the same cap the parser
516
+ // does. dropTab prunes a session closed while this page was open, but one
517
+ // that vanished while the page was CLOSED leaves an entry nothing else
518
+ // collects; unbounded, that would eventually push the map past the cap and
519
+ // make the parser discard whatever it read last — dropping fresh
520
+ // acknowledgements to keep dead ones.
521
+ while (cueSeen.size > MAX_PERSISTED_CUE_SEEN) {
522
+ const oldest = cueSeen.keys().next().value;
523
+ if (oldest === undefined) {
524
+ break;
525
+ }
526
+ cueSeen.delete(oldest);
527
+ }
528
+ writeCueSeen(cueSeen);
529
+ }
530
+ // forgetCueSeen drops an acknowledgement, so the session's NEXT input/done
531
+ // is a fresh cue: called when its status moves off the acknowledged value
532
+ // (a new working phase, an exit) and when the session goes away.
533
+ function forgetCueSeen(id: string): void {
534
+ if (cueSeen.delete(id)) {
535
+ writeCueSeen(cueSeen);
536
+ }
537
+ }
471
538
  function paintSwitchDot(): void {
472
539
  // Reuse the per-tab status-dot colours (single source, css/05-tabs.css
473
540
  // .wt-status-dot[data-status="input"|"done"]) instead of re-declaring
@@ -1100,6 +1167,13 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1100
1167
  }
1101
1168
  // Opening the list acknowledges any pending background-tab notification:
1102
1169
  // the user is now looking at the tabs, so clear the switch button's dot.
1170
+ // The tray lists EVERY tab with its own status dot, so every latched tab
1171
+ // is acknowledged here, not just the cue's latest subject — several tabs
1172
+ // can be latched at once while the dot only ever showed the newest, and an
1173
+ // unacknowledged sibling would re-raise it on the next load.
1174
+ for (const t of tabList) {
1175
+ markCueSeen(t.id, t.dot.dataset["status"] ?? "");
1176
+ }
1103
1177
  clearSwitchNotify();
1104
1178
  renderSwitcherList();
1105
1179
  setExpandedState(true);
@@ -1371,8 +1445,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1371
1445
  activeId = next.id;
1372
1446
  // Arriving on the tab that raised the switch-button cue resolves it
1373
1447
  // (a swipe through the tabs must dismiss the dot, not only opening the
1374
- // list).
1448
+ // list). Its current latch is acknowledged whether or not it was the
1449
+ // cue's subject: the terminal is now on screen, so a reload must not
1450
+ // notify about a state the user just looked at.
1375
1451
  acknowledgeSwitchNotify(next.id);
1452
+ markCueSeen(next.id, next.dot.dataset["status"] ?? "");
1376
1453
  try {
1377
1454
  localStorage.setItem(ACTIVE_TAB_KEY, next.id);
1378
1455
  } catch {
@@ -1749,8 +1826,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
1749
1826
  // the server reaping it cannot re-adopt the just-closed tab.
1750
1827
  tombstones.add(id);
1751
1828
  // A pending switch-button cue whose subject just closed is moot: clear
1752
- // it rather than leaving a dot no tab visit can ever resolve.
1829
+ // it rather than leaving a dot no tab visit can ever resolve. Its stored
1830
+ // acknowledgement goes too — the session is gone, so the entry would only
1831
+ // sit in storage forever.
1753
1832
  acknowledgeSwitchNotify(id);
1833
+ forgetCueSeen(id);
1754
1834
  tab.aria.remove();
1755
1835
  // Remove immediately (no exit animation): a lingering element made the
1756
1836
  // "+" teleport after a delay, and made a last-tab replacement appear in
@@ -2263,7 +2343,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
2263
2343
  // list opens. The active surface keeps its own needs-input cue (see
2264
2344
  // syncMobile); this is the moved + upgraded, glanceable version on the
2265
2345
  // dedicated button.
2266
- if (rec.id !== activeId && (rec.status === "input" || rec.status === "done")) {
2346
+ //
2347
+ // Both statuses are LATCHED server-side, so this runs on re-delivered
2348
+ // state as well as on genuine transitions: every SSE (re)open pushes a
2349
+ // snapshot and the poll fallback re-lists every few seconds. What makes a
2350
+ // dismissal stick across those is cueSeen — a latch this viewer already
2351
+ // acknowledged raises nothing, while a status that moved on drops the
2352
+ // acknowledgement so the next latch is a fresh cue.
2353
+ if (!isCueStatus(rec.status)) {
2354
+ forgetCueSeen(rec.id);
2355
+ } else if (rec.id === activeId) {
2356
+ // The user is looking at this terminal as it latches, so there is
2357
+ // nothing to notify — and nothing to re-raise once they move away.
2358
+ markCueSeen(rec.id, rec.status);
2359
+ } else if (cueSeen.get(rec.id) !== rec.status) {
2267
2360
  switchNotify = rec.status;
2268
2361
  switchNotifyId = rec.id;
2269
2362
  paintSwitchDot();
@@ -87,6 +87,85 @@ export function serializeTabOrder(ids: readonly string[]): string {
87
87
  return JSON.stringify(ids.slice(0, MAX_PERSISTED_TAB_ORDER));
88
88
  }
89
89
 
90
+ /** The two session statuses that raise the mobile switcher's aggregate attention
91
+ * cue: a background terminal blocked on the user ("input") or one whose turn
92
+ * finished ("done"). Declared once here so the raise test and the
93
+ * acknowledgement store cannot disagree about which statuses are cue-worthy. */
94
+ export type CueStatus = "input" | "done";
95
+
96
+ /** isCueStatus narrows a raw server status to a cue-worthy one. */
97
+ export function isCueStatus(status: string): status is CueStatus {
98
+ return status === "input" || status === "done";
99
+ }
100
+
101
+ /** localStorage key for the cues this viewer has already SEEN: session id -> the
102
+ * latched status that was acknowledged.
103
+ *
104
+ * It has to be remembered, because dismissing the cue does not change the
105
+ * session: `input` and `done` are LATCHED server-side (the engine clears them
106
+ * only on the session's next working phase) and the status stream re-delivers
107
+ * the latch in the snapshot it pushes on every open. So a dismissed dot came
108
+ * back on the next page load — and, since the snapshot is re-pushed on every
109
+ * SSE reconnect, on a phone simply returning to a backgrounded page.
110
+ *
111
+ * Client-side for the same reason as ACTIVE_TAB_KEY and TAB_ORDER_KEY: "I have
112
+ * seen this" is a property of the VIEWER, not of the session. A phone
113
+ * acknowledging a finished turn must not blank the dot on the desktop watching
114
+ * the same server, so this needs no engine API and the session REST surface
115
+ * stays the fixed four routes.
116
+ *
117
+ * Keyed per session rather than as one latest-wins slot: several background tabs
118
+ * can hold a latched status at once while the cue only ever shows the newest, so
119
+ * a single-slot acknowledgement would let every other one re-raise the dot on
120
+ * the next load. */
121
+ export const CUE_SEEN_KEY = "wt-cue-seen";
122
+
123
+ /** Bound on the acknowledgement map, same reasoning as MAX_PERSISTED_TAB_ORDER:
124
+ * every key is a live session server-side, so a real one is nowhere near this,
125
+ * and a corrupted or hostile stored value cannot make the restore path do
126
+ * unbounded work. */
127
+ export const MAX_PERSISTED_CUE_SEEN = 200;
128
+
129
+ /** parseCueSeen reads stored acknowledgements into a clean map. Anything it
130
+ * cannot trust is dropped (or, for a broken document, all of it): a lost
131
+ * acknowledgement only re-lights a dot the user can dismiss again, so degrading
132
+ * to "nothing acknowledged" is always safe. Pure, so it is testable without a
133
+ * storage backend; the caller owns the localStorage read and its try/catch. */
134
+ export function parseCueSeen(raw: string | null): Map<string, CueStatus> {
135
+ const out = new Map<string, CueStatus>();
136
+ if (raw === null || raw === "") {
137
+ return out;
138
+ }
139
+ let data: unknown;
140
+ try {
141
+ data = JSON.parse(raw);
142
+ } catch {
143
+ return out;
144
+ }
145
+ // Arrays and null are typeof "object" too, and neither is a cue map.
146
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
147
+ return out;
148
+ }
149
+ for (const [id, status] of Object.entries(data)) {
150
+ if (id === "" || typeof status !== "string" || !isCueStatus(status)) {
151
+ continue;
152
+ }
153
+ out.set(id, status);
154
+ if (out.size >= MAX_PERSISTED_CUE_SEEN) {
155
+ break;
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** serializeCueSeen encodes acknowledgements for storage. The caller keeps the
162
+ * live map within the cap (see markCueSeen), so this does not truncate: a silent
163
+ * truncation here would drop whichever entries the parser happened to read last,
164
+ * which is the opposite of what an eviction should discard. */
165
+ export function serializeCueSeen(seen: ReadonlyMap<string, CueStatus>): string {
166
+ return JSON.stringify(Object.fromEntries(seen));
167
+ }
168
+
90
169
  /** orderedInsertIndex returns where `id` belongs in `current` (a list of tab ids
91
170
  * in display order) to honour the stored arrangement `saved`.
92
171
  *