@swiftbrowser/web 0.9.3 → 0.9.5

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
@@ -247,7 +247,38 @@ chrome, `#screen` fills `100dvw × 100dvh` at (0, 0), the layout environment's
247
247
  `getComputedStyle` (`renderer.setSafeArea`). They are non-zero only in
248
248
  standalone (home screen) mode or landscape, which is what a native app would
249
249
  get. `resize`, `orientationchange` and `visualViewport` resizes re-run the
250
- engine (debounced 100ms, `renderer.viewportChanged`). The color scheme follows
250
+ engine (debounced 100ms, `renderer.viewportChanged`). The on-screen keyboard
251
+ does not resize `#screen` in iOS Safari (nor in a browser whose
252
+ `interactive-widget` is `resizes-visual`): the layout viewport keeps its
253
+ height and the browser shrinks (and pans) the visual viewport instead. (A
254
+ browser that does shrink the layout viewport for the keyboard goes through
255
+ the resize path above, and measures no keyboard.) `watchKeyboard` measures the
256
+ keyboard as the layout viewport's height less the visual one's, on every
257
+ `visualViewport` resize or scroll (once per frame, not debounced: the iOS
258
+ keyboard animates over a quarter of a second). The layout viewport's height
259
+ is `#device`'s (`100dvh`), not `window.innerHeight`, which iOS Safari shrinks
260
+ to the visual viewport's height while the keyboard is up. The browser's pan
261
+ to the focused field is not subtracted (Safari pans by the keyboard's whole
262
+ height for a field at the screen's bottom, which would read as no keyboard)
263
+ but undone: the page is scrolled back to its origin once the pan has held
264
+ still for a few frames, since Safari animates it with the keyboard's slide
265
+ and scrolling against that showed as a judder. Better still, the pan
266
+ mostly never happens: Safari pans only when the keyboard would cover the
267
+ field, so on focus the keyboard's last measured height (kept per layout
268
+ height in `localStorage`, so the next page load has it too) is reported at
269
+ once and the content is laid out above the keyboard before it shows; the
270
+ measurement then confirms or corrects it, and a reading of 0 in the second
271
+ after the focus is the keyboard not up yet. When the field loses focus and
272
+ no other text input takes it, 0 is reported as the keyboard starts down.
273
+ For a second after a text input gains or loses focus the viewport is read
274
+ every frame as well, in case its events come late or not at all;
275
+ `?debug=viewport` shows the numbers read, over the app, for checking a
276
+ phone's browser. The height goes to
277
+ `renderer.setKeyboard`, which the engine takes as a bottom safe-area inset
278
+ (`LayoutEnvironment.keyboard`; docs/ops-protocol.md, `ignoresKeyboard`): a
279
+ field or a chat's composer rises above the keyboard and a scroll view
280
+ anchored to its end keeps the last line in view, as on iOS, while a tab bar
281
+ under the keyboard stays put. The color scheme follows
251
282
  `prefers-color-scheme` (and its changes), the Dynamic Type size is `large`, and
252
283
  the `environment` event carries both as usual. `touch-action: manipulation`
253
284
  and `-webkit-text-size-adjust: 100%` stop double-tap zoom and text inflation;
@@ -292,6 +323,25 @@ remains for the landing page and for `/app/` without a parameter. In the
292
323
  `/manifest.webmanifest` is the app's, and the page is titled by the app's
293
324
  display name alone.
294
325
 
