@cplieger/web-terminal-ui 7.3.2 → 8.0.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.
Files changed (50) hide show
  1. package/NOTICE +3 -0
  2. package/README.md +114 -18
  3. package/THIRD_PARTY_NOTICES.md +7 -0
  4. package/css/30-tabs.css +21 -0
  5. package/css/31-switcher.css +15 -0
  6. package/css/32-split.css +176 -0
  7. package/css/MANIFEST +1 -0
  8. package/css/MANIFEST.tabbed +1 -0
  9. package/package.json +15 -12
  10. package/src/composition.ts +165 -237
  11. package/src/features/activity-monitor.ts +20 -55
  12. package/src/features/animations.ts +12 -30
  13. package/src/features/clipboard.ts +36 -64
  14. package/src/features/connection-banner.ts +10 -19
  15. package/src/features/context-menu.ts +70 -145
  16. package/src/features/dom.ts +44 -54
  17. package/src/features/menu-position.ts +24 -28
  18. package/src/features/mobile-toolbar.ts +32 -62
  19. package/src/features/predictive-echo.ts +15 -49
  20. package/src/features/scroll-to-bottom.ts +12 -24
  21. package/src/features/tabs/index.ts +1398 -1806
  22. package/src/features/tabs/ink-centre.ts +45 -54
  23. package/src/features/tabs/model.ts +301 -383
  24. package/src/features/tabs/strip.ts +45 -97
  25. package/src/features/tabs/switcher.ts +70 -48
  26. package/src/index.ts +13 -13
  27. package/src/kernel/a11y.ts +106 -27
  28. package/src/kernel/attention.ts +157 -0
  29. package/src/kernel/conn-state.ts +28 -22
  30. package/src/kernel/fatal.ts +76 -0
  31. package/src/kernel/feature-host.ts +136 -0
  32. package/src/kernel/gesture.ts +9 -13
  33. package/src/kernel/kernel.ts +57 -2139
  34. package/src/kernel/layout-policy.ts +44 -0
  35. package/src/kernel/loading-status.ts +30 -88
  36. package/src/kernel/navigation.ts +11 -17
  37. package/src/kernel/notify.ts +158 -0
  38. package/src/kernel/pane.ts +1415 -0
  39. package/src/kernel/realm.ts +11 -0
  40. package/src/kernel/regions.ts +8 -11
  41. package/src/kernel/scrollback.ts +88 -168
  42. package/src/kernel/selection.ts +25 -0
  43. package/src/kernel/shell.ts +1434 -0
  44. package/src/kernel/split-handle.ts +280 -0
  45. package/src/kernel/status-share.ts +100 -0
  46. package/src/kernel/types.ts +457 -441
  47. package/src/predict.ts +144 -170
  48. package/src/viewport.ts +124 -197
  49. package/src/features/tabs/attention.ts +0 -250
  50. package/src/features/tabs/notify.ts +0 -265
package/src/viewport.ts CHANGED
@@ -1,233 +1,160 @@
1
- // Viewport stability tracker.
2
- //
3
- // Coordinates iOS keyboard transitions, window resizes, font-load reflows,
4
- // and ResizeObserver fires into a single "transition → settle" lifecycle
5
- // so the rest of the app can act on stable viewport state without each
6
- // piece reinventing its own debounce/suppress timers.
7
- //
8
- // Behavior:
9
- // 1. Any viewport-affecting event marks "in transition".
10
- // 2. While in transition: autoscroll is suppressed, input dimensions
11
- // may shift, no resize is sent to the server.
12
- // 3. After SETTLE_MS of no further events, fire onSettled — the caller
13
- // then sends the final size, pins to the bottom if still following, etc.
14
- // The follow state is read AT SETTLE (see startTransition): a latch taken
15
- // at the start of the burst goes stale, because every further event
16
- // extends the window without re-reading it.
1
+ // Viewport stability tracker: iOS keyboard transitions, window resizes,
2
+ // font-load reflows and ResizeObserver fires fold into one "transition, then
3
+ // settle" lifecycle, so nothing else needs its own debounce. While in
4
+ // transition no resize is sent; after SETTLE_MS of quiet onSettled fires and the
5
+ // caller sends the final size.
17
6
 
