@cplieger/web-terminal-ui 7.3.1 → 8.0.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 +114 -18
- package/css/30-tabs.css +52 -13
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
package/src/viewport.ts
CHANGED
|
@@ -1,233 +1,160 @@
|
|
|
1
|
-
// Viewport stability tracker
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
7
|
+
import type { ScrollController } from "@cplieger/web-terminal-engine";
|
|
8
|
+
import { windowOf } from "./kernel/realm.js";
|
|
19
9
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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.
|
|
25
|
+
* the terminal. */
|
|
92
26
|
suppressKeyboardInset?: () => boolean;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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(
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
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
|
|
163
|
-
vv
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
ro.disconnect();
|
|
202
137
|
});
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
cleanup.push(() => {
|
|
206
|
-
window.removeEventListener("orientationchange", startTransition);
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
}
|
|
138
|
+
ro.observe(termWrap);
|
|
139
|
+
listen(win, "resize", startTransition);
|
|
210
140
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
}
|