@swiftbrowser/web 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,7 @@ src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixella
36
36
  src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
37
37
  src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
38
38
  src/press.ts iOS 26 press feedback: the Liquid Glass under a pressed bar button or glass button swells and springs back
39
+ src/barMorph.ts iOS 26 navigation: one bar through a push, pop or swipe back; the capsules change shape and the items come and go on an overlay while the screens slide
39
40
  src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
40
41
  src/page.css The app page around the frame: its toolbar, stage, error banner, device-mode chrome
41
42
  node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -0,0 +1,411 @@
1
+ /**
2
+ * iOS 26: one navigation bar through a push or pop.
3
+ *
4
+ * Each screen draws its own bar (the back and More circles in `.sb-navbar`,
5
+ * the glass capsules `.sb-bar-glass` and the `leading` / `trailing` toolbar
6
+ * items laid out on them, src/layoutDom.ts), and a screen slides as a whole,
7
+ * so the bar's items slid with it. On iOS 26 the navigation controller draws
8
+ * one bar: through a push or pop the items stay in it, a capsule both screens
9
+ * have changes shape from the one's to the other's, the old screen's items
10
+ * scale and fade out as the new screen's scale and fade in (the bar's own
11
+ * transition for an item that comes or goes, src/animation.ts), an item both
12
+ * screens draw the same at the same place stays as it is, and the back button
13
+ * appears or goes with the depth. The titles still travel with their screens
14
+ * (the large title with the content it heads), as the content does.
15
+ *
16
+ * The morph is an overlay on the stack (`.sb-navbar-morph`, styles.css: above
17
+ * the screens and the pop clone, taking no input) holding visual clones of
18
+ * both screens' bar parts, while the parts themselves are hidden
19
+ * (`data-sb-bar-morph`) for the slide. The renderer builds it at the commit
20
+ * that holds the push or pop, in the frame the slide starts (the layout has
21
+ * placed the new screen's parts by then), and it drops itself when the slide
22
+ * ends. A swipe back builds it when the drag claims the pointer and scrubs it
23
+ * with the drag; the release plays the rest forward (the pop, from the pop
24
+ * clone the screen leaves behind) or back (the snap back) over the slide's
25
+ * duration, as the screens move.
26
+ */
27
+
28
+ import { TOOLBAR_TRANSITION, transitionStart } from './animation';
29
+ import { ghostClone } from './ghost';
30
+ import type { Rect } from './layout';
31
+
32
+ /** On a screen's bar part the morph stands in for: hidden while it runs (styles.css). */
33
+ export const BAR_MORPH_ATTR = 'data-sb-bar-morph';
34
+ /** The overlay's class (styles.css). */
35
+ export const BAR_MORPH_CLASS = 'sb-navbar-morph';
36
+ /** The slide's duration under `prefers-reduced-motion` (styles.css: 0.01s). */
37
+ const REDUCED_MOTION_MS = 10;
38
+ /** The slide's easing (styles.css `--sb-nav-easing`), read from the stack; this when it cannot be. */
39
+ const NAV_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
40
+ /** Two boxes this close are one place. */
41
+ const RECT_EPSILON = 0.5;
42
+ /** A drag this close to an end has nothing left to play. */
43
+ const PROGRESS_EPSILON = 0.01;
44
+
45
+ export type BarPlacement = 'leading' | 'trailing';
46
+ export type BarPartKind = 'glass' | 'back' | 'more' | 'item';
47
+
48
+ /** A part of a screen's bar, as the plan sees it. */
49
+ export interface BarPart {
50
+ kind: BarPartKind;
51
+ /** The run an item or capsule belongs to; the back and More circles have none. */
52
+ placement: BarPlacement | null;
53
+ /** Its box, relative to the screen. */
54
+ rect: Rect;
55
+ /** What it draws, without element ids (two items that draw the same at the same place are one): `signatureOf`. */
56
+ signature: string;
57
+ }
58
+
59
+ export type MorphPhase = 'keep' | 'move' | 'exit' | 'enter';
60
+
61
+ /** One clone of the morph: the part drawn (the arriving screen's, or the leaving screen's for `exit`) and how it goes from `from` to `to`. */
62
+ export interface MorphStep<T extends BarPart> {
63
+ part: T;
64
+ from: Rect;
65
+ to: Rect;
66
+ phase: MorphPhase;
67
+ }
68
+
69
+ function sameRect(a: Rect, b: Rect): boolean {
70
+ return (
71
+ Math.abs(a.x - b.x) < RECT_EPSILON &&
72
+ Math.abs(a.y - b.y) < RECT_EPSILON &&
73
+ Math.abs(a.width - b.width) < RECT_EPSILON &&
74
+ Math.abs(a.height - b.height) < RECT_EPSILON
75
+ );
76
+ }
77
+
78
+ /**
79
+ * How the leaving screen's bar parts (`from`) become the arriving screen's
80
+ * (`to`): the capsules first (drawn under the rest), each run of a placement
81
+ * meeting the run in its place on the other screen, leading runs counted
82
+ * from the leading edge and trailing runs from the trailing edge, where the
83
+ * bar anchors them (a capsule with none opposite scales and fades as the
84
+ * items do); then the back and More circles, one each; then the items, an
85
+ * item the other screen draws the same at the same place staying as it is
86
+ * and every other leaving or arriving.
87
+ */
88
+ export function planBarMorph<T extends BarPart>(from: readonly T[], to: readonly T[]): MorphStep<T>[] {
89
+ const steps: MorphStep<T>[] = [];
90
+ const pair = (old: T | undefined, arriving: T | undefined): void => {
91
+ if (old && arriving) {
92
+ steps.push({ part: arriving, from: old.rect, to: arriving.rect, phase: sameRect(old.rect, arriving.rect) ? 'keep' : 'move' });
93
+ } else if (old) {
94
+ steps.push({ part: old, from: old.rect, to: old.rect, phase: 'exit' });
95
+ } else if (arriving) {
96
+ steps.push({ part: arriving, from: arriving.rect, to: arriving.rect, phase: 'enter' });
97
+ }
98
+ };
99
+ for (const placement of ['leading', 'trailing'] as const) {
100
+ const olds = from.filter((part) => part.kind === 'glass' && part.placement === placement);
101
+ const news = to.filter((part) => part.kind === 'glass' && part.placement === placement);
102
+ const count = Math.max(olds.length, news.length);
103
+ for (let i = 0; i < count; i++) {
104
+ const at = (list: readonly T[]): T | undefined => (placement === 'leading' ? list[i] : list[list.length - 1 - i]);
105
+ pair(at(olds), at(news));
106
+ }
107
+ }
108
+ for (const kind of ['back', 'more'] as const) {
109
+ pair(
110
+ from.find((part) => part.kind === kind),
111
+ to.find((part) => part.kind === kind),
112
+ );
113
+ }
114
+ const arriving = to.filter((part) => part.kind === 'item');
115
+ const kept = new Set<T>();
116
+ for (const old of from) {
117
+ if (old.kind !== 'item') continue;
118
+ const twin = arriving.find(
119
+ (item) => !kept.has(item) && item.placement === old.placement && sameRect(old.rect, item.rect) && item.signature === old.signature,
120
+ );
121
+ if (twin) {
122
+ kept.add(twin);
123
+ steps.push({ part: twin, from: twin.rect, to: twin.rect, phase: 'keep' });
124
+ } else {
125
+ steps.push({ part: old, from: old.rect, to: old.rect, phase: 'exit' });
126
+ }
127
+ }
128
+ for (const item of arriving) {
129
+ if (!kept.has(item)) steps.push({ part: item, from: item.rect, to: item.rect, phase: 'enter' });
130
+ }
131
+ return steps;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // The screens' parts
136
+ // ---------------------------------------------------------------------------
137
+
138
+ interface ScreenPart extends BarPart {
139
+ el: HTMLElement;
140
+ }
141
+
142
+ function placementOf(el: HTMLElement): BarPlacement | null {
143
+ const placement = el.dataset['sbPlacement'];
144
+ return placement === 'leading' || placement === 'trailing' ? placement : null;
145
+ }
146
+
147
+ /** A screen's (or pop clone's) glass capsules in the navigation bar, in the bar's order. */
148
+ function capsulesOf(screen: HTMLElement): HTMLElement[] {
149
+ return Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-bar-glass:not([data-sb-exiting])')).filter((el) => placementOf(el) !== null);
150
+ }
151
+
152
+ /** Its `leading` and `trailing` toolbar groups the bar draws. */
153
+ function groupsOf(screen: HTMLElement): HTMLElement[] {
154
+ return Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-navscreen-body > .sb-navscreen-content > .sb-toolbar')).filter(
155
+ (el) => placementOf(el) !== null && !el.hidden && !el.hasAttribute('data-sb-spacer'),
156
+ );
157
+ }
158
+
159
+ /** Its back and More circles, those shown. */
160
+ function circlesOf(screen: HTMLElement): { kind: 'back' | 'more'; el: HTMLElement }[] {
161
+ const out: { kind: 'back' | 'more'; el: HTMLElement }[] = [];
162
+ for (const kind of ['back', 'more'] as const) {
163
+ const el = screen.querySelector<HTMLElement>(`:scope > .sb-navbar > .sb-navbar-${kind}`);
164
+ if (el && !el.hidden) out.push({ kind, el });
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** The elements of a screen's bar the morph stands in for (hidden while it runs): the capsules, the circles and the groups with their items. */
170
+ function barElementsOf(screen: HTMLElement): HTMLElement[] {
171
+ return [...capsulesOf(screen), ...circlesOf(screen).map((circle) => circle.el), ...groupsOf(screen)];
172
+ }
173
+
174
+ /** An element's markup as the plan compares it: without element ids, the morph's own mark, or where its toolbar put it. */
175
+ function signatureOf(el: HTMLElement): string {
176
+ const copy = el.cloneNode(true) as HTMLElement;
177
+ for (const node of [copy, ...copy.querySelectorAll('*')]) {
178
+ node.removeAttribute('data-sb-id');
179
+ node.removeAttribute('data-sb-ghost');
180
+ node.removeAttribute(BAR_MORPH_ATTR);
181
+ }
182
+ copy.style.left = '';
183
+ copy.style.top = '';
184
+ return copy.outerHTML;
185
+ }
186
+
187
+ /** The parts of a screen's bar with their boxes relative to the screen (wherever its slide has it). */
188
+ function partsOf(screen: HTMLElement): ScreenPart[] {
189
+ const base = screen.getBoundingClientRect();
190
+ // The frame may be scaled to fit the page: boxes in the screen's own pixels.
191
+ const scale = screen.offsetWidth > 0 && base.width > 0 ? base.width / screen.offsetWidth : 1;
192
+ const rectOf = (el: HTMLElement): Rect => {
193
+ const r = el.getBoundingClientRect();
194
+ return { x: (r.left - base.left) / scale, y: (r.top - base.top) / scale, width: r.width / scale, height: r.height / scale };
195
+ };
196
+ const out: ScreenPart[] = [];
197
+ const add = (kind: BarPartKind, placement: BarPlacement | null, el: HTMLElement, signature = ''): void => {
198
+ const rect = rectOf(el);
199
+ if (rect.width === 0 && rect.height === 0) return;
200
+ out.push({ kind, placement, rect, signature, el });
201
+ };
202
+ for (const capsule of capsulesOf(screen)) add('glass', placementOf(capsule), capsule);
203
+ for (const circle of circlesOf(screen)) add(circle.kind, null, circle.el);
204
+ for (const group of groupsOf(screen)) {
205
+ const placement = placementOf(group);
206
+ for (const item of group.querySelectorAll<HTMLElement>(':scope > [data-sb-id], :scope > [data-sb-ghost]')) {
207
+ if (!item.hidden) add('item', placement, item, signatureOf(item));
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ function reducedMotion(): boolean {
214
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
215
+ }
216
+
217
+ function navEasing(stack: HTMLElement): string {
218
+ const easing = getComputedStyle(stack).getPropertyValue('--sb-nav-easing').trim();
219
+ return easing.length > 0 ? easing : NAV_EASING;
220
+ }
221
+
222
+ /** Put a clone at `rect` in the overlay (the screen's coordinates), free of what its own layout gave it. */
223
+ function place(clone: HTMLElement, rect: Rect): void {
224
+ const s = clone.style;
225
+ s.position = 'absolute';
226
+ s.left = `${rect.x}px`;
227
+ s.top = `${rect.y}px`;
228
+ s.width = `${rect.width}px`;
229
+ s.height = `${rect.height}px`;
230
+ s.margin = '0';
231
+ s.maxWidth = 'none';
232
+ }
233
+
234
+ const px = (value: number): string => `${value}px`;
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // The morph
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export interface BarMorphOptions {
241
+ /** The slide's duration in ms (reduced motion shortens it as the stylesheet does). */
242
+ duration: number;
243
+ /** A swipe back: built paused at its start, moved by `scrub`, then played on (`finish`) or back (`cancel`). */
244
+ interactive: boolean;
245
+ /** Called once the morph has dropped itself. */
246
+ onDone?: () => void;
247
+ }
248
+
249
+ export class BarMorph {
250
+ /** The screen (or its pop clone) whose bar leaves. */
251
+ from: HTMLElement;
252
+ /** The screen whose bar arrives. */
253
+ readonly to: HTMLElement;
254
+ private readonly overlay: HTMLElement;
255
+ private readonly animations: globalThis.Animation[] = [];
256
+ /** The screens' own parts, hidden until the morph is done. */
257
+ private readonly held = new Set<HTMLElement>();
258
+ private readonly duration: number;
259
+ private readonly onDone: (() => void) | undefined;
260
+ /** Paused and scrubbed by a drag (until the release plays it). */
261
+ private interactive: boolean;
262
+ /** Where a drag has it, 0 to 1. */
263
+ private progress = 0;
264
+ private disposed = false;
265
+ private timer: number | null = null;
266
+
267
+ /**
268
+ * Builds the morph of the bar of `from` into the bar of `to`, both screens
269
+ * of `stack`, from where their parts are now, and starts it (or holds it
270
+ * for a drag); null when neither screen has a bar part.
271
+ */
272
+ static begin(stack: HTMLElement, from: HTMLElement, to: HTMLElement, options: BarMorphOptions): BarMorph | null {
273
+ const steps = planBarMorph(partsOf(from), partsOf(to));
274
+ if (steps.length === 0) return null;
275
+ return new BarMorph(stack, from, to, steps, options);
276
+ }
277
+
278
+ private constructor(stack: HTMLElement, from: HTMLElement, to: HTMLElement, steps: readonly MorphStep<ScreenPart>[], options: BarMorphOptions) {
279
+ this.from = from;
280
+ this.to = to;
281
+ this.onDone = options.onDone;
282
+ this.interactive = options.interactive;
283
+ this.duration = reducedMotion() ? REDUCED_MOTION_MS : options.duration;
284
+ const easing = options.interactive ? 'linear' : navEasing(stack);
285
+ const overlay = document.createElement('div');
286
+ overlay.className = BAR_MORPH_CLASS;
287
+ overlay.setAttribute('aria-hidden', 'true');
288
+ overlay.inert = true;
289
+ // Over the arriving screen's box in the stack (its slide aside).
290
+ place(overlay, { x: to.offsetLeft, y: to.offsetTop, width: to.offsetWidth, height: to.offsetHeight });
291
+ for (const step of steps) {
292
+ const clone = ghostClone(step.part.el);
293
+ clone.removeAttribute(BAR_MORPH_ATTR);
294
+ if (step.part.kind === 'item') clone.dataset['sbBarItem'] = step.part.placement ?? '';
295
+ place(clone, step.to);
296
+ const animation = this.animate(clone, step, easing);
297
+ if (animation) this.animations.push(animation);
298
+ overlay.append(clone);
299
+ }
300
+ this.overlay = overlay;
301
+ for (const el of [...barElementsOf(from), ...barElementsOf(to)]) this.hold(el);
302
+ stack.append(overlay);
303
+ if (!options.interactive) this.settle();
304
+ }
305
+
306
+ /** The clone's tween: its box from the leaving part's to the arriving part's, and the bar's item transition in or out. */
307
+ private animate(clone: HTMLElement, step: MorphStep<ScreenPart>, easing: string): globalThis.Animation | null {
308
+ const start: Keyframe = {};
309
+ const end: Keyframe = {};
310
+ if (step.phase === 'move') {
311
+ start['left'] = px(step.from.x);
312
+ start['top'] = px(step.from.y);
313
+ start['width'] = px(step.from.width);
314
+ start['height'] = px(step.from.height);
315
+ end['left'] = px(step.to.x);
316
+ end['top'] = px(step.to.y);
317
+ end['width'] = px(step.to.width);
318
+ end['height'] = px(step.to.height);
319
+ }
320
+ if (step.phase === 'enter' || step.phase === 'exit') {
321
+ const gone = transitionStart(TOOLBAR_TRANSITION, step.phase === 'exit');
322
+ const own = clone.style.transform;
323
+ const goneTransform = gone.transform === undefined ? own || 'none' : own ? `${own} ${gone.transform}` : gone.transform;
324
+ const [away, here] = step.phase === 'enter' ? [start, end] : [end, start];
325
+ away['opacity'] = gone.opacity ?? 0;
326
+ away['transform'] = goneTransform;
327
+ here['opacity'] = 1;
328
+ here['transform'] = own || 'none';
329
+ }
330
+ if (Object.keys(start).length === 0) return null;
331
+ const animation = clone.animate([start, end], { duration: this.duration, easing, fill: 'both' });
332
+ if (this.interactive) {
333
+ animation.pause();
334
+ animation.currentTime = 0;
335
+ }
336
+ return animation;
337
+ }
338
+
339
+ private hold(el: HTMLElement): void {
340
+ el.setAttribute(BAR_MORPH_ATTR, '');
341
+ this.held.add(el);
342
+ }
343
+
344
+ /** A drag has the slide at `progress` (0: as before, 1: done): the morph follows. */
345
+ scrub(progress: number): void {
346
+ if (this.disposed || !this.interactive) return;
347
+ this.progress = Math.min(1, Math.max(0, progress));
348
+ const time = this.progress * this.duration;
349
+ for (const animation of this.animations) animation.currentTime = time;
350
+ }
351
+
352
+ /**
353
+ * The screen the drag was popping has left, `clone` sliding out in its
354
+ * place (renderer.ts `animatePop`): the clone's bar parts hide too, and the
355
+ * rest of the morph plays over the slide's duration, as the screens move.
356
+ */
357
+ popped(clone: HTMLElement): void {
358
+ if (this.disposed) return;
359
+ for (const el of barElementsOf(clone)) this.hold(el);
360
+ this.from = clone;
361
+ this.finish();
362
+ }
363
+
364
+ /** Plays the rest of a scrubbed morph forward over the slide's duration. */
365
+ finish(): void {
366
+ if (this.disposed || !this.interactive) return;
367
+ this.play(1 - this.progress);
368
+ }
369
+
370
+ /** Plays a scrubbed morph back to its start over the slide's duration (the snap back), then drops it. */
371
+ cancel(): void {
372
+ if (this.disposed || !this.interactive) return;
373
+ this.play(-this.progress);
374
+ }
375
+
376
+ /** The rest of the morph at `rate` (negative: back to the start), taking the slide's duration. */
377
+ private play(rate: number): void {
378
+ this.interactive = false;
379
+ if (Math.abs(rate) <= PROGRESS_EPSILON) {
380
+ this.dispose();
381
+ return;
382
+ }
383
+ for (const animation of this.animations) {
384
+ animation.playbackRate = rate;
385
+ animation.play();
386
+ }
387
+ this.settle();
388
+ }
389
+
390
+ /** Drops the morph once its tweens have ended (or after the slide's duration, with none, or should they never end). */
391
+ private settle(): void {
392
+ const done = (): void => this.dispose();
393
+ if (this.animations.length > 0) Promise.allSettled(this.animations.map((animation) => animation.finished)).then(done, done);
394
+ this.timer = window.setTimeout(done, this.duration + 100);
395
+ }
396
+
397
+ /** Removes the overlay and shows the screens' own parts again. */
398
+ dispose(): void {
399
+ if (this.disposed) return;
400
+ this.disposed = true;
401
+ if (this.timer !== null) {
402
+ window.clearTimeout(this.timer);
403
+ this.timer = null;
404
+ }
405
+ for (const animation of this.animations) animation.cancel();
406
+ this.overlay.remove();
407
+ for (const el of this.held) el.removeAttribute(BAR_MORPH_ATTR);
408
+ this.held.clear();
409
+ this.onDone?.();
410
+ }
411
+ }
@@ -102,6 +102,14 @@ export interface InteractionHost {
102
102
  /** Whether the popover of element `id` is open. */
103
103
  isMenuOpen(id: ElementId): boolean;
104
104
  closeMenu(): void;
105
+ /**
106
+ * iOS 26 (src/barMorph.ts): a swipe back of `screen` has started, with
107
+ * `below` about to show: its bar morphs into the screen below's with the
108
+ * drag (`barMorphScrub`, 0 to 1), and back should the screen snap back.
109
+ */
110
+ barMorphBegin(screen: HTMLElement, below: HTMLElement): void;
111
+ barMorphScrub(screen: HTMLElement, progress: number): void;
112
+ barMorphSnapBack(screen: HTMLElement): void;
105
113
  }
106
114
 
107
115
  interface Sample {
@@ -564,6 +572,7 @@ export class InteractionController {
564
572
  this.host.revealScreen(c.below);
565
573
  c.below.style.transition = 'none';
566
574
  c.below.style.visibility = 'visible';
575
+ this.host.barMorphBegin(c.screen, c.below);
567
576
  }
568
577
  this.swipeBack = { screen: c.screen, below: c.below, completing: false, timer: null };
569
578
  break;
@@ -626,6 +635,7 @@ export class InteractionController {
626
635
  if (c.below) {
627
636
  c.below.style.transform = `translateX(${-30 * (1 - progress)}%)`;
628
637
  c.below.style.setProperty('--sb-nav-dim', String(round2(0.12 * (1 - progress))));
638
+ this.host.barMorphScrub(c.screen, progress);
629
639
  }
630
640
  return;
631
641
  }
@@ -1187,6 +1197,7 @@ export class InteractionController {
1187
1197
  delete screen.dataset['sbSwipeDragging'];
1188
1198
  delete screen.dataset['sbSwipePop'];
1189
1199
  screen.style.removeProperty('--sb-nav-from');
1200
+ this.host.barMorphSnapBack(screen);
1190
1201
  // Dropping the inline transforms lets the stylesheet's 0.4 s transition
1191
1202
  // carry each screen from where it is to its resting transform.
1192
1203
  void screen.offsetWidth;
@@ -162,6 +162,10 @@ const NAV_BAR_HEIGHT = 44;
162
162
  const NAV_BAR_INLINE_BOTTOM = 10;
163
163
  // iOS 26: the large title's row is 62 (52 before).
164
164
  const LARGE_TITLE_HEIGHT = 62;
165
+ // iOS 26: `.largeTitle` toolbar items take the title's place 10 below the
166
+ // bar's 44 row, in a row exactly their height: the content starts right
167
+ // under them (measured in the iOS 26 simulator, Native's LargeTitleTests).
168
+ const LARGE_TITLE_ITEM_TOP = 10;
165
169
  const SHEET_GRABBER_ROW = 20;
166
170
  const SHEET_GRABBER: Size = { width: 36, height: 5 };
167
171
  const SHEET_GRABBER_TOP = 8;
@@ -3390,8 +3394,9 @@ export class LayoutPass {
3390
3394
 
3391
3395
  /**
3392
3396
  * The navigation bar is the status bar plus a 44 row (plus 62 for a large
3393
- * title); a Phase 6 `searchfield` is a capsule floating above the bottom
3394
- * (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
3397
+ * title, or 10 and the height of its `.largeTitle` items); a Phase 6
3398
+ * `searchfield` is a capsule floating above the bottom (iOS 26); `bottom`
3399
+ * toolbars float their items on glass in a 48 row 28
3395
3400
  * above the bottom (`placeBottomToolbars`); the content is proposed the
3396
3401
  * rest. Toolbar items are measured at their ideal size and laid out in iOS
3397
3402
  * 26 glass capsules in the 44 row: adjacent `leading` (or `trailing`)
@@ -3403,6 +3408,18 @@ export class LayoutPass {
3403
3408
  * inline title (`chrome.title`) is centered and truncated to the room the
3404
3409
  * bar items leave.
3405
3410
  */
3411
+ /** The tallest `.largeTitle` toolbar item at its ideal size, or null when the screen has none (the text title shows). */
3412
+ private largeTitleItemsHeight(toolbars: readonly LayoutNode[], cctx: Context, width: number): number | null {
3413
+ let height: number | null = null;
3414
+ for (const toolbar of toolbars) {
3415
+ if (str(toolbar.props['placement']) !== 'largeTitle' || str(toolbar.props['spacer']) !== null) continue;
3416
+ const itemCtx = this.childContext(toolbar, cctx);
3417
+ const room: Proposal = { width: Math.max(0, width - 2 * NAV_ITEM_MARGIN), height: null };
3418
+ for (const item of toolbar.children) height = Math.max(height ?? 0, this.measure(item, itemCtx, room).height);
3419
+ }
3420
+ return height;
3421
+ }
3422
+
3406
3423
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3407
3424
  const { safeArea, screen, dynamicTypeSize } = this.env;
3408
3425
  const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
@@ -3410,10 +3427,12 @@ export class LayoutPass {
3410
3427
  const depth = num(node.props['depth']) ?? 0;
3411
3428
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
3412
3429
  const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3413
- const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
3414
3430
  const parts = this.navScreenParts(node);
3415
3431
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3416
3432
  const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3433
+ // `.largeTitle` items size the large title row; the text title is 62.
3434
+ const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
3435
+ const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
3417
3436
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
3418
3437
  const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
3419
3438
  const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
@@ -3443,7 +3462,7 @@ export class LayoutPass {
3443
3462
  barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
3444
3463
  content,
3445
3464
  };
3446
- if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
3465
+ if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
3447
3466
  if (parts.search) {
3448
3467
  const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
3449
3468
  chrome['searchField'] = {
@@ -3711,9 +3730,11 @@ export class LayoutPass {
3711
3730
  const itemCtx = this.childContext(toolbar, cctx);
3712
3731
  const room: Proposal = { width: Math.max(0, row.width - 2 * NAV_ITEM_MARGIN), height: null };
3713
3732
  let cursor = row.x + NAV_ITEM_MARGIN;
3733
+ // The items' band: the row below its 10 top, as tall as the tallest item.
3734
+ const band = row.height - LARGE_TITLE_ITEM_TOP;
3714
3735
  for (const item of toolbar.children) {
3715
3736
  const itemSize = this.measure(item, itemCtx, room);
3716
- this.place(item, itemCtx, room, cursor, row.y + (row.height - itemSize.height) / 2);
3737
+ this.place(item, itemCtx, room, cursor, row.y + LARGE_TITLE_ITEM_TOP + (band - itemSize.height) / 2);
3717
3738
  cursor += itemSize.width + NAV_ITEM_SPACING;
3718
3739
  }
3719
3740
  this.setFrame(toolbar, row.x, row.y, { width: row.width, height: row.height });
package/src/layoutDom.ts CHANGED
@@ -944,6 +944,9 @@ function applyChrome(
944
944
  applyChartChrome(el, node, result, box);
945
945
  return;
946
946
  case 'listrow':
947
+ // A selectable or editable row is the list's row: the engine reads a
948
+ // badge through it and puts the badge's rect on it, so it draws it.
949
+ applyBadge(result, node, el, box);
947
950
  // Phase 8: the edit-mode control (selection circle / minus) at the
948
951
  // engine's `editControl` rect and the reorder grip at `reorderControl`;
949
952
  // hidden when the list is not editing.
package/src/renderer.ts CHANGED
@@ -59,6 +59,7 @@ import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
59
59
  import { bitmapStore, showBitmap } from './bitmaps';
60
60
  import { clearRichText, runTemplates } from './richText';
61
61
  import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
62
+ import { BarMorph } from './barMorph';
62
63
  import { ghostClone } from './ghost';
63
64
  import { ExternalDrops } from './dragDrop';
64
65
  import { InteractionController, type InteractionHost } from './interaction';
@@ -897,6 +898,13 @@ export class Renderer {
897
898
  revealScreen: (screen) => this.revealScreen(screen),
898
899
  isMenuOpen: (id) => this.menu?.anchorId === id,
899
900
  closeMenu: () => this.closeMenu(),
901
+ // iOS 26: the bar morphs with a swipe back (src/barMorph.ts).
902
+ barMorphBegin: (screen, below) => {
903
+ const stack = screen.closest<HTMLElement>('.sb-navstack');
904
+ if (stack) this.beginBarMorph(stack, screen, below, true);
905
+ },
906
+ barMorphScrub: (screen, progress) => this.swipeBackMorphOf(screen)?.scrub(progress),
907
+ barMorphSnapBack: (screen) => this.swipeBackMorphOf(screen)?.cancel(),
900
908
  };
901
909
  this.interaction = new InteractionController(host);
902
910
  // iOS 26: the glass under a pressed bar button swells and springs back (src/press.ts).
@@ -1070,6 +1078,7 @@ export class Renderer {
1070
1078
  this.root.dispatchEvent(new CustomEvent('sb:precommit', { detail: { commits: this.commits + 1 } }));
1071
1079
  const changes = this.layout();
1072
1080
  this.animator.commitNextFrame(pass, op.animation, changes);
1081
+ this.beginBarMorphsNextFrame();
1073
1082
  this.commits += 1;
1074
1083
  this.syncFocus();
1075
1084
  this.performScrolls(op.animation != null);
@@ -1153,6 +1162,8 @@ export class Renderer {
1153
1162
  /** Detach everything under the root and forget all non-root elements. */
1154
1163
  reset(): void {
1155
1164
  stopVideos(this.root);
1165
+ for (const morph of Array.from(this.barMorphs.values())) morph.dispose();
1166
+ this.pendingBarMorphs = [];
1156
1167
  this.root.replaceChildren();
1157
1168
  this.elements.clear();
1158
1169
  this.elements.set(ROOT_ID, this.root);
@@ -1316,6 +1327,55 @@ export class Renderer {
1316
1327
  */
1317
1328
  private instantPops = new Set<ElementId>();
1318
1329
 
1330
+ /**
1331
+ * iOS 26: one bar through a push or pop (src/barMorph.ts). The morph in
1332
+ * flight on each stack, and the pushes and pops this pass started, whose
1333
+ * morphs begin once the commit's layout has placed the screens.
1334
+ */
1335
+ private readonly barMorphs = new Map<HTMLElement, BarMorph>();
1336
+ private pendingBarMorphs: { stack: HTMLElement; from: HTMLElement; to: HTMLElement }[] = [];
1337
+
1338
+ /** Begins the morph of the bar of `from` into the bar of `to` on `stack`, in place of any in flight there. */
1339
+ private beginBarMorph(stack: HTMLElement, from: HTMLElement, to: HTMLElement, interactive: boolean): BarMorph | null {
1340
+ this.barMorphs.get(stack)?.dispose();
1341
+ if (!from.isConnected || !to.isConnected) return null;
1342
+ // (a push or pop that another in the same pass overtook: the later one's morph)
1343
+ if (!interactive && to.dataset['sbNavPosition'] !== 'top') return null;
1344
+ const morph = BarMorph.begin(stack, from, to, {
1345
+ duration: NAV_TRANSITION_MS,
1346
+ interactive,
1347
+ onDone: () => {
1348
+ if (this.barMorphs.get(stack) === morph) this.barMorphs.delete(stack);
1349
+ },
1350
+ });
1351
+ if (morph) this.barMorphs.set(stack, morph);
1352
+ return morph;
1353
+ }
1354
+
1355
+ /** The morph a swipe back of `screen` drives, if one is in flight. */
1356
+ private swipeBackMorphOf(screen: HTMLElement): BarMorph | null {
1357
+ const stack = screen.closest<HTMLElement>('.sb-navstack');
1358
+ const morph = stack ? this.barMorphs.get(stack) : undefined;
1359
+ return morph && morph.from === screen ? morph : null;
1360
+ }
1361
+
1362
+ /**
1363
+ * Begins the morphs of this pass's pushes and pops in the frame their
1364
+ * slides start, after the Animator's commit (`commitNextFrame`), from where
1365
+ * the layout has put the bars' parts; the last push or pop of a stack in
1366
+ * the pass is the one shown.
1367
+ */
1368
+ private beginBarMorphsNextFrame(): void {
1369
+ if (this.pendingBarMorphs.length === 0) return;
1370
+ const pending = new Map(this.pendingBarMorphs.map((entry) => [entry.stack, entry]));
1371
+ this.pendingBarMorphs = [];
1372
+ const begin = (): void => {
1373
+ for (const { stack, from, to } of pending.values()) this.beginBarMorph(stack, from, to, false);
1374
+ };
1375
+ if (typeof requestAnimationFrame !== 'function' || (typeof document !== 'undefined' && document.hidden)) begin();
1376
+ else requestAnimationFrame(begin);
1377
+ }
1378
+
1319
1379
  /**
1320
1380
  * The next removal of screen `id` is a pop the browser has already shown
1321
1381
  * (its back swipe or button, `browserHistory.ts`): the screen goes without
@@ -2186,7 +2246,14 @@ export class Renderer {
2186
2246
  this.engine?.invalidateDown(node);
2187
2247
  }
2188
2248
  if (el.classList.contains('sb-navscreen') && parent.classList.contains('sb-navstack')) {
2189
- if (container.children.length > 1 && previousParent !== container) animatePush(el);
2249
+ if (container.children.length > 1 && previousParent !== container) {
2250
+ animatePush(el);
2251
+ // iOS 26: the bar of the screen it covers morphs into its bar (at commit).
2252
+ const covered = el.previousElementSibling;
2253
+ if (covered instanceof HTMLElement && covered.classList.contains('sb-navscreen')) {
2254
+ this.pendingBarMorphs.push({ stack: parent, from: covered, to: el });
2255
+ }
2256
+ }
2190
2257
  refreshNavStack(parent);
2191
2258
  }
2192
2259
  if (previousParent) {
@@ -2233,6 +2300,10 @@ export class Renderer {
2233
2300
  const parent = el.parentElement;
2234
2301
  // Clones of what left this subtree earlier (a bar group's, src/animation.ts) go with it.
2235
2302
  this.animator.dropExitClonesIn(el);
2303
+ // So does the bar morph of a stack in it (its overlay is in the stack).
2304
+ for (const [stack, morph] of this.barMorphs) {
2305
+ if (stack === el || el.contains(stack)) morph.dispose();
2306
+ }
2236
2307
  const stack = parent?.classList.contains('sb-navstack-screens')
2237
2308
  ? parent.parentElement
2238
2309
  : null;
@@ -2240,9 +2311,19 @@ export class Renderer {
2240
2311
  // Phase 8: after a swipe back the clone starts where the drag left the
2241
2312
  // screen (`--sb-nav-from`), and the screen below is handed back to the
2242
2313
  // stylesheet so it finishes its slide to 0 as the new top screen.
2314
+ const uncovered = el.previousElementSibling as HTMLElement;
2243
2315
  this.interaction.screenRemoved(el);
2244
- if (this.instantPops.delete(id)) settleWithoutTransition(stack);
2245
- else animatePop(stack, el);
2316
+ const morph = this.barMorphs.get(stack);
2317
+ if (this.instantPops.delete(id)) {
2318
+ settleWithoutTransition(stack);
2319
+ morph?.dispose();
2320
+ } else {
2321
+ const clone = animatePop(stack, el);
2322
+ // iOS 26: its bar morphs into the uncovered screen's: on from where a
2323
+ // swipe back has it, else from the start, at commit.
2324
+ if (morph && morph.from === el) morph.popped(clone);
2325
+ else this.pendingBarMorphs.push({ stack, from: clone, to: uncovered });
2326
+ }
2246
2327
  }
2247
2328
  const isSheet = el.classList.contains('sb-sheet');
2248
2329
  if (isSheet && parent) animateSheetOut(parent, el);
@@ -3074,13 +3155,14 @@ function settleWithoutTransition(stack: HTMLElement): void {
3074
3155
  requestAnimationFrame(() => requestAnimationFrame(() => delete stack.dataset['sbInstant']));
3075
3156
  }
3076
3157
 
3077
- function animatePop(stack: HTMLElement, screen: HTMLElement): void {
3158
+ function animatePop(stack: HTMLElement, screen: HTMLElement): HTMLElement {
3078
3159
  const clone = ghostClone(screen);
3079
3160
  clone.removeAttribute('inert');
3080
3161
  clone.setAttribute('aria-hidden', 'true');
3081
3162
  clone.classList.add('sb-navscreen--pop');
3082
3163
  stack.append(clone);
3083
3164
  afterAnimation(clone, NAV_TRANSITION_MS, () => clone.remove());
3165
+ return clone;
3084
3166
  }
3085
3167
 
3086
3168
  // ---------------------------------------------------------------------------
package/src/styles.css CHANGED
@@ -1100,6 +1100,30 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1100
1100
  }
1101
1101
  }
1102
1102
 
1103
+ /* iOS 26: one bar through a push or pop (src/barMorph.ts). The morph is an
1104
+ overlay on the stack, above the screens and the pop clone, holding clones
1105
+ of both screens' bar parts (the capsules under the circles and the items)
1106
+ while the screens slide under it; the parts themselves are hidden for the
1107
+ slide. It takes no input. */
1108
+ .sb-navbar-morph {
1109
+ position: absolute;
1110
+ z-index: 3;
1111
+ pointer-events: none;
1112
+ user-select: none;
1113
+ }
1114
+
1115
+ /* Its clones of the circles and the items go over the capsule clones (z 2,
1116
+ as in a screen), as their toolbars (z 3) put them there. */
1117
+ .sb-navbar-morph > .sb-navbar-back,
1118
+ .sb-navbar-morph > .sb-navbar-more,
1119
+ .sb-navbar-morph > [data-sb-bar-item] {
1120
+ z-index: 3;
1121
+ }
1122
+
1123
+ [data-sb-bar-morph] {
1124
+ visibility: hidden;
1125
+ }
1126
+
1103
1127
  /* Navigation bar: the engine's `bar` rect (status bar + 44); the renderer
1104
1128
  pads the status bar height so the 44px row sits at the bottom. */
1105
1129
  .sb-navbar {
@@ -2597,13 +2621,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2597
2621
  }
2598
2622
  }
2599
2623
 
2600
- /* The exit clone of a bar item (src/animation.ts) is drawn as its toolbar
2624
+ /* The exit clone of a bar item (src/animation.ts), and an item's clone in
2625
+ the bar morph of a push or pop (src/barMorph.ts), is drawn as its toolbar
2601
2626
  drew it: the bar's symbol size and weight, the label color. */
2602
- .sb-exit-clone[data-sb-bar-item] {
2627
+ .sb-exit-clone[data-sb-bar-item],
2628
+ .sb-navbar-morph [data-sb-bar-item] {
2603
2629
  --sb-color-accent: var(--sb-color-primary);
2604
2630
  }
2605
2631
 
2606
- .sb-exit-clone[data-sb-bar-item] .sb-image > svg {
2632
+ .sb-exit-clone[data-sb-bar-item] .sb-image > svg,
2633
+ .sb-navbar-morph [data-sb-bar-item] .sb-image > svg {
2607
2634
  width: 1.57em;
2608
2635
  height: 1.57em;
2609
2636
  stroke-width: 1.85;
@@ -3037,9 +3064,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
3037
3064
  pointer-events: auto;
3038
3065
  }
3039
3066
 
3040
- :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],
3041
- :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 > *,
3042
- :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 {
3067
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-navbar-morph, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) [data-sb-kind],
3068
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-navbar-morph, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-navbar > *,
3069
+ :is([data-sb-hidden], [data-sb-disabled], [data-sb-hit-testing="false"], .sb-navscreen--pop, .sb-exit-clone, .sb-navbar-morph, .sb-sheet--out, .sb-alert--out, .sb-tab[data-sb-tab-hidden]) .sb-image[data-sb-web] > img {
3043
3070
  pointer-events: none !important;
3044
3071
  }
3045
3072