326
+ ## Browser history
327
+
328
+ `src/browserHistory.ts` mirrors the app's navigation in the browser's history
329
+ on the app page: pushing a screen or presenting a sheet adds an entry, and the
330
+ browser's back (a phone's edge swipe, the toolbar button, Alt+Left) pops or
331
+ dismisses it through the same `tap` the bar's back button and the sheet's
332
+ scrim send, so Swift's state stays the source of truth. A pop the app makes
333
+ itself takes the history back with it, and forward returns to the entry that
334
+ matches rather than re-pushing. Depth is read from the DOM after every
335
+ `commit`: the presented sheets plus the pushed screens of each visible stack
336
+ (the selected tab's, both columns of a split view, the top sheet's). The URL
337
+ is left alone. While it is on, the renderer's own edge swipe ignores touch and
338
+ pen: the browser's edge swipe is the swipe back, and it arrives as a
339
+ `popstate` (a mouse drag from the edge still pops through the renderer).
340
+ Its own `history.go` after a pop the app made itself is remembered until the
341
+ `popstate` lands, so commits meanwhile do not go back again.
342
+ An island has it on by default and opts out with `mount(..., { history: false })`
343
+ (`<SwiftUIView history={false}>` in @swiftbrowser/react).
344
+
295
345
  ## Deploying (Cloudflare Pages)
296
346
 
297
347
  `npm run build` writes a static site to `dist/`: `index.html`, `app/index.html`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The browser's history as the app's navigation: pushing a screen or
