@cplieger/web-terminal-ui 7.2.2 → 7.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
@@ -63,10 +63,16 @@ page that has one empty container element, then call
63
63
  **Full-page host** (the terminal IS the page, as in `web-terminal-server` and
64
64
  `web-terminal-kiro`): concatenate `css/MANIFEST` into the `style.css` your page
65
65
  links. That reference bundle is `css/page.css` (the page kit: `html/body`
66
- reset + the bundled web font's `@font-face`, expecting the font files at
67
- `/vendor/fonts/`) plus the complete component set. The bundled font backs both
68
- the terminal display and the chrome text, so serving those files is not
69
- optional for a full-page host.
66
+ reset + the `@font-face` rules for two families, expecting the font files at
67
+ `/vendor/fonts/`) plus the complete component set. The set is the four
68
+ `MonaspaceNeonNF-*.woff2` faces, which back both the terminal display and the
69
+ chrome text, plus `WebTerminalGlyphs.woff2` — a tiling overlay that supplies the
70
+ box-drawing, block, braille and powerline cells the terminal draws — served
71
+ alongside that font's `LICENSE` and `NOTICE`. Serving the Monaspace faces is not
72
+ optional for a full-page host. A host that has not yet added the overlay file
73
+ degrades cleanly rather than breaking: the face errors out, and the terminal
74
+ renders exactly as it did before, with the tiling ranges coming from Monaspace
75
+ and the system font.
70
76
 
