@cplieger/web-terminal-ui 5.2.3 → 5.3.0

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
@@ -25,8 +25,12 @@ terminal:
25
25
  needs-input / exited) from the server's status stream
26
26
  - a **mobile key toolbar** (Tab / Esc / arrows / Enter / sticky-Ctrl) and a
27
27
  scroll-to-bottom control
28
- - a **context menu** (Copy / Select All / Paste), reachable by right-click and
29
- by long-press on touch
28
+ - a **context menu** (Copy / Select All / Paste), on a right-click and on a
29
+ touch long-press. Paste is the reason it exists: the keyboard target is a
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
32
+ callout run untouched, and the menu appears on release only when the press
33
+ selected nothing
30
34
  - **predictive local echo**, **IME / composition** (CJK, dictation,
31
35
  autocorrect), and **viewport + keyboard-inset** handling for the iOS soft
32
36
  keyboard, rotation, and font-load reflows
@@ -160,15 +164,17 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
160
164
 
161
165
  ### Options
162
166
 
163
- | Option | Default | Purpose |
164
- | -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
- | `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. |
166
- | `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). |
167
- | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
168
- | `fontReady` | `'14px "MonaspiceNe NFM"'` | 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. |
169
- | `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. |
170
- | `onFatalError` | _(built-in recovery)_ | Called with a `TerminalStartupFailure` after a fatal startup failure, in either phase (`feature-setup` or `kernel-init`); behavior below. |
171
- | `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
+ | `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. |
172
178
 
173
179
  `createTerminal()` returns a handle: `focus()` re-focuses the terminal input
174
180
  (and opens the soft keyboard on touch); `send(bytes)` sends bytes to the active
@@ -178,6 +184,138 @@ screen without injecting keystrokes (send a redraw keystroke yourself if you
178
184
  want one, for example Ctrl+L); and `destroy()` tears every feature down and
179
185
  releases the kernel.
180
186
 
187
+ ### Persisting scrollback across a discard
188
+
189
+ `persistScrollback` takes the storage, not a switch, because storage is a
190
+ decision this library should not make for you. Chrome's page-lifecycle guidance
191
+ is to close IndexedDB connections on freeze, since a held connection costs
192
+ bfcache eligibility — and this kernel depends on bfcache, so it must not own a
193
+ database. `localStorage` is also a shared, origin-wide, ~5 MB resource, and this
194
+ package is embedded in applications that keep their own state there: consuming it
195
+ uninvited would risk the host's writes failing, and how that degrades is theirs to
196
+ design. Ours degrades gracefully — nothing restored, terminal unaffected.
197
+
198
+ So the option is off until you pass storage, and `localScrollbackStorage()` is the
199
+ ready-made answer for consumers who want the ordinary one — one import, and it
200
+ handles the quota sweep, the age bound and the `localStorage`-unavailable case:
201
+
202
+ ```ts
203
+ import { createTerminal, localScrollbackStorage, presetTabbed } from "@cplieger/web-terminal-ui";
204
+
205
+ createTerminal("#terminal", {
206
+ features: presetTabbed,
207
+ persistScrollback: localScrollbackStorage(),
208
+ });
209
+ ```
210
+
211
+ All three reference apps (web-terminal-kiro, web-terminal-server, vibekit's shell
212
+ panel) enable it that way; web-terminal-server additionally exposes an operator
213
+ opt-out, because it runs a command its operator chooses.
214
+
215
+ Supply your own storage instead when the snapshots belong somewhere else — a shared
216
+ IndexedDB, a server round-trip, an in-memory cache for a test. The rest of this
217
+ section is that case:
218
+
219
+ ```ts
220
+ import { createTerminal } from "@cplieger/web-terminal-ui";
221
+ import type { PersistedScrollback } from "@cplieger/web-terminal-ui";
222
+
223
+ // Read your snapshots into memory BEFORE mounting; see the note below.
224
+ const cache = new Map<string, PersistedScrollback>(await loadAllFromIndexedDB());
225
+
226
+ createTerminal("#terminal", {
227
+ features: presetTabbed,
228
+ persistScrollback: {
229
+ load: (sessionId) => cache.get(sessionId) ?? null,
230
+ save: (sessionId, entry) => {
231
+ cache.set(sessionId, entry);
232
+ void writeToIndexedDB(sessionId, entry); // fire and forget
233
+ },
234
+ drop: (sessionId) => {
235
+ cache.delete(sessionId);
236
+ void deleteFromIndexedDB(sessionId);
237
+ },
238
+ },
239
+ });
240
+ ```
241
+
242
+ `load` is synchronous, which is the one constraint worth understanding rather
243
+ than working around: a restore has to be in place before the resume announces
244
+ what the client already holds, and a resume cannot be taken back. A store
245
+ hydrated after that has already lost the argument. So an asynchronous store is
246
+ read into memory first — which is also the shape that holds no database
247
+ connection open.
248
+
249
+ `sessionId` is a real session id, never a key of your invention: the id the tabs
250
+ feature uses, or — for a single terminal — the engine's own per-tab id. That is
251
+ load-bearing rather than tidy, because the same id is what the persisted server
252
+ boot epoch is checked against. Prefix it however you like inside your storage.
253
+
254
+ Knobs, all optional. On the option itself: `lines` (default 200) bounds how much of
255
+ each session's tail is written, `maxAgeMs` (default 7 days) bounds how long an entry
256
+ may be used, and `saveIntervalMs` (default 10s) sets the background save cadence.
257
+ `localScrollbackStorage` adds `prefix` and `maxBytes` (default 512 KiB of
258
+ characters, roughly 1 MiB of quota since `localStorage` is accounted in UTF-16 —
259
+ about a fifth of the ~5 MiB floor, leaving the rest to the host application).
260
+
261
+ The 200-line default is a measurement rather than a guess: VS Code's own
262
+ `terminal.integrated.persistentSessionScrollback` restores 100 lines by default, and
263
+ at 200 a coloured session serialises to ~60 K characters in under a millisecond,
264
+ against ~300 K and ~4 ms at 1000. A write happens on every backgrounding and on a
265
+ timer while output advances, so that cost is paid repeatedly on the device this
266
+ exists for.
267
+
268
+ The library writes on `visibilitychange` to hidden, on `pagehide`, on `destroy()`,
269
+ and on that timer — in every case only for a session whose content has advanced
270
+ since its last recorded save. That predicate is not an optimisation: two pages of
271
+ the same app hold the same session ids, and an unconditional write let a background
272
+ page roll a foreground page's newer entry back. `pagehide` is documented as not
273
+ guaranteed, which is why the timer exists.
274
+
275
+ Every callback may throw or return nonsense without consequence — a failure
276
+ degrades to "nothing was restored", exactly as if the option were absent. An
277
+ entry is also discarded, and dropped, when it is too old, unreadable, or from a
278
+ different server process: absolute line indices only mean anything within one
279
+ server boot, so an entry that cannot be checked against the live server is
280
+ thrown away rather than shown. A slow restore is a much better failure than a
281
+ terminal that is confidently wrong.
282
+
283
+ No permission prompt is involved: `localStorage` is not in the Permissions API, so
284
+ there is nothing for a user to grant or refuse. (`navigator.storage.persist()` is
285
+ the call that can prompt, and this library never makes it — these snapshots are
286
+ disposable by design, so asking a user to exempt them from eviction would be asking
287
+ for the wrong thing.) Storage can still be _unavailable_ with no prompt either way,
288
+ and the failures differ: a browser set to block site data makes
289
+ `window.localStorage` **throw on access** (Chrome, Firefox), while Safari
290
+ historically allowed access in private browsing and threw on the **write**; Firefox
291
+ can clear it on close; Safari's ITP evicts script-writable storage after seven days
292
+ without interaction; and a cross-origin iframe embed gets **partitioned, ephemeral**
293
+ storage rather than the top-level origin's (all three engines partition; Safari has
294
+ the strictest lifetime). `localScrollbackStorage` guards access as well as writes, so
295
+ the read side always degrades to "nothing restored" and the terminal is unaffected; a
296
+ refused write is reported instead, so the library never records a save that did not
297
+ happen.
298
+
299
+ Cleanup, since a store that only ever writes fills up. Four mechanisms, and the
300
+ first is the one that matters day to day: the kernel calls `drop` when a session
301
+ closes, so a tab the user closes — or one the server reaps while the page is open —
302
+ takes its entry with it. On top of that, an entry past `maxAgeMs` is deleted when it
303
+ is next read; `localScrollbackStorage` sweeps both at construction and after every
304
+ write (expired entries first, then the oldest until the byte budget fits); and a
305
+ rejected entry is always dropped rather than left to be re-read. What no mechanism
306
+ can catch promptly is a session that disappeared while the page was CLOSED — nothing
307
+ runs then, and there is no reliable "this tab is closing for good" event to hook — so
308
+ that case is the sweep's, which is why a budget exists rather than the age bound
309
+ alone.
310
+
311
+ The budget is bytes rather than an entry count deliberately, because a count bounds
312
+ the wrong thing. Measured on the real stored value at the 200-line default, a plain
313
+ 80-column session is ~24 K characters and a wide coloured one ~112 K, so a 20-entry
314
+ allowance would have permitted over 4 MiB of a ~5 MiB quota — the cap would have
315
+ guaranteed the refusal it was meant to prevent. A single snapshot larger than the
316
+ whole budget is refused outright rather than evicting everything for something that
317
+ still would not fit.
318
+
181
319
  ### Themes that provably apply
182
320
 
183
321
  `theme` is an open `Record<string, string>` — the kernel sets every key on the
package/css/00-tokens.css CHANGED
@@ -134,14 +134,14 @@
134
134
  asymmetry as a known constant (--label-ink-shift below), and an unknown
135
135
  font has no constant, so no value could ever be right on both.
136
136
 
137
- MonaspiceNe NFM is already served at /vendor/fonts by every full-page host
137
+ Monaspace Neon NF is already served at /vendor/fonts by every full-page host
138
138
  (page.css declares the faces, the Dockerfiles ship the files), so this adds
139
139
  no download. A consumer serving a component-only manifest has no face for
140
140
  this family and falls back to the platform monospace — and also ships no tab
141
141
  strip (MANIFEST.touch excludes 30-tabs.css), so nothing metric-dependent
142
142
  rides on that fallback. */
143
- --font-ui: "MonaspiceNe NFM", ui-monospace, monospace;
144
- --font-mono: "MonaspiceNe NFM", monospace;
143
+ --font-ui: "Monaspace Neon NF", ui-monospace, monospace;
144
+ --font-mono: "Monaspace Neon NF", monospace;
145
145
  /* How far a chip label's INK sits below its line box's centre, so flex
146
146
  centring can be corrected by exactly that much (see .wt-tab-label in
147
147
  30-tabs.css).
package/css/30-tabs.css CHANGED
@@ -27,11 +27,26 @@
27
27
  /* Breathing room around the tab area (a roomier bottom bar). */
28
28
  padding: var(--sp-2);
29
29
  background: var(--surface);
30
- backdrop-filter: var(--blur);
31
30
  border-top: 1px solid var(--border);
32
31
  z-index: 15;
33
32
  }
34
33
 
34
+ /* Frosted bar only where something can actually sit behind it. In the
35
+ full-page product (wt-viewport) the terminal content stops ABOVE the bar —
36
+ the strip's clearance rules pull the surface up off it
37
+ (`.term.wt-with-tabbar { bottom: var(--wt-tabbar-h) }`, below) — so the
38
+ bar's backdrop is the page kit's own flat background and blurring it is
39
+ invisible. It is NOT free: every backdrop-filter surface holds an offscreen
40
+ copy of what is behind it plus the blurred result, re-derived when that
41
+ content changes — a standing compositor allocation on exactly the
42
+ memory-constrained devices this UI targets (see the iOS-memory section of
43
+ the steering doc). An EMBEDDED root (wt-container) keeps the frosting: the
44
+ host's own page can sit behind the bar there, which is what the blur is
45
+ for. */
46
+ :where(.wt-root.wt-container) .wt-tab-bar {
47
+ backdrop-filter: var(--blur);
48
+ }
49
+
35
50
  /* The scrolling tab list inside the bar: the tabs and nothing else — the "+"
36
51
  and the keyboard button are fixed bar siblings after it. Horizontal
37
52
  scrolling lives HERE, not on the bar, so neither control ever scrolls away.
@@ -519,8 +534,10 @@
519
534
  }
520
535
 
521
536
  /* The drag preview clone (setDragGhost): a full-opacity copy of the chip parked
522
- under .wt-root — OUTSIDE the bar's backdrop-filter, which is what stops WebKit
523
- from rendering the automatic drag image (webkit.org/b/22787) — and laid exactly
537
+ under .wt-root — OUTSIDE the bar's backdrop-filter (embedded wt-container
538
+ roots only, since 5.3.0; the full-page bar no longer carries one), which is
539
+ what stops WebKit from rendering the automatic drag image (webkit.org/b/22787)
540
+ — and laid exactly
524
541
  over the real chip via inline left/top/width/height. It exists for less than a
525
542
  frame (the browser snapshots it, then a rAF removes it), so it must not be
526
543
  hidden (an invisible element makes no snapshot) and must not intercept the
@@ -733,7 +750,15 @@
733
750
  position: relative;
734
751
  background: var(--dot-color);
735
752
  border-color: var(--dot-color);
736
- animation: wt-working-glow 1.2s ease-in-out infinite;
753
+ /* Own compositing layer. The glow overlay (::before) and the wave (::after)
754
+ are compositor-driven layers, and a nested layer's position snaps to
755
+ DEVICE pixels independently of its parent's fractional page position — at
756
+ 9px that reads as the pulse sitting visibly off-centre (measured on a
757
+ DPR-1 display: bottom-left or bottom-right depending on rounding).
758
+ Promoting the dot makes disc, overlay and wave raster in one frame of
759
+ reference, so they snap together. The disc itself is STATIC now; the beat
760
+ lives on the overlay (see the ::before rule below). */
761
+ transform: translateZ(0);
737
762
  }
738
763
 
739
764
  /* The one difference between the three animated states. Kept as three
@@ -752,6 +777,43 @@
752
777
  --dot-color: var(--status-failed);
753
778
  }
754
779
 
780
+ /* The glow beat, compositor-only (2026-08; owner-ratified against a live A/B
781
+ demo). It was an element-level background-color/border-color animation,
782
+ which repainted the disc every frame for the whole time an agent works —
783
+ pure battery/heat on a phone (the demo measured 2x fps once multiplied).
784
+ The disc never changes now; this pre-painted overlay carries the 35%-peak
785
+ bright colour and animates ONLY opacity, so after one rasterization the
786
+ GPU blends two static layers.
787
+
788
+ Geometry is load-bearing: a HARD-edged overlay at this size shows any
789
+ half-pixel layer snap as an off-centre pulse (three positioning strategies
790
+ all failed on a DPR-1 display), so the overlay is a radial with a solid
791
+ bright core and a ~1px feather ending at the disc's rim — a snapped
792
+ half-pixel falls inside the feather. The travelling wave below has proven
793
+ that property for the same geometry all along.
794
+
795
+ The stale-raster hazard the old colors-at-every-frame beat existed to pin
796
+ (a dot entering working in the same frame input/done ends re-pulsing the
797
+ previously rasterized paint) is held by construction instead: this pseudo
798
+ exists only in the three animated states and resolves --dot-color at
799
+ raster time, so there is no previous-state paint to re-pulse. */
800
+ :where(.wt-root) .wt-status-dot[data-status="working"]::before,
801
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::before,
802
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::before {
803
+ content: "";
804
+ position: absolute;
805
+ inset: -2px;
806
+ border-radius: 50%;
807
+ background: radial-gradient(
808
+ closest-side,
809
+ color-mix(in oklch, var(--dot-color) 78%, #fff) 0 70%,
810
+ transparent 92%
811
+ );
812
+ opacity: 0;
813
+ animation: wt-working-glow 1.2s ease-in-out infinite;
814
+ will-change: opacity;
815
+ }
816
+
755
817
  /* The ripple layer: a feathered radial BAND (not a hairline border) that starts
756
818
  hugging the disc's edge (inset -2px, the 9->13px box) and then travels
757
819
  outward, dissolving. The band is painted as a radial-gradient — soft on both
@@ -840,25 +902,21 @@
840
902
  }
841
903
 
842
904
  /* The disc's glow beat: base -> brighter -> base, then a rest until the next
843
- cycle. Colors are set at EVERY frame, which both drives the glow and pins
844
- the paint — a dot entering working in the same frame input/done ends can
845
- never re-pulse the previously rasterized paint (the hazard the old
846
- opacity-only pulse had to pin box-shadow against). Element opacity stays 1
847
- throughout; see the layer-independence note on the working rule.
848
-
849
- --dot-color, not --status-working: this beat is shared verbatim by working,
850
- warning and failed, each of which supplies its own hue. */
905
+ cycle — expressed as the opacity envelope of the pre-painted bright overlay
906
+ (the ::before rule above), so the beat costs compositing, not repaints.
907
+ The element's own opacity stays 1 throughout; only the overlay fades.
908
+
909
+ Named for what it does, shared verbatim by working, warning and failed —
910
+ each supplies its own hue through --dot-color at the overlay's raster. */
851
911
  @keyframes wt-working-glow {
852
912
  0%,
853
913
  70%,
854
914
  100% {
855
- background-color: var(--dot-color);
856
- border-color: var(--dot-color);
915
+ opacity: 0;
857
916
  }
858
917
 
859
918
  35% {
860
- background-color: color-mix(in oklch, var(--dot-color) 78%, #fff);
861
- border-color: color-mix(in oklch, var(--dot-color) 78%, #fff);
919
+ opacity: 1;
862
920
  }
863
921
  }
864
922
 
@@ -904,7 +962,6 @@
904
962
  :where(.wt-root) .wt-status-dot[data-status="working"],
905
963
  :where(.wt-root) .wt-status-dot[data-status="warning"],
906
964
  :where(.wt-root) .wt-status-dot[data-status="failed"] {
907
- animation: none;
908
965
  /* Motion is what separates the animated states from input's frozen ping;
909
966
  with it removed, punch an inner hole (dot -> donut) and add a static ring
910
967
  so they differ from input (solid ringed disc) and done (bare disc) by
@@ -915,7 +972,11 @@
915
972
  0 0 0 2px color-mix(in srgb, var(--dot-color) 30%, transparent);
916
973
  }
917
974
 
918
- /* The ripple layer disappears entirely (not just paused mid-wave). */
975
+ /* The glow overlay and the ripple layer disappear entirely (not paused
976
+ mid-beat / mid-wave; the beat lives on ::before, the wave on ::after). */
977
+ :where(.wt-root) .wt-status-dot[data-status="working"]::before,
978
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::before,
979
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::before,
919
980
  :where(.wt-root) .wt-status-dot[data-status="working"]::after,
920
981
  :where(.wt-root) .wt-status-dot[data-status="warning"]::after,
921
982
  :where(.wt-root) .wt-status-dot[data-status="failed"]::after {
@@ -33,10 +33,32 @@
33
33
  display: flex;
34
34
  flex-direction: column; /* bar on top; the tab list expands below it as the bar slides up */
35
35
  background: var(--surface);
36
- backdrop-filter: var(--blur);
37
36
  border-top: 1px solid var(--border);
38
37
  z-index: 25;
39
38
  }
39
+
40
+ /* Frosting is paid for only where it shows (same policy as .wt-tab-bar in
41
+ 30-tabs.css, where the full rationale lives). The COLLAPSED bar in the
42
+ full-page product sits over the page kit's flat background — terminal
43
+ content stops above it via --wt-reserve-bottom — so a standing
44
+ backdrop-filter there buys nothing and holds compositor surfaces on the
45
+ one device class (an iPhone under memory pressure) that cannot spare
46
+ them, for the whole life of the page. The EXPANDED tray is the deliberate
47
+ overlay exception: it slides up OVER the terminal, so it keeps the blur —
48
+ a transient, user-invoked surface. An embedded root keeps the blur in
49
+ both states, since the host's own content can sit behind the bar.
50
+
51
+ The expand/collapse hand-off timing lives with the rest of the switcher
52
+ motion in 40-animations.css (gated on .wt-animate like all of it): ON
53
+ instantly at expand, OFF delayed past the collapse animation so the
54
+ shrinking tray stays frosted while still over the terminal. Without the
55
+ animations feature both the tray collapse and the blur toggle are
56
+ instant, which is consistent; over the flat viewport background the
57
+ hand-off is invisible either way by construction. */
58
+ :where(.wt-root.wt-narrow.wt-container) .wt-switcher,
59
+ :where(.wt-root.wt-narrow) .wt-switcher.wt-switcher-expanded {
60
+ backdrop-filter: var(--blur);
61
+ }
40
62
  }
41
63
 
42
64
  /* The always-visible bar row: active tab + keyboard + "+". The padding gives the
@@ -64,6 +64,24 @@
64
64
  padding var(--dur-enter) var(--ease-standard);
65
65
  }
66
66
 
67
+ /* The switcher's frosting hand-off (the blur state rules live in
68
+ 31-switcher.css: viewport-collapsed = none, expanded/container = blurred).
69
+ Asymmetric by design: ON instantly at expand — terminal content appears
70
+ behind the tray at once — and OFF only after the tray's collapse above has
71
+ run, or the shrinking tray would lose its frosting while still over the
72
+ terminal. The off-delay equals the collapse duration (--dur-enter). The
73
+ near-zero 0.01s duration is a delayed switch, not a visible fade (and the
74
+ reduced-motion reset in 01-scope.css flattens durations, not delays, so the
75
+ late switch-off survives there too — invisible over the flat viewport
76
+ background either way). */
77
+ :where(.wt-root.wt-animate.wt-narrow) .wt-switcher {
78
+ transition: backdrop-filter 0.01s linear var(--dur-enter);
79
+ }
80
+
81
+ :where(.wt-root.wt-animate.wt-narrow) .wt-switcher.wt-switcher-expanded {
82
+ transition: backdrop-filter 0.01s linear;
83
+ }
84
+
67
85
  /* When the tab list expands, the keyboard button collapses to zero width and
68
86
  fades (the key grid is closed in JS); the active row flex-grows into the freed
69
87
  space in lockstep as the button's width animates. */
package/css/page.css CHANGED
@@ -217,32 +217,32 @@ body {
217
217
  either ships its own @font-face or lets both tokens fall back to the platform
218
218
  monospace stack. */
219
219
  @font-face {
220
- font-family: "MonaspiceNe NFM";
221
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Regular.otf") format("opentype");
220
+ font-family: "Monaspace Neon NF";
221
+ src: url("/vendor/fonts/MonaspaceNeonNF-Regular.woff2") format("woff2");
222
222
  font-weight: 400;
223
223
  font-style: normal;
224
224
  font-display: block;
225
225
  }
226
226
 
227
227
  @font-face {
228
- font-family: "MonaspiceNe NFM";
229
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Bold.otf") format("opentype");
228
+ font-family: "Monaspace Neon NF";
229
+ src: url("/vendor/fonts/MonaspaceNeonNF-Bold.woff2") format("woff2");
230
230
  font-weight: 700;
231
231
  font-style: normal;
232
232
  font-display: block;
233
233
  }
234
234
 
235
235
  @font-face {
236
- font-family: "MonaspiceNe NFM";
237
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Italic.otf") format("opentype");
236
+ font-family: "Monaspace Neon NF";
237
+ src: url("/vendor/fonts/MonaspaceNeonNF-Italic.woff2") format("woff2");
238
238
  font-weight: 400;
239
239
  font-style: italic;
240
240
  font-display: block;
241
241
  }
242
242
 
243
243
  @font-face {
244
- font-family: "MonaspiceNe NFM";
245
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-BoldItalic.otf") format("opentype");
244
+ font-family: "Monaspace Neon NF";
245
+ src: url("/vendor/fonts/MonaspaceNeonNF-BoldItalic.woff2") format("woff2");
246
246
  font-weight: 700;
247
247
  font-style: italic;
248
248
  font-display: block;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.2.3",
3
+ "version": "5.3.0",
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",
@@ -40,7 +40,7 @@
40
40
  "verify": "bash scripts/verify.sh"
41
41
  },
42
42
  "peerDependencies": {
43
- "@cplieger/web-terminal-engine": "^3.3.0"
43
+ "@cplieger/web-terminal-engine": "^3.7.0"
44
44
  },
45
45
  "overrides": {
46
46
  "qs": "^6.15.2"
@@ -53,16 +53,16 @@
53
53
  "@types/node": "25.9.5",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/coverage-v8": "4.1.10",
56
- "eslint": "10.8.0",
56
+ "eslint": "10.8.1",
57
57
  "fast-check": "4.9.0",
58
- "happy-dom": "20.11.1",
58
+ "happy-dom": "20.11.2",
59
59
  "html-validate": "11.6.2",
60
- "knip": "6.31.0",
60
+ "knip": "6.32.0",
61
61
  "prettier": "3.9.6",
62
62
  "stylelint": "17.14.1",
63
63
  "stylelint-config-standard": "40.0.0",
64
64
  "typescript": "npm:@typescript/typescript6@6.0.2",
65
- "typescript-eslint": "8.65.0",
65
+ "typescript-eslint": "8.66.0",
66
66
  "vitest": "4.1.10"
67
67
  },
68
68
  "publishConfig": {
@@ -72,6 +72,22 @@
72
72
  "type": "git",
73
73
  "url": "git+https://github.com/cplieger/web-terminal-ui.git"
74
74
  },
75
+ "homepage": "https://github.com/cplieger/web-terminal-ui",
76
+ "bugs": {
77
+ "url": "https://github.com/cplieger/web-terminal-ui/issues"
78
+ },
79
+ "author": "cplieger <917744+cplieger@users.noreply.github.com>",
80
+ "keywords": [
81
+ "terminal",
82
+ "terminal-ui",
83
+ "web-terminal",
84
+ "touch",
85
+ "mobile",
86
+ "tabs",
87
+ "ime",
88
+ "browser",
89
+ "typescript"
90
+ ],
75
91
  "files": [
76
92
  "src/",
77
93
  "css/",