3
+ * presenting a sheet adds a history entry, and the browser's back (a
4
+ * phone's edge swipe, the toolbar button, Alt+Left) pops or dismisses it,
5
+ * as the bar's own back button would. Forward cannot re-push a screen, so a
6
+ * forward step lands back where the app is.
7
+ *
8
+ * Depth is read from the DOM after every commit: the presented sheets, plus
9
+ * the pushed screens of every visible stack (the selected tab's, both
10
+ * columns of a split view, the top sheet's). Pops go through the same event
11
+ * the back button and the scrim send, so the app's own state stays the
12
+ * source of truth; the page's URL is never touched, and an app that maps
13
+ * screens to paths should set them with `replaceState` on the entry the
14
+ * renderer made.
15
+ *
16
+ * A two-column split view pushes nothing when its sidebar's selection
17
+ * changes, so that is an entry of its own: choosing another sidebar row
18
+ * (one the selection moves to, not the first choice) adds one, and back or
19
+ * forward taps the row the entry recorded, through the same `tap` a finger
20
+ * sends. In a compact width the selection pushes the detail, a level like
21
+ * any other.
22
+ *
23
+ * While enabled, the renderer's own edge swipe to pop is off for touch and
24
+ * pen: the browser's edge swipe (Safari's, Android's back gesture) is the
25
+ * swipe back, and it lands here as a `popstate`. A mouse drag from the edge
26
+ * still pops through the renderer, since no browser turns one into history.
27
+ */
28
+ import type { Renderer } from './renderer';
29
+
30
+ interface HistoryState {
31
+ /** Our marker; entries without it belong to the page or the app. */
32
+ sbHistory: true;
33
+ /** How many levels (screens and sheets) are above the base in this entry. */
34
+ sbDepth: number;
35
+ /** The selected sidebar rows of the visible two-column split views (`sbId`s, comma-separated; absent or empty for none). */
36
+ sbSelection?: string;
37
+ }
38
+
39
+ /** What the renderer's elements need to say for this to read them (`sbId`, `sbDepth` datasets). */
40
+ const stateOf = (value: unknown): HistoryState | null => (typeof value === 'object' && value !== null && (value as HistoryState).sbHistory === true ? (value as HistoryState) : null);
41
+
42
+ function shown(win: Window, el: HTMLElement): boolean {
43
+ if (el.hidden || el.getClientRects().length === 0) return false;
44
+ return win.getComputedStyle(el).visibility !== 'hidden';
45
+ }
46
+
47
+ export class BrowserHistory {
48
+ private readonly win: Window;
49
+ /** Levels the latest `popstate` still asks to dismiss, one commit at a time. */
50
+ private pendingPops = 0;
51
+ /** `popstate` events to ignore: the ones our own `history.go` causes. */
52
+ private ignorePops = 0;
53
+ /**
54
+ * The depth our own `history.go` is travelling to while its `popstate` is
55
+ * still on its way (`go` is asynchronous): commits meanwhile compare
56
+ * against it, so a second commit does not go back a second time.
57
+ */
58
+ private movingTo: number | null = null;
59
+ private movingTimer: ReturnType<typeof setTimeout> | null = null;
60
+ /** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
61
+ private restoringSelection = false;
62
+ private enabled = false;
63
+
64
+ constructor(
65
+ private readonly renderer: Renderer,
66
+ private readonly root: HTMLElement,
67
+ win: Window = window,
68
+ ) {
69
+ this.win = win;
70
+ }
71
+
72
+ /** Marks the current entry as the base and starts mirroring pushes and pops. */
73
+ enable(): void {
74
+ if (this.enabled) return;
75
+ this.enabled = true;
76
+ const current = stateOf(this.win.history.state);
77
+ if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
78
+ this.renderer.interaction.nativeBackGesture = true;
79
+ this.root.addEventListener('sb:commit', this.onCommit);
80
+ this.win.addEventListener('popstate', this.onPopState);
81
+ this.sync();
82
+ }
83
+
84
+ dispose(): void {
85
+ if (!this.enabled) return;
86
+ this.enabled = false;
87
+ this.renderer.interaction.nativeBackGesture = false;
88
+ if (this.movingTimer) clearTimeout(this.movingTimer);
89
+ this.movingTo = null;
90
+ this.root.removeEventListener('sb:commit', this.onCommit);
91
+ this.win.removeEventListener('popstate', this.onPopState);
92
+ }
93
+
94
+ /** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
95
+ depth(): number {
96
+ const sheets = this.sheets();
97
+ const top = sheets.at(-1) ?? this.root;
98
+ let depth = sheets.length;
99
+ for (const stack of this.stacks(top)) depth += this.topDepth(stack);
100
+ return depth;
101
+ }
102
+
103
+ /** The selected sidebar rows of the visible two-column split views, as an entry records them. */
104
+ selection(): string {
105
+ const ids: string[] = [];
106
+ for (const split of this.root.querySelectorAll<HTMLElement>('.sb-navsplit')) {
107
+ if (!shown(this.win, split)) continue;
108
+ // The sidebar column is the first stack; its selectable rows carry the selection.
109
+ const sidebar = split.querySelector<HTMLElement>('.sb-navstack');
110
+ if (!sidebar) continue;
111
+ for (const row of sidebar.querySelectorAll<HTMLElement>('.sb-listrow[data-sb-selectable][data-sb-selected]')) {
112
+ const id = row.dataset['sbId'];
113
+ if (id !== undefined) ids.push(id);
114
+ }
115
+ }
116
+ return ids.join(',');
117
+ }
118
+
119
+ /** Taps the sidebar rows an entry recorded as selected that are not selected now; false when none can be. */
120
+ private selectRows(recorded: string): boolean {
121
+ const current = new Set(this.selection().split(',').filter(Boolean));
122
+ let tapped = false;
123
+ for (const id of recorded.split(',').filter(Boolean)) {
124
+ if (current.has(id)) continue;
125
+ const row = this.root.querySelector<HTMLElement>(`.sb-listrow[data-sb-selectable][data-sb-id="${id}"]`);
126
+ if (!row || !shown(this.win, row)) continue;
127
+ this.renderer.tap(Number(id));
128
+ tapped = true;
129
+ }
130
+ return tapped;
131
+ }
132
+
133
+ private sheets(): HTMLElement[] {
134
+ return [...this.root.querySelectorAll<HTMLElement>(':scope > .sb-sheet')].filter((sheet) => shown(this.win, sheet));
135
+ }
136
+
137
+ /** The visible navigation stacks within `container`, not those inside a deeper sheet. */
138
+ private stacks(container: HTMLElement): HTMLElement[] {
139
+ return [...container.querySelectorAll<HTMLElement>('.sb-navstack')].filter((stack) => shown(this.win, stack) && stack.closest('.sb-sheet') === (container.classList.contains('sb-sheet') ? container : null));
140
+ }
141
+
142
+ private topScreen(stack: HTMLElement): HTMLElement | null {
143
+ const screens = stack.querySelectorAll<HTMLElement>(':scope > .sb-slot > .sb-navscreen');
144
+ return screens.length ? screens[screens.length - 1]! : null;
145
+ }
146
+
147
+ private topDepth(stack: HTMLElement): number {
148
+ const top = this.topScreen(stack);
149
+ return top ? Number(top.dataset['sbDepth'] ?? '0') || 0 : 0;
150
+ }
151
+
152
+ /** Asks the app to dismiss one level: the top sheet when nothing is pushed inside it, else the deepest visible stack's top screen. */
153
+ private dismissOne(): boolean {
154
+ const sheets = this.sheets();
155
+ const top = sheets.at(-1) ?? this.root;
156
+ const stacks = this.stacks(top)
157
+ .map((stack) => ({ stack, depth: this.topDepth(stack) }))
158
+ .filter((s) => s.depth > 0)
159
+ .sort((a, b) => b.depth - a.depth);
160
+ const deepest = stacks[0];
161
+ if (deepest) {
162
+ const screen = this.topScreen(deepest.stack);
163
+ const id = Number(screen?.dataset['sbId']);
164
+ if (!Number.isInteger(id)) return false;
165
+ this.renderer.tap(id); // what the back button sends
166
+ return true;
167
+ }
168
+ const sheet = sheets.at(-1);
169
+ const id = Number(sheet?.dataset['sbId']);
170
+ if (!Number.isInteger(id)) return false;
171
+ this.renderer.tap(id); // a dismiss request, as the scrim's tap
172
+ return true;
173
+ }
174
+
175
+ private recorded(): number {
176
+ return this.movingTo ?? stateOf(this.win.history.state)?.sbDepth ?? 0;
177
+ }
178
+
179
+ private recordedSelection(): string {
180
+ return stateOf(this.win.history.state)?.sbSelection ?? '';
181
+ }
182
+
183
+ private entry(depth: number, selection: string): HistoryState {
184
+ return selection ? { sbHistory: true, sbDepth: depth, sbSelection: selection } : { sbHistory: true, sbDepth: depth };
185
+ }
186
+
187
+ /** Our own `history.go`, remembered until its `popstate` lands (or a moment passes without one: a `go` past the ends fires none). */
188
+ private go(delta: number, to: number): void {
189
+ this.ignorePops += 1;
190
+ this.movingTo = to;
191
+ if (this.movingTimer) clearTimeout(this.movingTimer);
192
+ this.movingTimer = setTimeout(() => {
193
+ if (this.movingTo === null) return;
194
+ this.movingTo = null;
195
+ this.ignorePops = Math.max(0, this.ignorePops - 1);
196
+ }, 1000);
197
+ this.win.history.go(delta);
198
+ }
199
+
200
+ private arrived(): void {
201
+ this.movingTo = null;
202
+ if (this.movingTimer) clearTimeout(this.movingTimer);
203
+ this.movingTimer = null;
204
+ }
205
+
206
+ private readonly onCommit = (): void => this.sync();
207
+
208
+ /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. */
209
+ sync(): void {
210
+ if (!this.enabled) return;
211
+ const depth = this.depth();
212
+ const recorded = this.recorded();
213
+ const selection = this.selection();
214
+ if (this.pendingPops > 0) {
215
+ // Dismissing for a `popstate`: the entry already names the target depth.
216
+ if (depth > recorded) {
217
+ if (!this.dismissOne()) this.pendingPops = 0;
218
+ else this.pendingPops -= 1;
219
+ } else this.pendingPops = 0;
220
+ return;
221
+ }
222
+ if (depth > recorded) {
223
+ for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
224
+ } else if (depth < recorded) {
225
+ // The app popped or dismissed on its own (back button, swipe, code): leave those entries.
226
+ this.go(depth - recorded, depth);
227
+ } else if (this.movingTo === null && selection !== this.recordedSelection()) {
228
+ // The same levels, another sidebar selection. The first choice (from
229
+ // none) and the one a `popstate` asked for belong to the entry at hand;
230
+ // a move from one row to another is a new entry.
231
+ const history = this.win.history;
232
+ if (this.restoringSelection || !this.recordedSelection()) {
233
+ const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
234
+ history.replaceState({ ...rest, ...this.entry(depth, selection) }, '');
235
+ } else history.pushState(this.entry(depth, selection), '');
236
+ }
237
+ this.restoringSelection = false;
238
+ }
239
+
240
+ private readonly onPopState = (event: PopStateEvent): void => {
241
+ if (this.ignorePops > 0) {
242
+ this.ignorePops -= 1;
243
+ this.arrived();
244
+ return;
245
+ }
246
+ const target = stateOf(event.state);
247
+ if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
248
+ const depth = this.depth();
249
+ if (target.sbDepth < depth) {
250
+ // Back: dismiss one level now, the rest as each commit lands.
251
+ this.pendingPops = depth - target.sbDepth;
252
+ if (!this.dismissOne()) this.pendingPops = 0;
253
+ else this.pendingPops -= 1;
254
+ } else if (target.sbDepth > depth) {
255
+ // Forward: a screen cannot be re-pushed; return to the entry that matches.
256
+ this.go(depth - target.sbDepth, depth);
257
+ return;
258
+ }
259
+ // The entry's sidebar selection, where it differs: tap the rows it names
260
+ // (back and forward alike, a row can be chosen again). What the app
261
+ // shows after that is recorded on this entry rather than pushed.
262
+ const recorded = target.sbSelection ?? '';
263
+ if (recorded !== this.selection()) {
264
+ this.restoringSelection = true;
265
+ this.selectRows(recorded);
266
+ }
267
+ };
268
+ }
package/src/deviceMode.ts CHANGED
@@ -85,3 +85,242 @@ export function watchViewport(onChange: () => void, debounceMs = 100): () => voi
85
85
  vv?.removeEventListener('resize', schedule);
86
86
  };