71
77
  **Embedder** (the terminal lives inside your app's layout, as a panel or pane):
72
78
  concatenate the per-preset manifest matching your composition instead:
@@ -133,16 +139,17 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
133
139
  - `presetTabbed()`: the generic tabbed UI, `presetTouch()` plus tabs, the
134
140
  activity monitor, and animations. Each tab's title is OSC-first: it follows
135
141
  the process window title (OSC 0/2) when the program sets one and keeps it
136
- updated, otherwise the last command submitted. The per-tab activity dot
142
+ updated, otherwise a name the server infers from the foreground process or the
143
+ working directory. The per-tab activity dot
137
144
  reveals itself only when a session reports OSC 9;4 progress, so a plain shell
138
145
  keeps clean, label-only tabs. Requires a server that speaks the session API
139
- (`/api/sessions` and `/ws?session=`), such as `web-terminal-server`.
146
+ (`/api/sessions`, `/ws?session=`, and the status SSE `/api/sessions/events`),
147
+ such as `web-terminal-server`.
140
148
  - `presetAgentTabbed()`: the same feature set as `presetTabbed()`, tuned for an
141
- agent shell such as `web-terminal-kiro`. With `preferInputTitle`, each tab's
142
- label follows the latest submitted line (persisted server-side and recovered
143
- on reload) and the program's OSC 0/2 title is ignored. With `presumeReports`,
144
- the idle activity dot shows from tab creation instead of waiting for the
145
- session's first OSC 9;4 signal. Its status dots come from the same activity
149
+ agent shell such as `web-terminal-kiro`, and differing from `presetTabbed()`
150
+ only in presumed activity reporting. With `presumeReports`, the idle activity
151
+ dot shows from tab creation instead of waiting for the session's first
152
+ OSC 9;4 signal. Its status dots come from the same activity
146
153
  monitor and cover eight states: the three animated OSC 9;4 progress states
147
154
  (working, warning, error), the frozen ringed needs-input dot, two static discs
148
155
  (a green finished turn, a red crashed process), and two hollow ones (idle and a
@@ -185,7 +192,7 @@ are importable from `…/features/<name>` (`clipboard`, `context-menu`,
185
192
  | `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. |
186
193
  | `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). |
187
194
  | `wsPath` | `"/ws"` | WebSocket endpoint path the engine connects to. |
188
- | `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. |
195
+ | `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. Only the family that CARRIES those metrics is named: the tiling overlay copies them and holds no letter for the width probe to measure, and naming it as well would open the gate on the overlay alone in WebKit (Safari, every iOS browser), while Monaspace was still loading. The wait is bounded at 3s whatever the fonts do, the `font-display: block` period the bundled faces declare, so a request that stalls leaves the terminal sized on fallback metrics rather than never sized at all, and a font that lands after the bound costs one corrective resize. |
189
196
  | `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. |
190
197
  | `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. |
191
198
  | `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. |
@@ -485,7 +492,7 @@ about your server rather than about this terminal.
485
492
  | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
486
493
  | `src/**/*.ts` | The UI modules: the kernel (`kernel/`), opt-in features (`features/`), per-preset entries (`presets/`), IME, predictive echo, viewport. |
487
494
  | `css/*.css` + manifests | Root-scoped component styles. `MANIFEST` = the reference full-page bundle (`page.css` + the tabbed set); `MANIFEST.single/touch/tabbed` = component-only per-preset bundles for embedders. Concatenate in manifest order. |
488
- | `css/page.css` | The page kit (full-page hosts only): `html/body` reset + the terminal web font's `@font-face`. |
495
+ | `css/page.css` | The page kit (full-page hosts only): `html/body` reset + the `@font-face` rules for the terminal's two web fonts, `Monaspace Neon NF` and the `Web Terminal Glyphs` tiling overlay. |
489
496
  | `scaffold/index.html` | A reference full-page HTML host: `<head>` + one empty root element + importmap. |
490
497
 
491
498
  ## Related projects
@@ -518,3 +525,9 @@ This project was built with AI-assisted tooling using [Claude](https://claude.co
518
525
  ## License
519
526
 
520
527
  MPL-2.0. See [LICENSE](LICENSE).
528
+
529
+ The `Web Terminal Glyphs` tiling overlay named in `css/page.css` is a separate
530
+ Apache-2.0 work, and its licence travels with the FONT FILE — which this package
531
+ does not ship, it only names the URL. So no new obligation lands on an npm or JSR
532
+ consumer; the obligation is the serving host's, which is why the full-page host
533
+ notes above ask for the font's `LICENSE` and `NOTICE` beside the `.woff2`.
package/css/00-tokens.css CHANGED
@@ -141,13 +141,44 @@
141
141
  strip (MANIFEST.touch excludes 30-tabs.css), so nothing metric-dependent
142
142
  rides on that fallback. */
143
143
  --font-ui: "Monaspace Neon NF", ui-monospace, monospace;
144
- --font-mono: "Monaspace Neon NF", monospace;
144
+ /* The terminal display is an OVERLAY over a companion: "Web Terminal Glyphs"
145
+ is a tiling-only face (box drawing, blocks, braille, powerline — 1,098
146
+ codepoints, no letters, no digits, not even a space), so the browser takes
147
+ those cells from it and everything else from Monaspace by ordinary family
148
+ fallback. It leads deliberately, so its cell-exact glyphs win where the
149
+ companion also has a codepoint; carrying no text glyphs, it cannot move the
150
+ companion's metrics or its look. page.css declares the four faces and owns
151
+ the rest of the reasoning.
152
+
153
+ --font-ui above does NOT get the overlay, and that is a scope choice rather
154
+ than an omission: the chrome renders labels and never a tiling codepoint, so
155
+ the overlay would be a family the browser consults on every glyph and never
156
+ draws from. It would not cost a label geometry to add it — the overlay's
157
+ vertical metrics are copied from the companion (the upstream font's
158
+ dist/cell.json, 1890/-400/200 with USE_TYPO_METRICS set on both), so the
159
+ line box is the same either way. The reason to keep the chrome's stack at one
160
+ family is least mechanism, and --label-ink-shift below is why that matters:
161
+ it is written from the line box the engine actually built
162
+ (src/features/tabs/ink-centre.ts), so anything that DOES move the chrome's
163
+ metrics lands straight in the chip labels. */
164
+ --font-mono: "Web Terminal Glyphs", "Monaspace Neon NF", monospace;
145
165
  /* Pre-measurement default for the chip labels' ink centring; the tabs feature
146
166
  writes a measured px value per site (src/features/tabs/ink-centre.ts owns
147
167
  why). Zero: flex centring already lands within 0.4px of the cap band, and is
148
168
  the best answer when the measurement cannot run. Do not tune it by eye. */
149
169
  --label-ink-shift: 0em;
150
170
  --dur-micro: 0.1s;
171
+ /* The activity vocabulary's one beat period, read by all three animated tab
172
+ cues — the working dot's glow, its travelling ripple, and the secondary
173
+ activity mark's breath (30-tabs.css). A token rather than three literals
174
+ because the three are phase-locked BY sharing it: the ripple launches off the
175
+ disc as the glow peaks, and the mark beside them reports on the same clock, so
176
+ retuning one alone desynchronises cues a reader sees side by side. It was a
177
+ comment saying "change both or neither" until 2026-09, which is the same rule
178
+ with nothing enforcing it.
179
+ Shared with vibekit's `--dot-beat-dur` (01-tokens.css) for the reason the
180
+ --status-* inks are: one signal across the two apps, beat included. */
181
+ --dot-beat-dur: 1.2s;
151
182
  --dur-standard: 0.2s;
152
183
  --dur-enter: 0.25s;
153
184
  --dur-exit: 0.15s;
@@ -45,7 +45,9 @@
45
45
  touch-action: pan-x pan-y;
46
46
  font-family: var(--font-mono);
47
47
  /* Bold and italic are real faces (page.css); a synthesized stand-in must
48
- never be drawn in their place. */
48
+ never be drawn in their place. Not redundant with those declarations: Blink
49
+ obliques an italic run over the tiling overlay despite its exact italic face,
50
+ so this line is the only thing keeping box drawing upright there. */
49
51
  font-synthesis: none;
50
52
  font-size: 14px;
51
53
  /* The cell height. The engine reads it (render.ts updateFontMetrics) and sizes
@@ -35,6 +35,7 @@
35
35
  font: inherit;
36
36
  cursor: pointer;
37
37
  touch-action: manipulation;
38
+ -webkit-user-select: none;
38
39
  user-select: none;
39
40
  }
40
41
 
@@ -111,6 +111,7 @@
111
111
  font-weight: 500;
112
112
  cursor: pointer;
113
113
  touch-action: manipulation;
114
+ -webkit-user-select: none;
114
115
  user-select: none;
115
116
  display: flex;
116
117
  align-items: center;
package/css/30-tabs.css CHANGED
@@ -815,7 +815,7 @@
815
815
  transparent 92%
816
816
  );
817
817
  opacity: 0;
818
- animation: wt-working-glow 1.2s ease-in-out infinite;
818
+ animation: wt-working-glow var(--dot-beat-dur) ease-in-out infinite;
819
819
  will-change: opacity;
820
820
  }
821
821
 
@@ -850,7 +850,7 @@
850
850
  var(--dot-color) 76%,
851
851
  transparent 100%
852
852
  );
853
- animation: wt-working-ripple 1.2s linear infinite;
853
+ animation: wt-working-ripple var(--dot-beat-dur) linear infinite;
854
854
  }
855
855
 
856
856
  /* Agent done / ready: solid disc (--status-done), no motion. The default green
@@ -983,14 +983,44 @@
983
983
  this size reads as an off-centre pulse. */
984
984
  :where(.wt-root) .wt-activity-mark[data-activity="working"] {
985
985
  --wt-mark-ink: var(--status-working);
986
+ /* The band, named because the MASK below has to know it: the overlay's hole is
987
+ the mark's inradius less this. Paired with the declaration it describes so the
988
+ two cannot drift apart on width. */
989
+ --wt-mark-band: 2px;
986
990
 
987
- border-width: 2px;
991
+ border-width: var(--wt-mark-band);
988
992
  transform: translateZ(0);
989
993
  }
990
994
 
991
995
  /* The breath: a pre-painted radial animating OPACITY only, never colours, so the
992
996
  beat costs compositing rather than a repaint per frame for as long as a
993
- background task runs (the battery reason the dot's glow overlay records). */
997
+ background task runs (the battery reason the dot's glow overlay records).
998
+
999
+ PLUS A MASK, which is the one thing a ring needs and a disc does not, and whose
1000
+ absence was a real defect rather than a missing nicety. Over a solid disc the
1001
+ overlay's bright core has almost no headroom above its own backdrop; over a
1002
+ ring's transparent HOLE it has all of it, so an unmasked core FILLED the hole at
1003
+ the peak and the mark rendered as a solid rounded square. That is the one thing
1004
+ this element may never be: the mark and the activity dot 8px away share their ink
1005
+ at zero hue distance, so the ring-against-disc silhouette is the entire WCAG
1006
+ 1.4.1 channel between them, and a fill collapses it for the brightest third of
1007
+ every cycle. Masked, the beat lights the BAND and the hole's four corners, so
1008
+ what a reader sees is the hole morphing between a square and the circle inscribed
1009
+ in it — the ring survives the peak.
1010
+
1011
+ The divisor is the arithmetic rather than a literal, for the reason the footprint
1012
+ cancel above is: the size is a variable and the band is now one too, so a literal
1013
+ would be right until either moved. Ported from vibekit's `.tab-run-dot`
1014
+ (`12-tabs.css`, "THE GLOW BEAT"), which is the same mark solving the same problem
1015
+ and had the mask from the start.
1016
+
1017
+ NOTHING HERE EXTENDS PAST THE MARK. `box-sizing: border-box` (01-scope.css) puts
1018
+ the padding box — an absolutely positioned child's containing block — INSIDE the
1019
+ band, so on a 9px mark with a 2px band that box is 5x5 and `inset: -1px` resolves
1020
+ to 7x7, still inside the border box. Read it as "fill the mark", not as "overhang
1021
+ it": the same declaration on a mark with no band would reach outside, where a
1022
+ feathered edge's visible radius moves with the beat and renders differently at
1023
+ every pixel density. */
994
1024
  :where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
995
1025
  content: "";
996
1026
  position: absolute;
@@ -1002,8 +1032,13 @@
1002
1032
  transparent 92%
1003
1033
  );
1004
1034
  opacity: 0;
1005
- animation: wt-activity-breath 1.2s ease-in-out infinite;
1035
+ animation: wt-activity-breath var(--dot-beat-dur) ease-in-out infinite;
1006
1036
  will-change: opacity;
1037
+ mask: radial-gradient(
1038
+ closest-side,
1039
+ transparent 0 calc(var(--wt-mark-size) / 2 - var(--wt-mark-band)),
1040
+ #000 calc(var(--wt-mark-size) / 2 - var(--wt-mark-band))
1041
+ );
1007
1042
  }
1008
1043
 
1009
1044
  /* Waiting: the thin band plus a static halo. Stopped and resumable, nobody being
@@ -1054,8 +1089,10 @@
1054
1089
  usable weight through the travel. The ramp-in is short enough to read as
1055
1090
  instant while stopping the emission from popping in.
1056
1091
 
1057
- The disc's own glow shares this 1.2s period so the two cues stay phase-locked;
1058
- change both or neither. */
1092
+ The disc's own glow shares this period so the two cues stay phase-locked, and
1093
+ since 2026-09 that is STRUCTURAL rather than a request: all three animated cues
1094
+ read `--dot-beat-dur` (00-tokens.css), so there is no longer a pair of literals
1095
+ to change both of. */
1059
1096
  @keyframes wt-working-ripple {
1060
1097
  0% {
1061
1098
  transform: scale(1);
@@ -1076,9 +1113,16 @@
1076
1113
  }
1077
1114
  }
1078
1115
 
1079
- /* The activity mark's breath, same envelope as the dot's glow: rest, one bright
1080
- swell, rest. Opacity only — a colour keyframe here would reintroduce the
1081
- per-frame repaint this shape exists to avoid. */
1116
+ /* The activity mark's breath: rest, one swell, rest. Opacity only — a colour
1117
+ keyframe here would reintroduce the per-frame repaint this shape exists to avoid.
1118
+
1119
+ The dot's glow ENVELOPE, at a lower CEILING, and the ceiling is where the two
1120
+ deliberately part. 0.55 rather than 1 is vibekit's number for the same mark
1121
+ (`12-tabs.css`), and it is what keeps a strip of busy tabs reading as one pulse
1122
+ instead of a row of flashes — the dot can afford 1 because its overlay lifts a
1123
+ solid disc of its own hue and has almost no headroom above its own backdrop,
1124
+ while this one is masked onto a 2px band over the page and has all of it. Used by
1125
+ the mark alone, so the value moves nothing else. */
1082
1126
  @keyframes wt-activity-breath {
1083
1127
  0%,
1084
1128
  70%,
@@ -1087,7 +1131,7 @@
1087
1131
  }
1088
1132
 
1089
1133
  35% {
1090
- opacity: 1;
1134
+ opacity: 0.55;
1091
1135
  }
1092
1136
  }
