@swiftbrowser/web 0.9.12 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.9.12",
3
+ "version": "0.10.0",
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": {
@@ -11,7 +11,11 @@
11
11
  * the back button and the scrim send, so the app's own state stays the
12
12
  * source of truth; the page's URL is never touched, and an app that maps
13
13
  * screens to paths should set them with `replaceState` on the entry the
14
- * renderer made.
14
+ * renderer made. When the app pops on its own, the renderer's `history.go`
15
+ * back is asynchronous and the app's `replaceState` for the screen it shows
16
+ * now runs before the move lands, on the entry being left; the renderer
17
+ * applies that URL again to the entry it arrives at, so the address bar
18
+ * follows the bar's back button as it follows the browser's.
15
19
  *
16
20
  * A two-column split view pushes nothing when its sidebar's selection
17
21
  * changes, so that is an entry of its own: choosing another sidebar row
@@ -24,6 +28,12 @@
24
28
  * pen: the browser's edge swipe (Safari's, Android's back gesture) is the
25
29
  * swipe back, and it lands here as a `popstate`. A mouse drag from the edge
26
30
  * still pops through the renderer, since no browser turns one into history.
31
+ *
32
+ * Every `popstate` the browser makes also starts the renderer's repaint
33
+ * keep-alive (`Renderer.keepRepainting`): Safari's swipe shows a snapshot of
34
+ * the entry and, once its animation ends, hides the live page until a new
35
+ * frame is committed, so a pop that finished animating by then would stay
36
+ * under the snapshot for Safari's 3 s watchdog, taking the touches unseen.
27
37
  */
28
38
  import type { Renderer } from './renderer';
29
39
 
