@cplieger/web-terminal-ui 5.0.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.0.2",
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
  *