@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.
- package/css/10-primitives.css +28 -1
- package/css/30-tabs.css +15 -12
- package/css/31-switcher.css +21 -15
- package/package.json +2 -2
- package/src/features/tabs/index.ts +97 -4
- package/src/features/tabs/model.ts +79 -0
package/css/10-primitives.css
CHANGED
|
@@ -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
|
|
162
|
-
active, so a touchscreen's sticky :hover leaves with the
|
|
163
|
-
ACTIVE chip changes nothing, so an ungated hover would stay
|
|
164
|
-
tap somewhere else. Touch feedback there is :active, which is
|
|
165
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
}
|
package/css/31-switcher.css
CHANGED
|
@@ -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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
324
|
-
:
|
|
325
|
-
press feedback
|
|
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
|
|
462
|
-
when closing a tab shifts the list under the
|
|
463
|
-
|
|
464
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
*
|