@@ -60,6 +70,13 @@ export class BrowserHistory {
60
70
  /** A `popstate` asked for the entry's sidebar selection: the change it brings is recorded, not pushed. */
61
71
  private restoringSelection = false;
62
72
  private enabled = false;
73
+ /** The page's own `replaceState`, while ours stands in for it. */
74
+ private pageReplaceState: History['replaceState'] | null = null;
75
+ private pageReplaceStateWasOwn = false;
76
+ /** A URL the page set while our own `history.go` was on its way: meant for the entry it lands on. */
77
+ private landingUrl: string | URL | null = null;
78
+ /** A level appeared while our own `history.go` was on its way: its entry is pushed once the move lands. */
79
+ private syncOnArrival = false;
63
80
 
64
81
  constructor(
65
82
  private readonly renderer: Renderer,
@@ -75,10 +92,15 @@ export class BrowserHistory {
75
92
  this.enabled = true;
76
93
  const current = stateOf(this.win.history.state);
77
94
  if (!current) this.win.history.replaceState({ ...(this.win.history.state ?? {}), ...this.entry(0, this.selection()) }, '');
95
+ this.watchReplaceState();
78
96
  this.renderer.interaction.nativeBackGesture = true;
79
97
  this.root.addEventListener('sb:commit', this.onCommit);
80
98
  this.win.addEventListener('popstate', this.onPopState);
81
- this.sync();
99
+ // Enabled before the first commit (a mount, or a remount such as React's
100
+ // StrictMode makes in development) the screen is not drawn yet: fewer
101
+ // levels than the entry records is not the app popping, so only the
102
+ // levels already shown are pushed here and the first commit settles the rest.
103
+ this.sync(true);
82
104
  }
83
105
 
84
106
  dispose(): void {
@@ -87,10 +109,44 @@ export class BrowserHistory {
87
109
  this.renderer.interaction.nativeBackGesture = false;
88
110
  if (this.movingTimer) clearTimeout(this.movingTimer);
89
111
  this.movingTo = null;
112
+ this.landingUrl = null;
113
+ this.syncOnArrival = false;
114
+ this.unwatchReplaceState();
90
115
  this.root.removeEventListener('sb:commit', this.onCommit);
91
116
  this.win.removeEventListener('popstate', this.onPopState);
92
117
  }
93
118
 
119
+ /**
120
+ * Stands in for the page's `replaceState`: it still runs at once, and a
121
+ * URL given while our own `go` is on its way is remembered for the entry
122
+ * the move lands on.
123
+ */
124
+ private watchReplaceState(): void {
125
+ const history = this.win.history;
126
+ const original = history.replaceState;
127
+ this.pageReplaceState = original;
128
+ this.pageReplaceStateWasOwn = Object.prototype.hasOwnProperty.call(history, 'replaceState');
129
+ history.replaceState = (data: unknown, unused: string, url?: string | URL | null): void => {
130
+ if (this.movingTo !== null && url !== undefined && url !== null) this.landingUrl = url;
131
+ original.call(history, data, unused, url);
132
+ };
133
+ }
134
+
135
+ private unwatchReplaceState(): void {
136
+ const original = this.pageReplaceState;
137
+ if (!original) return;
138
+ const history = this.win.history;
139
+ // A browser's is the prototype's: removing ours uncovers it. A stand-in's own property is put back.
140
+ if (this.pageReplaceStateWasOwn) history.replaceState = original;
141
+ else delete (history as Partial<History>).replaceState;
142
+ this.pageReplaceState = null;
143
+ }
144
+
145
+ /** `replaceState` as the page had it, for our own entries. */
146
+ private replaceEntry(data: unknown, url?: string | URL | null): void {
147
+ (this.pageReplaceState ?? this.win.history.replaceState).call(this.win.history, data, '', url);
148
+ }
149
+
94
150
  /** The levels above the base the app shows now: sheets, plus pushed screens in each visible stack of the top layer. */
95
151
  depth(): number {
96
152
  const sheets = this.sheets();
@@ -192,7 +248,13 @@ export class BrowserHistory {
192
248
  this.movingTimer = setTimeout(() => {
193
249
  if (this.movingTo === null) return;
194
250
  this.movingTo = null;
251
+ // No move (a go past the ends fires nothing): the URL already sits on the entry at hand.
252
+ this.landingUrl = null;
195
253
  this.ignorePops = Math.max(0, this.ignorePops - 1);
254
+ if (this.syncOnArrival) {
255
+ this.syncOnArrival = false;
256
+ this.sync();
257
+ }
196
258
  }, 1000);
197
259
  this.win.history.go(delta);
198
260
  }
@@ -201,12 +263,21 @@ export class BrowserHistory {
201
263
  this.movingTo = null;
202
264
  if (this.movingTimer) clearTimeout(this.movingTimer);
203
265
  this.movingTimer = null;
266
+ // The page's URL for what it shows now, set on the entry we left: it belongs here.
267
+ const url = this.landingUrl;
268
+ this.landingUrl = null;
269
+ if (url !== null) this.replaceEntry(this.win.history.state, url);
270
+ // Levels that appeared during the move get their entries now.
271
+ if (this.syncOnArrival) {
272
+ this.syncOnArrival = false;
273
+ this.sync();
274
+ }
204
275
  }
205
276
 
206
277
  private readonly onCommit = (): void => this.sync();
207
278
 
208
- /** After a commit: new levels get entries; levels the app dismissed itself take the history back with them; a moved sidebar selection is an entry too. */
209
- sync(): void {
279
+ /** 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. `enabling`: nothing is drawn yet, so no level is taken as dismissed. */
280
+ sync(enabling = false): void {
210
281
  if (!this.enabled) return;
211
282
  const depth = this.depth();
212
283
  const recorded = this.recorded();
@@ -220,8 +291,15 @@ export class BrowserHistory {
220
291
  return;
221
292
  }
222
293
  if (depth > recorded) {
294
+ // Under our own pending `go`, a push would land relative to wherever the browser is when it runs (and
295
+ // Chrome moves back past it), leaving the app on an entry below what it shows: push once the move lands.
296
+ if (this.movingTo !== null) {
297
+ this.syncOnArrival = true;
298
+ return;
299
+ }
223
300
  for (let level = recorded + 1; level <= depth; level++) this.win.history.pushState(this.entry(level, selection), '');
224
301
  } else if (depth < recorded) {
302
+ if (enabling) return;
225
303
  // The app popped or dismissed on its own (back button, swipe, code): leave those entries.
226
304
  this.go(depth - recorded, depth);
227
305
  } else if (this.movingTo === null && selection !== this.recordedSelection()) {
@@ -231,7 +309,7 @@ export class BrowserHistory {
231
309
  const history = this.win.history;
232
310
  if (this.restoringSelection || !this.recordedSelection()) {
233
311
  const { sbSelection: _, ...rest } = (history.state ?? {}) as Partial<HistoryState>;
234
- history.replaceState({ ...rest, ...this.entry(depth, selection) }, '');
312
+ this.replaceEntry({ ...rest, ...this.entry(depth, selection) });
235
313
  } else history.pushState(this.entry(depth, selection), '');
236
314
  }
237
315
  this.restoringSelection = false;
@@ -245,6 +323,8 @@ export class BrowserHistory {
245
323
  }
246
324
  const target = stateOf(event.state);
247
325
  if (!target) return; // an entry of the page's own (before the base, or the app's router): not ours
326
+ // The browser moved on its own (a swipe, a button): keep painting past its transition.
327
+ this.renderer.keepRepainting();
248
328
  const depth = this.depth();
249
329
  if (target.sbDepth < depth) {
250
330
  // Back: dismiss one level now, the rest as each commit lands.
package/src/largeTitle.ts CHANGED
@@ -42,6 +42,20 @@ export function largeTitleOf(screen: HTMLElement): HTMLElement | null {
42
42
  return title;
43
43
  }
44
44
 
45
+ /**
46
+ * The large title's text: a span of its own, as the title element also
47
+ * holds a `.largeTitle` toolbar (which then hides the text, styles.css).
48
+ */
49
+ export function largeTitleTextOf(title: HTMLElement): HTMLElement {
50
+ let text = title.querySelector<HTMLElement>(':scope > .sb-navbar-large-title-text');
51
+ if (!text) {
52
+ text = document.createElement('span');
53
+ text.className = 'sb-navbar-large-title-text';
54
+ title.prepend(text);
55
+ }
56
+ return text;
57
+ }
58
+
45
59
  /** Whether a screen's large title currently scrolls inside one of its scroll views. */
46
60
  export function largeTitleScrolls(screen: HTMLElement, title: HTMLElement): boolean {
47
61
  return title.parentElement !== null && !title.parentElement.classList.contains('sb-navscreen-body') && screen.contains(title);
@@ -85,6 +85,8 @@ export interface Context {
85
85
  imageScale: number;
86
86
  /** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
87
87
  splitSidebar: boolean;
88
+ /** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
89
+ navBarItem?: boolean;
88
90
  /** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
89
91
  sidebarList?: boolean;
90
92
  /**
@@ -219,6 +221,17 @@ const TAB_BAR_ITEM_INSET = 4;
219
221
  const PICKER_SEGMENTED_HEIGHT = 32;
220
222
  const PICKER_SEGMENT_PAD = 20;
221
223
  const PICKER_SEGMENT_FONT_SIZE = 13;
224
+ // iOS 26, a segmented picker in a navigation bar item (measured in the
225
+ // simulator, Examples/Fidelity `topbarsegmented`, with the item's shared
226
+ // glass hidden): the control is a 44-tall glass capsule itself, no track;
227
+ // the selected segment a 36-tall gray capsule inset 4 (no shadow); labels
228
+ // 15 regular; segments of equal width, the widest label and 21 either side.
229
+ // The capsule keeps 4 clear of the bar's other capsules beyond their usual
230
+ // 12 (it sits 16 from the next one).
231
+ const BAR_SEGMENTED_FONT_SIZE = 15;
232
+ const BAR_SEGMENT_PAD = 42;
233
+ const BAR_SEGMENTED_INSET = 4;
234
+ const BAR_SEGMENTED_MARGIN = 4;
222
235
  const PICKER_MENU_ROW_HEIGHT = 44;
223
236
  const PICKER_MENU_HEIGHT = 34;
224
237
  /** `chevron.up.chevron.down` glyph width plus the gap to the value text. */
@@ -290,6 +303,7 @@ function isSymbolItem(item: LayoutNode): boolean {
290
303
 
291
304
  /** A bar item's slot in its glass capsule. */
292
305
  function navSlot(item: LayoutNode, width: number): number {
306
+ if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
293
307
  if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
294
308
  return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
295
309
  }
@@ -521,6 +535,12 @@ function ignoresKeyboard(node: LayoutNode): boolean {
521
535
  }
522
536
 
523
537
  /** The element under a chain of `styled` wrappers. */
538
+ /** A segmented picker as a navigation bar item (under its styled wrappers): it is a capsule of its own. */
539
+ function isSegmentedItem(item: LayoutNode): boolean {
540
+ const inner = unwrapStyled(item);
541
+ return inner.kind === 'picker' && str(inner.props['style']) === 'segmented';
542
+ }
543
+
524
544
  function unwrapStyled(node: LayoutNode): LayoutNode {
525
545
  let current = node;
526
546
  while (current.kind === 'styled' && current.children.length === 1) {
@@ -1131,6 +1151,8 @@ interface PickerPlan {
1131
1151
  valueWidth: number;
1132
1152
  /** Inline style: whether a label row precedes the options. */
1133
1153
  labelRow: boolean;
1154
+ /** Segmented style in a navigation bar item: the bar's glass capsule, its segments inset. */
1155
+ bar?: boolean;
1134
1156
  }
1135
1157
 
1136
1158
  // Phase 6 plans.
@@ -1550,6 +1572,12 @@ export class LayoutPass {
1550
1572
  if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
1551
1573
  return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
1552
1574
  }
1575
+ case 'toolbar': {
1576
+ const placement = str(node.props['placement']);
1577
+ const navBarItem = placement === 'leading' || placement === 'trailing';
1578
+ if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem) return ctx;
1579
+ return { ...ctx, stackAxis: null, listRow: false, navBarItem };
1580
+ }
1553
1581
  case 'gesture':
1554
1582
  case 'swipeactions':
1555
1583
  case 'contextmenu':
@@ -2014,6 +2042,23 @@ export class LayoutPass {
2014
2042
  labelRow,
2015
2043
  };
2016
2044
  }
2045
+ if (style === 'segmented' && ctx.navBarItem) {
2046
+ const font = makeFont('system', BAR_SEGMENTED_FONT_SIZE, 400);
2047
+ let widest = 0;
2048
+ for (const option of options) widest = Math.max(widest, this.measurer.measureWidth(option, font));
2049
+ const natural = options.length * (widest + BAR_SEGMENT_PAD) + 2 * BAR_SEGMENTED_INSET;
2050
+ return {
2051
+ size: { width: finite(p.width) ? p.width : natural, height: NAV_GLASS_SIZE },
2052
+ style: 'segmented',
2053
+ font,
2054
+ options,
2055
+ selected,
2056
+ labelWidth: 0,
2057
+ valueWidth: 0,
2058
+ labelRow: false,
2059
+ bar: true,
2060
+ };
2061
+ }
2017
2062
  if (style === 'segmented') {
2018
2063
  const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2019
2064
  let natural = 0;
@@ -2057,9 +2102,14 @@ export class LayoutPass {
2057
2102
  const chrome: Record<string, Rect> = { ...existing };
2058
2103
  if (plan.style === 'segmented') {
2059
2104
  const n = Math.max(1, plan.options.length);
2060
- const segmentWidth = size.width / n;
2105
+ // In a bar the segments sit inside the capsule, inset all round; `bar` (the
2106
+ // control's frame) tells the page to draw it as the bar draws it.
2107
+ const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
2108
+ if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
2109
+ else delete chrome['bar'];
2110
+ const segmentWidth = Math.max(0, size.width - 2 * inset) / n;
2061
2111
  for (let i = 0; i < plan.options.length; i++) {
2062
- chrome[`segment${i}`] = { x: x + i * segmentWidth, y, width: segmentWidth, height: size.height };
2112
+ chrome[`segment${i}`] = { x: x + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
2063
2113
  }
2064
2114
  } else if (plan.style === 'inline') {
2065
2115
  // Rows span the list row when there is one (the hit area and the
@@ -3429,7 +3479,7 @@ export class LayoutPass {
3429
3479
  for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3430
3480
  const barToolbars = parts.toolbars.filter((t) => {
3431
3481
  const placement = str(t.props['placement']);
3432
- return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
3482
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && !isSpacer(t);
3433
3483
  });
3434
3484
  const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
3435
3485
  const groups: Group[] = barToolbars.map((toolbar) => {
@@ -3448,6 +3498,10 @@ export class LayoutPass {
3448
3498
  interface Run {
3449
3499
  placement: 'leading' | 'trailing';
3450
3500
  groups: Group[];
3501
+ /** A segmented picker's own run: the glass is the control, inside its margin. */
3502
+ segmented?: boolean;
3503
+ /** `.sharedBackgroundVisibility(.hidden)`: a run of its own with no glass. */
3504
+ bare?: boolean;
3451
3505
  /** The capsule's width: the slots 12 apart, a point past the end slots (none for a lone item). */
3452
3506
  width: number;
3453
3507
  }
@@ -3469,6 +3523,20 @@ export class LayoutPass {
3469
3523
  }
3470
3524
  const group = groupOf.get(toolbar);
3471
3525
  if (!group) continue;
3526
+ // A segmented picker is a capsule of its own: it neither joins nor is joined (iOS 26
3527
+ // draws its glass, and an item's shared glass around it would show as a second capsule).
3528
+ if (toolbar.children.some(isSegmentedItem)) {
3529
+ delete open[placement];
3530
+ runs.push({ placement, groups: [group], width: 0, segmented: true });
3531
+ continue;
3532
+ }
3533
+ // An item that hides the bar's shared glass stands alone, at its own width.
3534
+ if (str(toolbar.props['sharedBackground']) === 'hidden') {
3535
+ delete open[placement];
3536
+ group.slots = group.sizes.map((s) => s.width);
3537
+ runs.push({ placement, groups: [group], width: 0, bare: true });
3538
+ continue;
3539
+ }
3472
3540
  let run = open[placement];
3473
3541
  if (!run) {
3474
3542
  run = { placement, groups: [], width: 0 };
@@ -3564,8 +3632,13 @@ export class LayoutPass {
3564
3632
  // items, in slots 12 apart from `startX`; each toolbar's frame spans its
3565
3633
  // own items' slots. Runs of a placement follow their order in the
3566
3634
  // content, leading-to-trailing.
3567
- const placeRun = (run: Run, startX: number, index: number): void => {
3568
- chrome[`${run.placement}Glass${index}`] = { x: startX, y: rowY, width: run.width, height: NAV_BAR_HEIGHT };
3635
+ // The capsules are numbered as they are drawn (the page reads them until one is missing).
3636
+ const glassCount = { leading: 0, trailing: 0 };
3637
+ const placeRun = (run: Run, startX: number): void => {
3638
+ const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
3639
+ if (!run.bare) {
3640
+ chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: rowY, width: Math.max(0, run.width - 2 * margin), height: NAV_BAR_HEIGHT };
3641
+ }
3569
3642
  const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
3570
3643
  let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
3571
3644
  for (const group of run.groups) {
@@ -3581,19 +3654,18 @@ export class LayoutPass {
3581
3654
  this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
3582
3655
  }
3583
3656
  };
3584
- let leadingIndex = 0;
3585
3657
  for (const run of runs.filter((r) => r.placement === 'leading')) {
3586
3658
  if (run.width === 0) continue;
3587
- placeRun(run, leadingCursor, leadingIndex++);
3659
+ placeRun(run, leadingCursor);
3588
3660
  leadingCursor += run.width + NAV_GROUP_SPACING;
3589
3661
  }
3590
3662
  const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
3591
3663
  let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
3592
3664
  const trailingStart = trailingCursor;
3593
- trailingRuns.forEach((run, index) => {
3594
- placeRun(run, trailingCursor, index);
3665
+ for (const run of trailingRuns) {
3666
+ placeRun(run, trailingCursor);
3595
3667
  trailingCursor += run.width + NAV_GROUP_SPACING;
3596
- });
3668
+ }
3597
3669
  if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
3598
3670
  for (const group of groups) {
3599
3671
  if (group.placement !== 'principal') continue;
@@ -3624,6 +3696,28 @@ export class LayoutPass {
3624
3696
  if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
3625
3697
  else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
3626
3698
  }
3699
+ // `.largeTitle` items (iOS 26): in the large title row, in place of the
3700
+ // title's text, from its leading margin and centered in the row; the
3701
+ // renderer moves the toolbar into the title element, so they scroll with
3702
+ // the content as the title does, and the bar shows the title once the row
3703
+ // has gone under it. While the title is inline there is no row: hidden.
3704
+ for (const toolbar of parts.toolbars) {
3705
+ if (str(toolbar.props['placement']) !== 'largeTitle' || isSpacer(toolbar)) continue;
3706
+ const row = chrome['largeTitle'];
3707
+ if (!row) {
3708
+ this.hide(toolbar, node);
3709
+ continue;
3710
+ }
3711
+ const itemCtx = this.childContext(toolbar, cctx);
3712
+ const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
3713
+ let cursor = row.x + NAV_ITEM_MARGIN;
3714
+ for (const item of toolbar.children) {
3715
+ const itemSize = this.measure(item, itemCtx, room);
3716
+ this.place(item, itemCtx, room, cursor, row.y + (row.height - itemSize.height) / 2);
3717
+ cursor += itemSize.width + NAV_ITEM_SPACING;
3718
+ }
3719
+ this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
3720
+ }
3627
3721
  if (bottomBarHeight > 0) {
3628
3722
  this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
3629
3723
  }
package/src/layoutDom.ts CHANGED
@@ -41,7 +41,7 @@ import type { ShapeStyle } from './protocol';
41
41
  import { applyChartChrome } from './chartDom';
42
42
  import { bitmapLaidOut } from './bitmaps';
43
43
  import { drawRichText } from './richText';
44
- import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
44
+ import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls, largeTitleTextOf } from './largeTitle';
45
45
  import { bindScrollEdgeBottom } from './scrollEdge';
46
46
  import { beginScrollSyncBatch, flushScrollSyncBatch, type ScrollExtent } from './scrollSync';
47
47
  import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
@@ -122,7 +122,14 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
122
122
  /** The element's box relative to the origin its `left`/`top` use; applied when it differs from the last pass's. */
123
123
  const applyBox = (id: number, node: LayoutNode, el: HTMLElement, frame: Rect): { box: Rect; moved: boolean; previous: Rect | null } => {
124
124
  const box = drawnBox(node, boxOf(ctx, result, node, frame));
125
- const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : isSectionSupplement(node) ? supplementOrigin(ctx, result, id) : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
125
+ const origin =
126
+ node.kind === 'sheet'
127
+ ? { x: 0, y: 0 }
128
+ : isSectionSupplement(node)
129
+ ? supplementOrigin(ctx, result, id)
130
+ : isLargeTitleToolbar(node)
131
+ ? largeTitleToolbarOrigin(ctx, result, id)
132
+ : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
126
133
  const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
127
134
  const previous = ctx.boxes.get(id) ?? null;
128
135
  boxes.set(id, relative);
@@ -233,6 +240,18 @@ function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: numbe
233
240
  return boxOf(ctx, result, parent, frame);
234
241
  }
235
242
 
243
+ /** A `.largeTitle` toolbar: the renderer keeps it in its screen's large title element. */
244
+ export function isLargeTitleToolbar(node: LayoutNode): boolean {
245
+ return node.kind === 'toolbar' && node.props['placement'] === 'largeTitle';
246
+ }
247
+
248
+ /** A `.largeTitle` toolbar sits in the large title element, which is placed at the screen's `largeTitle` row. */
249
+ function largeTitleToolbarOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
250
+ const parentId = ctx.parents.get(id) ?? ROOT_ID;
251
+ const row = result.chrome.get(parentId)?.['largeTitle'];
252
+ return row ?? originOf(ctx, result, parentId);
253
+ }
254
+
236
255
  /** Screen-coordinate origin that the children of `parentId` are positioned against. */
237
256
  export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
238
257
  if (parentId === ROOT_ID) return { x: 0, y: 0 };
@@ -837,7 +856,8 @@ function applyChrome(
837
856
  if (texts?.['largeTitle']) {
838
857
  setFont(largeTitle, texts['largeTitle'].font);
839
858
  const line = texts['largeTitle'].lines.join('\n');
840
- if (largeTitle.textContent !== line) largeTitle.textContent = line;
859
+ const text = largeTitleTextOf(largeTitle);
860
+ if (text.textContent !== line) text.textContent = line;
841
861
  }
842
862
  } else {
843
863
  largeTitle.hidden = true;
@@ -933,6 +953,8 @@ function applyChrome(
933
953
  case 'picker': {
934
954
  setFont(el, result.fonts.get(node.id));
935
955
  if (node.props['style'] === 'segmented') {
956
+ // In a navigation bar item the control is the bar's glass capsule (engine `bar`).
957
+ el.toggleAttribute('data-sb-bar', chrome?.['bar'] !== undefined);
936
958
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), frame, box);
937
959
  const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
938
960
  segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
package/src/protocol.ts CHANGED
@@ -489,6 +489,8 @@ export type ButtonRole = 'destructive' | 'cancel' | null;
489
489
  export interface ButtonProps {
490
490
  style?: ButtonStyle;
491
491
  role?: ButtonRole;
492
+ /** An `EditButton`: the edit mode it shows ("Done" / "Edit") and flips. A native renderer draws the platform's edit button from it. */
493
+ editMode?: 'active' | 'inactive';
492
494
  }
493
495
  export interface StyledProps {
494
496
  style: Style;
@@ -539,6 +541,13 @@ export interface UrlImageProps {
539
541
  resizable: boolean;
540
542
  width: number;
541
543
  height: number;
544
+ /**
545
+ * React's opt-in (`AsyncImage webInteractions`): the `<img>` keeps the
546
+ * browser's own image interactions (a long press's Save / Share / Copy on
547
+ * iOS, dragging it out, the right-click menu) instead of being inert as a
548
+ * SwiftUI image is. Swift apps never send it.
549
+ */
550
+ webInteractions?: boolean;
542
551
  }
543
552
  /**
544
553
  * A `UIImage` / `CGImage` bitmap (`Image(uiImage:)`): a recipe the page draws
@@ -620,6 +629,14 @@ export interface TextFieldProps {
620
629
  submit?: boolean;
621
630
  /** Phase 12 `.submitLabel`: the return key's label (`enterkeyhint`). */
622
631
  submitLabel?: string;
632
+ /** `.keyboardType(_:)`: a `UIKeyboardType` case name (`emailAddress`, `URL`, `numberPad`…), the on-screen keyboard (`inputmode`). */
633
+ keyboard?: string;
634
+ /** `.textContentType(_:)`: a `UITextContentType` name (`emailAddress`, `oneTimeCode`…), what autofill offers (`autocomplete`). */
635
+ contentType?: string;
636
+ /** `.textInputAutocapitalization(_:)` (`autocapitalize`). */
637
+ autocapitalization?: 'never' | 'words' | 'sentences' | 'characters';
638
+ /** `.autocorrectionDisabled()` (`autocorrect="off"`). */
639
+ autocorrectionDisabled?: boolean;
623
640
  }
624
641
  export interface ToggleProps {
625
642
  isOn: boolean;
@@ -897,7 +914,13 @@ export interface DecoratedProps {
897
914
  export interface MenuProps {
898
915
  deferred?: boolean;
899
916
  }
900
- export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary';
917
+ /**
918
+ * `largeTitle` (iOS 26 `.largeTitle`): the items take the large title row in
919
+ * place of the title's text, scrolling with the content as the title does;
920
+ * the bar shows the title once the row has gone under it. Hidden while the
921
+ * screen's title is inline.
922
+ */
923
+ export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom' | 'secondary' | 'largeTitle';
901
924
  /**
902
925
  * `.toolbar { ToolbarItem(placement:) { ... } }`, an extra child of a
903
926
  * `navscreen` after its content. The children are the items, laid out at
@@ -912,6 +935,12 @@ export interface ToolbarProps {
912
935
  * apart; fixed ones are 8 wide.
913
936
  */
914
937
  spacer?: 'flexible' | 'fixed';
938
+ /**
939
+ * `.sharedBackgroundVisibility(.hidden)`: a leading or trailing group of
940
+ * the navigation bar draws no glass of the bar's and is a run of its own
941
+ * (a segmented picker is drawn as its own glass capsule either way).
942
+ */
943
+ sharedBackground?: 'hidden';
915
944
  }
916
945
  /**
917
946
  * `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
@@ -1884,6 +1913,16 @@ export interface Style {
1884
1913
  hitTesting?: boolean;
1885
1914
  }
1886
1915
 
1916
+ export interface Style {
1917
+ /**
1918
+ * `.onScrollVisibilityChange(threshold:_:)`: the page sends
1919
+ * `scrollvisibility` to the box when the share of it inside its scroll
1920
+ * view's visible area (below a bar the view scrolls under, above a bar
1921
+ * covering its bottom) crosses `threshold` (src/scrollVisibility.ts).
1922
+ */
1923
+ scrollVisibility?: { threshold: number };
1924
+ }
1925
+
1887
1926
  export interface Style {
1888
1927
  /** `.blendMode`: the CSS `mix-blend-mode` the box is composited with. */
1889
1928
  blendMode?: string;
@@ -1957,6 +1996,16 @@ export interface ScrollPositionEvent {
1957
1996
  type: 'scrollposition';
1958
1997
  id: ElementId;
1959
1998
  }
1999
+ /**
2000
+ * Sent to a `styled` box with `scrollVisibility`: the share of it inside its
2001
+ * scroll view's visible area came to the threshold or above (`visible`), or
2002
+ * fell below it. The first check after the box appears says where it is.
2003
+ */
2004
+ export interface ScrollVisibilityEvent {
2005
+ type: 'scrollvisibility';
2006
+ id: ElementId;
2007
+ visible: boolean;
2008
+ }
1960
2009
  /**
1961
2010
  * A movable `listrow` was dragged to a new place: `to` is the index among
1962
2011
  * its container's children (the `list`'s, or its `section`'s) before which
@@ -2002,7 +2051,7 @@ export interface DropEvent {
2002
2051
  }
2003
2052
 
2004
2053
  /** The Phase 8 events (merged into `RendererEvent` above). */
2005
- export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2054
+ export type InteractionEvent = DragEvent | PinchEvent | LongPressEvent | DeleteEvent | RefreshEvent | ScrollPositionEvent | ScrollVisibilityEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
2006
2055
  // Phase 8: alerts, controls, effects
2007
2056
  // (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
2008
2057
  // context menus, list rows and refresh live in their own block)
package/src/renderer.ts CHANGED
@@ -153,13 +153,14 @@ import { applyCanvas, createCanvasElement } from './canvasDrawing';
153
153
  import { scrollToTarget } from './scrollTo';
154
154
  import { POSITION_SETTLE_MS, POSITION_SNAP_WAITS, anchorPoint, positionTarget, restsOnSnapPoint, type PositionCandidate } from './scrollPosition';
155
155
  import { TouchPaging } from './touchPaging';
156
+ import { isScrollVisible } from './scrollVisibility';
156
157
  import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
157
158
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
158
159
  import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
159
160
  import { hostContent, onHostContentChange } from './hostContent';
160
161
  import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
161
162
  import { colorMultiplyFilter } from './colorMultiply';
162
- import { largeTitleOf } from './largeTitle';
163
+ import { largeTitleOf, largeTitleTextOf } from './largeTitle';
163
164
  import { applyEdgeEffect } from './scrollEdge';
164
165
  import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
165
166
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
@@ -216,6 +217,16 @@ function dismissMenu(menu: HTMLElement): void {
216
217
 
217
218
  /** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
218
219
  export const NAV_TRANSITION_MS = 400;
220
+ /**
221
+ * How long `keepRepainting` paints after a browser history navigation:
222
+ * through Safari's 3 s snapshot-removal watchdog, with room for a slow
223
+ * device. Measured on an iPhone: painting for 1.5 s (past the swipe
224
+ * animation alone) removed the stall but left a single freeze of the touch
225
+ * in progress at the 3 s mark; 5 s left none.
226
+ */
227
+ export const REPAINT_KEEPALIVE_MS = 5000;
228
+ /** Between two repaints of the keep-alive: a few frames, so the cost is nothing and the first frame after Safari hides the page is near. */
229
+ const REPAINT_KEEPALIVE_INTERVAL_MS = 50;
219
230
  /** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
220
231
  export const SHEET_TRANSITION_MS = 400;
221
232
  // Phase 8: the sheet drag (and every other pointer gesture) lives in src/interaction.ts.
@@ -440,6 +451,10 @@ export class Renderer {
440
451
  private pendingScrolls: ElementId[] = [];
441
452
  /** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
442
453
  private lazySpacers = new Set<ElementId>();
454
+ /** `styled` boxes with `scrollVisibility`, and what each was last told (src/scrollVisibility.ts). */
455
+ private readonly visibilityTargets = new Map<ElementId, number>();
456
+ private readonly visibilityReported = new Map<ElementId, boolean>();
457
+ private visibilityCheckQueued = false;
443
458
  private lazyReported = new Set<ElementId>();
444
459
  private lazyCheckQueued = false;
445
460
  /** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
@@ -864,6 +879,7 @@ export class Renderer {
864
879
  root.addEventListener('focusout', this.onFocusChange);
865
880
  root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
866
881
  root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
882
+ root.addEventListener('scroll', this.queueVisibilityCheck, { capture: true, passive: true });
867
883
  root.addEventListener('scroll', this.onPositionScroll, { capture: true, passive: true });
868
884
  root.addEventListener('scrollend', this.onPositionScrollEnd, { capture: true, passive: true });
869
885
  root.addEventListener('touchstart', this.onPositionTouchStart, { capture: true, passive: true });
@@ -1288,6 +1304,52 @@ export class Renderer {
1288
1304
  this.onEvent({ type: 'tap', id });
1289
1305
  }
1290
1306
 
1307
+ /** The repaint keep-alive (`keepRepainting`): its box, timer and deadline while it runs. */
1308
+ private repaintKeepAlive: { el: HTMLElement; timer: number; until: number; on: boolean } | null = null;
1309
+
1310
+ /**
1311
+ * Keeps the page painting for `duration` ms: a 1 px box in the root
1312
+ * alternates between two near-identical colors every few frames, so each
1313
+ * frame carries a real repaint. Safari's back and forward swipe shows a
1314
+ * snapshot of the entry it goes to and, once its own animation ends, hides
1315
+ * the live page until the page commits a new frame; a pop that has already
1316
+ * finished animating by then leaves nothing to commit, and Safari keeps the
1317
+ * snapshot (a still image, under which the live page takes the touches)
1318
+ * until a 3 s watchdog; and a page that stops painting before that
1319
+ * watchdog has a touch in progress interrupted when it fires, so the
1320
+ * painting runs through it. Called from browserHistory.ts for every
1321
+ * history navigation the browser makes; a call while one runs extends it.
1322
+ */
1323
+ keepRepainting(duration = REPAINT_KEEPALIVE_MS): void {
1324
+ const until = performance.now() + duration;
1325
+ if (this.repaintKeepAlive) {
1326
+ this.repaintKeepAlive.until = Math.max(this.repaintKeepAlive.until, until);
1327
+ return;
1328
+ }
1329
+ const el = document.createElement('div');
1330
+ el.setAttribute('aria-hidden', 'true');
1331
+ el.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;pointer-events:none;z-index:2147483647;background:rgba(127,127,127,0.02)';
1332
+ this.root.append(el);
1333
+ const state = { el, timer: 0, until, on: false };
1334
+ this.repaintKeepAlive = state;
1335
+ state.timer = window.setInterval(() => {
1336
+ if (performance.now() >= state.until) {
1337
+ this.stopRepainting();
1338
+ return;
1339
+ }
1340
+ state.on = !state.on;
1341
+ el.style.background = state.on ? 'rgba(127,127,127,0.03)' : 'rgba(127,127,127,0.02)';
1342
+ }, REPAINT_KEEPALIVE_INTERVAL_MS);
1343
+ }
1344
+
1345
+ private stopRepainting(): void {
1346
+ const state = this.repaintKeepAlive;
1347
+ if (!state) return;
1348
+ this.repaintKeepAlive = null;
1349
+ window.clearInterval(state.timer);
1350
+ state.el.remove();
1351
+ }
1352
+
1291
1353
  /**
1292
1354
  * Run the layout engine over the current tree and position every element.
1293
1355
  * Returns the elements whose box changed. Runs at every `commit`; call it
@@ -1335,6 +1397,7 @@ export class Renderer {
1335
1397
  this.collectGeometry(result);
1336
1398
  this.collectLayouts(result);
1337
1399
  if (this.applying === 0) this.flushGeometry();
1400
+ this.queueVisibilityCheck();
1338
1401
  this.afterLayout?.();
1339
1402
  return changes;
1340
1403
  }
@@ -1577,6 +1640,7 @@ export class Renderer {
1577
1640
  }
1578
1641
 
1579
1642
  dispose(): void {
1643
+ this.stopRepainting();
1580
1644
  this.closeMenu();
1581
1645
  this.root.removeEventListener('click', this.onClick);
1582
1646
  this.root.removeEventListener('pointerdown', this.onRowPress);
@@ -1585,6 +1649,7 @@ export class Renderer {
1585
1649
  this.root.removeEventListener('focusin', this.onFocusChange);
1586
1650
  this.root.removeEventListener('focusout', this.onFocusChange);
1587
1651
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1652
+ this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
1588
1653
  this.root.removeEventListener('scroll', this.onPositionScroll, true);
1589
1654
  this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
1590
1655
  this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
@@ -1626,6 +1691,7 @@ export class Renderer {
1626
1691
  if (kind === 'geometry' || (props as { reportsSize?: unknown }).reportsSize === true) this.geometryReporters.add(id);
1627
1692
  applyProps(el, kind, props, this.assets);
1628
1693
  this.noteScroll(id, kind, props);
1694
+ this.noteVisibility(id, kind, props);
1629
1695
  if (kind === 'list' || kind === 'scrollview') trackScroller(el);
1630
1696
  if (kind === 'lazyspacer') this.lazySpacers.add(id);
1631
1697
  if (kind === 'host') {
@@ -1634,6 +1700,18 @@ export class Renderer {
1634
1700
  }
1635
1701
  }
1636
1702
 
1703
+ /** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
1704
+ private rehomeToolbar(id: ElementId, el: HTMLElement, op: Op): void {
1705
+ const parentId = this.parents.get(id);
1706
+ const parent = parentId === undefined ? undefined : this.elements.get(parentId);
1707
+ if (parentId === undefined || !parent || !parent.classList.contains('sb-navscreen') || !el.isConnected) return;
1708
+ const expected = el.dataset['sbPlacement'] === 'largeTitle' ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
1709
+ if (el.parentElement === expected) return;
1710
+ const node = this.nodes.get(id);
1711
+ const index = node ? (this.nodes.get(parentId)?.children.indexOf(node) ?? -1) : -1;
1712
+ if (index >= 0) this.insert(parentId, id, index, op);
1713
+ }
1714
+
1637
1715
  /** A `host` element takes the page's content element (src/hostContent.ts) and watches its size. */
1638
1716
  private adoptHostContent(id: ElementId, el: HTMLElement, props: HostProps): void {
1639
1717
  const content = hostContent(props.content);
@@ -1680,6 +1758,64 @@ export class Renderer {
1680
1758
  }
1681
1759
  }
1682
1760
 
1761
+ // -------------------------------------------------------------------------
1762
+ // `.onScrollVisibilityChange` (src/scrollVisibility.ts)
1763
+ // -------------------------------------------------------------------------
1764
+
1765
+ /** A `styled` box with `scrollVisibility` is watched; any other forgets it was. */
1766
+ private noteVisibility(id: ElementId, kind: ElementKind, props: ElementProps): void {
1767
+ const spec = kind === 'styled' ? (props as Partial<StyledProps>)?.style?.scrollVisibility : undefined;
1768
+ if (spec && typeof spec.threshold === 'number') {
1769
+ this.visibilityTargets.set(id, spec.threshold);
1770
+ return;
1771
+ }
1772
+ this.visibilityTargets.delete(id);
1773
+ this.visibilityReported.delete(id);
1774
+ }
1775
+
1776
+ /** After a scroll or a layout, the watched boxes are checked on the next frame (once per frame). */
1777
+ private readonly queueVisibilityCheck = (): void => {
1778
+ if (this.visibilityCheckQueued || this.visibilityTargets.size === 0) return;
1779
+ this.visibilityCheckQueued = true;
1780
+ requestAnimationFrame(() => {
1781
+ this.visibilityCheckQueued = false;
1782
+ this.checkVisibility();
1783
+ });
1784
+ };
1785
+
1786
+ /**
1787
+ * Each watched box against its scroll view's visible area (the view's box
1788
+ * less the bars it runs under), or the root's outside one: told when it
1789
+ * comes into view or leaves it, and once when first checked.
1790
+ */
1791
+ private checkVisibility(): void {
1792
+ for (const [id, threshold] of this.visibilityTargets) {
1793
+ const el = this.elements.get(id);
1794
+ if (!el || !el.isConnected) {
1795
+ this.visibilityTargets.delete(id);
1796
+ this.visibilityReported.delete(id);
1797
+ continue;
1798
+ }
1799
+ const box = el.getBoundingClientRect();
1800
+ if (box.width === 0 && box.height === 0) continue;
1801
+ const scroller = el.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list') ?? null;
1802
+ const area = (scroller ?? this.root).getBoundingClientRect();
1803
+ const viewport = { x: area.left, y: area.top, width: area.width, height: area.height };
1804
+ if (scroller) {
1805
+ const chrome = this.layoutResult?.chrome.get(Number(scroller.dataset[ID_ATTR]));
1806
+ const top = chrome?.['topInset']?.height ?? 0;
1807
+ const covered = chrome?.['bottomInset'];
1808
+ viewport.y += top;
1809
+ viewport.height -= top;
1810
+ if (covered) viewport.height = Math.max(0, viewport.height - covered.height);
1811
+ }
1812
+ const visible = isScrollVisible({ x: box.left, y: box.top, width: box.width, height: box.height }, viewport, threshold);
1813
+ if (this.visibilityReported.get(id) === visible) continue;
1814
+ this.visibilityReported.set(id, visible);
1815
+ this.onEvent({ type: 'scrollvisibility', id, visible });
1816
+ }
1817
+ }
1818
+
1683
1819
  // -------------------------------------------------------------------------
1684
1820
  // `.scrollPosition(id:)` reading back (src/scrollPosition.ts)
1685
1821
  // -------------------------------------------------------------------------
@@ -1796,6 +1932,29 @@ export class Renderer {
1796
1932
  this.pendingScrolls.push(id);
1797
1933
  }
1798
1934
 
1935
+ /**
1936
+ * `ScrollContext.animateScroll` (src/scrollTo.ts): an animated `scrollTo`
1937
+ * of a snapping scroll view on one axis runs as the touch pager's run (the
1938
+ * snap off until it rests, the last frame writing the offset), held from
1939
+ * reporting its position until then as a finger's drive is, so an app
1940
+ * re-centring its pages on the page it reports does so once, at rest.
1941
+ */
1942
+ animateScroll(el: HTMLElement, left: number, top: number): boolean {
1943
+ if (el.dataset['sbSnap'] === undefined) return false;
1944
+ const axes = el.dataset['sbAxes'];
1945
+ if (axes !== 'horizontal' && axes !== 'vertical') return false;
1946
+ if (el.dataset['sbReportsPosition'] !== undefined) {
1947
+ this.heldScrolls.add(el);
1948
+ const pending = this.positionTimers.get(el);
1949
+ if (pending !== undefined) {
1950
+ window.clearTimeout(pending);
1951
+ this.positionTimers.delete(el);
1952
+ }
1953
+ }
1954
+ this.touchPaging.animateTo(el, axes === 'horizontal', axes === 'horizontal' ? left : top);
1955
+ return true;
1956
+ }
1957
+
1799
1958
  /** `ScrollViewProxy.scrollTo`: scroll to the targets this pass marked (src/scrollTo.ts). */
1800
1959
  private performScrolls(animated: boolean): void {
1801
1960
  if (this.pendingScrolls.length === 0) return;
@@ -1910,7 +2069,9 @@ export class Renderer {
1910
2069
  if (kind === 'text') el.textContent = '';
1911
2070
  applyProps(el, kind, props, this.assets);
1912
2071
  this.noteScroll(id, kind, props);
2072
+ this.noteVisibility(id, kind, props);
1913
2073
  if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
2074
+ if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
1914
2075
  const newToken = el.dataset[ANIMATION_TOKEN];
1915
2076
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1916
2077
  const record: UpdatedRecord = { el, before, tokenChanged };
@@ -1951,7 +2112,9 @@ export class Renderer {
1951
2112
  // A section's view header / footer lives beside its rows' card, in the
1952
2113
  // section element itself; the rows after a header are one DOM slot lower.
1953
2114
  const supplement = isSectionSupplement(el);
1954
- const container = supplement && parent.classList.contains('sb-section') ? parent : slotOf(parent);
2115
+ // A `.largeTitle` toolbar lives in its screen's large title element, which scrolls with the content.
2116
+ const inLargeTitle = el.dataset['sbPlacement'] === 'largeTitle' && parent.classList.contains('sb-navscreen');
2117
+ const container = supplement && parent.classList.contains('sb-section') ? parent : inLargeTitle ? (largeTitleOf(parent) ?? slotOf(parent)) : slotOf(parent);
1955
2118
  // Moves: `index` is the element's position in the parent's child list
1956
2119
  // *after* the op is applied. Detaching first makes the index arithmetic
1957
2120
  // identical for fresh inserts and for moves within the same parent.
@@ -1963,9 +2126,21 @@ export class Renderer {
1963
2126
  const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
1964
2127
  if (first?.kind === 'sectionheader') slotIndex -= 1;
1965
2128
  }
2129
+ // A screen's children may live in two containers (its content slot and
2130
+ // its large title): the next sibling in the same container is the reference.
2131
+ const screenReference = (): Element | null => {
2132
+ const siblings = (this.nodes.get(parentId)?.children ?? []).filter((child) => child.id !== id);
2133
+ for (const sibling of siblings.slice(index)) {
2134
+ const sib = this.elements.get(sibling.id);
2135
+ if (sib && sib.parentElement === container) return sib;
2136
+ }
2137
+ return null;
2138
+ };
1966
2139
  const reference = supplement
1967
2140
  ? el.classList.contains('sb-sectionheader') ? container.firstChild : null
1968
- : (container.children[slotIndex] ?? null);
2141
+ : parent.classList.contains('sb-navscreen')
2142
+ ? screenReference()
2143
+ : (container.children[slotIndex] ?? null);
1969
2144
  container.insertBefore(el, reference);
1970
2145
  if (!wasAttached) this.pass.inserted.add(el);
1971
2146
  // Mirror the move in the layout tree.
@@ -3234,7 +3409,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
3234
3409
  const placement = (props as ToolbarProps).placement;
3235
3410
  // `secondary` items shown in the bar look like the trailing ones (glass capsules).
3236
3411
  el.dataset['sbPlacement'] =
3237
- placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
3412
+ placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
3238
3413
  // A `ToolbarSpacer`: draws nothing.
3239
3414
  el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
3240
3415
  break;
@@ -3703,8 +3878,12 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3703
3878
  el.dataset['sbUrl'] = props.url;
3704
3879
  if (props.resizable) el.dataset['sbResizable'] = '';
3705
3880
  else delete el.dataset['sbResizable'];
3881
+ // React's opt-in to the browser's own image interactions (styles.css).
3882
+ const web = props.webInteractions === true;
3883
+ el.toggleAttribute('data-sb-web', web);
3706
3884
  const existing = el.firstElementChild;
3707
3885
  if (existing instanceof HTMLImageElement) {
3886
+ existing.draggable = web;
3708
3887
  if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
3709
3888
  swapImageSource(existing, props.url);
3710
3889
  }
@@ -3713,12 +3892,13 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
3713
3892
  const img = document.createElement('img');
3714
3893
  img.src = props.url;
3715
3894
  img.alt = '';
3716
- img.draggable = false;
3895
+ img.draggable = web;
3717
3896
  img.decoding = 'async';
3718
3897
  found(props.url, img);
3719
3898
  return;
3720
3899
  }
3721
3900
  delete el.dataset['sbUrl'];
3901
+ el.removeAttribute('data-sb-web');
3722
3902
  if (isAssetImage(props)) {
3723
3903
  const name = props.name ?? '';
3724
3904
  delete el.dataset['sbSymbol'];
@@ -3827,6 +4007,54 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
3827
4007
  else delete input.dataset['sbSubmit'];
3828
4008
  if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
3829
4009
  else input.removeAttribute('enterkeyhint');
4010
+ applyTextInputHints(input, props);
4011
+ }
4012
+
4013
+ /** `UIKeyboardType` → `inputmode`: the browser's nearest on-screen keyboard. */
4014
+ const INPUT_MODES: Record<string, string> = {
4015
+ emailAddress: 'email',
4016
+ URL: 'url',
4017
+ numberPad: 'numeric',
4018
+ asciiCapableNumberPad: 'numeric',
4019
+ decimalPad: 'decimal',
4020
+ phonePad: 'tel',
4021
+ namePhonePad: 'tel',
4022
+ webSearch: 'search',
4023
+ };
4024
+
4025
+ /** `UITextContentType` → `autocomplete`: what autofill offers. */
4026
+ const AUTOCOMPLETE: Record<string, string> = {
4027
+ name: 'name',
4028
+ givenName: 'given-name',
4029
+ familyName: 'family-name',
4030
+ emailAddress: 'email',
4031
+ telephoneNumber: 'tel',
4032
+ username: 'username',
4033
+ password: 'current-password',
4034
+ newPassword: 'new-password',
4035
+ oneTimeCode: 'one-time-code',
4036
+ URL: 'url',
4037
+ fullStreetAddress: 'street-address',
4038
+ postalCode: 'postal-code',
4039
+ creditCardNumber: 'cc-number',
4040
+ };
4041
+
4042
+ /**
4043
+ * `.keyboardType`, `.textContentType`, `.textInputAutocapitalization` and
4044
+ * `.autocorrectionDisabled` as the input's attributes (docs/ops-protocol.md,
4045
+ * "Text input hints"). Without a content type autofill stays off, as a field
4046
+ * in an app does not offer the page's saved values.
4047
+ */
4048
+ function applyTextInputHints(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
4049
+ const mode = props.keyboard ? INPUT_MODES[props.keyboard] : undefined;
4050
+ if (mode) input.inputMode = mode;
4051
+ else input.removeAttribute('inputmode');
4052
+ input.autocomplete = ((props.contentType ? AUTOCOMPLETE[props.contentType] : undefined) ?? 'off') as AutoFill;
4053
+ const capitalize = props.autocapitalization === 'never' ? 'none' : props.autocapitalization;
4054
+ if (capitalize) input.setAttribute('autocapitalize', capitalize);
4055
+ else input.removeAttribute('autocapitalize');
4056
+ if (props.autocorrectionDisabled === true) input.setAttribute('autocorrect', 'off');
4057
+ else input.removeAttribute('autocorrect');
3830
4058
  }
3831
4059
 
3832
4060
  /** Phase 12 `.scrollTargetLayout()`: the stack's children snap (styles.css). */
@@ -3864,9 +4092,17 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
3864
4092
  const largeTitle = largeTitleOf(el);
3865
4093
  // A large title that scrolls away collapses into the inline one (shown
3866
4094
  // with `data-sb-collapsed`, src/largeTitle.ts).
3867
- if (inlineTitle) inlineTitle.textContent = title;
4095
+ if (inlineTitle) {
4096
+ const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
4097
+ inlineTitle.textContent = title;
4098
+ // An inline title given to an empty bar (a header row that has scrolled
4099
+ // under it handing the bar its name) fades in as a collapsing large title does.
4100
+ if (appearing && !large && typeof inlineTitle.animate === 'function') {
4101
+ inlineTitle.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'ease' });
4102
+ }
4103
+ }
3868
4104
  if (largeTitle) {
3869
- largeTitle.textContent = large ? title : '';
4105
+ largeTitleTextOf(largeTitle).textContent = large ? title : '';
3870
4106
  largeTitle.hidden = !large;
3871
4107
  }
3872
4108
  el.setAttribute('aria-label', title);
package/src/scrollTo.ts CHANGED
@@ -49,6 +49,14 @@ export interface ScrollContext {
49
49
  nodes: Map<number, LayoutNode>;
50
50
  parents: Map<number, number>;
51
51
  elements: Map<number, HTMLElement>;
52
+ /**
53
+ * Runs an animated scroll of `el` to the offsets itself, returning false
54
+ * to leave it to the browser's smooth scrolling. The renderer takes the
55
+ * snapping scroll views (`scrollTargetBehavior`): a browser animating a
56
+ * scroll-snapped container may snap it again once the animation ends or
57
+ * the content moves (Safari does), so they run as a finger's page does.
58
+ */
59
+ animateScroll?: (el: HTMLElement, left: number, top: number) => boolean;
52
60
  }
53
61
 
54
62
  function anchorOf(command: ScrollToCommand | undefined): UnitPoint | null {
@@ -116,7 +124,9 @@ export function scrollToTarget(
116
124
  changed = true;
117
125
  }
118
126
  if (changed) {
119
- if (smooth && typeof el.scrollTo === 'function') {
127
+ if (smooth && ctx.animateScroll?.(el, left, top)) {
128
+ // Run by the context (a snapping scroll view).
129
+ } else if (smooth && typeof el.scrollTo === 'function') {
120
130
  el.scrollTo({ left, top, behavior: 'smooth' });
121
131
  } else {
122
132
  el.scrollLeft = left;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `.onScrollVisibilityChange(threshold:_:)` (iOS 18; docs/ops-protocol.md,
3
+ * "Scroll visibility"): a `styled` box with `scrollVisibility` is told when
4
+ * the share of it inside its scroll view's visible area crosses its
5
+ * threshold. The visible area is the scroll view's box less a bar it scrolls
6
+ * under (its `topInset`) and a bar covering its bottom (`bottomInset`), as
7
+ * content under a bar is not in view; outside a scroll view it is the root.
8
+ */
9
+
10
+ export interface Span {
11
+ start: number;
12
+ length: number;
13
+ }
14
+
15
+ /** The share (0...1) of `target` along an axis inside `viewport`; 0 for a target of no extent. */
16
+ export function visibleShare(target: Span, viewport: Span): number {
17
+ if (target.length <= 0) return 0;
18
+ const start = Math.max(target.start, viewport.start);
19
+ const end = Math.min(target.start + target.length, viewport.start + viewport.length);
20
+ return Math.max(0, end - start) / target.length;
21
+ }
22
+
23
+ /**
24
+ * Whether a box is visible: some of it is in view, and at least `threshold`
25
+ * of it along the scroll axes (both shares multiplied for a view scrolling
26
+ * both ways, the other axis counted too, as a box off to the side is not in
27
+ * view).
28
+ */
29
+ export function isScrollVisible(
30
+ target: { x: number; y: number; width: number; height: number },
31
+ viewport: { x: number; y: number; width: number; height: number },
32
+ threshold: number,
33
+ ): boolean {
34
+ const horizontal = visibleShare({ start: target.x, length: target.width }, { start: viewport.x, length: viewport.width });
35
+ const vertical = visibleShare({ start: target.y, length: target.height }, { start: viewport.y, length: viewport.height });
36
+ const share = horizontal * vertical;
37
+ return share > 0 && share >= Math.min(1, Math.max(0, threshold));
38
+ }
package/src/styles.css CHANGED
@@ -666,6 +666,17 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
666
666
  -webkit-user-select: none;
667
667
  }
668
668
 
669
+ /* React's `AsyncImage webInteractions`: the picture is a web page's image
670
+ again, with the browser's long press (Save / Share / Copy on iOS), drag
671
+ out and right-click menu. */
672
+ .sb-image[data-sb-web] > img {
673
+ pointer-events: auto;
674
+ user-select: auto;
675
+ -webkit-user-select: auto;
676
+ -webkit-touch-callout: default;
677
+ -webkit-user-drag: auto;
678
+ }
679
+
669
680
  /* `.interpolation(.none)`: a QR code's modules stay sharp when scaled up. */
670
681
  .sb-image[data-sb-interpolation='none'] > img,
671
682
  .sb-image[data-sb-interpolation='none'] > canvas {
@@ -1253,6 +1264,22 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1253
1264
  display: none;
1254
1265
  }
1255
1266
 
1267
+ /* A `.largeTitle` toolbar (iOS 26) takes the row in place of the title's
1268
+ text, which the bar still shows once the row has scrolled under it. Its
1269
+ items are content, drawn as anywhere else and answering touches. */
1270
+ .sb-navbar-large-title:has(> .sb-toolbar[data-sb-placement="largeTitle"]:not([hidden])) > .sb-navbar-large-title-text {
1271
+ visibility: hidden;
1272
+ }
1273
+
1274
+ .sb-navbar-large-title > .sb-toolbar {
1275
+ font-size: var(--sb-font-size-body);
1276
+ font-weight: 400;
1277
+ line-height: normal;
1278
+ letter-spacing: normal;
1279
+ white-space: normal;
1280
+ pointer-events: auto;
1281
+ }
1282
+
1256
1283
  /* The large title scrolls with the screen's root list or scroll view
1257
1284
  (src/largeTitle.ts moves it into it); drags on it scroll that view. There
1258
1285
  it is content, under the bar. */
@@ -1898,6 +1925,28 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1898
1925
  max-width: calc(100% - 8px);
1899
1926
  }
1900
1927
 
1928
+ /* iOS 26: a segmented picker in a navigation bar item is the bar's glass
1929
+ capsule itself (the run's glass behind it, engine `bar`): no track, the
1930
+ labels in the engine's font (15 regular), the selected segment a gray
1931
+ capsule the glass tints, with no shadow, 2 in from its neighbours. */
1932
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment-track {
1933
+ display: none;
1934
+ }
1935
+
1936
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment {
1937
+ font: inherit;
1938
+ letter-spacing: normal;
1939
+ }
1940
+
1941
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment::before {
1942
+ inset: 0 2px;
1943
+ }
1944
+
1945
+ .sb-picker[data-sb-style="segmented"][data-sb-bar] > .sb-segment[aria-checked="true"]::before {
1946
+ background: var(--sb-glass-selected);
1947
+ box-shadow: none;
1948
+ }
1949
+
1901
1950
  .sb-segment:focus-visible {
1902
1951
  outline: 2px solid var(--sb-color-accent);
1903
1952
  outline-offset: -2px;
@@ -2982,7 +3031,8 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2982
3031
  }
2983
3032
 
2984
3033
  :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
2985
- :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > * {
3034
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > *,
3035
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-image[data-sb-web] > img {
2986
3036
  pointer-events: none !important;
2987
3037
  }
2988
3038
 
@@ -334,6 +334,22 @@ export class TouchPaging {
334
334
  this.runTo(el, g.horizontal, offset, destination);
335
335
  };
336
336
 
337
+ /**
338
+ * The app scrolls `el` to `to` with an animation (`withAnimation` around
339
+ * `ScrollViewProxy.scrollTo`): the same eased run a finger's page ends
340
+ * with, the snap off until it rests, the last frame writing `to` itself
341
+ * and `onRest` reporting the page. A browser's own smooth scroll of a
342
+ * snapping container is left to snap it again when it likes; Safari does,
343
+ * after the animation or when the app re-centres its pages.
344
+ */
345
+ animateTo(el: HTMLElement, horizontal: boolean, to: number): void {
346
+ this.stopRun();
347
+ const max = horizontal ? el.scrollWidth - el.clientWidth : el.scrollHeight - el.clientHeight;
348
+ const target = Math.min(Math.max(0, max), Math.max(0, to));
349
+ el.style.scrollSnapType = 'none';
350
+ this.runTo(el, horizontal, horizontal ? el.scrollLeft : el.scrollTop, target);
351
+ }
352
+
337
353
  /** Runs the offset to `to` with an ease-out, then lets the container snap again. */
338
354
  private runTo(el: HTMLElement, horizontal: boolean, from: number, to: number): void {
339
355
  this.stopRun();