@cplieger/web-terminal-ui 1.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/LICENSE +692 -0
- package/README.md +76 -0
- package/css/00-tokens.css +33 -0
- package/css/01-reset.css +73 -0
- package/css/02-app.css +730 -0
- package/css/MANIFEST +5 -0
- package/package.json +58 -0
- package/scaffold/index.html +52 -0
- package/src/composition.ts +171 -0
- package/src/index.ts +10 -0
- package/src/mount.ts +798 -0
- package/src/predict.ts +183 -0
- package/src/status.ts +173 -0
- package/src/viewport.ts +98 -0
package/src/predict.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Predictive local echo (INPUT-01) — Mosh-style mini-VT that advances
|
|
2
|
+
// a "predicted cursor" optimistically as the user types, before the
|
|
3
|
+
// server's next screen frame confirms or contradicts the prediction.
|
|
4
|
+
//
|
|
5
|
+
// On the wire we still send everything through the connection module;
|
|
6
|
+
// this is purely a local-render optimisation. The visible effect is
|
|
7
|
+
// that the cursor moves immediately on each keystroke even on a 200 ms
|
|
8
|
+
// link, so typing feels responsive instead of laggy.
|
|
9
|
+
//
|
|
10
|
+
// Scope (deliberately conservative — wrong predictions are worse than
|
|
11
|
+
// missing predictions, since they look like the terminal is broken):
|
|
12
|
+
// - printable ASCII / UTF-8 codepoints: advance one cell per
|
|
13
|
+
// codepoint (approximate for fullwidth / combining marks; snaps
|
|
14
|
+
// to truth on next server frame).
|
|
15
|
+
// - Backspace (0x08) and DEL (0x7f): cursor back one cell.
|
|
16
|
+
// - CR (0x0d): col = 0.
|
|
17
|
+
// - LF (0x0a): row += 1 (clamped to height-1).
|
|
18
|
+
// - Anything else (ESC sequences, TAB, other C0): suspend prediction
|
|
19
|
+
// until the next server frame. Suspending hides the predicted
|
|
20
|
+
// cursor so the user sees only the server's truth.
|
|
21
|
+
//
|
|
22
|
+
// Each server screen frame resets the predicted cursor to the server's
|
|
23
|
+
// reported position and re-arms prediction. So a wrong prediction is
|
|
24
|
+
// at most one flushInterval (50 ms) old before correction.
|
|
25
|
+
|
|
26
|
+
let predRow = 0;
|
|
27
|
+
let predCol = 0;
|
|
28
|
+
let predActive = false;
|
|
29
|
+
let predPendingWrap = false; // matches server VT pendingWrap semantics
|
|
30
|
+
let predFrozen = false; // true when server cursor is hidden (selection prompts)
|
|
31
|
+
let cols = 80;
|
|
32
|
+
let rows = 30;
|
|
33
|
+
|
|
34
|
+
let onChange: (() => void) | null = null;
|
|
35
|
+
|
|
36
|
+
/** Subscribe to predicted-cursor changes (the renderer registers
|
|
37
|
+
* this so it can move its predicted-cursor overlay). */
|
|
38
|
+
export function subscribe(cb: () => void): void {
|
|
39
|
+
onChange = cb;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Update the screen dimensions used for wrap calculations. Should be
|
|
43
|
+
* called whenever the renderer learns a new size from the server. */
|
|
44
|
+
export function setDimensions(c: number, r: number): void {
|
|
45
|
+
if (c > 0) {
|
|
46
|
+
cols = c;
|
|
47
|
+
}
|
|
48
|
+
if (r > 0) {
|
|
49
|
+
rows = r;
|
|
50
|
+
}
|
|
51
|
+
// Clamp predicted position to new bounds so a resize mid-typing
|
|
52
|
+
// doesn't leave predCol/predRow past the edge.
|
|
53
|
+
if (predCol >= cols) {
|
|
54
|
+
predCol = cols - 1;
|
|
55
|
+
}
|
|
56
|
+
if (predRow >= rows) {
|
|
57
|
+
predRow = rows - 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reset all prediction state. Called on server restart to avoid
|
|
62
|
+
* stale cursor overlay from the previous session. */
|
|
63
|
+
export function reset(): void {
|
|
64
|
+
predRow = 0;
|
|
65
|
+
predCol = 0;
|
|
66
|
+
predActive = false;
|
|
67
|
+
predPendingWrap = false;
|
|
68
|
+
predFrozen = false;
|
|
69
|
+
if (onChange) {
|
|
70
|
+
onChange();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reset prediction to the server's reported cursor and re-arm.
|
|
75
|
+
* When the server cursor is hidden (e.g. during a selection prompt),
|
|
76
|
+
* prediction is frozen — the ghost cursor disappears and typed input
|
|
77
|
+
* doesn't move it. */
|
|
78
|
+
export function onScreenFrame(serverRow: number, serverCol: number, cursorHidden?: boolean): void {
|
|
79
|
+
predRow = serverRow;
|
|
80
|
+
predCol = serverCol;
|
|
81
|
+
predPendingWrap = false;
|
|
82
|
+
predFrozen = cursorHidden ?? false;
|
|
83
|
+
predActive = !predFrozen;
|
|
84
|
+
if (onChange) {
|
|
85
|
+
onChange();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Apply locally-typed input bytes to the predicted cursor. Bails out
|
|
90
|
+
* (suspending prediction) on the first byte we can't model. Also
|
|
91
|
+
* bails when frozen (cursor hidden — user is in a selection prompt). */
|
|
92
|
+
export function applyInput(bytes: Uint8Array): void {
|
|
93
|
+
if (!predActive || predFrozen) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < bytes.length) {
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index checked by loop condition
|
|
99
|
+
const b = bytes[i]!;
|
|
100
|
+
if (b === 0x1b) {
|
|
101
|
+
// ESC — start of an escape sequence. Don't try to model it.
|
|
102
|
+
predActive = false;
|
|
103
|
+
if (onChange) {
|
|
104
|
+
onChange();
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (b === 0x08 || b === 0x7f) {
|
|
109
|
+
// BS / DEL — backspace one cell.
|
|
110
|
+
predPendingWrap = false;
|
|
111
|
+
if (predCol > 0) {
|
|
112
|
+
predCol--;
|
|
113
|
+
} else if (predRow > 0) {
|
|
114
|
+
// wrap to end of previous row (terminals typically don't,
|
|
115
|
+
// but kiro-cli's prompt frequently does on Backspace at col
|
|
116
|
+
// 0, so this is closer to expected behavior than staying put)
|
|
117
|
+
predRow--;
|
|
118
|
+
predCol = cols - 1;
|
|
119
|
+
}
|
|
120
|
+
i++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (b === 0x0d) {
|
|
124
|
+
// CR
|
|
125
|
+
predPendingWrap = false;
|
|
126
|
+
predCol = 0;
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (b === 0x0a) {
|
|
131
|
+
// LF
|
|
132
|
+
predPendingWrap = false;
|
|
133
|
+
predRow = Math.min(predRow + 1, rows - 1);
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (b < 0x20) {
|
|
138
|
+
// Other C0 — probably interpreted by the application; bail.
|
|
139
|
+
predActive = false;
|
|
140
|
+
if (onChange) {
|
|
141
|
+
onChange();
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Printable ASCII or UTF-8 lead byte. Detect the codepoint length
|
|
146
|
+
// and advance one cell. Mirror the server's pendingWrap semantics
|
|
147
|
+
// so the predicted cursor matches the server VT exactly: when
|
|
148
|
+
// pendingWrap is set we wrap to (row+1, 0) BEFORE writing, then
|
|
149
|
+
// record pendingWrap if the new col is the last one.
|
|
150
|
+
let len = 1;
|
|
151
|
+
if (b >= 0xc0 && b < 0xe0) {
|
|
152
|
+
len = 2;
|
|
153
|
+
} else if (b >= 0xe0 && b < 0xf0) {
|
|
154
|
+
len = 3;
|
|
155
|
+
} else if (b >= 0xf0) {
|
|
156
|
+
len = 4;
|
|
157
|
+
}
|
|
158
|
+
i += len;
|
|
159
|
+
if (predPendingWrap) {
|
|
160
|
+
predCol = 0;
|
|
161
|
+
predRow = Math.min(predRow + 1, rows - 1);
|
|
162
|
+
predPendingWrap = false;
|
|
163
|
+
}
|
|
164
|
+
if (predCol >= cols - 1) {
|
|
165
|
+
// Last column: don't advance, set pendingWrap for the next char.
|
|
166
|
+
predCol = cols - 1;
|
|
167
|
+
predPendingWrap = true;
|
|
168
|
+
} else {
|
|
169
|
+
predCol++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (onChange) {
|
|
173
|
+
onChange();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Current predicted cursor state. `active` is false when prediction
|
|
178
|
+
* is suspended (e.g. after an unmodelled byte). When the predicted
|
|
179
|
+
* position equals the server's position, the renderer should hide
|
|
180
|
+
* the overlay so the user sees only one cursor. */
|
|
181
|
+
export function get(): { row: number; col: number; active: boolean } {
|
|
182
|
+
return { row: predRow, col: predCol, active: predActive };
|
|
183
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Connection-state banner. Shows "Reconnecting…" / "Offline" / "Slow
|
|
2
|
+
// connection" feedback so users on flaky links understand why typing
|
|
3
|
+
// is unresponsive. Hidden by default; appears only when the
|
|
4
|
+
// connection state diverges from "open" for more than a brief window
|
|
5
|
+
// (debounced to avoid flicker on healthy networks).
|
|
6
|
+
//
|
|
7
|
+
// State machine:
|
|
8
|
+
// open → no banner.
|
|
9
|
+
// closed → "Reconnecting…" after CONNECTING_GRACE_MS, "Offline"
|
|
10
|
+
// after several backoff failures.
|
|
11
|
+
// reconnecting (during a connect retry) → "Reconnecting…".
|
|
12
|
+
// slow → "Slow connection" overlaid on top of "open" when the
|
|
13
|
+
// server's pingstat reports a capped RTO. (Not wired
|
|
14
|
+
// yet; placeholder for a future ping-RTT feedback path.)
|
|
15
|
+
|
|
16
|
+
const CONNECTING_GRACE_MS = 600;
|
|
17
|
+
|
|
18
|
+
type State = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
|
|
19
|
+
|
|
20
|
+
let banner: HTMLElement | null = null;
|
|
21
|
+
let state: State = "connecting";
|
|
22
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
let consecutiveFailures = 0;
|
|
24
|
+
|
|
25
|
+
/** Mark the connection as open. Hides the banner. */
|
|
26
|
+
export function open(): void {
|
|
27
|
+
consecutiveFailures = 0;
|
|
28
|
+
setState("open", 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mark a reconnect attempt in progress (after a close, before re-open). */
|
|
32
|
+
export function reconnecting(): void {
|
|
33
|
+
if (document.getElementById("loading")) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setState("reconnecting", CONNECTING_GRACE_MS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mark the connection as closed; shows banner after grace period. */
|
|
40
|
+
export function closed(): void {
|
|
41
|
+
consecutiveFailures++;
|
|
42
|
+
if (document.getElementById("loading")) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Surface that the server has restarted (epoch mismatch). The
|
|
49
|
+
* caller has already reset bytesSent/bytesAcked; this just shows
|
|
50
|
+
* a brief banner so the user knows old input may have been lost. */
|
|
51
|
+
export function restarted(): void {
|
|
52
|
+
setState("restarted", 0);
|
|
53
|
+
// Auto-clear the banner after a few seconds; "open" will re-arrive
|
|
54
|
+
// when the next screen frame renders, but we don't want to wait.
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
if (state === "restarted") {
|
|
57
|
+
setState("open", 0);
|
|
58
|
+
}
|
|
59
|
+
}, 4000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setState(next: State, delay: number): void {
|
|
63
|
+
if (stableTimer !== null) {
|
|
64
|
+
clearTimeout(stableTimer);
|
|
65
|
+
stableTimer = null;
|
|
66
|
+
}
|
|
67
|
+
if (delay === 0) {
|
|
68
|
+
state = next;
|
|
69
|
+
applyState();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
stableTimer = setTimeout(() => {
|
|
73
|
+
stableTimer = null;
|
|
74
|
+
state = next;
|
|
75
|
+
applyState();
|
|
76
|
+
}, delay);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Surface a brief transient message (e.g. "Copied") using the same
|
|
80
|
+
* banner system as connection status. Auto-clears after `ms`. */
|
|
81
|
+
export function toast(msg: string, ms = 3000): void {
|
|
82
|
+
if (!banner) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
banner.textContent = msg;
|
|
86
|
+
banner.dataset["state"] = "toast";
|
|
87
|
+
banner.classList.add("visible");
|
|
88
|
+
scheduleToastDismiss(ms);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let toastDismissTimer: ReturnType<typeof setTimeout> | null = null;
|
|
92
|
+
let toastDismissMs = 3000;
|
|
93
|
+
|
|
94
|
+
function scheduleToastDismiss(ms: number): void {
|
|
95
|
+
toastDismissMs = ms;
|
|
96
|
+
if (toastDismissTimer !== null) {
|
|
97
|
+
clearTimeout(toastDismissTimer);
|
|
98
|
+
}
|
|
99
|
+
toastDismissTimer = setTimeout(dismissToast, ms);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function dismissToast(): void {
|
|
103
|
+
toastDismissTimer = null;
|
|
104
|
+
if (!banner) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Only clear if still showing a toast (not overridden by connection state).
|
|
108
|
+
if (state === "open" && banner.dataset["state"] === "toast") {
|
|
109
|
+
banner.classList.remove("visible");
|
|
110
|
+
banner.textContent = "";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function init(): void {
|
|
115
|
+
banner = document.getElementById("conn-banner");
|
|
116
|
+
applyState();
|
|
117
|
+
|
|
118
|
+
// Hover-pause: stop the auto-dismiss timer while the user hovers
|
|
119
|
+
// (gives them time to read longer messages or click actions).
|
|
120
|
+
banner?.addEventListener("mouseenter", () => {
|
|
121
|
+
if (toastDismissTimer !== null) {
|
|
122
|
+
clearTimeout(toastDismissTimer);
|
|
123
|
+
toastDismissTimer = null;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
banner?.addEventListener("mouseleave", () => {
|
|
127
|
+
if (banner?.dataset["state"] === "toast" && banner.classList.contains("visible")) {
|
|
128
|
+
scheduleToastDismiss(toastDismissMs);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Escape-dismiss: pressing Escape while a toast is visible clears it.
|
|
133
|
+
document.addEventListener("keydown", (ev) => {
|
|
134
|
+
if (
|
|
135
|
+
ev.key === "Escape" &&
|
|
136
|
+
banner?.classList.contains("visible") &&
|
|
137
|
+
banner.dataset["state"] === "toast"
|
|
138
|
+
) {
|
|
139
|
+
if (toastDismissTimer !== null) {
|
|
140
|
+
clearTimeout(toastDismissTimer);
|
|
141
|
+
}
|
|
142
|
+
dismissToast();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function applyState(): void {
|
|
148
|
+
if (!banner) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
switch (state) {
|
|
152
|
+
case "open":
|
|
153
|
+
banner.classList.remove("visible");
|
|
154
|
+
banner.textContent = "";
|
|
155
|
+
break;
|
|
156
|
+
case "connecting":
|
|
157
|
+
case "reconnecting":
|
|
158
|
+
banner.textContent = "Reconnecting…";
|
|
159
|
+
banner.dataset["state"] = "reconnecting";
|
|
160
|
+
banner.classList.add("visible");
|
|
161
|
+
break;
|
|
162
|
+
case "offline":
|
|
163
|
+
banner.textContent = "Offline";
|
|
164
|
+
banner.dataset["state"] = "offline";
|
|
165
|
+
banner.classList.add("visible");
|
|
166
|
+
break;
|
|
167
|
+
case "restarted":
|
|
168
|
+
banner.textContent = "Server restarted — recent input may have been lost";
|
|
169
|
+
banner.dataset["state"] = "restarted";
|
|
170
|
+
banner.classList.add("visible");
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/viewport.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
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, snaps to bottom if was-at-bottom, etc.
|
|
14
|
+
|
|
15
|
+
import { scroll } from "@cplieger/web-terminal-engine";
|
|
16
|
+
|
|
17
|
+
// Time to wait after the last viewport-changing event before declaring
|
|
18
|
+
// the viewport "settled". Long enough to bridge the iOS keyboard slide
|
|
19
|
+
// (~250ms) with margin for fonts and reflow.
|
|
20
|
+
const SETTLE_MS = 350;
|
|
21
|
+
|
|
22
|
+
let termWrap: HTMLElement;
|
|
23
|
+
let onSettled: ((wasAtBottom: boolean) => void) | null = null;
|
|
24
|
+
let inTransition = false;
|
|
25
|
+
let wasAtBottomAtStart = true;
|
|
26
|
+
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
28
|
+
function startTransition(): void {
|
|
29
|
+
// Capture pre-transition scroll state at the *start* of the transition,
|
|
30
|
+
// not on every event in the burst.
|
|
31
|
+
if (!inTransition) {
|
|
32
|
+
inTransition = true;
|
|
33
|
+
wasAtBottomAtStart = !scroll.isUserScrolledUp();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (settleTimer !== null) {
|
|
37
|
+
clearTimeout(settleTimer);
|
|
38
|
+
}
|
|
39
|
+
settleTimer = setTimeout(() => {
|
|
40
|
+
settleTimer = null;
|
|
41
|
+
inTransition = false;
|
|
42
|
+
if (wasAtBottomAtStart) {
|
|
43
|
+
scroll.scrollToBottom();
|
|
44
|
+
}
|
|
45
|
+
if (onSettled) {
|
|
46
|
+
onSettled(wasAtBottomAtStart);
|
|
47
|
+
}
|
|
48
|
+
}, SETTLE_MS);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function init(opts: {
|
|
52
|
+
termWrap: HTMLElement;
|
|
53
|
+
onSettled: (wasAtBottom: boolean) => void;
|
|
54
|
+
}): void {
|
|
55
|
+
termWrap = opts.termWrap;
|
|
56
|
+
onSettled = opts.onSettled;
|
|
57
|
+
|
|
58
|
+
// iOS soft keyboard. interactive-widget=resizes-content makes the
|
|
59
|
+
// layout viewport shrink; we still apply a manual bottom inset as
|
|
60
|
+
// fallback for older iOS / other mobile browsers.
|
|
61
|
+
if (window.visualViewport) {
|
|
62
|
+
const vv = window.visualViewport;
|
|
63
|
+
const onChange = (): void => {
|
|
64
|
+
const inset = Math.max(0, Math.round(window.innerHeight - vv.height));
|
|
65
|
+
termWrap.style.bottom = inset > 0 ? `${inset}px` : "";
|
|
66
|
+
// Expose viewport geometry as CSS vars so fixed chrome stays in view:
|
|
67
|
+
// --kb-inset lifts the bottom banner above the keyboard; --vv-top lets
|
|
68
|
+
// the top toolbar counter iOS shifting content up to reveal the
|
|
69
|
+
// bottom-pinned input (otherwise the toolbar scrolls off the top).
|
|
70
|
+
const root = document.documentElement.style;
|
|
71
|
+
root.setProperty("--kb-inset", `${inset}px`);
|
|
72
|
+
root.setProperty("--vv-top", `${Math.max(0, Math.round(vv.offsetTop))}px`);
|
|
73
|
+
startTransition();
|
|
74
|
+
};
|
|
75
|
+
vv.addEventListener("resize", onChange);
|
|
76
|
+
vv.addEventListener("scroll", onChange);
|
|
77
|
+
onChange();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Term wrap dimension changes: window resize, font load, devtools dock.
|
|
81
|
+
const ro = new ResizeObserver(startTransition);
|
|
82
|
+
ro.observe(termWrap);
|
|
83
|
+
window.addEventListener("resize", startTransition);
|
|
84
|
+
|
|
85
|
+
// Orientation change on mobile. iOS Safari often emits the
|
|
86
|
+
// window.resize event late or not at all on rotation while
|
|
87
|
+
// visualViewport.resize fires reliably; screen.orientation.change is
|
|
88
|
+
// the canonical signal that survives both. Modern browsers expose it
|
|
89
|
+
// on screen.orientation; older Safari falls back to the deprecated
|
|
90
|
+
// window.orientationchange event.
|
|
91
|
+
const orientation = (screen as Screen & { orientation?: ScreenOrientation }).orientation;
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
|
|
93
|
+
if (orientation) {
|
|
94
|
+
orientation.addEventListener("change", startTransition);
|
|
95
|
+
} else if ("onorientationchange" in window) {
|
|
96
|
+
window.addEventListener("orientationchange", startTransition);
|
|
97
|
+
}
|
|
98
|
+
}
|