@cplieger/web-terminal-ui 5.2.3 → 5.3.1

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,37 +134,43 @@
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
- 30-tabs.css).
147
+ 30-tabs.css). The band is the CAP band — cap top to baseline, the visible
148
+ mass of a label — not cap-to-descender, which reserves empty descender room
149
+ below the letters and reads high (measured: 1.32px above the chip's centre
150
+ at 13px, while the dot and close glyph beside it sat at 0.00px).
148
151
 
149
152
  This value is only the PRE-MEASUREMENT DEFAULT — what the first paint uses
150
153
  before the tabs feature replaces it, per site, with a pixel value measured
151
154
  in the live engine (src/features/tabs/ink-centre.ts). It is not the answer,
152
155
  because no single value can be: engines round the font's ascent and descent
153
156
  to whole CSS pixels before building the line box, so the ideal shift is a
154
- sawtooth in font size rather than a ratio. Against the bundled font in
155
- WebKit it is 0.1190em at 13px and 0.0915em at 14px, since 0.25em x 13 =
156
- 3.25 rounds to 3 (a 16px box) while 0.25em x 14 = 3.5 rounds to 4 (an 18px
157
- box). Engines then disagree with each other (Firefox makes that 14px box
158
- 17px) and platforms disagree within one engine (iOS reads metrics through
159
- CoreText, Linux WebKit through FreeType). Four fixes were hardcoded
160
- constants and each was correct only where it was tuned.
157
+ sawtooth in font size rather than a ratio, and on the cap band it does not
158
+ even keep one sign. Measured against the bundled font it runs from about
159
+ -0.021em (Blink 151 at 13px, a 17px box) to +0.028em (Gecko at 14px, where
160
+ the box is 17px against Blink's 18px), and platforms disagree inside one
161
+ engine too (iOS reads metrics through CoreText, Linux WebKit through
162
+ FreeType) — which is why four hardcoded constants each held only where they
163
+ were tuned.
161
164
 
162
- 0.1147em is kept as the default because it is the midpoint of that
163
- sawtooth: worst case 0.34px before the measurement lands, versus 1.5px for
164
- no correction at all. Do not tune it by eye — a wrong default is invisible
165
- the moment the measured value arrives, so eye-tuning it optimises nothing
166
- and pins the wrong number in place. */
167
- --label-ink-shift: 0.1147em;
165
+ ZERO is the default because the whole range is within ~0.03em of it: flex
166
+ centring of the font box is already within 0.4px of cap-band centring, so
167
+ the first paint starts uncorrected and the measurement lands within a frame.
168
+ That is also the best behaviour if the measurement never runs at all (no
169
+ canvas, a display:none probe): 0.4px out rather than the 1.3px a
170
+ descender-inclusive constant was. Do not tune it by eye — a wrong default is
171
+ invisible the moment the measured value arrives, so eye-tuning it optimises
172
+ nothing and pins the wrong number in place. */
173
+ --label-ink-shift: 0em;
168
174
  --dur-micro: 0.1s;
169
175
  --dur-standard: 0.2s;
170
176
  --dur-enter: 0.25s;
@@ -45,6 +45,12 @@
45
45
  touch-action: pan-x pan-y;
46
46
  font-family: var(--font-mono);
47
47
  font-size: 14px;
48
+ /* The cell height. It is PAIRED with the bundled font's overridden vertical
49
+ metrics (page.css): a cell's background is painted by an inline span, whose
50
+ box comes from the font's ascent + descent, so those must stay >= this value
51
+ or every row boundary shows an unpainted stripe. Changing either number
52
+ alone reopens that gap. The engine reads this as its cell height
53
+ (render.ts updateFontMetrics) and sizes the PTY from it. */
48
54
  line-height: 17px;
49
55
  padding: 4px;
50
56
  background: var(--bg);
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.
@@ -200,9 +215,16 @@
200
215
  the baseline than below it (the bundled font: 0.995em over, 0.250em under),
201
216
  while the ink of a mixed-case title runs ~0.734em over (cap height) to ~0.203em
202
217
  under (descender). Flex centring centres the BOX, which leaves the visible text
203
- --label-ink-shift low — about 1.5px at this size — reading as baseline-aligned
204
- with the activity dot's bottom edge instead of level with its centre. Two
205
- declarations, one job, and a third that must NOT be added:
218
+ --label-ink-shift low — reading as baseline-aligned with the activity dot's
219
+ bottom edge instead of level with its centre. Two declarations, one job, and a
220
+ third that must NOT be added:
221
+
222
+ The band being centred is cap top to BASELINE. Descender ink is deliberately
223
+ outside it: including it reserves empty room below the letters, and the label
224
+ then reads HIGH — measured on this chip at 13px, the title's cap band sat
225
+ 1.32px above the chip's centre while the dot and the close glyph beside it sat
226
+ at 0.00px. Same edges as `text-box-edge: cap alphabetic`. Descenders overhang
227
+ the band, which is what the padding below is for.
206
228
 
207
229
  - padding-block is the ink allowance, not spacing. `overflow: hidden` (which
208
230
  the ellipsis requires) clips at the padding box, and the line box clears the
@@ -212,6 +234,9 @@
212
234
  - margin-block hangs TWICE the shift below the box. Flex centring centres the
213
235
  MARGIN box, so a bottom-heavy margin lifts the border box by half of it --
214
236
  exactly one shift -- and unlike padding it does not enlarge the clipping box.
237
+ The shift can be NEGATIVE (a top-heavy margin, pushing the box down) at sizes
238
+ where the engine's rounding already overshoots; that is a normal outcome of
239
+ the measurement, not a sign the arithmetic is inverted.
215
240
  - line-height stays at `normal`, deliberately. Setting it looks like the
216
241
  responsible thing to do and it is the trap: `normal` is the ONE value whose
217
242
  half-leading is zero by definition, which is what makes the baseline sit at
@@ -225,16 +250,19 @@
225
250
  --label-ink-shift is MEASURED, not declared. The tabs feature writes a pixel
226
251
  value per site from the line box the running engine produced for this font at
227
252
  this size (src/features/tabs/ink-centre.ts); the em value in 00-tokens.css is
228
- only what the first paint uses. Four earlier fixes were constants, and the
229
- reason none held is that engines round the font's ascent and descent to whole
230
- CSS pixels before laying out the line box, so the required shift is a sawtooth
231
- in font size, not a ratio: in WebKit 0.1190em at 13px against 0.0915em at 14px,
232
- because 0.25em x 13 = 3.25 rounds to 3 (a 16px box) while 0.25em x 14 = 3.5
233
- rounds to 4 (an 18px box). The three engines also report slightly different ink
234
- extents for one font file (cap 0.7344 / 0.7350 / 0.7300em in WebKit, Blink and
235
- Gecko), Firefox makes that 14px box 17px rather than 18px, and iOS resolves
236
- metrics through CoreText where Linux WebKit uses FreeType — which is why the
237
- 13px strip measured centred in every local check and still read high on an
253
+ only what the first paint uses, and it is 0 because the cap-band correction is
254
+ within 0.4px of nothing on every engine measured. Four earlier fixes were
255
+ constants, and the reason none held is that engines round the font's ascent and
256
+ descent to whole CSS pixels before laying out the line box, so the required
257
+ shift is a sawtooth in font size, not a ratio: on the cap band it runs from
258
+ about -0.021em (Blink 151 at 13px, a 17px box) to +0.0279em (Gecko at 14px,
259
+ where the box is 17px against Blink's 18px), changing sign along the way,
260
+ because 0.25em x 13 = 3.25 and 0.25em x 14 = 3.5 round to different whole
261
+ pixels. The three engines
262
+ also report slightly different ink extents for one font file (cap 0.7344 /
263
+ 0.7350 / 0.7300em in WebKit, Blink and Gecko), and iOS resolves metrics through
264
+ CoreText where Linux WebKit uses FreeType — which is why the 13px strip
265
+ measured centred in every local check and still read high on an
238
266
  iPad. Nothing here is tunable by eye; if the centring looks wrong, run
239
267
  scripts/verify-chip-geometry.mjs and fix what it measures.
240
268
 
@@ -519,8 +547,10 @@
519
547
  }
520
548
 
521
549
  /* 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
550
+ under .wt-root — OUTSIDE the bar's backdrop-filter (embedded wt-container
551
+ roots only, since 5.3.0; the full-page bar no longer carries one), which is
552
+ what stops WebKit from rendering the automatic drag image (webkit.org/b/22787)
553
+ — and laid exactly
524
554
  over the real chip via inline left/top/width/height. It exists for less than a
525
555
  frame (the browser snapshots it, then a rAF removes it), so it must not be
526
556
  hidden (an invisible element makes no snapshot) and must not intercept the
@@ -733,7 +763,15 @@
733
763
  position: relative;
734
764
  background: var(--dot-color);
735
765
  border-color: var(--dot-color);
736
- animation: wt-working-glow 1.2s ease-in-out infinite;
766
+ /* Own compositing layer. The glow overlay (::before) and the wave (::after)
767
+ are compositor-driven layers, and a nested layer's position snaps to
768
+ DEVICE pixels independently of its parent's fractional page position — at
769
+ 9px that reads as the pulse sitting visibly off-centre (measured on a
770
+ DPR-1 display: bottom-left or bottom-right depending on rounding).
771
+ Promoting the dot makes disc, overlay and wave raster in one frame of
772
+ reference, so they snap together. The disc itself is STATIC now; the beat
773
+ lives on the overlay (see the ::before rule below). */
774
+ transform: translateZ(0);
737
775
  }