18
- import { scroll } from "@cplieger/web-terminal-engine";
7
+ import type { ScrollController } from "@cplieger/web-terminal-engine";
8
+ import { windowOf } from "./kernel/realm.js";
19
9
 
20
- // Time to wait after the last viewport-changing event before declaring
21
- // the viewport "settled". Long enough to bridge the iOS keyboard slide
22
- // (~250ms) with margin for fonts and reflow.
10
+ // Long enough to bridge the iOS keyboard slide (~250ms) with margin for fonts
11
+ // and reflow.
23
12
  const SETTLE_MS = 350;
24
13
 
25
- let termWrap: HTMLElement;
26
- // The terminal root (.wt-root): the geometry CSS vars (--kb-inset, --vv-top)
27
- // are published here so the terminal subtree — and nothing else on the host
28
- // page — inherits them. Falls back to termWrap if init was somehow given no
29
- // root (never in practice; the kernel always passes it).
30
- let varTarget: HTMLElement;
31
- let onSettled: ((wasAtBottom: boolean) => void) | null = null;
32
- let inTransition = false;
33
- let settleTimer: ReturnType<typeof setTimeout> | null = null;
34
- // When true, ignore the visualViewport keyboard geometry entirely (a hardware
35
- // keyboard/trackpad is present, so the soft keyboard never opens). Set at init
36
- // from the kernel's hasFinePointer; see the onChange note below.
37
- let suppressKeyboardInset: () => boolean = () => false;
38
- // Removals for every listener/observer init() attaches, so teardown() can
39
- // release them on kernel destroy(). Without it the window/visualViewport/
40
- // screen.orientation listeners and the ResizeObserver survive destroy() and a
41
- // re-created terminal double-binds them.
42
- let cleanup: (() => void)[] = [];
43
-
44
- function startTransition(): void {
45
- inTransition = true;
46
- if (settleTimer !== null) {
47
- clearTimeout(settleTimer);
48
- }
49
- settleTimer = setTimeout(() => {
50
- settleTimer = null;
51
- inTransition = false;
52
- // Read the follow state AT SETTLE, not at the start of the burst. Every
53
- // viewport event re-arms this timer without re-reading, and on iOS a wake or
54
- // keyboard slide emits a stream of them, so a latch captured at the start
55
- // stays true across a user scrolling up mid-burst and then yanks them back
56
- // down. Asking now is only safe because a content shrink can no longer
57
- // corrupt the follow state on its own (the engine's scroll controller
58
- // re-engages follow only on a downward user move); before that, this snap
59
- // was accidentally repairing the corruption it was also causing.
60
- const stillFollowing = !scroll.isUserScrolledUp();
61
- if (stillFollowing) {
62
- // Pin, do not force. A geometry change can leave scrollTop stale short of
63
- // the bottom, which the pin fixes; scrollToBottom would additionally
64
- // OVERRIDE the follow state, which is not this handler's call to make.
65
- scroll.stickToBottom();
66
- }
67
- if (onSettled) {
68
- onSettled(stillFollowing);
69
- }
70
- }, SETTLE_MS);
71
- }
72
-
73
- /**
74
- * Whether a viewport transition is in flight (an iOS keyboard slide, a rotation,
75
- * a font-load reflow). Geometry measured now is provisional, so a caller that
76
- * puts a measurement on the wire should wait for the settle instead.
77
- */
78
- export function isInTransition(): boolean {
79
- return inTransition;
80
- }
81
-
82
- export function init(opts: {
14
+ export interface ViewportOptions {
83
15
  termWrap: HTMLElement;
84
- /** The terminal root (.wt-root): receives the geometry CSS vars the sibling
85
- * chrome reads (--kb-inset, --vv-top), so they scope to the terminal
86
- * subtree instead of leaking onto the host document. */
16
+ /** The terminal root: receives the geometry CSS vars the sibling chrome reads
17
+ * (--kb-inset, --vv-top), so they scope to the terminal subtree instead of
18
+ * leaking onto the host document. */
87
19
  root?: HTMLElement;
20
+ /** This pane's scroll controller, read at settle. */
21
+ scroll: Pick<ScrollController, "isUserScrolledUp" | "stickToBottom">;
88
22
  onSettled: (wasAtBottom: boolean) => void;
89
23
  /** Ignore the visualViewport keyboard geometry (a hardware-keyboard device
90
24
  * has no soft keyboard to accommodate); only reserved bottom chrome insets
91
- * the terminal. The kernel passes its hasFinePointer here. */
25
+ * the terminal. */
92
26
  suppressKeyboardInset?: () => boolean;
93
- }): void {
94
- termWrap = opts.termWrap;
95
- varTarget = opts.root ?? opts.termWrap;
96
- onSettled = opts.onSettled;
97
- suppressKeyboardInset = opts.suppressKeyboardInset ?? (() => false);
27
+ }
28
+
29
+ /** One pane's viewport tracker. */
30
+ export interface Viewport {
31
+ /** Whether a transition is in flight, so geometry measured now is provisional. */
32
+ isInTransition(): boolean;
33
+ /** Release every listener and observer, stop the settle timer, and clear the
34
+ * CSS vars published on the root. */
35
+ teardown(): void;
36
+ }
37
+
38
+ export function createViewport(opts: ViewportOptions): Viewport {
39
+ const { termWrap, scroll } = opts;
40
+ const varTarget = opts.root ?? opts.termWrap;
41
+ const suppressKeyboardInset = opts.suppressKeyboardInset ?? ((): boolean => false);
42
+ // The terminal's own window: its viewport, its keyboard and its rotation, not
43
+ // the importing page's.
44
+ const win = windowOf(termWrap.ownerDocument);
45
+ let inTransition = false;
46
+ let settleTimer: number | null = null;
47
+ // Every listener and the observer push their release as they are acquired,
48
+ // so a registration that throws drains what came before it.
49
+ const cleanup: (() => void)[] = [];
50
+ function listen(target: EventTarget, type: string, handler: () => void): void {
51
+ target.addEventListener(type, handler);
52
+ cleanup.push(() => {
53
+ target.removeEventListener(type, handler);
54
+ });
55
+ }
56
+ function release(): void {
57
+ while (cleanup.length > 0) {
58
+ cleanup.pop()?.();
59
+ }
60
+ if (settleTimer !== null) {
61
+ win.clearTimeout(settleTimer);
62
+ settleTimer = null;
63
+ }
64
+ varTarget.style.removeProperty("--kb-inset");
65
+ varTarget.style.removeProperty("--vv-top");
66
+ inTransition = false;
67
+ }
68
+
69
+ function startTransition(): void {
70
+ inTransition = true;
71
+ if (settleTimer !== null) {
72
+ win.clearTimeout(settleTimer);
73
+ }
74
+ settleTimer = win.setTimeout(() => {
75
+ settleTimer = null;
76
+ inTransition = false;
77
+ // Read at SETTLE, not at the start of the burst: every further event
78
+ // re-arms the timer without re-reading, and on iOS a wake emits a stream
79
+ // of them, so a latch taken at the start would yank a reader who scrolled
80
+ // up mid-burst back down.
81
+ const stillFollowing = !scroll.isUserScrolledUp();
82
+ if (stillFollowing) {
83
+ scroll.stickToBottom();
84
+ }
85
+ opts.onSettled(stillFollowing);
86
+ }, SETTLE_MS);
87
+ }
98
88
 
99
- // iOS soft keyboard. interactive-widget=resizes-content makes the
100
- // layout viewport shrink; we still apply a manual bottom inset as
101
- // fallback for older iOS / other mobile browsers.
102
- if (window.visualViewport) {
103
- const vv = window.visualViewport;
89
+ function keyboardInsets(vv: VisualViewport): void {
104
90
  const onChange = (): void => {
105
- // Pin the terminal's fixed box to the exact region the VISUAL viewport
106
- // occupies inside the layout viewport. On iOS the soft keyboard shrinks
107
- // (and can offset) the visual viewport WITHOUT resizing the layout
108
- // viewport — interactive-widget=resizes-content is not honored on iOS
109
- // Safari — so a `position: fixed; inset: 0` terminal keeps the full-screen
110
- // height (keyboard overlaying the bottom) while iOS scrolls the focused
111
- // input into view and shifts the fixed box up, sliding the output and
112
- // prompt behind the keyboard (the "content jumps up, hidden until I
113
- // scroll" symptom). Driving top AND bottom from visualViewport keeps the
114
- // terminal exactly over the visible area in every browser, so nothing is
115
- // left behind the keyboard for iOS to scroll to.
116
- // suppressKeyboardInset (set at init from the kernel's hasFinePointer): on
117
- // a device with a hardware keyboard/trackpad (a desktop, or an iPad with a
118
- // Magic Keyboard) the on-screen keyboard never opens, so there is no
119
- // keyboard geometry to accommodate. iPadOS has been seen to briefly report
120
- // a keyboard-sized visualViewport shrink with no keyboard shown, which this
121
- // handler then pinned as a bottom inset and left stuck (the terminal "moved
122
- // up ~50%", black below, surviving tab switches because nothing recomputes
123
- // it — only a reload cleared it). Ignore the keyboard geometry there and
124
- // keep the terminal full-height; only the reserved bottom chrome applies.
91
+ // iOS shrinks (and can offset) the visual viewport without resizing the
92
+ // layout viewport, so a `position: fixed; inset: 0` box keeps the
93
+ // full-screen height behind the keyboard; driving top and bottom from
94
+ // visualViewport keeps the terminal over the visible area everywhere.
95
+ // iPadOS has been seen to report a keyboard-sized shrink with no keyboard
96
+ // shown and pin it, which is what suppressKeyboardInset defends against.
125
97
  const offsetTop = suppressKeyboardInset() ? 0 : Math.max(0, Math.round(vv.offsetTop));
126
98
  const bottomInset = suppressKeyboardInset()
127
99
  ? 0
128
- : Math.max(0, Math.round(window.innerHeight - vv.offsetTop - vv.height));
129
- // Reserved bottom chrome (a bottom tab bar) the content must clear, on top
130
- // of the keyboard inset. A feature sets --wt-reserve-bottom (px) on the
131
- // root, 0 when none; viewport owns the terminal's fixed-box geometry, so it
132
- // folds the reserve into the bottom offset here. The reserve excludes the
133
- // keyboard (it is measured with the keyboard closed), so adding it to
134
- // bottomInset does not double-count.
135
- // Read the reserve off the terminal itself: tabs publishes it on the
136
- // root (.wt-root), and termWrap inherits it — nothing terminal-scoped
137
- // lives on the document root anymore.
100
+ : Math.max(0, Math.round(win.innerHeight - vv.offsetTop - vv.height));
101
+ // A feature sets --wt-reserve-bottom (px) on the root for bottom chrome the
102
+ // content must clear; it is measured with the keyboard closed, so adding it
103
+ // to the keyboard inset does not double-count.
138
104
  const rawReserve = Math.max(
139
105
  0,
140
106
  Math.round(
141
- parseFloat(getComputedStyle(termWrap).getPropertyValue("--wt-reserve-bottom")) || 0,
107
+ parseFloat(win.getComputedStyle(termWrap).getPropertyValue("--wt-reserve-bottom")) || 0,
142
108
  ),
143
109
  );
144
- // Sanity cap: the reserve is bottom chrome (a tab bar), tens of px. A value
145
- // near half the screen is a bad measurement — e.g. the switcher bar
146
- // measured while a phantom keyboard inset had lifted it — that would
147
- // otherwise strand the lower half of the terminal black. Never let it
148
- // exceed a third of the viewport height.
149
- const reserve = Math.min(rawReserve, Math.round(window.innerHeight / 3));
110
+ // The reserve is a tab bar, tens of px; a value near half the screen is a
111
+ // bad measurement that would strand the lower half of the terminal black.
112
+ const reserve = Math.min(rawReserve, Math.round(win.innerHeight / 3));
150
113
  const bottom = bottomInset + reserve;
151
114
  termWrap.style.top = offsetTop > 0 ? `${offsetTop}px` : "";
152
115
  termWrap.style.bottom = bottom > 0 ? `${bottom}px` : "";
153
- // Expose the same geometry as CSS vars for the sibling chrome, on the
154
- // terminal ROOT (scoped: the host page never sees them): --kb-inset
155
- // lifts the bottom banner above the keyboard; --vv-top lets the top key
156
- // toolbar follow the visual viewport's offset (otherwise it scrolls off
157
- // the top when iOS shifts the layout up).
158
116
  varTarget.style.setProperty("--kb-inset", `${bottomInset}px`);
159
117
  varTarget.style.setProperty("--vv-top", `${offsetTop}px`);
160
118
  startTransition();
161
119
  };
162
- vv.addEventListener("resize", onChange);
163
- vv.addEventListener("scroll", onChange);
164
- // Self-heal a stuck inset (see the fine-pointer note above): recompute when
165
- // the window regains focus or is restored from the bfcache, so a one-off bad
166
- // visualViewport reading that got pinned clears on the next natural
167
- // interaction instead of surviving until a full page reload.
168
- window.addEventListener("focus", onChange);
169
- window.addEventListener("pageshow", onChange);
170
- cleanup.push(() => {
171
- window.removeEventListener("focus", onChange);
172
- window.removeEventListener("pageshow", onChange);
173
- });
174
- cleanup.push(() => {
175
- vv.removeEventListener("resize", onChange);
176
- vv.removeEventListener("scroll", onChange);
177
- });
120
+ listen(vv, "resize", onChange);
121
+ listen(vv, "scroll", onChange);
122
+ // Recompute on focus and bfcache restore, so a one-off bad visualViewport
123
+ // reading clears on the next natural interaction instead of at reload.
124
+ listen(win, "focus", onChange);
125
+ listen(win, "pageshow", onChange);
178
126
  onChange();
179
127
  }
180
128
 
181
- // Term wrap dimension changes: window resize, font load, devtools dock.
182
- const ro = new ResizeObserver(startTransition);
183
- ro.observe(termWrap);
184
- window.addEventListener("resize", startTransition);
185
- cleanup.push(() => {
186
- ro.disconnect();
187
- window.removeEventListener("resize", startTransition);
188
- });
129
+ try {
130
+ if (win.visualViewport) {
131
+ keyboardInsets(win.visualViewport);
132
+ }
189
133
 
190
- // Orientation change on mobile. iOS Safari often emits the
191
- // window.resize event late or not at all on rotation while
192
- // visualViewport.resize fires reliably; screen.orientation.change is
193
- // the canonical signal that survives both. Modern browsers expose it
194
- // on screen.orientation; older Safari falls back to the deprecated
195
- // window.orientationchange event.
196
- const orientation = (screen as Screen & { orientation?: ScreenOrientation }).orientation;
197
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
198
- if (orientation) {
199
- orientation.addEventListener("change", startTransition);
134
+ const ro = new win.ResizeObserver(startTransition);
200
135
  cleanup.push(() => {
201
- orientation.removeEventListener("change", startTransition);
136
+ ro.disconnect();
202
137
  });
203
- } else if ("onorientationchange" in window) {
204
- window.addEventListener("orientationchange", startTransition);
205
- cleanup.push(() => {
206
- window.removeEventListener("orientationchange", startTransition);
207
- });
208
- }
209
- }
138
+ ro.observe(termWrap);
139
+ listen(win, "resize", startTransition);
210
140
 
211
- // Release every listener/observer init() attached and stop the settle timer.
212
- // The kernel calls this from destroy() so a create->destroy->create remount
213
- // does not leave stale global listeners double-bound.
214
- export function teardown(): void {
215
- for (const fn of cleanup) {
216
- fn();
217
- }
218
- cleanup = [];
219
- if (settleTimer !== null) {
220
- clearTimeout(settleTimer);
221
- settleTimer = null;
222
- }
223
- // Clear the CSS vars onChange published on the terminal root, so a destroy
224
- // without a remount (which would recompute them) leaves no stale inset.
225
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- teardown may run before any init in tests
226
- if (varTarget) {
227
- varTarget.style.removeProperty("--kb-inset");
228
- varTarget.style.removeProperty("--vv-top");
141
+ // iOS Safari often emits window.resize late or not at all on rotation while
142
+ // screen.orientation.change survives; older Safari has only the deprecated
143
+ // window event.
144
+ const orientation = (win.screen as Screen & { orientation?: ScreenOrientation }).orientation;
145
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
146
+ if (orientation) {
147
+ listen(orientation, "change", startTransition);
148
+ } else if ("onorientationchange" in win) {
149
+ listen(win, "orientationchange", startTransition);
150
+ }
151
+ } catch (err) {
152
+ release();
153
+ throw err;
229
154
  }
230
- inTransition = false;
231
- onSettled = null;
232
- suppressKeyboardInset = () => false;
155
+
156
+ return {
157
+ isInTransition: () => inTransition,
158
+ teardown: release,
159
+ };
233
160
  }
@@ -1,250 +0,0 @@
1
- // tabs/attention.ts — the unseen-cue set, rendered onto the surfaces that exist
2
- // OUTSIDE this page's chrome: the browser tab title, the installed app's icon
3
- // badge, and the tab icon.
4
- //
5
- // Why these are needed at all: a tab holding a latched status paints a dot on its
6
- // chip, and that dot is not reliably on screen. The desktop strip scrolls
7
- // horizontally once it is full, a narrow coarse-pointer device hides the strip
8
- // entirely (only the active row plus the switcher's aggregate dot remain), and a
9
- // hidden page shows no chrome at all. notify.ts already covers the last case with
10
- // a browser Notification, but that API is absent on iOS Safari outside an
11
- // installed web app, which is exactly where this UI spends its time.
12
- //
13
- // STATE, not events. A notification fires once per occurrence and is deduped on a
14
- // sequence number; everything here is a pure render of what is currently true, so
15
- // it is safe to recompute on every status sweep and each sink no-ops when nothing
16
- // changed. That split is why this module and notify.ts stay separate.
17
- //
18
- // The set is CueStatus and nothing else, so these surfaces cannot disagree with
19
- // the switcher's aggregate dot about what wants the user; the fold and the raise
20
- // share isUnseenCue (model.ts).
21
- //
22
- // DOM-free by construction, like notify.ts: every capability arrives through
23
- // AttentionEnv, so the fold, the idempotence and the degradation paths are driven
24
- // in the tests by plain recorder functions, and browserAttentionEnv() below is the
25
- // only function here that reads a global (globalThis.navigator, document).
26
- //
27
- // That last clause is a reading of the source, and nothing enforces it — on
28
- // purpose, not by omission. An ambient-absence test would assert the RUNTIME
29
- // rather than this module: the only importer is tabs/index.ts, the node project
30
- // holds one file and it reads stylesheets off disk, so nothing here loads this
31
- // module outside a browser and no test can observe an absent `document` without
32
- // passing for a third wrong reason (.kiro/steering/testing-ts.md,
33
- // "Capability-absence is NOT a project selection"). The half that does ship IS
34
- // enforced, and at the site: attention.test.ts shadows the whole `navigator`
35
- // object and drives the real browserAttentionEnv through it. The mechanism that
36
- // could enforce the load-time half is an AST scan of this file's top-level
37
- // statements — vibekit carries one for its ported copy, where a node-project test
38
- // statically imports the module and a module-scope global read really does break
39
- // something. Copy it here the day anything needs this module to load without a
40
- // document.
41
-
42
- import { type CueStatus, cueIconName, isUnseenCue, worseCue } from "./model.js";
43
-
44
- /** What the fold reads per tab. A structural type, so the caller passes its own
45
- * tab objects without this module knowing what else they carry. */
46
- export interface CueCandidate {
47
- readonly id: string;
48
- readonly status: string;
49
- }
50
-
51
- /** The whole attention state, and the only thing the sinks are allowed to see. */
52
- export interface Attention {
53
- /** How many sessions hold an unacknowledged cue. */
54
- readonly count: number;
55
- /** The most severe of them, or "" when there is none. */
56
- readonly worst: CueStatus | "";
57
- }
58
-
59
- export const NO_ATTENTION: Attention = { count: 0, worst: "" };
60
-
61
- /** summarize folds the tab list and this viewer's acknowledgements into the one
62
- * value every surface renders.
63
- *
64
- * A COUNT for the title and the badge, a single WORST for the icon. That split
65
- * is deliberate: a count is set-valued, so it needs no rule for choosing among
66
- * sessions, and severity is a total order, so the icon's choice is not arbitrary
67
- * either. Neither surface can name a session, which is the standing constraint
68
- * on anything written to a page-wide surface here (see the decision against
69
- * per-session progress in the document title, index.ts). */
70
- export function summarize(
71
- tabs: readonly CueCandidate[],
72
- seen: ReadonlyMap<string, CueStatus>,
73
- ): Attention {
74
- let count = 0;
75
- let worst: CueStatus | "" = "";
76
- for (const tab of tabs) {
77
- if (!isUnseenCue(tab.status, tab.id, seen)) {
78
- continue;
79
- }
80
- count += 1;
81
- worst = worseCue(worst, tab.status);
82
- }
83
- return { count, worst };
84
- }
85
-
86
- /** The capabilities the sinks need, all injected, all optional except the title.
87
- *
88
- * An absent capability is an absent sink and therefore a silent no-op, the same
89
- * contract notify.ts uses for a missing Notification constructor: an unsupported
90
- * surface is a normal state of the world, not an error to report.
91
- *
92
- * Two of these can also fail INVISIBLY, and that is accepted rather than
93
- * handled. `setBadge` resolves on Linux where the desktop paints no badge at
94
- * all, and `setIcon` assigns an href that Safari ignores because it caches the
95
- * first icon it fetched. Neither is detectable, and neither costs anything when
96
- * it fails, because the title is not gated on any capability and so is the
97
- * floor: every platform that renders a title at all gets the count. Do not
98
- * arrange these into a fallback ladder where the title only appears if the
99
- * others are missing — on those two platforms the detection would report
100
- * success and the user would be left with nothing. */
101
- export interface AttentionEnv {
102
- /** Set (or clear, with "") the document-title prefix. The kernel's composing
103
- * writer, so this cannot be erased by a program's OSC 0/2 window title. */
104
- titlePrefix: (text: string) => void;
105
- /** Set the installed app's icon badge to a count, or clear it at zero. */
106
- setBadge?: ((count: number) => void) | undefined;
107
- /** Point the page's icon links at a variant, or restore them with null. */
108
- setIcon?: ((variant: "input" | "done" | "alert" | null) => void) | undefined;
109
- }
110
-
111
- export interface AttentionSurfaces {
112
- /** Render an attention state. Idempotent: a value equal to the last one
113
- * applied touches nothing. */
114
- apply(next: Attention): void;
115
- }
116
-
117
- /** titlePrefixFor is the title text for a count, and the format is load-bearing
118
- * enough to name: the count goes FIRST, because a browser tab strip truncates a
119
- * title to its first few characters and a suffix would be the part that is cut.
120
- * Parenthesised digits are also the convention every mail and chat client uses,
121
- * so it needs no legend. */
122
- export function titlePrefixFor(count: number): string {
123
- return count > 0 ? `(${String(count)}) ` : "";
124
- }
125
-
126
- export function createAttention(env: AttentionEnv): AttentionSurfaces {
127
- // Last applied, so each sink is called only on a real change. This matters
128
- // beyond cost: the document title doubles as the browser-tab label and the
129
- // bookmark name, and re-assigning an icon href makes some browsers re-fetch it.
130
- let applied: Attention = NO_ATTENTION;
131
- let first = true;
132
-
133
- return {
134
- apply(next: Attention): void {
135
- const countChanged = first || next.count !== applied.count;
136
- const worstChanged = first || next.worst !== applied.worst;
137
- first = false;
138
- applied = next;
139
-
140
- if (countChanged) {
141
- env.titlePrefix(titlePrefixFor(next.count));
142
- // The badge takes the SAME number as the title, which is the whole
143
- // reason both read one fold: two surfaces disagreeing about how many
144
- // things want you is worse than either being absent.
145
- env.setBadge?.(next.count);
146
- }
147
- if (worstChanged) {
148
- env.setIcon?.(next.worst === "" ? null : cueIconName(next.worst));
149
- }
150
- },
151
- };
152
- }
153
-
154
- /** iconVariantHref rewrites an icon URL to its variant, by the naming convention
155
- * the variant assets must follow (they are the consuming app's own files, served
156
- * alongside its icons rather than shipped by this library): the `favicon` token
157
- * of the filename gains `-<variant>`, so `/favicon.svg` becomes
158
- * `/favicon-input.svg` and `/favicon-32x32.png` becomes
159
- * `/favicon-input-32x32.png`. The extension is preserved, so each link keeps
160
- * pointing at its own format and no `type` attribute has to change.
161
- *
162
- * A URL whose filename does not start with `favicon` is returned unchanged,
163
- * which leaves that link alone rather than pointing it at a 404. Exported for
164
- * the test that pins this against that naming convention. */
165
- export function iconVariantHref(href: string, variant: string): string | null {
166
- const match = /(^|\/)favicon(?=[-.])/.exec(href);
167
- if (!match) {
168
- return null;
169
- }
170
- const at = match.index + match[0].length;
171
- return `${href.slice(0, at)}-${variant}${href.slice(at)}`;
172
- }
173
-
174
- /** browserAttentionEnv binds the sinks to the real browser, given the kernel's
175
- * title setter. Everything optional is decided HERE, once, so the core never
176
- * probes for a capability.
177
- *
178
- * `icons` opts the icon sink in. It is off unless the consumer asks for it,
179
- * because it needs asset files the library cannot ship: the dot's colour comes
180
- * from the app's own `--status-*` theme, so the variants are per-app artifacts.
181
- */
182
- export function browserAttentionEnv(
183
- titlePrefix: (text: string) => void,
184
- icons: boolean,
185
- ): AttentionEnv {
186
- const env: AttentionEnv = { titlePrefix };
187
-
188
- // The Badging API, read through `unknown` for the same reason notify.ts reads
189
- // Notification that way: it is absent on most browsers and must degrade rather
190
- // than be asserted. Installed apps only — a badge lives on an app icon, which
191
- // exists only after the app is installed.
192
- const nav: unknown = globalThis.navigator;
193
- const setAppBadge = (nav as { setAppBadge?: unknown } | undefined)?.setAppBadge;
194
- const clearAppBadge = (nav as { clearAppBadge?: unknown } | undefined)?.clearAppBadge;
195
- if (typeof setAppBadge === "function") {
196
- env.setBadge = (count: number): void => {
197
- // Always a NUMBER, never the spec's bare flag form: iOS renders nothing at
198
- // all for `setAppBadge()` with no argument. Zero clears, via clearAppBadge
199
- // where it exists (the documented way) and setAppBadge(0) otherwise.
200
- //
201
- // Both return promises that reject on an unsupported platform, so the
202
- // rejection is swallowed: a badge the OS declines to paint is not an error
203
- // this page can do anything about, and an unhandled rejection in a status
204
- // sweep would be reported as a page fault.
205
- try {
206
- const call =
207
- count > 0
208
- ? (setAppBadge as (n: number) => unknown).call(nav, count)
209
- : typeof clearAppBadge === "function"
210
- ? (clearAppBadge as () => unknown).call(nav)
211
- : (setAppBadge as (n: number) => unknown).call(nav, 0);
212
- void Promise.resolve(call).catch(() => {
213
- /* an OS that will not paint a badge is a title-only OS */
214
- });
215
- } catch {
216
- /* a synchronous throw is the same non-event */
217
- }
218
- };
219
- }
220
-
221
- if (icons) {
222
- // Every icon link, not one of them: which link a browser picks differs
223
- // between browsers (Chrome prefers the SVG), so mutating a single element is
224
- // unreliable. apple-touch-icon is deliberately NOT matched — `rel~="icon"`
225
- // does not select it — because that icon and the manifest's are cached by the
226
- // OS when the app is installed and a swap cannot reach them.
227
- const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]')];
228
- const originals = new Map<HTMLLinkElement, string>();
229
- for (const link of links) {
230
- originals.set(link, link.getAttribute("href") ?? "");
231
- }
232
- if (links.length > 0) {
233
- env.setIcon = (variant): void => {
234
- for (const link of links) {
235
- const original = originals.get(link) ?? "";
236
- if (variant === null) {
237
- link.setAttribute("href", original);
238
- continue;
239
- }
240
- const next = iconVariantHref(original, variant);
241
- if (next !== null) {
242
- link.setAttribute("href", next);
243
- }
244
- }
245
- };
246
- }
247
- }
248
-
249
- return env;
250
- }