@swiftbrowser/web 0.9.4 → 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.4",
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": {
@@ -13,6 +13,13 @@
13
13
  * screens to paths should set them with `replaceState` on the entry the
14
14
  * renderer made.
15
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
+ *
16
23
  * While enabled, the renderer's own edge swipe to pop is off for touch and
17
24
  * pen: the browser's edge swipe (Safari's, Android's back gesture) is the
18
25
  * swipe back, and it lands here as a `popstate`. A mouse drag from the edge
@@ -25,6 +32,8 @@ interface HistoryState {
25
32
  sbHistory: true;
26
33
  /** How many levels (screens and sheets) are above the base in this entry. */
27
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;
28
37
  }
29
38
 
30
39
  /** What the renderer's elements need to say for this to read them (`sbId`, `sbDepth` datasets). */
@@ -48,6 +57,8 @@ export class BrowserHistory {
48
57
  */
49
58
  private movingTo: number | null = null;
50
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;
51
62
  private enabled = false;
52
63
 
53
64
  constructor(
@@ -63,7 +74,7 @@ export class BrowserHistory {
63
74
  if (this.enabled) return;
64
75
  this.enabled = true;
65
76
  const current = stateOf(this.win.history.state);
66
- if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), sbHistory: true, sbDepth: 0 }, '');
77
+ if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
67
78
  this.renderer.interaction.nativeBackGesture = true;
68
79
  this.root.addEventListener('sb:commit', this.onCommit);
69
80
  this.win.addEventListener('popstate', this.onPopState);
@@ -89,6 +100,36 @@ export class BrowserHistory {
89
100
  return depth;
90
101
  }
91
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
+
92
133
  private sheets(): HTMLElement[] {
93
134
  return [...this.root.querySelectorAll<HTMLElement>(':scope > .sb-sheet')].filter((sheet) => shown(this.win, sheet));
94
135
  }
@@ -135,6 +176,14 @@ export class BrowserHistory {
135
176
  return this.movingTo ?? stateOf(this.win.history.state)?.sbDepth ?? 0;
136
177
  }
137
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
+
138
187
  /** Our own `history.go`, remembered until its `popstate` lands (or a moment passes without one: a `go` past the ends fires none). */
139
188
  private go(delta: number, to: number): void {
140
189
  this.ignorePops += 1;
@@ -156,11 +205,12 @@ export class BrowserHistory {
156
205
 
157
206
  private readonly onCommit = (): void => this.sync();
158
207
 
159
- /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them. */
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. */
160
209
  sync(): void {
161
210
  if (!this.enabled) return;
162
211
  const depth = this.depth();
163
212
  const recorded = this.recorded();
213
+ const selection = this.selection();
164
214
  if (this.pendingPops > 0) {
165
215
  // Dismissing for a `popstate`: the entry already names the target depth.
166
216
  if (depth > recorded) {
@@ -170,11 +220,21 @@ export class BrowserHistory {
170
220
  return;
171
221
  }
172
222
  if (depth > recorded) {
173
- for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState({ sbHistory: true, sbDepth: level } satisfies HistoryState, '');
223
+ for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
174
224
  } else if (depth < recorded) {
175
225
  // The app popped or dismissed on its own (back button, swipe, code): leave those entries.
176
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), '');
177
236
  }
237
+ this.restoringSelection = false;
178
238
  }
179
239
 
180
240
  private readonly onPopState = (event: PopStateEvent): void => {
@@ -194,6 +254,15 @@ export class BrowserHistory {
194
254
  } else if (target.sbDepth > depth) {
195
255
  // Forward: a screen cannot be re-pushed; return to the entry that matches.
196
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);
197
266
  }
198
267
  };
199
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
+ }
@@ -507,6 +507,17 @@ function flexibleFrameLength(proposed: number | null, child: number, min: number
507
507
  return length;
508
508
  }
509
509
 
510
+ /** Whether a root (through its `styled` wrappers) carries `ignoresKeyboard`: it keeps the keyboardless height. */
511
+ function ignoresKeyboard(node: LayoutNode): boolean {
512
+ let current = node;
513
+ while (current.kind === 'styled') {
514
+ if (styleOf(current)['ignoresKeyboard'] === true) return true;
515
+ if (current.children.length !== 1) return false;
516
+ current = current.children[0] as LayoutNode;
517
+ }
518
+ return false;
519
+ }
520
+
510
521
  /** The element under a chain of `styled` wrappers. */
511
522
  function unwrapStyled(node: LayoutNode): LayoutNode {
512
523
  let current = node;
@@ -1260,6 +1271,12 @@ export class LayoutPass {
1260
1271
  };
1261
1272
  }
1262
1273
 
