@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 +149 -11
- package/css/00-tokens.css +23 -17
- package/css/02-terminal.css +6 -0
- package/css/30-tabs.css +105 -31
- package/css/31-switcher.css +31 -7
- package/css/40-animations.css +18 -0
- package/css/page.css +46 -9
- package/package.json +22 -6
- package/src/features/context-menu.ts +159 -197
- package/src/features/menu-position.ts +13 -7
- package/src/features/scroll-to-bottom.ts +16 -5
- package/src/features/tabs/index.ts +177 -51
- package/src/features/tabs/ink-centre.ts +38 -30
- package/src/features/tabs/model.ts +15 -2
- package/src/index.ts +4 -0
- package/src/kernel/gesture.ts +32 -0
- package/src/kernel/kernel.ts +431 -18
- package/src/kernel/options.ts +58 -0
- package/src/kernel/scrollback-storage.ts +316 -0
- package/src/kernel/scrollback.ts +333 -0
- package/src/kernel/types.ts +209 -4
- package/src/viewport.ts +29 -12
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),
|
|
29
|
-
|
|
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
|
|
164
|
-
|
|
|
165
|
-
| `features`
|
|
166
|
-
| `layout`
|
|
167
|
-
| `wsPath`
|
|
168
|
-
| `fontReady`
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
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
|
-
|
|
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: "
|
|
144
|
-
--font-mono: "
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
box
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
package/css/02-terminal.css
CHANGED
|
@@ -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 —
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
523
|
-
|
|
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
|
-
|
|
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
|
|
844
|
-
the
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
856
|
-
border-color: var(--dot-color);
|
|
928
|
+
opacity: 0;
|
|
857
929
|
}
|
|
858
930
|
|
|
859
931
|
35% {
|
|
860
|
-
|
|
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
|
|
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 {
|
package/css/31-switcher.css
CHANGED
|
@@ -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
|
|
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
|
|
181
|
-
|
|
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
|
|
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
|
|
456
|
-
|
|
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 {
|
package/css/40-animations.css
CHANGED
|
@@ -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. */
|