738
776
 
739
777
  /* The one difference between the three animated states. Kept as three
@@ -752,6 +790,43 @@
752
790
  --dot-color: var(--status-failed);
753
791
  }
754
792
 
793
+ /* The glow beat, compositor-only (2026-08; owner-ratified against a live A/B
794
+ demo). It was an element-level background-color/border-color animation,
795
+ which repainted the disc every frame for the whole time an agent works —
796
+ pure battery/heat on a phone (the demo measured 2x fps once multiplied).
797
+ The disc never changes now; this pre-painted overlay carries the 35%-peak
798
+ bright colour and animates ONLY opacity, so after one rasterization the
799
+ GPU blends two static layers.
800
+
801
+ Geometry is load-bearing: a HARD-edged overlay at this size shows any
802
+ half-pixel layer snap as an off-centre pulse (three positioning strategies
803
+ all failed on a DPR-1 display), so the overlay is a radial with a solid
804
+ bright core and a ~1px feather ending at the disc's rim — a snapped
805
+ half-pixel falls inside the feather. The travelling wave below has proven
806
+ that property for the same geometry all along.
807
+
808
+ The stale-raster hazard the old colors-at-every-frame beat existed to pin
809
+ (a dot entering working in the same frame input/done ends re-pulsing the
810
+ previously rasterized paint) is held by construction instead: this pseudo
811
+ exists only in the three animated states and resolves --dot-color at
812
+ raster time, so there is no previous-state paint to re-pulse. */
813
+ :where(.wt-root) .wt-status-dot[data-status="working"]::before,
814
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::before,
815
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::before {
816
+ content: "";
817
+ position: absolute;
818
+ inset: -2px;
819
+ border-radius: 50%;
820
+ background: radial-gradient(
821
+ closest-side,
822
+ color-mix(in oklch, var(--dot-color) 78%, #fff) 0 70%,
823
+ transparent 92%
824
+ );
825
+ opacity: 0;
826
+ animation: wt-working-glow 1.2s ease-in-out infinite;
827
+ will-change: opacity;
828
+ }
829
+
755
830
  /* The ripple layer: a feathered radial BAND (not a hairline border) that starts
756
831
  hugging the disc's edge (inset -2px, the 9->13px box) and then travels
757
832
  outward, dissolving. The band is painted as a radial-gradient — soft on both
@@ -840,25 +915,21 @@
840
915
  }
841
916
 
842
917
  /* 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. */
918
+ cycle — expressed as the opacity envelope of the pre-painted bright overlay
919
+ (the ::before rule above), so the beat costs compositing, not repaints.
920
+ The element's own opacity stays 1 throughout; only the overlay fades.
921
+
922
+ Named for what it does, shared verbatim by working, warning and failed —
923
+ each supplies its own hue through --dot-color at the overlay's raster. */
851
924
  @keyframes wt-working-glow {
852
925
  0%,
853
926
  70%,
854
927
  100% {
855
- background-color: var(--dot-color);
856
- border-color: var(--dot-color);
928
+ opacity: 0;
857
929
  }
858
930
 
859
931
  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);
932
+ opacity: 1;
862
933
  }
