@cplieger/web-terminal-ui 6.1.2 → 6.1.4

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/README.md CHANGED
@@ -28,7 +28,7 @@ terminal:
28
28
  - a **context menu** (Copy / Select All / Paste), on a right-click and on a
29
29
  touch long-press. Paste is the reason it exists: the keyboard target is a
30
30
  hidden `<textarea>`, so no platform can offer a native paste over the output.
31
- On touch the platform keeps its long-press — word selection and the OS copy
31
+ On touch the platform keeps its long-press: word selection and the OS copy
32
32
  callout run untouched, and the menu appears on release only when the press
33
33
  selected nothing
34
34
  - **predictive local echo**, **IME / composition** (CJK, dictation,
@@ -109,7 +109,7 @@ entry modules beside it:
109
109
  plus every feature's chrome) inside the target element itself. `target` is a CSS
110
110
  selector or an element: pass a **selector** from a page (`"#terminal"`) and pass
111
111
  an **element** only when you already hold one you created yourself. The
112
- difference matters — see "Startup failures" below. There is no element-id
112
+ difference matters; see "Startup failures" below. There is no element-id
113
113
  contract for the host page to reproduce, and every style and CSS custom
114
114
  property is scoped to the `wt-root` class it stamps on your element (removed
115
115
  again by `destroy()`). Call it exactly once; the engine's
@@ -164,18 +164,18 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
164
164
 
165
165
  ### Options
166
166
 
167
- | Option | Default | Purpose |
168
- | ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169
- | `features` | _(none; bare kernel)_ | A FUNCTION returning the feature list. Omitted builds only the terminal (no chrome). Pass a preset by name (`features: presetTabbed`) or a factory of your own; it is called inside the startup-failure boundary, so a preset that throws is handled rather than escaping. |
170
- | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
171
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
172
- | `fontReady` | `'14px "Monaspace Neon NF"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
173
- | `scrollbackLines` | _(engine default)_ | Retained scrollback lines per terminal — and per tab under the tabs feature (every per-tab cache is built through the same budget). This is the page's dominant memory dial: it bounds the styled-run arrays each session retains AND the DOM rows the renderer keeps (one row element per retained line), so a memory-constrained host (iOS Safari, where the content process is reclaimed under system pressure) passes a smaller budget at the price of a shorter scroll-back. A history budget floored at the live screen: the engine never evicts the current window, so a value at or below the terminal height keeps the full screen with no scrollback — but choose a value comfortably above the largest expected terminal height (a few multiples), since near or below it the batched eviction degrades to per-line churn. Non-integer or non-positive values are ignored. Left unset, the engine decides, and it decides twice: 5000 against a server that cannot serve history back, dropping to a 1500-line resident tail plus an on-demand cache once a server declares demand-paged scrollback. Prefer leaving it unset — the depth then lives on the server and the phone holds a working set. An explicit value opts OUT of that flip: it is an explicit memory decision and holds in both states. |
174
- | `persistScrollback` | _(off)_ | Persist each session's scrollback across a page discard, through storage YOU supply. A page that is discarded and reloaded otherwise resumes holding nothing, so it asks the server for everything and refills its whole buffer over the wire — the normal case on iOS, where Safari evicts backgrounded tabs under memory pressure and returning to one re-runs the page. With a snapshot restored, the resume asks only for what was printed while the tab was gone. It helps the FRESH-LOAD case only: a warm reconnect and an in-page tab switch already replay nothing. Off by default because `localStorage` is a shared, origin-wide, quota-limited resource and this library is embedded in applications that keep their own state there — an application decides durability for its own users, a library does not decide it for an embedder. Applies to a single terminal and to every tab alike. See below. |
175
- | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
176
- | `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
177
- | `onSessionEnded` | _(none)_ | Called when the active session's process has ended and nothing is retrying — the fact the banner renders as "Session ended". Wire it only if you can act on it: the recovery move is `handle.reattach()`, and whether that helps depends on what your endpoint does on the next connect (see "When a session ends"). Observation only; everything the kernel does about the end happens first and happens regardless, and a handler that throws is logged rather than allowed to take the banner down with it. |
178
- | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input`, `--status-warning`, `--status-failed`. The library ships neutral defaults. If you retheme the animated trio (`--status-working` / `--status-warning` / `--status-failed`), keep their LIGHTNESS spread: they differ only in hue, so equal-lightness replacements collapse into one another in greyscale and under deuteranopia. |
167
+ | Option | Default | Purpose |
168
+ | ------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169
+ | `features` | _(none; bare kernel)_ | A FUNCTION returning the feature list. Omitted builds only the terminal (no chrome). Pass a preset by name (`features: presetTabbed`) or a factory of your own; it is called inside the startup-failure boundary, so a preset that throws is handled rather than escaping. |
170
+ | `layout` | `"viewport"` | How the terminal claims space. `"viewport"`: the root becomes a fixed full-viewport box (the full-page product). `"container"`: the root fills your container element, which becomes the styling and positioning boundary (the embedded case). |
171
+ | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
172
+ | `fontReady` | `'14px "Monaspace Neon NF"'` | CSS font shorthand awaited before the first resize, so the server is sized against the real web font's cell metrics rather than a fallback. |
173
+ | `scrollbackLines` | _(engine default)_ | Retained scrollback lines per terminal, and per tab under the tabs feature (every per-tab cache is built through the same budget). This is the page's dominant memory dial: it bounds the styled-run arrays each session retains AND the DOM rows the renderer keeps (one row element per retained line), so a memory-constrained host (iOS Safari, where the content process is reclaimed under system pressure) passes a smaller budget at the price of a shorter scroll-back. A history budget floored at the live screen: the engine never evicts the current window, so a value at or below the terminal height keeps the full screen with no scrollback, but choose a value comfortably above the largest expected terminal height (a few multiples), since near or below it the batched eviction degrades to per-line churn. Non-integer or non-positive values are ignored. Left unset, the engine decides, and it decides twice: 5000 against a server that cannot serve history back, dropping to a 1500-line resident tail plus an on-demand cache once a server declares demand-paged scrollback. Prefer leaving it unset: the depth then lives on the server and the phone holds a working set. An explicit value opts OUT of that flip: it is an explicit memory decision and holds in both states. |
174
+ | `persistScrollback` | _(off)_ | Persist each session's scrollback across a page discard, through storage YOU supply. A page that is discarded and reloaded otherwise resumes holding nothing, so it asks the server for everything and refills its whole buffer over the wire (the normal case on iOS, where Safari evicts backgrounded tabs under memory pressure and returning to one re-runs the page). With a snapshot restored, the resume asks only for what was printed while the tab was gone. It helps the FRESH-LOAD case only: a warm reconnect and an in-page tab switch already replay nothing. Off by default because `localStorage` is a shared, origin-wide, quota-limited resource and this library is embedded in applications that keep their own state there: an application decides durability for its own users, a library does not decide it for an embedder. Applies to a single terminal and to every tab alike. See below. |
175
+ | `loading` | _(none)_ | A pre-JS loading overlay element (kept in your served HTML so it paints before this module loads); it is faded out and removed once the first frame renders. |
176
+ | `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
177
+ | `onSessionEnded` | _(none)_ | Called when the active session's process has ended and nothing is retrying: the fact the banner renders as "Session ended". Wire it only if you can act on it: the recovery move is `handle.reattach()`, and whether that helps depends on what your endpoint does on the next connect (see "When a session ends"). Observation only; everything the kernel does about the end happens first and happens regardless, and a handler that throws is logged rather than allowed to take the banner down with it. |
178
+ | `theme` | _(none)_ | Theme overrides (CSS custom properties on the terminal root): `--accent`, `--tab-bg`, `--tab-hover-bg`, `--tab-active-bg`, `--tab-active-fg`, `--tab-active-border`, plus the activity-dot palette `--status-working`, `--status-done`, `--status-input`, `--status-warning`, `--status-failed`. The library ships neutral defaults. If you retheme the animated trio (`--status-working` / `--status-warning` / `--status-failed`), keep their LIGHTNESS spread: they differ only in hue, so equal-lightness replacements collapse into one another in greyscale and under deuteranopia. |
179
179
 
180
180
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
181
181
  (and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
@@ -191,15 +191,15 @@ serves now, for a session that has ended (see "When a session ends"); and
191
191
  `persistScrollback` takes the storage, not a switch, because storage is a
192
192
  decision this library should not make for you. Chrome's page-lifecycle guidance
193
193
  is to close IndexedDB connections on freeze, since a held connection costs
194
- bfcache eligibility — and this kernel depends on bfcache, so it must not own a
194
+ bfcache eligibility, and this kernel depends on bfcache, so it must not own a
195
195
  database. `localStorage` is also a shared, origin-wide, ~5 MB resource, and this
196
196
  package is embedded in applications that keep their own state there: consuming it
197
197
  uninvited would risk the host's writes failing, and how that degrades is theirs to
198
- design. Ours degrades gracefully — nothing restored, terminal unaffected.
198
+ design. Ours degrades gracefully: nothing restored, terminal unaffected.
199
199
 
200
200
  So the option is off until you pass storage, and `localScrollbackStorage()` is the
201
- ready-made answer for consumers who want the ordinary one — one import, and it
202
- handles the quota sweep, the age bound and the `localStorage`-unavailable case:
201
+ ready-made answer for consumers who want the ordinary one (one import, and it
202
+ handles the quota sweep, the age bound and the `localStorage`-unavailable case):
203
203
 
204
204
  ```ts
205
205
  import { createTerminal, localScrollbackStorage, presetTabbed } from "@cplieger/web-terminal-ui";
@@ -214,7 +214,7 @@ All three reference apps (web-terminal-kiro, web-terminal-server, vibekit's shel
214
214
  panel) enable it that way; web-terminal-server additionally exposes an operator
215
215
  opt-out, because it runs a command its operator chooses.
216
216
 
217
- Supply your own storage instead when the snapshots belong somewhere else — a shared
217
+ Supply your own storage instead when the snapshots belong somewhere else: a shared
218
218
  IndexedDB, a server round-trip, an in-memory cache for a test. The rest of this
219
219
  section is that case:
220
220
 
@@ -245,11 +245,11 @@ createTerminal("#terminal", {
245
245
  than working around: a restore has to be in place before the resume announces
246
246
  what the client already holds, and a resume cannot be taken back. A store
247
247
  hydrated after that has already lost the argument. So an asynchronous store is
248
- read into memory first — which is also the shape that holds no database
248
+ read into memory first, which is also the shape that holds no database
249
249
  connection open.
250
250
 
251
251
  `sessionId` is a real session id, never a key of your invention: the id the tabs
252
- feature uses, or — for a single terminal — the engine's own per-tab id. That is
252
+ feature uses, or (for a single terminal) the engine's own per-tab id. That is
253
253
  load-bearing rather than tidy, because the same id is what the persisted server
254
254
  boot epoch is checked against. Prefix it however you like inside your storage.
255
255
 
@@ -257,7 +257,7 @@ Knobs, all optional. On the option itself: `lines` (default 200) bounds how much
257
257
  each session's tail is written, `maxAgeMs` (default 7 days) bounds how long an entry
258
258
  may be used, and `saveIntervalMs` (default 10s) sets the background save cadence.
259
259
  `localScrollbackStorage` adds `prefix` and `maxBytes` (default 512 KiB of
260
- characters, roughly 1 MiB of quota since `localStorage` is accounted in UTF-16 —
260
+ characters, roughly 1 MiB of quota since `localStorage` is accounted in UTF-16;
261
261
  about a fifth of the ~5 MiB floor, leaving the rest to the host application).
262
262
 
263
263
  The 200-line default is a measurement rather than a guess: VS Code's own
@@ -268,13 +268,13 @@ timer while output advances, so that cost is paid repeatedly on the device this
268
268
  exists for.
269
269
 
270
270
  The library writes on `visibilitychange` to hidden, on `pagehide`, on `destroy()`,
271
- and on that timer — in every case only for a session whose content has advanced
271
+ and on that timer; in every case only for a session whose content has advanced
272
272
  since its last recorded save. That predicate is not an optimisation: two pages of
273
273
  the same app hold the same session ids, and an unconditional write let a background
274
274
  page roll a foreground page's newer entry back. `pagehide` is documented as not
275
275
  guaranteed, which is why the timer exists.
276
276
 
277
- Every callback may throw or return nonsense without consequence — a failure
277
+ Every callback may throw or return nonsense without consequence: a failure
278
278
  degrades to "nothing was restored", exactly as if the option were absent. An
279
279
  entry is also discarded, and dropped, when it is too old, unreadable, or from a
280
280
  different server process: absolute line indices only mean anything within one
@@ -284,7 +284,7 @@ terminal that is confidently wrong.
284
284
 
285
285
  No permission prompt is involved: `localStorage` is not in the Permissions API, so
286
286
  there is nothing for a user to grant or refuse. (`navigator.storage.persist()` is
287
- the call that can prompt, and this library never makes it — these snapshots are
287
+ the call that can prompt, and this library never makes it; these snapshots are
288
288
  disposable by design, so asking a user to exempt them from eviction would be asking
289
289
  for the wrong thing.) Storage can still be _unavailable_ with no prompt either way,
290
290
  and the failures differ: a browser set to block site data makes
@@ -300,28 +300,28 @@ happen.
300
300
 
301
301
  Cleanup, since a store that only ever writes fills up. Four mechanisms, and the
302
302
  first is the one that matters day to day: the kernel calls `drop` when a session
303
- closes, so a tab the user closes — or one the server reaps while the page is open —
303
+ closes, so a tab the user closes (or one the server reaps while the page is open)
304
304
  takes its entry with it. On top of that, an entry past `maxAgeMs` is deleted when it
305
305
  is next read; `localScrollbackStorage` sweeps both at construction and after every
306
306
  write (expired entries first, then the oldest until the byte budget fits); and a
307
307
  rejected entry is always dropped rather than left to be re-read. What no mechanism
308
- can catch promptly is a session that disappeared while the page was CLOSED — nothing
309
- runs then, and there is no reliable "this tab is closing for good" event to hook — so
308
+ can catch promptly is a session that disappeared while the page was CLOSED. Nothing
309
+ runs then, and there is no reliable "this tab is closing for good" event to hook, so
310
310
  that case is the sweep's, which is why a budget exists rather than the age bound
311
311
  alone.
312
312
 
313
313
  The budget is bytes rather than an entry count deliberately, because a count bounds
314
314
  the wrong thing. Measured on the real stored value at the 200-line default, a plain
315
315
  80-column session is ~24 K characters and a wide coloured one ~112 K, so a 20-entry
316
- allowance would have permitted over 4 MiB of a ~5 MiB quota — the cap would have
316
+ allowance would have permitted over 4 MiB of a ~5 MiB quota; the cap would have
317
317
  guaranteed the refusal it was meant to prevent. A single snapshot larger than the
318
318
  whole budget is refused outright rather than evicting everything for something that
319
319
  still would not fit.
320
320
 
321
321
  ### Themes that provably apply
322
322
 
323
- `theme` is an open `Record<string, string>` — the kernel sets every key on the
324
- terminal root verbatim — so a key this library renamed or retired becomes a live
323
+ `theme` is an open `Record<string, string>` (the kernel sets every key on the
324
+ terminal root verbatim), so a key this library renamed or retired becomes a live
325
325
  declaration nothing reads: no error, just the library's neutral defaults where
326
326
  your brand should be. The supported keys are therefore published as data:
327
327
 
@@ -348,23 +348,23 @@ that markup usually lives in a static HTML file no compiler reads.
348
348
  ### Startup failures
349
349
 
350
350
  You do not need your own startup-failure UI. Every way starting up can fail ends
351
- at one recovery surface this package owns — a "Terminal failed to start" panel
352
- with a Reload button — and your loading overlay is lowered so that panel is
351
+ at one recovery surface this package owns (a "Terminal failed to start" panel
352
+ with a Reload button), and your loading overlay is lowered so that panel is
353
353
  visible.
354
354
 
355
355
  Two phases can fail. If a feature's setup throws or rejects
356
356
  (`phase: "feature-setup"`), the kernel stops the connection, tears down every
357
357
  completed feature and core listener, clears the broken subtree, and shows the
358
- panel. If `createTerminal` itself throws (`phase: "kernel-init"` — an
358
+ panel. If `createTerminal` itself throws (`phase: "kernel-init"`: an
359
359
  unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
360
360
  invariant), it shows the same panel and then rethrows, so a caller with its own
361
361
  error handling still sees the error. Either way the panel is modal when the
362
362
  terminal owns the viewport and non-modal when it fills an embedded container.
363
363
 
364
364
  **This is why `target` takes a selector and `features` takes a function.** Both
365
- are resolved INSIDE that boundary. Written the other way round —
366
- `createTerminal(document.getElementById("terminal"), { features: presetTabbed() })`
367
- — the lookup and the preset call both happen at your call site, before
365
+ are resolved INSIDE that boundary. Written the other way round
366
+ (`createTerminal(document.getElementById("terminal"), { features: presetTabbed() })`),
367
+ the lookup and the preset call both happen at your call site, before
368
368
  `createTerminal` is entered, so a missing element or a throwing preset escapes
369
369
  the library entirely and leaves the page spinning under your overlay with nothing
370
370
  but a console error. Those were the two failures every consumer used to
@@ -382,8 +382,8 @@ feature composition never began. `surface` names the element the built-in panel
382
382
  would fill, and is the element to render into if you claim it. Return `true` only
383
383
  when you have rendered replacement recovery UI there.
384
384
 
385
- If your page also carries an inline bootstrap watchdog — a script that reports
386
- "the JS bundle never loaded at all", a rung below `import` — it cannot import
385
+ If your page also carries an inline bootstrap watchdog (a script that reports
386
+ "the JS bundle never loaded at all", a rung below `import`), it cannot import
387
387
  anything by definition. Take its wording from `STARTUP_FAILURE_COPY` (exported at
388
388
  the package root and at `@cplieger/web-terminal-ui/startup-copy`, which imports
389
389
  nothing and touches no DOM so a build script can read it) and substitute the
@@ -398,8 +398,8 @@ endpoint, reconnecting could only collect the same close again, which is an
398
398
  endless "Reconnecting…" flap over a screen that will never change.
399
399
 
400
400
  Some hosts are the other shape. If your endpoint hands out a NEW session on the
401
- next connect — one shared PTY the server replaces once it is spent, an endpoint
402
- that spawns on attach — then the connection the engine declines to make is exactly
401
+ next connect (one shared PTY the server replaces once it is spent, an endpoint
402
+ that spawns on attach), then the connection the engine declines to make is exactly
403
403
  what would produce a working terminal, and you are the only party who knows that.
404
404
  So the library gives you the fact and the move, and keeps the policy out of it:
405
405
 
@@ -427,7 +427,7 @@ drops history the server may have evicted since.
427
427
 
428
428
  **Bound your own retries.** A shell that dies as fast as it is spawned will end
429
429
  again the moment it is replaced, so a handler that reattaches unconditionally is a
430
- hot loop. Count consecutive ends, back off, and stop after a few — the banner is
430
+ hot loop. Count consecutive ends, back off, and stop after a few: the banner is
431
431
  the honest outcome when a session cannot stay up. The library will not do this for
432
432
  you, because how many attempts is worth making, and whether any are, is a fact
433
433
  about your server rather than about this terminal.
package/css/30-tabs.css CHANGED
@@ -56,8 +56,8 @@
56
56
  invisible. It is NOT free: every backdrop-filter surface holds an offscreen
57
57
  copy of what is behind it plus the blurred result, re-derived when that
58
58
  content changes — a standing compositor allocation on exactly the
59
- memory-constrained devices this UI targets (see the iOS-memory section of
60
- the steering doc). An EMBEDDED root (wt-container) keeps the frosting: the
59
+ memory-constrained devices this UI targets. An EMBEDDED root (wt-container)
60
+ keeps the frosting: the
61
61
  host's own page can sit behind the bar there, which is what the blur is
62
62
  for. */
63
63
  :where(.wt-root.wt-container) .wt-tab-bar {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "6.1.2",
3
+ "version": "6.1.4",
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": "MPL-2.0",
@@ -40,7 +40,7 @@
40
40
  "verify": "bash scripts/verify.sh"
41
41
  },
42
42
  "peerDependencies": {
43
- "@cplieger/web-terminal-engine": "^3.10.0 || ^4.0.0"
43
+ "@cplieger/web-terminal-engine": "^3.10.0 || ^4.0.0 || ^5.0.0"
44
44
  },
45
45
  "overrides": {
46
46
  "qs": "^6.15.2"
@@ -56,7 +56,7 @@
56
56
  "eslint": "10.8.1",
57
57
  "fast-check": "4.9.0",
58
58
  "happy-dom": "20.11.2",
59
- "html-validate": "11.6.2",
59
+ "html-validate": "11.8.0",
60
60
  "knip": "6.32.2",
61
61
  "prettier": "3.9.6",
62
62
  "stylelint": "17.14.1",
@@ -152,7 +152,13 @@ export interface TabsOptions {
152
152
  *
153
153
  * Not every platform honours it: Safari caches the first icon it fetched and
154
154
  * ignores later changes, and an installed app has no tab icon at all. The
155
- * title count is not gated on this, so those cases lose nothing. */
155
+ * title count is not gated on this, so those cases lose nothing.
156
+ *
157
+ * The links are restored on `pagehide` as well as on teardown, because a
158
+ * browser remembers one icon per URL and renders it for the bookmark, the
159
+ * history row and the new-tab tile. Best-effort there, since the write races
160
+ * the unload. Deliberately not on `freeze`: a frozen background tab is still in
161
+ * the strip rendering its icon, which is the case this exists for. */
156
162
  attentionIcons?: boolean;
157
163
  }
158
164
 
@@ -601,6 +607,39 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
601
607
  }
602
608
  document.addEventListener("visibilitychange", onPageVisible);
603
609
 
610
+ // onPageGone hands the page's own icon back before the page GOES AWAY,
611
+ // which is a different question from the page being hidden and needs a
612
+ // different event. A browser remembers ONE icon per URL and renders it for
613
+ // the bookmark, the history row and the new-tab tile, so a tab closed while
614
+ // a cue was lit leaves a status variant standing in for the app until the
615
+ // page is next loaded.
616
+ //
617
+ // `pagehide`, and deliberately NOT `freeze`. freeze fires for a background
618
+ // tab the browser is conserving resources on (Android after five minutes in
619
+ // the background; desktop for a collapsed tab group and, since Chrome 133, a
620
+ // CPU-heavy tab under Energy Saver), and that tab is STILL in the strip
621
+ // rendering its icon — restoring there would blank the cue in exactly the
622
+ // case the cue exists for. pagehide fires on unload, tab close and bfcache
623
+ // entry, where no strip entry is left to render.
624
+ //
625
+ // Through apply() rather than the icon sink directly, because the sinks are
626
+ // change-gated on the last applied value: writing behind that memo would
627
+ // leave it believing the variant is still up, and a page restored from the
628
+ // bfcache would then never repaint it. Which is what onPageBack is for — a
629
+ // bfcache restore re-runs the fold, so the cue returns for a page that did
630
+ // not actually go away. Best-effort on a real unload, where the icon write
631
+ // races the teardown, and a no-op when nothing was lit.
632
+ function onPageGone(): void {
633
+ attention.apply(NO_ATTENTION);
634
+ }
635
+ function onPageBack(event: PageTransitionEvent): void {
636
+ if (event.persisted) {
637
+ paintAttention();
638
+ }
639
+ }
640
+ window.addEventListener("pagehide", onPageGone);
641
+ window.addEventListener("pageshow", onPageBack);
642
+
604
643
  // paintAttention re-derives the whole attention state from the tab list and
605
644
  // this viewer's acknowledgements, and hands it to the surfaces.
606
645
  //
@@ -4115,6 +4154,8 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
4115
4154
  offMenuKey();
4116
4155
  document.removeEventListener("click", onDocClickMenu);
4117
4156
  document.removeEventListener("visibilitychange", onPageVisible);
4157
+ window.removeEventListener("pagehide", onPageGone);
4158
+ window.removeEventListener("pageshow", onPageBack);
4118
4159
  document.removeEventListener("contextmenu", onDocContextMenu);
4119
4160
  document.removeEventListener("pointerup", onDocTapDismiss, true);
4120
4161
  document.removeEventListener("dragover", onDocTabDrop);