@cplieger/web-terminal-ui 5.2.2 → 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 +149 -11
- package/css/00-tokens.css +3 -3
- package/css/02-terminal.css +38 -7
- package/css/30-tabs.css +79 -18
- package/css/31-switcher.css +23 -1
- package/css/40-animations.css +18 -0
- package/css/page.css +8 -8
- package/package.json +23 -7
- 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 +1 -1
- 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,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
|
-
|
|
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
147
|
30-tabs.css).
|
package/css/02-terminal.css
CHANGED
|
@@ -191,13 +191,14 @@
|
|
|
191
191
|
display: block;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/* Clickable URLs. OSC 8 hyperlinks (base .term-link) underline on HOVER
|
|
195
|
-
an application can attach one OSC 8 link to a whole
|
|
196
|
-
e.g. a markdown link whose URL wraps inside a table
|
|
197
|
-
keeps the link open across the cell padding, the
|
|
198
|
-
adjacent column — so a persistent underline
|
|
199
|
-
and, on wrap, the full row. Hover-only matches
|
|
200
|
-
own text colour (which the engine renders)
|
|
194
|
+
/* Clickable URLs. OSC 8 hyperlinks (base .term-link) underline on HOVER where a
|
|
195
|
+
pointer can hover: an application can attach one OSC 8 link to a whole
|
|
196
|
+
padded/bordered region — e.g. a markdown link whose URL wraps inside a table
|
|
197
|
+
cell, where the terminal keeps the link open across the cell padding, the
|
|
198
|
+
borders and the empty adjacent column — so a persistent underline risks
|
|
199
|
+
bleeding across the whole cell and, on wrap, the full row. Hover-only matches
|
|
200
|
+
iTerm2 / VTE / kitty. The app's own text colour (which the engine renders)
|
|
201
|
+
keeps the link discoverable at rest.
|
|
201
202
|
Auto-detected bare URLs (.term-autolink) are tightly scoped to the URL text and
|
|
202
203
|
never bleed, so they keep a subtle persistent underline. (The engine also only
|
|
203
204
|
anchors runs with real link text, so the underline hugs the text either way.) */
|
|
@@ -215,6 +216,36 @@
|
|
|
215
216
|
text-decoration: underline solid;
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
/* Where nothing can hover, hover-only is no affordance at all: a touch user sees
|
|
220
|
+
an app-declared link as plain text and has to guess. Measured on kiro-cli at 41
|
|
221
|
+
columns: it emits OSC 8 for every markdown link (its hyperlink capability
|
|
222
|
+
unlocks on the TERM_PROGRAM=iTerm.app identity the engine advertises), so a
|
|
223
|
+
phone session is full of tappable, invisible links. Give them the same subtle
|
|
224
|
+
dotted underline the auto-detected ones carry.
|
|
225
|
+
|
|
226
|
+
The bleed risk above is what kept this off, and it is narrower than it reads:
|
|
227
|
+
the engine refuses to anchor a run with no real link text, so the padding, the
|
|
228
|
+
box-drawing borders and the indent cells a wrapped link leaks onto the next row
|
|
229
|
+
stay unanchored and unmarked (verified against a captured OSC 8 link that wraps
|
|
230
|
+
mid-URL: the three leaked indent cells carry the href on the wire and get no
|
|
231
|
+
anchor). What remains is an app leaving one link open across genuine text in an
|
|
232
|
+
adjacent column, which would be underlined too. On a device that cannot hover,
|
|
233
|
+
an occasional over-underline beats a link with no affordance.
|
|
234
|
+
|
|
235
|
+
`hover: none` is the pointing device's capability, not a viewport width, so a
|
|
236
|
+
narrow window on a desktop keeps hover styling and a tablet with a trackpad is
|
|
237
|
+
treated as hoverable. */
|
|
238
|
+
@media (hover: none) {
|
|
239
|
+
:where(.wt-root) .term-link {
|
|
240
|
+
text-decoration: underline dotted;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* Keep the pressed/solid distinction reachable without hover. */
|
|
244
|
+
:where(.wt-root) .term-link:active {
|
|
245
|
+
text-decoration: underline solid;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
218
249
|
/* Inline cursor cell (xterm.js convention): the character at the cursor
|
|
219
250
|
position gets the .term-cursor class. CSS gives it inverted colors;
|
|
220
251
|
the blink is driven by a JS interval that toggles .cursor-blink-off on
|
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
|
|
523
|
-
|
|
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
|
-
|
|
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
|
|
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. */
|
|
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
|
-
|
|
856
|
-
border-color: var(--dot-color);
|
|
915
|
+
opacity: 0;
|
|
857
916
|
}
|
|
858
917
|
|
|
859
918
|
35% {
|
|
860
|
-
|
|
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
|
|
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 {
|
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
|
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. */
|
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: "
|
|
221
|
-
src: url("/vendor/fonts/
|
|
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: "
|
|
229
|
-
src: url("/vendor/fonts/
|
|
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: "
|
|
237
|
-
src: url("/vendor/fonts/
|
|
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: "
|
|
245
|
-
src: url("/vendor/fonts/
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
"eslint": "10.8.1",
|
|
57
57
|
"fast-check": "4.9.0",
|
|
58
|
-
"happy-dom": "20.11.
|
|
59
|
-
"html-validate": "11.6.
|
|
60
|
-
"knip": "6.
|
|
58
|
+
"happy-dom": "20.11.2",
|
|
59
|
+
"html-validate": "11.6.2",
|
|
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
|
+
"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/",
|