863
934
  }
864
935
 
@@ -904,7 +975,6 @@
904
975
  :where(.wt-root) .wt-status-dot[data-status="working"],
905
976
  :where(.wt-root) .wt-status-dot[data-status="warning"],
906
977
  :where(.wt-root) .wt-status-dot[data-status="failed"] {
907
- animation: none;
908
978
  /* Motion is what separates the animated states from input's frozen ping;
909
979
  with it removed, punch an inner hole (dot -> donut) and add a static ring
910
980
  so they differ from input (solid ringed disc) and done (bare disc) by
@@ -915,7 +985,11 @@
915
985
  0 0 0 2px color-mix(in srgb, var(--dot-color) 30%, transparent);
916
986
  }
917
987
 
918
- /* The ripple layer disappears entirely (not just paused mid-wave). */
988
+ /* The glow overlay and the ripple layer disappear entirely (not paused
989
+ mid-beat / mid-wave; the beat lives on ::before, the wave on ::after). */
990
+ :where(.wt-root) .wt-status-dot[data-status="working"]::before,
991
+ :where(.wt-root) .wt-status-dot[data-status="warning"]::before,
992
+ :where(.wt-root) .wt-status-dot[data-status="failed"]::before,
919
993
  :where(.wt-root) .wt-status-dot[data-status="working"]::after,
920
994
  :where(.wt-root) .wt-status-dot[data-status="warning"]::after,
921
995
  :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
@@ -174,11 +196,12 @@
174
196
 
175
197
  /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
176
198
  the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
177
- that lifts the ink to the row centre. These labels are 14px against the strip's
199
+ that lifts the CAP band (cap top to baseline, never cap-to-descender) to the row
200
+ centre. These labels are 14px against the strip's
178
201
  13px, which is exactly why --label-ink-shift is MEASURED per site at runtime
179
202
  (src/features/tabs/ink-centre.ts) rather than declared once: the shift an engine
180
- needs is a sawtooth in font size, not a ratio, so the single em constant that
181
- served both sizes was correct at 13px and 0.33px high here. See 30-tabs.css for
203
+ needs is a sawtooth in font size, not a ratio — it does not even keep one sign
204
+ across the two sizes. See 30-tabs.css for
182
205
  the full derivation, for why line-height must stay `normal`, and for why a
183
206
  measured shift beats a text-box trim behind an @supports gate. */
184
207
  :where(.wt-root) .wt-switcher-label {
@@ -449,11 +472,12 @@
449
472
 
450
473
  /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
451
474
  the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
452
- that lifts the ink to the row centre. These labels are 14px against the strip's
475
+ that lifts the CAP band (cap top to baseline, never cap-to-descender) to the row
476
+ centre. These labels are 14px against the strip's
453
477
  13px, which is exactly why --label-ink-shift is MEASURED per site at runtime
454
478
  (src/features/tabs/ink-centre.ts) rather than declared once: the shift an engine
455
- needs is a sawtooth in font size, not a ratio, so the single em constant that
456
- served both sizes was correct at 13px and 0.33px high here. See 30-tabs.css for
479
+ needs is a sawtooth in font size, not a ratio — it does not even keep one sign
480
+ across the two sizes. See 30-tabs.css for
457
481
  the full derivation, for why line-height must stay `normal`, and for why a
458
482
  measured shift beats a text-box trim behind an @supports gate. */
459
483
  :where(.wt-root) .wt-switcher-row-label {
@@ -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. */