87
87
  }
88
+
89
+ /** What `keyboardHeight` reads of the visual viewport (`window.visualViewport`). */
90
+ export interface VisualViewportLike {
91
+ height: number;
92
+ scale: number;
93
+ }
94
+
95
+ /**
96
+ * The height of the on-screen keyboard over the bottom of the layout
97
+ * viewport, in CSS px: 0 while it is down. `layoutHeight` is the layout
98
+ * viewport's height: the height of the root element that fills it
99
+ * (`100dvh`), not `window.innerHeight`, which iOS Safari shrinks to the
100
+ * visual viewport's height while the keyboard is up (an iPhone read inner
101
+ * 352, visual 352, and the root still 692 tall). iOS Safari (and a browser
102
+ * whose `interactive-widget` is `resizes-visual`) does not shrink the layout
103
+ * viewport for the keyboard; it shrinks the visual viewport to the part of
104
+ * the screen above the keyboard and pans it down the page so the focused
105
+ * field shows. The keyboard is therefore the difference of the two heights.
106
+ * The pan (`offsetTop`) is not subtracted: Safari pans by the keyboard's
107
+ * full height when the field is at the bottom of the screen, which would
108
+ * read as no keyboard at all; `watchKeyboard` undoes the pan instead. A
109
+ * browser that shrinks the layout viewport for the keyboard measures 0 here
110
+ * and resizes the root. A pinch zoom shrinks the visual viewport too, so a
111
+ * scale other than 1 counts as no keyboard.
112
+ */
113
+ export function keyboardHeight(layoutHeight: number, vv: VisualViewportLike | null | undefined): number {
114
+ if (!vv || !Number.isFinite(vv.height) || !Number.isFinite(layoutHeight) || Math.abs((vv.scale || 1) - 1) > 0.01) return 0;
115
+ const covered = layoutHeight - vv.height;
116
+ return covered > 0 ? Math.round(covered) : 0;
117
+ }
118
+
119
+ export interface WatchKeyboardOptions {
120
+ /** The element that fills the layout viewport (`#device`): its height is the layout viewport's. */
121
+ root: HTMLElement;
122
+ /** Gets every reading (the `?debug=viewport` readout). */
123
+ onRead?: ((reading: ViewportReading) => void) | undefined;
124
+ /** Where the last measured height is kept for the next focus (and the next page load); `localStorage` by default, null for none. */
125
+ storage?: Pick<Storage, 'getItem' | 'setItem'> | null | undefined;
126
+ }
127
+
128
+ /**
129
+ * Call `onChange` with the keyboard's height (`keyboardHeight`) whenever the
130
+ * visual viewport resizes or pans, once per animation frame: the iOS
131
+ * keyboard animates over a quarter of a second and sends several resizes,
132
+ * so unlike `watchViewport` this does not debounce. A text input gaining or
133
+ * losing focus is what raises or drops the keyboard, so for a second after
134
+ * either the viewport is read every frame as well, in case its events come
135
+ * late or not at all.
136
+ *
137
+ * The browser pans the page to the focused field when the keyboard would
138
+ * cover it, which would leave the top of the screen (the navigation bar)
139
+ * scrolled off, and it does so while the keyboard slides, so undoing the
140
+ * pan as it happens shows as a judder. Better that it never happens: on
141
+ * focus the keyboard's last measured height (kept per layout height, in
142
+ * `localStorage` so the next page load has it too) is reported at once,
143
+ * before the keyboard shows, so the app has laid its content out above the
144
+ * keyboard, field included, by the time the browser decides whether to pan.
145
+ * The measurement then confirms or corrects it; a reading of 0 during the
146
+ * second after the focus is the keyboard not up yet, not a correction. A
147
+ * pan that does happen (the first focus ever, a keyboard taller than the
148
+ * last) is scrolled back, once it has held still for a few frames: Safari
149
+ * animates its pan with the keyboard's slide, and scrolling against that
150
+ * animation showed as a judder. (Safari's pan is a window scroll, `scrollY`
151
+ * 340 for a 340 keyboard on an iPhone; moving the root to counter a pan read
152
+ * from `visualViewport.offsetTop` was tried and dipped the content mid-slide
153
+ * on passing offsets.) When the field loses focus and no other text input
154
+ * has it, 0 is reported at once too, as the keyboard starts down. Returns a
155
+ * function that stops listening.
156
+ */
157
+ export function watchKeyboard(onChange: (height: number) => void, options: WatchKeyboardOptions): () => void {
158
+ const vv = window.visualViewport;
159
+ if (!vv) return () => {};
160
+ const { root, onRead } = options;
161
+ const storage = options.storage === undefined ? safeLocalStorage() : options.storage;
162
+ let frame: number | undefined;
163
+ let last = 0;
164
+ /** While set, the viewport is read every frame until this time (performance.now() ms). */
165
+ let pollUntil = 0;
166
+ /** While set, a reading of 0 is the keyboard not up yet (a focus just reported the known height). */
167
+ let anticipatingUntil = 0;
168
+ let blurTimer: ReturnType<typeof setTimeout> | undefined;
169
+ let lastEvent = '';
170
+ /** The pan read last frame (`scrollY:offsetTop`) and for how many frames in a row it has held. */
171
+ let lastPan = '';
172
+ let panHeld = 0;
173
+ const storageKey = (): string => `sb-keyboard-${root.clientHeight}`;
174
+ const knownHeight = (): number => {
175
+ const value = Number(storage?.getItem(storageKey()) ?? 0);
176
+ return Number.isFinite(value) && value > 0 ? value : 0;
177
+ };
178
+ const remember = (height: number): void => {
179
+ try {
180
+ storage?.setItem(storageKey(), String(height));
181
+ } catch {
182
+ // Storage full or blocked: the next focus measures instead of anticipating.
183
+ }
184
+ };
185
+ const report = (height: number): void => {
186
+ if (height === last) return;
187
+ last = height;
188
+ onChange(height);
189
+ };
190
+ const update = (): void => {
191
+ frame = undefined;
192
+ const layoutHeight = root.clientHeight;
193
+ const height = keyboardHeight(layoutHeight, vv);
194
+ const up = height > 0 || (last > 0 && performance.now() < anticipatingUntil);
195
+ const panNow = `${Math.round(window.scrollY)}:${Math.round(vv.offsetTop)}`;
196
+ panHeld = panNow === lastPan ? panHeld + 1 : 0;
197
+ lastPan = panNow;
198
+ const settled = panHeld >= PAN_SETTLE_FRAMES;
199
+ if (up && settled && (window.scrollY !== 0 || window.scrollX !== 0)) window.scrollTo(0, 0);
200
+ onRead?.({
201
+ layoutHeight,
202
+ innerHeight: window.innerHeight,
203
+ visualHeight: vv.height,
204
+ scale: vv.scale,
205
+ offsetTop: vv.offsetTop,
206
+ pageTop: vv.pageTop,
207
+ scrollY: window.scrollY,
208
+ keyboard: height,
209
+ event: lastEvent,
210
+ });
211
+ // The readout wants every frame; otherwise only the second after a focus change.
212
+ if (onRead || performance.now() < pollUntil) schedule();
213
+ if (height > 0) {
214
+ anticipatingUntil = 0;
215
+ remember(height);
216
+ report(height);
217
+ } else if (performance.now() >= anticipatingUntil) {
218
+ report(0);
219
+ }
220
+ };
221
+ const schedule = (): void => {
222
+ if (frame === undefined) frame = requestAnimationFrame(update);
223
+ };
224
+ const onViewport = (event: Event): void => {
225
+ lastEvent = `visualViewport ${event.type}`;
226
+ // Read on for a moment after a change, to see the pan hold still.
227
+ pollUntil = Math.max(pollUntil, performance.now() + PAN_SETTLE_MS);
228
+ schedule();
229
+ };
230
+ const isTextInput = (target: EventTarget | Element | null): boolean =>
231
+ target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
232
+ const onFocus = (event: FocusEvent): void => {
233
+ if (!isTextInput(event.target)) return;
234
+ lastEvent = event.type;
235
+ pollUntil = performance.now() + FOCUS_POLL_MS;
236
+ if (blurTimer !== undefined) clearTimeout(blurTimer);
237
+ blurTimer = undefined;
238
+ if (event.type === 'focusin') {
239
+ const known = knownHeight();
240
+ if (known > 0) {
241
+ anticipatingUntil = pollUntil;
242
+ report(known);
243
+ }
244
+ } else {
245
+ anticipatingUntil = 0;
246
+ // The focus may be moving to another text input (its `focusin` follows at once): wait a moment.
247
+ blurTimer = setTimeout(() => {
248
+ blurTimer = undefined;
249
+ if (!isTextInput(document.activeElement)) report(0);
250
+ }, BLUR_SETTLE_MS);
251
+ }
252
+ schedule();
253
+ };
254
+ vv.addEventListener('resize', onViewport);
255
+ vv.addEventListener('scroll', onViewport);
256
+ document.addEventListener('focusin', onFocus);
257
+ document.addEventListener('focusout', onFocus);
258
+ return () => {
259
+ if (frame !== undefined) cancelAnimationFrame(frame);
260
+ if (blurTimer !== undefined) clearTimeout(blurTimer);
261
+ pollUntil = 0;
262
+ anticipatingUntil = 0;
263
+ vv.removeEventListener('resize', onViewport);
264
+ vv.removeEventListener('scroll', onViewport);
265
+ document.removeEventListener('focusin', onFocus);
266
+ document.removeEventListener('focusout', onFocus);
267
+ };
268
+ }
269
+
270
+ /** `localStorage`, or null where reading it throws (a private window, blocked site data). */
271
+ function safeLocalStorage(): Storage | null {
272
+ try {
273
+ return window.localStorage;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ /** After a text input loses focus, how long to wait for another to take it before reporting the keyboard down. */
280
+ const BLUR_SETTLE_MS = 60;
281
+
282
+ /** How many frames in a row the browser's pan must hold still before it is scrolled back. */
283
+ const PAN_SETTLE_FRAMES = 3;
284
+
285
+ /** How long after a visual viewport event the viewport keeps being read every frame, to see the pan settle. */
286
+ const PAN_SETTLE_MS = 250;
287
+
288
+ /** How long after a text input gains or loses focus the viewport is read every frame (the keyboard's animation and some). */
289
+ const FOCUS_POLL_MS = 1000;
290
+
291
+ /** One reading of the viewport, for the `?debug=viewport` readout. */
292
+ export interface ViewportReading {
293
+ /** The root's height: the layout viewport's. */
294
+ layoutHeight: number;
295
+ innerHeight: number;
296
+ visualHeight: number;
297
+ scale: number;
298
+ /** The visual viewport's offset within the layout viewport: the browser's pan to the focused field. */
299
+ offsetTop: number;
300
+ pageTop: number;
301
+ scrollY: number;
302
+ keyboard: number;
303
+ /** The event that led to this reading (`visualViewport resize`, `focusin`…), empty before the first. */
304
+ event: string;
305
+ }
306
+
307
+ /**
308
+ * `?debug=viewport`: a small readout over the app with the numbers the
309
+ * keyboard measurement reads, for checking a phone's browser (which has no
310
+ * console to hand). It follows the visual viewport (a fixed element is
311
+ * panned with the layout viewport, off the top of the screen, when the
312
+ * browser pans to a field). Returns the function that updates it.
313
+ */
314
+ export function installViewportReadout(): (reading: ViewportReading) => void {
315
+ const el = document.createElement('pre');
316
+ el.className = 'sb-viewport-readout';
317
+ el.setAttribute('aria-hidden', 'true');
318
+ document.body.appendChild(el);
319
+ return (r) => {
320
+ el.style.transform = `translateY(${Math.round(r.offsetTop)}px)`;
321
+ el.textContent =
322
+ `root ${window.innerWidth}×${r.layoutHeight} inner ${r.innerHeight} visual ${Math.round(r.visualHeight)} scale ${r.scale.toFixed(3)}\n` +
323
+ `offsetTop ${Math.round(r.offsetTop)} pageTop ${Math.round(r.pageTop)} scrollY ${Math.round(r.scrollY)}\n` +
324
+ `keyboard ${r.keyboard} ${r.event || 'no event yet'} ${new Date().toLocaleTimeString()}`;
325
+ };
326
+ }
@@ -202,6 +202,14 @@ export class InteractionController {
202
202
  private session: Session | null = null;
203
203
  private openSwipe: OpenSwipe | null = null;
204
204
  private swipeBack: SwipeBackState | null = null;
205
+ /**
206
+ * The browser's own back gesture is in charge (browserHistory.ts mirrors
207
+ * navigation in the history): the renderer's edge swipe then ignores touch
208
+ * and pen, whose edge swipe the browser turns into a `popstate` that pops,
209
+ * so running both would pop twice. A mouse drag from the edge still pops
210
+ * here: no browser navigates on one.
211
+ */
212
+ nativeBackGesture = false;
205
213
  /** Wheel-driven swipe of a row: the host, its live offset and the idle timer that settles it. */
206
214
  private wheelSwipe: { host: SwipeHost; offset: number; timer: number } | null = null;
207
215
  /** Wheel-driven pull at the top of a refreshable scroller. */
@@ -426,7 +434,8 @@ export class InteractionController {
426
434
  }
427
435
  const byPress = event.pointerType !== 'mouse';
428
436
  const screen = target.closest<HTMLElement>('.sb-navscreen');
429
- if (screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
437
+ const browserSwipes = this.nativeBackGesture && event.pointerType !== 'mouse';
438
+ if (!browserSwipes && screen && screen.dataset['sbNavPosition'] === 'top' && Number(screen.dataset['sbDepth'] ?? '0') > 0 && !this.swipeBack) {
430
439
  const rect = screen.getBoundingClientRect();
431
440
  const id = Number(screen.dataset[ID_ATTR]);
432
441
  if (event.clientX - rect.left <= SWIPE_BACK_EDGE && Number.isInteger(id)) {