1093
1137
 
@@ -1131,9 +1175,13 @@
1131
1175
 
1132
1176
  /* Working loses its breath, so it takes the HEAVIEST band instead: motion is
1133
1177
  REPLACED by a channel rather than removed, and the three states then separate
1134
- by band alone (3px bare / 1px halo / 2px halo). */
1178
+ by band alone (3px bare / 1px halo / 2px halo).
1179
+
1180
+ Re-pointing --wt-mark-band rather than declaring `border-width` keeps the number
1181
+ in the one place the state's own rule reads it from, the mask's divisor included
1182
+ — which is moot here only because the overlay is gone one rule below. */
1135
1183
  :where(.wt-root) .wt-activity-mark[data-activity="working"] {
1136
- border-width: 3px;
1184
+ --wt-mark-band: 3px;
1137
1185
  }
1138
1186
 
1139
1187
  :where(.wt-root) .wt-activity-mark[data-activity="working"]::before {
package/css/page.css CHANGED
@@ -236,7 +236,33 @@ body {
236
236
  monospace stack.
237
237
 
238
238
  No face carries ascent-override or descent-override: the terminal's cell
239
- background is the run padding in 02-terminal.css, never these metrics. */
239
+ background is the run padding in 02-terminal.css, never these metrics.
240
+
241
+ The tiling overlay, "Web Terminal Glyphs", is the second family below, and it
242
+ is ONE file declared under FOUR descriptor sets. The released face is a single
243
+ upright 400 weight, so a lone rule would leave the engine free to synthesise a
244
+ stand-in for a bold or italic run. What the four sets (400/700 x normal/italic)
245
+ against the same URL foreclose is the EMBOLDENED stand-in, in Blink and in
246
+ Gecko both: a bold run resolves a real face where the same asset under one rule
247
+ is thickened. They do NOT foreclose the OBLIQUE one in Blink, which honours the
248
+ italic descriptor for face SELECTION and slants the run anyway; the only thing
249
+ keeping an italic box-drawing corner upright there is font-synthesis: none on
250
+ .term (02-terminal.css). Both mechanisms are load-bearing and neither is the
251
+ whole guarantee. All four rules point at one file, so the bytes download once.
252
+
253
+ No overlay face declares unicode-range, and that absence is deliberate: the
254
+ font's own cmap IS its range (1,098 tiling codepoints, no letters, no digits,
255
+ no space), so a letter falls through to Monaspace by ordinary family fallback,
256
+ and a range here would only narrow that fallback to a list somebody has to
257
+ maintain against the next release of the font.
258
+
259
+ The overlay is listed FIRST in --font-mono and never in --font-ui
260
+ (00-tokens.css owns why). A full-page host must serve
261
+ WebTerminalGlyphs.woff2 beside the Monaspace faces, along with that font's
262
+ LICENSE and NOTICE: it is a separate Apache-2.0 work, and §4 binds whoever
263
+ ships the bytes. This package ships none, it only names the URL — so a host
264
+ that has not caught up errors the face out and renders exactly as it did
265
+ before, with the tiling ranges coming from Monaspace and the system font. */
240
266
  @font-face {
241
267
  font-family: "Monaspace Neon NF";
242
268
  src: url("/vendor/fonts/MonaspaceNeonNF-Regular.woff2") format("woff2");
@@ -268,3 +294,35 @@ body {
268
294
  font-style: italic;
269
295
  font-display: block;
270
296
  }
297
+
298
+ @font-face {
299
+ font-family: "Web Terminal Glyphs";
300
+ src: url("/vendor/fonts/WebTerminalGlyphs.woff2") format("woff2");
301
+ font-weight: 400;
302
+ font-style: normal;
303
+ font-display: block;
304
+ }
305
+
306
+ @font-face {
307
+ font-family: "Web Terminal Glyphs";
308
+ src: url("/vendor/fonts/WebTerminalGlyphs.woff2") format("woff2");
309
+ font-weight: 700;
310
+ font-style: normal;
311
+ font-display: block;
312
+ }
313
+
314
+ @font-face {
315
+ font-family: "Web Terminal Glyphs";
316
+ src: url("/vendor/fonts/WebTerminalGlyphs.woff2") format("woff2");
317
+ font-weight: 400;
318
+ font-style: italic;
319
+ font-display: block;
320
+ }
321
+
322
+ @font-face {
323
+ font-family: "Web Terminal Glyphs";
324
+ src: url("/vendor/fonts/WebTerminalGlyphs.woff2") format("woff2");
325
+ font-weight: 700;
326
+ font-style: italic;
327
+ font-display: block;
328
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "7.2.2",
3
+ "version": "7.3.1",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "MPL-2.0",
@@ -1,13 +1,17 @@
1
- // activityMonitor feature: subscribes to the server's status SSE
2
- // (/api/sessions/events via the engine's connectStatusStream) and exposes each
3
- // session's live status through its API (design sections 7, 22.4, 22.5). It is a
4
- // pure data source with no chrome of its own; the tabs feature consumes it via
5
- // ctx.use to render per-tab activity dots and drop exited/removed tabs. The
6
- // server pushes an initial snapshot on every (re)open — but the snapshot
7
- // carries no tombstones for sessions a REPLACEMENT server never knew (manager
8
- // restart), so the stream-open signal is surfaced via onStreamOpen and the
9
- // consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
10
- // zombie tabs.
1
+ /**
2
+ * activityMonitor feature: subscribes to the server's status SSE
3
+ * (/api/sessions/events via the engine's connectStatusStream) and exposes each
4
+ * session's live status through its API. It is a
5
+ * pure data source with no chrome of its own; the tabs feature consumes it via
6
+ * ctx.use to render per-tab activity dots and drop exited/removed tabs. The
7
+ * server pushes an initial snapshot on every (re)open — but the snapshot
8
+ * carries no tombstones for sessions a REPLACEMENT server never knew (manager
9
+ * restart), so the stream-open signal is surfaced via onStreamOpen and the
10
+ * consumer (tabs) runs a one-shot GET /api/sessions reconcile there to drop
11
+ * zombie tabs.
12
+ *
13
+ * @module
14
+ */
11
15
 
12
16
  import { connectStatusStream } from "@cplieger/web-terminal-engine";
13
17
  import type { SessionStatus } from "@cplieger/web-terminal-engine";
@@ -15,6 +19,14 @@ import type { TerminalFeature, Unsubscribe } from "../kernel/types.js";
15
19
 
16
20
  const DEFAULT_EVENTS_PATH = "/api/sessions/events";
17
21
 
22
+ /** The value a peer feature reads through `ctx.use(activityMonitor(...))`: the
23
+ * live status of every session the server knows about.
24
+ *
25
+ * Read-only — nothing here starts, stops or mutates a session. `current` answers
26
+ * from the last event seen rather than from the network, so it is undefined for
27
+ * a session no event has covered yet (the initial snapshot arrives
28
+ * asynchronously after setup) and for one the stream has since reported
29
+ * `removed`. A caller that needs "eventually" rather than "now" subscribes. */
18
30
  export interface ActivityMonitorApi {
19
31
  /** Subscribe to every status update (idle/working/warning/failed/input/done/
20
32
  * exited/crashed, plus removed), including the OSC 9;4 percentage and any OSC
@@ -32,6 +44,21 @@ export interface ActivityMonitorApi {
32
44
  onStreamOpen?(cb: () => void): Unsubscribe;
33
45
  }
34
46
 
47
+ /** Build the activityMonitor feature.
48
+ *
49
+ * Requires a server that serves the status SSE endpoint; on a server without one
50
+ * the stream never opens, every `current` stays undefined and no subscriber ever
51
+ * fires, which is why `tabs` treats the monitor as optional and polls
52
+ * `GET /api/sessions` instead when it is absent.
53
+ *
54
+ * `eventsPath` overrides the endpoint (default `/api/sessions/events`). Order it
55
+ * BEFORE any feature that reads its API through `ctx.use`, since a feature's
56
+ * setup can only see peers already set up. Teardown closes the stream and drops
57
+ * every subscriber and cached status.
58
+ *
59
+ * A throwing subscriber is caught and logged rather than propagated: it must not
60
+ * skip the remaining subscribers, and it must not reach the engine's SSE reader
61
+ * and kill the stream for everyone. */
35
62
  export function activityMonitor(
36
63
  opts: { eventsPath?: string } = {},
37
64
  ): TerminalFeature<ActivityMonitorApi> {
@@ -1,16 +1,29 @@
1
- // animations feature: opt-in motion for the chrome surfaces (design sections
2
- // 13, 22.4). Without it the chrome surfaces are instant (no transitions); with
3
- // it, and when the user has not asked for reduced motion, it enables the shared
4
- // animation vocabulary by marking the terminal root, which the feature
5
- // stylesheets key their transitions off. A few pieces of motion animate
6
- // unconditionally regardless of this feature (terminal-core cursor/SGR blink and
7
- // the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
8
- // pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
9
- // still defeats all of it. It tracks prefers-reduced-motion live so toggling the
10
- // OS setting takes effect.
1
+ /**
2
+ * animations feature: opt-in motion for the chrome surfaces.
3
+ * Without it the chrome surfaces are instant (no transitions); with
4
+ * it, and when the user has not asked for reduced motion, it enables the shared
5
+ * animation vocabulary by marking the terminal root, which the feature
6
+ * stylesheets key their transitions off. A few pieces of motion animate
7
+ * unconditionally regardless of this feature (terminal-core cursor/SGR blink and
8
+ * the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
9
+ * pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
10
+ * still defeats all of it. It tracks prefers-reduced-motion live so toggling the
11
+ * OS setting takes effect.
12
+ *
13
+ * @module
14
+ */
11
15
 
12
16
  import type { TerminalFeature } from "../kernel/types.js";
13
17
 
18
+ /** Build the animations feature. Exposes no API — its whole effect is the
19
+ * `wt-animate` class on the terminal root, which the shipped stylesheets gate
20
+ * their transitions on, so a composition either includes it or gets instant
21
+ * chrome.
22
+ *
23
+ * Position in the feature list does not matter (nothing reads it through
24
+ * `ctx.use`). The class is applied during setup, re-evaluated whenever the
25
+ * `prefers-reduced-motion` media query changes, and removed on teardown along
26
+ * with the listener. */
14
27
  export function animations(): TerminalFeature {
15
28
  return {
16
29
  name: "animations",
@@ -1,10 +1,23 @@
1
- // clipboard feature: copy/paste helpers, the desktop Ctrl+Shift+C/V shortcuts,
2
- // and OSC 52 mirroring (design section 22.4). Exposes a typed API so contextMenu
3
- // can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
4
- // OSC 52 has no subscriber and is intentionally a no-op.
1
+ /**
2
+ * clipboard feature: copy/paste helpers, the desktop Ctrl+Shift+C/V shortcuts,
3
+ * and OSC 52 mirroring. Exposes a typed API so contextMenu
4
+ * can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
5
+ * OSC 52 has no subscriber and is intentionally a no-op.
6
+ *
7
+ * @module
8
+ */
5
9
 
6
10
  import type { TerminalFeature } from "../kernel/types.js";
7
11
 
12
+ /** The value a peer feature reads through `ctx.use(clipboard())` — contextMenu's
13
+ * Copy and Paste items are this API behind a button.
14
+ *
15
+ * Both members are fire-and-forget: the underlying Clipboard API calls are async
16
+ * and their outcome is reported to the user as a toast rather than returned, so a
17
+ * caller cannot await or branch on success. Both require a secure context
18
+ * (`navigator.clipboard` is absent on a plain-HTTP non-loopback host, a supported
19
+ * web-terminal-server deployment), where they toast "Clipboard unavailable" and
20
+ * do nothing. */
8
21
  export interface ClipboardApi {
9
22
  /** Write text to the system clipboard (surfaces a toast on success/failure). */
10
23
  copy(text: string): void;
@@ -12,6 +25,15 @@ export interface ClipboardApi {
12
25
  paste(): void;
13
26
  }
14
27
 
28
+ /** Build the clipboard feature.
29
+ *
30
+ * It claims three things for the lifetime of the terminal: a kernel keydown
31
+ * intercept (Ctrl+Shift+C copies the browser selection, Ctrl+Shift+V pastes, and
32
+ * a plain Ctrl+V is consumed WITHOUT preventDefault so the browser's own paste
33
+ * event reaches the hidden textarea instead of being mapped to `\x16`), the
34
+ * `wire:clipboard` event that mirrors an application's OSC 52 copy to the system
35
+ * clipboard, and a document `copy` listener that toasts "Copied" for a selection
36
+ * inside the terminal surface only. Teardown releases all three. */
15
37
  export function clipboard(): TerminalFeature<ClipboardApi> {
16
38
  return {
17
39
  name: "clipboard",
@@ -1,8 +1,12 @@
1
- // connectionBanner feature: renders the kernel's connection state as a visible
2
- // banner (design sections 22.3, 22.4). The state machine, the loading
3
- // lifecycle, and the give-up hook are kernel-owned; this feature is a pure
4
- // renderer subscribing to connection:state. It uses the kernel's single
5
- // announcer (ctx.announce) rather than spawning its own aria-live region.
1
+ /**
2
+ * connectionBanner feature: renders the kernel's connection state as a visible
3
+ * banner. The state machine, the loading
4
+ * lifecycle, and the give-up hook are kernel-owned; this feature is a pure
5
+ * renderer subscribing to connection:state. It uses the kernel's single
6
+ * announcer (ctx.announce) rather than spawning its own aria-live region.
7
+ *
8
+ * @module
9
+ */
6
10
 
7
11
  import type { ConnState, TerminalFeature } from "../kernel/types.js";
8
12
 
@@ -17,6 +21,15 @@ const TEXT: Partial<Record<ConnState, string>> = {
17
21
  incompatible: "Terminal protocol mismatch; update the server or reload this page",
18
22
  };
19
23
 
24
+ /** Build the connectionBanner feature. Exposes no API — it owns one element in
25
+ * the kernel's "banner" region and nothing else reads from it.
26
+ *
27
+ * Only the states with copy above are shown; `connected` and any other state
28
+ * hides the banner, so it is a transient surface rather than a permanent status
29
+ * line. `restarted`, `ended` and `incompatible` are the terminal ones a user
30
+ * cannot wait out. Each change is announced once through `ctx.announce`, so a
31
+ * flaky link re-entering the same state does not repeat itself to a screen
32
+ * reader. Teardown unsubscribes and removes the element. */
20
33
  export function connectionBanner(): TerminalFeature {
21
34
  return {
22
35
  name: "connectionBanner",