1274
+ /** The on-screen keyboard's height over the screen's bottom (device mode, a field being edited); 0 while it is down. */
1275
+ private get keyboard(): number {
1276
+ const keyboard = this.env.keyboard;
1277
+ return typeof keyboard === 'number' && Number.isFinite(keyboard) && keyboard > 0 ? keyboard : 0;
1278
+ }
1279
+
1263
1280
  run(roots: readonly LayoutNode[]): LayoutResult {
1264
1281
  this.runId += 1;
1265
1282
  this.touched = new Set();
@@ -1285,9 +1302,12 @@ export class LayoutPass {
1285
1302
  const size = this.measure(root, ctx, proposal);
1286
1303
  this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
1287
1304
  } else {
1305
+ // The keyboard (device mode) is a bottom inset too, unless the root
1306
+ // ignores it (`.ignoresSafeArea(.keyboard)`: the screen keeps its height).
1307
+ const keyboard = ignoresKeyboard(root) ? 0 : this.keyboard;
1288
1308
  const safe: Size = {
1289
1309
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
1290
- height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
1310
+ height: Math.max(0, screen.height - safeArea.top - Math.max(safeArea.bottom, keyboard)),
1291
1311
  };
1292
1312
  if (this.env.contentHeight) {
1293
1313
  // An island: the root takes its content's height and sits at the top.
@@ -3591,7 +3611,9 @@ export class LayoutPass {
3591
3611
  this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
3592
3612
  }
3593
3613
 
3594
- // The inline title: centered, no wider than 56% of the bar, and clear of
3614
+ // The inline title: centered, in a compact width no wider than 56% of the
3615
+ // bar (as UIKit keeps it on a phone; a wide bar on an iPad or in a split
3616
+ // view's column gives a title all the room its items leave), and clear of
3595
3617
  // whatever the back button and the bar items occupy on either side. Its
3596
3618
  // width is rounded up to whole pixels: snapping floors both edges, which
3597
3619
  // takes up to a pixel off a fractional width at a fractional origin, and
@@ -3615,9 +3637,10 @@ export class LayoutPass {
3615
3637
  // each side), and truncated only when it does not fit even there.
3616
3638
  const roomStart = x + leftUsed + 8;
3617
3639
  const roomEnd = x + size.width - rightUsed - 8;
3640
+ const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
3618
3641
  const titleWidth = Math.min(
3619
3642
  Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001),
3620
- Math.floor(size.width * 0.56),
3643
+ compactCap,
3621
3644
  Math.floor(Math.max(0, roomEnd - roomStart)),
3622
3645
  );
3623
3646
  const centered = x + (size.width - titleWidth) / 2;
@@ -3651,8 +3674,13 @@ export class LayoutPass {
3651
3674
  const bottomCover = Math.max(bottomBarHeight, searchBottom);
3652
3675
  // Under a tab bar the bar (which includes the bottom safe area) is what
3653
3676
  // non-scrolling content must clear; a bottom toolbar already excluded it.
3654
- const bottomInset =
3655
- bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
3677
+ const clears = bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
3678
+ // The on-screen keyboard (device mode) covers the screen's bottom, the
3679
+ // bars with it: the content stops above it as on iOS (the bars stay
3680
+ // put, covered), and a scroll view runs under only the part of a bar
3681
+ // still showing above it.
3682
+ const keyboard = reachesBottom ? this.keyboard : 0;
3683
+ const bottomInset = Math.max(clears, keyboard - bottomCover);
3656
3684
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
3657
3685
  // large title / search field), which is transparent over it.
3658
3686
  const contentCtx: Context = {
@@ -3663,6 +3691,10 @@ export class LayoutPass {
3663
3691
  barAbove: undefined,
3664
3692
  ...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
3665
3693
  };
3694
+ if (keyboard > 0) {
3695
+ contentCtx.bottomBar = Math.max(0, (bottomCover > 0 ? bottomCover : cctx.bottomBar) - keyboard);
3696
+ contentCtx.bottomEdge = y + size.height - keyboard;
3697
+ }
3666
3698
  if (scroller) {
3667
3699
  // iOS 26: the scroll view starts at the top of the screen and its
3668
3700
  // content scrolls under the bar (and the large title moves with it);
@@ -3674,7 +3706,7 @@ export class LayoutPass {
3674
3706
  largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
3675
3707
  search: searchRow,
3676
3708
  });
3677
- this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
3709
+ this.place(child, contentCtx, { width: content.width, height: Math.max(0, content.height + top + bottomCover - keyboard) }, content.x, y);
3678
3710
  return;
3679
3711
  }
3680
3712
  if (direct) this.scrollInsets.delete(direct);
@@ -3683,7 +3715,11 @@ export class LayoutPass {
3683
3715
  // One that reaches the bottom of the content runs down under all the
3684
3716
  // content clears there.
3685
3717
  if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
3686
- if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
3718
+ if (keyboard > 0) {
3719
+ contentCtx.bottomBar = Math.max(0, (bottomCover > 0 ? bottomCover + clears : cctx.bottomBar) - keyboard);
3720
+ } else if (bottomCover > 0) {
3721
+ contentCtx.bottomBar = bottomCover + clears;
3722
+ }
3687
3723
  if (direct && direct.kind === 'scrollview') {
3688
3724
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
3689
3725
  return;
@@ -3872,13 +3908,16 @@ export class LayoutPass {
3872
3908
  * centered between the status bar and the tab bar.
3873
3909
  */
3874
3910
  private placeTabContent(tab: LayoutNode, cctx: Context, x: number, y: number, size: Size, barHeight: number): void {
3875
- const { safeArea } = this.env;
3911
+ const { safeArea, screen } = this.env;
3876
3912
  const topInset = y <= 0 ? safeArea.top : 0;
3913
+ // The keyboard (device mode) covers the tab bar: the content stops above
3914
+ // whichever is taller (a `navstack` handles the keyboard in its screens).
3915
+ const keyboard = y + size.height >= screen.height - EPSILON ? this.keyboard : 0;
3877
3916
  const area: Rect = {
3878
3917
  x,
3879
3918
  y: y + topInset,
3880
3919
  width: size.width,
3881
- height: Math.max(0, size.height - topInset - barHeight),
3920
+ height: Math.max(0, size.height - topInset - Math.max(barHeight, keyboard)),
3882
3921
  };
3883
3922
  this.setChrome(tab, { content: area });
3884
3923
  const child = this.contentOf(tab);
@@ -3889,8 +3928,9 @@ export class LayoutPass {
3889
3928
  return;
3890
3929
  }
3891
3930
  if (kind === 'list' || kind === 'scrollview') {
3892
- const height = Math.max(0, size.height - topInset);
3893
- this.place(child, cctx, { width: size.width, height }, x, y + topInset);
3931
+ const height = Math.max(0, size.height - topInset - keyboard);
3932
+ const ctx: Context = keyboard > 0 ? { ...cctx, bottomBar: Math.max(0, barHeight - keyboard), bottomEdge: y + size.height - keyboard } : cctx;
3933
+ this.place(child, ctx, { width: size.width, height }, x, y + topInset);
3894
3934
  return;
3895
3935
  }
3896
3936
  const proposal: Proposal = { width: area.width, height: area.height };
@@ -4044,20 +4084,23 @@ export class LayoutPass {
4044
4084
  * box touches (or is inside) that safe-area inset band; null when nothing grows.
4045
4085
  */
4046
4086
  private safeAreaExtension(node: LayoutNode, ctx: Context, x: number, y: number, size: Size): EdgeInsets | null {
4047
- const raw = styleOf(node)['ignoresSafeArea'];
4048
- if (raw === undefined || raw === null) return null;
4087
+ const style = styleOf(node);
4088
+ const raw = style['ignoresSafeArea'];
4049
4089
  const edges: string[] = raw === 'all' ? [...SAFE_AREA_EDGES] : Array.isArray(raw) ? raw.map(str).filter((e): e is string => e !== null) : [];
4050
- if (edges.length === 0) return null;
4090
+ // `.ignoresSafeArea(.keyboard)`: the box runs down under the keyboard
4091
+ // the content above it stopped at (device mode, while it is up).
4092
+ const keyboard = style['ignoresKeyboard'] === true ? this.keyboard : 0;
4093
+ if (edges.length === 0 && keyboard === 0) return null;
4051
4094
  const { screen, safeArea } = this.env;
4052
4095
  // Inside a `navscreen` the bar is what the content clears (`ctx.safeTop`);
4053
4096
  // under a tab bar, the bar (`ctx.bottomBar`, which includes the bottom
4054
4097
  // safe area).
4055
4098
  const top = ctx.safeTop;
4056
- const bottom = Math.max(safeArea.bottom, ctx.bottomBar);
4099
+ const bottom = Math.max(edges.includes('bottom') ? Math.max(safeArea.bottom, ctx.bottomBar) : 0, keyboard);
4057
4100
  const ext: EdgeInsets = { top: 0, leading: 0, bottom: 0, trailing: 0 };
4058
4101
  if (edges.includes('top') && top > 0 && y <= top + EPSILON) ext.top = Math.max(0, y);
4059
4102
  if (edges.includes('leading') && safeArea.leading > 0 && x <= safeArea.leading + EPSILON) ext.leading = Math.max(0, x);
4060
- if (edges.includes('bottom') && bottom > 0 && y + size.height >= screen.height - bottom - EPSILON) {
4103
+ if (bottom > 0 && y + size.height >= screen.height - bottom - EPSILON) {
4061
4104
  ext.bottom = Math.max(0, screen.height - (y + size.height));
4062
4105
  }
4063
4106
  if (edges.includes('trailing') && safeArea.trailing > 0 && x + size.width >= screen.width - safeArea.trailing - EPSILON) {
@@ -4451,7 +4494,10 @@ export class LayoutPass {
4451
4494
  this.setChrome(node, chrome);
4452
4495
  const child = this.contentOf(node);
4453
4496
  if (!child) return;
4454
- const proposal: Proposal = { width: content.width, height: content.height };
4497
+ // The keyboard (device mode) covers the sheet's bottom too: a list, a
4498
+ // scroll view or plain content stops above it (a navigation stack's
4499
+ // screens handle it themselves).
4500
+ const covered = Math.max(0, y + size.height - (this.env.screen.height - this.keyboard));
4455
4501
  const kind = unwrapStyled(child).kind;
4456
4502
  if (kind === 'navstack') {
4457
4503
  this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
@@ -4460,12 +4506,14 @@ export class LayoutPass {
4460
4506
  if (kind === 'list' || kind === 'scrollview') {
4461
4507
  // A scrolling view fills the sheet below the grabber row, if any.
4462
4508
  const top = grabber ? SHEET_GRABBER_ROW : 0;
4463
- this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
4509
+ this.place(child, cctx, { width: size.width, height: Math.max(0, size.height - top - covered) }, x, y + top);
4464
4510
  return;
4465
4511
  }
4512
+ const area: Rect = { ...content, height: Math.max(0, content.height - covered) };
4513
+ const proposal: Proposal = { width: area.width, height: area.height };
4466
4514
  const childSize = this.measure(child, cctx, proposal);
4467
- const { dx, dy } = alignIn(content, childSize, 'center');
4468
- this.place(child, cctx, proposal, content.x + dx, content.y + dy);
4515
+ const { dx, dy } = alignIn(area, childSize, 'center');
4516
+ this.place(child, cctx, proposal, area.x + dx, area.y + dy);
4469
4517
  }
4470
4518
 
4471
4519
  // -------------------------------------------------------------------------
@@ -30,7 +30,7 @@ export class CanvasTextMeasurer implements TextMeasurer {
30
30
 
31
31
  measureWidth(text: string, font: ResolvedFont): number {
32
32
  if (text.length === 0) return 0;
33
- const key = `${font.css}\u0000${text}`;
33
+ const key = `${font.css}\u0000${font.monospacedDigit ? 'T' : ''}\u0000${text}`;
34
34
  const cached = this.cache.get(key);
35
35
  if (cached !== undefined) return cached;
36
36
  const context = this.acquireContext();
@@ -41,7 +41,9 @@ export class CanvasTextMeasurer implements TextMeasurer {
41
41
  context.font = css;
42
42
  this.currentFont = css;
43
43
  }
44
- width = context.measureText(text).width;
44
+ // A canvas has no `font-variant-numeric`: tabular digits are measured
45
+ // as the font's widest digit, which is the width a tabular figure takes.
46
+ width = context.measureText(font.monospacedDigit ? text.replace(/[0-9]/g, this.widestDigit(context, css)) : text).width;
45
47
  } else {
46
48
  width = this.fallback.measureWidth(text, font);
47
49
  }
@@ -50,9 +52,29 @@ export class CanvasTextMeasurer implements TextMeasurer {
50
52
  return width;
51
53
  }
52
54
 
55
+ private readonly widestDigits = new Map<string, string>();
56
+
57
+ /** The font's widest digit (its `font` already set on `context`). */
58
+ private widestDigit(context: Canvas2D, css: string): string {
59
+ const known = this.widestDigits.get(css);
60
+ if (known !== undefined) return known;
61
+ let widest = '0';
62
+ let max = -1;
63
+ for (const digit of '0123456789') {
64
+ const width = context.measureText(digit).width;
65
+ if (width > max) {
66
+ max = width;
67
+ widest = digit;
68
+ }
69
+ }
70
+ this.widestDigits.set(css, widest);
71
+ return widest;
72
+ }
73
+
53
74
  /** Drop cached widths (call when web fonts finish loading). */
54
75
  clearCache(): void {
55
76
  this.cache.clear();
77
+ this.widestDigits.clear();
56
78
  }
57
79
 
58
80
  private acquireContext(): Canvas2D | null {
@@ -76,6 +76,8 @@ export interface ResolvedFont {
76
76
  */
77
77
  weightModifier?: number;
78
78
  familyModifier?: FontFamily;
79
+ /** `.monospacedDigit()`: digits at one width, drawn as `tabular-nums` and measured as the font's widest digit. */
80
+ monospacedDigit?: boolean;
79
81
  }
80
82
 
81
83
  export interface TextMeasurer {
@@ -108,6 +110,14 @@ export interface LayoutEnvironment {
108
110
  * navigation and tab roots still fill the screen.
109
111
  */
110
112
  contentHeight?: boolean;
113
+ /**
114
+ * The height of the on-screen keyboard covering the screen's bottom (device
115
+ * mode, while a field is being edited), 0 or absent when it is down. As on
116
+ * iOS it is a bottom safe-area inset: content stops above it (a bar under
117
+ * it stays put, covered), and a `styled` box with `ignoresKeyboard` runs
118
+ * under it.
119
+ */
120
+ keyboard?: number;
111
121
  }
112
122
 
113
123
  export type TextAlignment = 'leading' | 'center' | 'trailing';
@@ -155,13 +155,14 @@ export function makeFont(
155
155
  family: FontFamily,
156
156
  size: number,
157
157
  weight: number,
158
- modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
158
+ modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined; monospacedDigit?: boolean | undefined } = {},
159
159
  ): ResolvedFont {
160
160
  const lineHeight = lineHeightFor(size);
161
161
  const css = `${weight} ${formatPx(size)}/${formatPx(lineHeight)} ${FONT_FAMILIES[family]}`;
162
162
  const font: ResolvedFont = { family, size, weight, lineHeight, leading: leadingFor(size), css };
163
163
  if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
164
164
  if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
165
+ if (modifiers.monospacedDigit) font.monospacedDigit = true;
165
166
  return font;
166
167
  }
167
168
 
@@ -218,8 +219,17 @@ export function resolveFont(
218
219
  // A weight or design without a new font is a modifier the text further in keeps.
219
220
  const weightModifier = !isNewFont && explicitWeight !== undefined ? explicitWeight : inherited.weightModifier;
220
221
  const familyModifier = !isNewFont && explicitFamily !== undefined ? explicitFamily : inherited.familyModifier;
221
- if (!isNewFont && weight === inherited.weight && family === inherited.family && weightModifier === inherited.weightModifier && familyModifier === inherited.familyModifier) {
222
+ // Tabular digits stay on until a font says otherwise, like a weight modifier.
223
+ const monospacedDigit = typeof font['monospacedDigit'] === 'boolean' ? font['monospacedDigit'] : inherited.monospacedDigit === true;
224
+ if (
225
+ !isNewFont &&
226
+ weight === inherited.weight &&
227
+ family === inherited.family &&
228
+ weightModifier === inherited.weightModifier &&
229
+ familyModifier === inherited.familyModifier &&
230
+ monospacedDigit === (inherited.monospacedDigit === true)
231
+ ) {
222
232
  return inherited;
223
233
  }
224
- return makeFont(family, size, weight, { weightModifier, familyModifier });
234
+ return makeFont(family, size, weight, { weightModifier, familyModifier, monospacedDigit });
225
235
  }
package/src/layoutDom.ts CHANGED
@@ -118,15 +118,8 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
118
118
  ids = result.frames.keys();
119
119
  }
120
120
 
121
- for (const id of ids) {
122
- const frame = result.frames.get(id);
123
- const el = ctx.elements.get(id);
124
- const node = ctx.nodes.get(id);
125
- if (!el || !node) continue;
126
- if (!frame) {
127
- boxes.delete(id);
128
- continue;
129
- }
121
+ /** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
122
+ const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
130
123
  const box = drawnBox(node, boxOf(ctx, result, node, frame));
131
124
  const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : isSectionSupplement(node) ? supplementOrigin(ctx, result, id) : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
132
125
  const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
@@ -138,11 +131,37 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
138
131
  if (node.kind === 'sheet') positionSheetCard(el, box);
139
132
  else positionBox(el, box, origin);
140
133
  }
134
+ return { box, moved, previous };
135
+ };
136
+
137
+ const visited = result.changed ? new Set(ids) : null;
138
+ for (const id of ids) {
139
+ const frame = result.frames.get(id);
140
+ const el = ctx.elements.get(id);
141
+ const node = ctx.nodes.get(id);
142
+ if (!el || !node) continue;
143
+ if (!frame) {
144
+ boxes.delete(id);
145
+ continue;
146
+ }
147
+ const { box, moved, previous } = applyBox(id, node, el, frame);
141
148
  // A former menu item that is laid out again (moved out of the menu), or a
142
149
  // `viewthatfits` candidate chosen again, shows again.
143
150
  if (el.hidden) el.hidden = false;
144
151
  applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
145
152
  if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
153
+ // A child the engine did not touch kept its screen frame, but if this
154
+ // element's box moved, the child's `left`/`top` (relative to it) did
155
+ // not: the plus button at the bottom of a chat's composer stays put on
156
+ // screen while the composer grows upwards around it. Re-place such
157
+ // children; their own subtrees are relative to them and unchanged.
158
+ if (!moved || !visited) continue;
159
+ for (const child of node.children) {
160
+ if (visited.has(child.id)) continue;
161
+ const childFrame = result.frames.get(child.id);
162
+ const childEl = ctx.elements.get(child.id);
163
+ if (childFrame && childEl) applyBox(child.id, child, childEl, childFrame);
164
+ }
146
165
  }
147
166
 
148
167
  // Phase 6: menu items are hidden without a frame (tabs keep theirs, above);
@@ -414,6 +433,9 @@ function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): vo
414
433
  if (!font) return;
415
434
  const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
416
435
  if (el.style.font !== css) el.style.font = css;
436
+ // The `font` shorthand resets the variant, so it goes after it.
437
+ const variant = font.monospacedDigit ? 'tabular-nums' : '';
438
+ if (el.style.fontVariantNumeric !== variant) el.style.fontVariantNumeric = variant;
417
439
  }
418
440
 
419
441
  const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
package/src/main.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { easingFor, springEasing, timingFor } from './animation';
3
3
  import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
4
4
  import { ChromeColor } from './chromeColor';
5
- import { applyPageMode, decidePageMode, readViewportInsets, watchViewport, type PageMode } from './deviceMode';
5
+ import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
6
6
  import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
7
7
  import { createMockApp } from './mock';
8
8
  import {
@@ -250,6 +250,13 @@ if (deviceMode) {
250
250
  // A rotation or window resize may cross the compact / regular threshold.
251
251
  if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
252
252
  });
253
+ // The on-screen keyboard does not shrink `#screen` (the layout viewport
254
+ // keeps its height); it covers the screen's bottom. The engine gets its
255
+ // height as a bottom inset, so a field or a chat's composer rises above
256
+ // it and a scroll view keeps its end in view, as on iOS.
257
+ // `?debug=viewport` shows the numbers the measurement reads, over the app.
258
+ const readout = new URLSearchParams(location.search).get('debug') === 'viewport' ? installViewportReadout() : undefined;
259
+ watchKeyboard((height) => renderer.setKeyboard(height), { root: device, onRead: readout });
253
260
  }
254
261
 
255
262
  // ---------------------------------------------------------------------------
package/src/protocol.ts CHANGED
@@ -223,6 +223,8 @@ export interface Font {
223
223
  relativeTo?: TextStyle;
224
224
  weight?: FontWeight;
225
225
  design?: FontDesign;
226
+ /** `.monospacedDigit()`: digits of one width (`font-variant-numeric: tabular-nums`). */
227
+ monospacedDigit?: boolean;
226
228
  }
227
229
 
228
230
  export type FrameAlignment =
@@ -2140,6 +2142,13 @@ export interface Style {
2140
2142
  * child's proposal and position are unchanged (the extension is background).
2141
2143
  */
2142
2144
  ignoresSafeArea?: 'all' | Edge[];
2145
+ /**
2146
+ * `.ignoresSafeArea(.keyboard)` (and `.all`, the default): while the
2147
+ * on-screen keyboard is up (device mode), the engine extends the box down
2148
+ * under it instead of stopping above it, and a root that carries it keeps
2149
+ * the keyboardless height.
2150
+ */
2151
+ ignoresKeyboard?: boolean;
2143
2152
  }
2144
2153
 
2145
2154
  /** `.repeatCount(_:autoreverses:)` / `.repeatForever(autoreverses:)`. */
@@ -2197,6 +2206,12 @@ export interface ScrollViewProps {
2197
2206
  dismissesKeyboard?: boolean;
2198
2207
  /** As for `list`. */
2199
2208
  edgeEffect?: EdgeEffectProps;
2209
+ /**
2210
+ * `.defaultScrollAnchor(_:)`: the content starts with this point of it at
2211
+ * the same point of the viewport (`{x: 0.5, y: 1}`: the end in view), and
2212
+ * keeps it when the content grows while the viewer is at it (src/scrollAnchor.ts).
2213
+ */
2214
+ defaultScrollAnchor?: UnitPoint;
2200
2215
  }
2201
2216
 
2202
2217
  /**
package/src/renderer.ts CHANGED
@@ -151,6 +151,7 @@ import { VideoEvents, applyVideo, stopVideos } from './video';
151
151
  import { PinchController } from './pinch';
152
152
  import { applyCanvas, createCanvasElement } from './canvasDrawing';
153
153
  import { scrollToTarget } from './scrollTo';
154
+ import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
154
155
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
155
156
  import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
156
157
  import { hostContent, onHostContentChange } from './hostContent';
@@ -406,6 +407,8 @@ export class Renderer {
406
407
  private assetStyle: HTMLStyleElement | null = null;
407
408
  /** Safe-area insets; read from the device tokens on first use unless `setSafeArea` set them. */
408
409
  private safeArea: LayoutEnvironment['safeArea'] | null = null;
410
+ /** Device mode: the on-screen keyboard's height over the screen's bottom (`setKeyboard`), 0 while it is down. */
411
+ private keyboard = 0;
409
412
  /** DOM boxes applied by the last layout, by id (see layoutDom.ts). */
410
413
  private boxes = new Map<ElementId, Rect>();
411
414
  /** Elements whose inline style was reset since the last layout. */
@@ -1208,6 +1211,19 @@ export class Renderer {
1208
1211
  if (this.commits > 0) this.layout();
1209
1212
  }
1210
1213
 
1214
+ /**
1215
+ * Device mode: the on-screen keyboard covers `height` CSS px of the
1216
+ * screen's bottom (0: it is down). The engine treats it as a bottom
1217
+ * safe-area inset, as iOS does: content stops above it, a bar under it
1218
+ * stays put. Re-lays the tree out when it changed.
1219
+ */
1220
+ setKeyboard(height: number): void {
1221
+ const next = Number.isFinite(height) && height > 0 ? Math.round(height) : 0;
1222
+ if (next === this.keyboard) return;
1223
+ this.keyboard = next;
1224
+ if (this.commits > 0) this.layout();
1225
+ }
1226
+
1211
1227
  /**
1212
1228
  * The root's size changed (device mode: a resize or rotation): re-read it
1213
1229
  * and re-lay out now rather than when the ResizeObserver reports it.
@@ -1229,6 +1245,7 @@ export class Renderer {
1229
1245
  colorScheme: this.environment.colorScheme,
1230
1246
  assets: this.assets,
1231
1247
  ...(this.contentHeight ? { contentHeight: true } : {}),
1248
+ ...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
1232
1249
  };
1233
1250
  }
1234
1251
 
@@ -1284,6 +1301,8 @@ export class Renderer {
1284
1301
  };
1285
1302
  const changes = applyLayout(ctx, result);
1286
1303
  this.boxes = ctx.boxes;
1304
+ // `.defaultScrollAnchor`: scroll views that keep their end (or another point) in view as they grow.
1305
+ keepScrollAnchors();
1287
1306
  if (this.contentHeight) {
1288
1307
  // The island's root is as tall as its content.
1289
1308
  let bottom = 0;
@@ -3114,6 +3133,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3114
3133
  else delete el.dataset['sbSnap'];
3115
3134
  if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
3116
3135
  applyEdgeEffect(el, scroll.edgeEffect);
3136
+ applyDefaultScrollAnchor(el, scroll.defaultScrollAnchor);
3117
3137
  break;
3118
3138
  }
3119
3139
  case 'section':
@@ -3826,7 +3846,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3826
3846
  }
3827
3847
 
3828
3848
  if (style.font) {
3829
- const { size, weight, design, textStyle, relativeTo } = style.font;
3849
+ const { size, weight, design, textStyle, relativeTo, monospacedDigit } = style.font;
3830
3850
  if (size != null) {
3831
3851
  // A fixed size; `relativeTo` scales it like that text style (the
3832
3852
  // scale factor is a CSS variable the page sets per Dynamic Type size).
@@ -3849,6 +3869,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3849
3869
  else if (design === 'rounded') s.fontFamily = FONT_FAMILY_ROUNDED;
3850
3870
  else if (design === 'serif') s.fontFamily = FONT_FAMILY_SERIF;
3851
3871
  else if (design === 'default') s.fontFamily = 'var(--sb-font-family)';
3872
+ // Inherited by the DOM inside; the engine's text fonts carry it too (setFont).
3873
+ if (monospacedDigit !== undefined) s.fontVariantNumeric = monospacedDigit ? 'tabular-nums' : 'normal';
3852
3874
  }
3853
3875
 
3854
3876
  if (style.foreground) {
package/src/richText.ts CHANGED
@@ -20,7 +20,7 @@ const drawn = new WeakMap<HTMLElement, { templates: HTMLElement[]; key: string }
20
20
  /** Draws the engine's lines of a rich text into its element. */
21
21
  export function drawRichText(el: HTMLElement, rich: RichTextLayout): void {
22
22
  const templates = runTemplates.get(el) ?? [];
23
- const key = JSON.stringify([rich.lines, rich.heights, rich.fonts.map((font) => font.css)]);
23
+ const key = JSON.stringify([rich.lines, rich.heights, rich.fonts.map((font) => [font.css, font.monospacedDigit === true])]);
24
24
  const last = drawn.get(el);
25
25
  if (last && last.templates === templates && last.key === key) return;
26
26
  drawn.set(el, { templates, key });
@@ -48,7 +48,10 @@ export function drawRichText(el: HTMLElement, rich: RichTextLayout): void {
48
48
  }
49
49
  const span = (template?.cloneNode(false) as HTMLElement | undefined) ?? doc.createElement('span');
50
50
  const font = rich.fonts[fragment.run];
51
- if (font) span.style.font = font.css;
51
+ if (font) {
52
+ span.style.font = font.css;
53
+ if (font.monospacedDigit) span.style.fontVariantNumeric = 'tabular-nums';
54
+ }
52
55
  // Every fragment shares the line's baseline (its height is the tallest run's).
53
56
  span.style.lineHeight = 'inherit';
54
57
  span.textContent = fragment.text;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `.defaultScrollAnchor(_:)` on a `ScrollView` (docs/ops-protocol.md, "Default
3
+ * scroll anchor"): the scroll view carries `defaultScrollAnchor`, a unit
4
+ * point. The content starts there (`.bottom`: the end is in view, as a chat
5
+ * opens on its latest message), and when the content or the viewport changes
6
+ * size in a pass, the anchor is kept *if the viewer was at it* before the
7
+ * pass: new lines at the end of a chat keep the end in view, while a viewer
8
+ * who scrolled up to read stays where they are. The offset before the pass
9
+ * is the one scrollSync.ts read for it, so a pass's own writes do not count
10
+ * as the viewer's scrolling.
11
+ */
12
+
13
+ import type { UnitPoint } from './protocol';
14
+ import { scrollTopOf } from './scrollSync';
15
+
16
+ /** The scroll offset on one axis that puts the anchor fraction of the content at the same fraction of the viewport. */
17
+ export function anchoredOffset(content: number, viewport: number, anchor: number): number {
18
+ return Math.max(0, Math.round((content - viewport) * anchor));
19
+ }
20
+
21
+ /** Within a couple of pixels of the anchor: browsers round offsets, and a smooth scroll may stop short. */
22
+ export const ANCHOR_TOLERANCE = 2;
23
+
24
+ export function isAtAnchor(offset: number, content: number, viewport: number, anchor: number): boolean {
25
+ return Math.abs(offset - anchoredOffset(content, viewport, anchor)) <= ANCHOR_TOLERANCE;
26
+ }
27
+
28
+ /** What `keepAnchor` reads and writes on a scrolling element; `HTMLElement` fits. */
29
+ export interface AnchoredScroller {
30
+ scrollTop: number;
31
+ scrollLeft: number;
32
+ scrollHeight: number;
33
+ scrollWidth: number;
34
+ clientHeight: number;
35
+ clientWidth: number;
36
+ }
37
+
38
+ /** The extents a scroller had after the last pass. */
39
+ interface Extents {
40
+ content: { width: number; height: number };
41
+ viewport: { width: number; height: number };
42
+ }
43
+
44
+ /**
45
+ * After a pass laid the scroller out: on its first pass the content goes to
46
+ * the anchor; after that, an axis whose extents changed keeps the anchor
47
+ * when `offsetBefore` (the viewer's position before the pass) was at it.
48
+ * Returns the extents to remember for the next pass.
49
+ */
50
+ export function keepAnchor(
51
+ el: AnchoredScroller,
52
+ anchor: UnitPoint,
53
+ axes: { horizontal: boolean; vertical: boolean },
54
+ before: Extents | undefined,
55
+ offsetBefore: { top: number; left: number },
56
+ ): Extents {
57
+ const now: Extents = { content: { width: el.scrollWidth, height: el.scrollHeight }, viewport: { width: el.clientWidth, height: el.clientHeight } };
58
+ if (axes.vertical) {
59
+ const changed = !before || before.content.height !== now.content.height || before.viewport.height !== now.viewport.height;
60
+ const wasAt = !before || isAtAnchor(offsetBefore.top, before.content.height, before.viewport.height, anchor.y);
61
+ if (changed && wasAt) el.scrollTop = anchoredOffset(now.content.height, now.viewport.height, anchor.y);
62
+ }
63
+ if (axes.horizontal) {
64
+ const changed = !before || before.content.width !== now.content.width || before.viewport.width !== now.viewport.width;
65
+ const wasAt = !before || isAtAnchor(offsetBefore.left, before.content.width, before.viewport.width, anchor.x);
66
+ if (changed && wasAt) el.scrollLeft = anchoredOffset(now.content.width, now.viewport.width, anchor.x);
67
+ }
68
+ return now;
69
+ }
70
+
71
+ const anchors = new WeakMap<HTMLElement, UnitPoint>();
72
+ const extents = new WeakMap<HTMLElement, Extents>();
73
+ const anchored = new Set<HTMLElement>();
74
+
75
+ /** A scroll view's `defaultScrollAnchor` prop, kept for the passes to come (none: the view scrolls as any other). */
76
+ export function applyDefaultScrollAnchor(el: HTMLElement, anchor: UnitPoint | undefined): void {
77
+ if (anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number') {
78
+ anchors.set(el, { x: anchor.x, y: anchor.y });
79
+ anchored.add(el);
80
+ } else {
81
+ anchors.delete(el);
82
+ extents.delete(el);
83
+ anchored.delete(el);
84
+ }
85
+ }
86
+
87
+ /** The axes a `scrollview` element scrolls on, from its `data-sb-axes`. */
88
+ function axesOf(el: HTMLElement): { horizontal: boolean; vertical: boolean } {
89
+ const axes = el.dataset['sbAxes'] ?? 'vertical';
90
+ return { horizontal: axes === 'horizontal' || axes === 'both', vertical: axes !== 'horizontal' };
91
+ }
92
+
93
+ /** Called by the renderer after each pass's frames are applied: every anchored scroll view keeps (or takes) its anchor. */
94
+ export function keepScrollAnchors(): void {
95
+ for (const el of anchored) {
96
+ if (!el.isConnected) {
97
+ anchored.delete(el);
98
+ extents.delete(el);
99
+ continue;
100
+ }
101
+ const anchor = anchors.get(el);
102
+ if (!anchor) continue;
103
+ extents.set(el, keepAnchor(el, anchor, axesOf(el), extents.get(el), { top: scrollTopOf(el), left: el.scrollLeft }));
104
+ }
105
+ }
106
+
107
+ /** For tests: forget every anchored scroller. */
108
+ export function resetScrollAnchors(): void {
109
+ anchored.clear();
110
+ }
package/src/styles.css CHANGED
@@ -396,6 +396,22 @@ html[data-sb-mode="device"] .device {
396
396
  box-shadow: none;
397
397
  }
398
398
 
399
+ /* `?debug=viewport` (src/deviceMode.ts installViewportReadout): the viewport
400
+ numbers the keyboard measurement reads, over the app, under the status bar. */
401
+ .sb-viewport-readout {
402
+ position: fixed;
403
+ top: calc(var(--sb-inset-top) + 4px);
404
+ left: 8px;
405
+ z-index: 1000;
406
+ margin: 0;
407
+ padding: 4px 6px;
408
+ font: 11px/1.3 ui-monospace, Menlo, monospace;
409
+ color: #fff;
410
+ background: rgba(0, 0, 0, 0.7);
411
+ border-radius: 6px;
412
+ pointer-events: none;
413
+ }
414
+
399
415
  html[data-sb-mode="device"] .device::before,
400
416
  html[data-sb-mode="device"] .device::after,
401
417
  html[data-sb-mode="device"] .dynamic-island,
@@ -1162,12 +1178,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1162
1178
 
1163
1179
  .sb-navbar-title {
1164
1180
  /* Positioned at the engine's `title` rect (Phase 6), centered and truncated
1165
- to the room the back button and the toolbar items leave. Not a grid item:
1166
- an absolutely positioned grid child would be placed against its grid
1167
- area, not the bar's padding box. */
1181
+ to the room the back button and the toolbar items leave; the rect is the
1182
+ only limit on its width (a wide bar on an iPad gives a long title the
1183
+ room). Not a grid item: an absolutely positioned grid child would be
1184
+ placed against its grid area, not the bar's padding box. */
1168
1185
  position: absolute;
1169
1186
  display: block;
1170
- max-width: 220px;
1171
1187
  overflow: hidden;
1172
1188
  text-overflow: ellipsis;
1173
1189
  white-space: nowrap;