@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.
@@ -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 {
@@ -14,6 +14,7 @@ import {
14
14
  type RendererEvent,
15
15
  type ScenePhase,
16
16
  } from './protocol';
17
+ import { BrowserHistory } from './browserHistory';
17
18
  import { Renderer, RendererError } from './renderer';
18
19
  import { withBlockedHosts } from './blockedHosts';
19
20
  import { withCORSProxy } from './corsProxy';
@@ -97,6 +98,9 @@ function dispatch(event: RendererEvent): void {
97
98
 
98
99
  const renderer = new Renderer(screen, dispatch);
99
100
 
101
+ // The browser's back pops the app's screens and dismisses its sheets (browserHistory.ts).
102
+ new BrowserHistory(renderer, screen).enable();
103
+
100
104
  // On a phone the browser's status bar strip and safe areas take the color the
101
105
  // app draws at its top edge (chromeColor.ts).
102
106
  const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
@@ -246,6 +250,13 @@ if (deviceMode) {
246
250
  // A rotation or window resize may cross the compact / regular threshold.
247
251
  if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
248
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 });
249
260
  }
250
261
 
251
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
 
@@ -1242,6 +1259,15 @@ export class Renderer {
1242
1259
  /** Called after each layout pass (main.ts keeps the browser chrome's color in step). */
1243
1260
  afterLayout: (() => void) | null = null;
1244
1261
 
1262
+ /**
1263
+ * Sends a tap for an element, as a click on it would: a `navscreen` pops
1264
+ * it (the back button's tap), a `sheet` asks to be dismissed (the scrim's).
1265
+ * For the page's own controls, such as the browser's history (browserHistory.ts).
1266
+ */
1267
+ tap(id: ElementId): void {
1268
+ this.onEvent({ type: 'tap', id });
1269
+ }
1270
+
1245
1271
  /**
1246
1272
  * Run the layout engine over the current tree and position every element.
1247
1273
  * Returns the elements whose box changed. Runs at every `commit`; call it
@@ -1275,6 +1301,8 @@ export class Renderer {
1275
1301
  };
1276
1302
  const changes = applyLayout(ctx, result);
1277
1303
  this.boxes = ctx.boxes;
1304
+ // `.defaultScrollAnchor`: scroll views that keep their end (or another point) in view as they grow.
1305
+ keepScrollAnchors();
1278
1306
  if (this.contentHeight) {
1279
1307
  // The island's root is as tall as its content.
1280
1308
  let bottom = 0;
@@ -3105,6 +3133,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3105
3133
  else delete el.dataset['sbSnap'];
3106
3134
  if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
3107
3135
  applyEdgeEffect(el, scroll.edgeEffect);
3136
+ applyDefaultScrollAnchor(el, scroll.defaultScrollAnchor);
3108
3137
  break;
3109
3138
  }
3110
3139
  case 'section':
@@ -3817,7 +3846,7 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3817
3846
  }
3818
3847
 
3819
3848
  if (style.font) {
3820
- const { size, weight, design, textStyle, relativeTo } = style.font;
3849
+ const { size, weight, design, textStyle, relativeTo, monospacedDigit } = style.font;
3821
3850
  if (size != null) {
3822
3851
  // A fixed size; `relativeTo` scales it like that text style (the
3823
3852
  // scale factor is a CSS variable the page sets per Dynamic Type size).
@@ -3840,6 +3869,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
3840
3869
  else if (design === 'rounded') s.fontFamily = FONT_FAMILY_ROUNDED;
3841
3870
  else if (design === 'serif') s.fontFamily = FONT_FAMILY_SERIF;
3842
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';
3843
3874
  }
3844
3875
 
3845
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;