@swiftbrowser/web 0.5.6 → 0.6.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/src/layoutDom.ts CHANGED
@@ -34,11 +34,15 @@
34
34
  */
35
35
 
36
36
  import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
37
+ import { listRowBackgroundOf } from './layout';
38
+ import { styleToCSS } from './styles';
39
+ import type { ShapeStyle } from './protocol';
37
40
  import { applyChartChrome } from './chartDom';
38
41
  import { bitmapLaidOut } from './bitmaps';
39
42
  import { drawRichText } from './richText';
40
43
  import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
41
44
  import { bindScrollEdgeBottom } from './scrollEdge';
45
+ import { BOTTOM_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
42
46
 
43
47
  export interface LayoutDomContext {
44
48
  root: HTMLElement;
@@ -113,7 +117,7 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
113
117
  // A former menu item that is laid out again (moved out of the menu), or a
114
118
  // `viewthatfits` candidate chosen again, shows again.
115
119
  if (el.hidden) el.hidden = false;
116
- applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame));
120
+ applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
117
121
  if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
118
122
  }
119
123
 
@@ -260,22 +264,79 @@ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, orig
260
264
 
261
265
  /**
262
266
  * iOS 26: the glass a screen's bottom toolbar items float on, one element
263
- * per capsule (engine `bottomGlass0` …), under the items (styles.css).
267
+ * per capsule (engine `bottomGlass0` …), under the items (styles.css). A
268
+ * capsule is known by the bottom toolbars drawn on it (`data-sb-toolbars`:
269
+ * the ids of those whose frames lie in it), not by its index, so a group
270
+ * that goes takes its own capsule with it and the groups after it keep
271
+ * theirs. A capsule that moves or resizes is reported in `changes` (kind
272
+ * `bottomglass`, no id) so the Animator tweens it with the toolbar's items;
273
+ * one that appears on a screen already there, or goes, scales and fades as
274
+ * the items do (`TOOLBAR_TRANSITION`), in the toolbar's own spring.
264
275
  */
265
- function syncBottomGlass(screen: HTMLElement, chrome: Record<string, Rect> | undefined, frame: Rect): void {
266
- const existing = Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass'));
267
- let count = 0;
268
- for (let rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${count}`]) {
269
- let glass = existing[count];
270
- if (!glass) {
276
+ function syncBottomGlass(
277
+ result: LayoutResult,
278
+ node: LayoutNode,
279
+ screen: HTMLElement,
280
+ chrome: Record<string, Rect> | undefined,
281
+ frame: Rect,
282
+ screenIsNew: boolean,
283
+ changes: BoxChange[],
284
+ ): void {
285
+ const unmatched = new Set(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass:not([data-sb-exiting])'));
286
+ const toolbars = node.children.filter((child) => child.kind === 'toolbar' && child.props['placement'] === 'bottom');
287
+ for (let count = 0, rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${++count}`]) {
288
+ const capsule = rect;
289
+ const ids = toolbars.filter((toolbar) => centeredIn(result.frames.get(toolbar.id), capsule)).map((toolbar) => String(toolbar.id));
290
+ const relative = { x: rect.x - frame.x, y: rect.y - frame.y, width: rect.width, height: rect.height };
291
+ let glass = Array.from(unmatched).find((candidate) => (candidate.dataset['sbToolbars'] ?? '').split(' ').some((id) => ids.includes(id)));
292
+ if (glass) {
293
+ unmatched.delete(glass);
294
+ const from = glassBox(glass);
295
+ if (!sameRect(from, relative)) changes.push({ id: -1, el: glass, kind: BOTTOM_GLASS_KIND, from, to: relative });
296
+ } else {
271
297
  glass = document.createElement('div');
272
298
  glass.className = 'sb-bottom-glass';
273
299
  screen.append(glass);
300
+ if (!screenIsNew) transitionGlass(glass, false);
301
+ changes.push({ id: -1, el: glass, kind: BOTTOM_GLASS_KIND, from: null, to: relative });
274
302
  }
303
+ glass.dataset['sbToolbars'] = ids.join(' ');
275
304
  placeChrome(glass, rect, frame);
276
- count += 1;
277
305
  }
278
- for (const extra of existing.slice(count)) extra.remove();
306
+ for (const extra of unmatched) transitionGlass(extra, true);
307
+ }
308
+
309
+ /** Whether `rect`'s center lies in `area` (a toolbar's frame spans its items, all on one capsule). */
310
+ function centeredIn(rect: Rect | undefined, area: Rect): boolean {
311
+ if (!rect) return false;
312
+ const cx = rect.x + rect.width / 2;
313
+ const cy = rect.y + rect.height / 2;
314
+ return cx >= area.x && cx <= area.x + area.width && cy >= area.y && cy <= area.y + area.height;
315
+ }
316
+
317
+ /** The box a glass capsule was placed at (`placeChrome`), relative to its screen. */
318
+ function glassBox(glass: HTMLElement): Rect {
319
+ const s = glass.style;
320
+ return { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, width: parseFloat(s.width) || 0, height: parseFloat(s.height) || 0 };
321
+ }
322
+
323
+ /** Scales and fades a bottom bar capsule in, or out (then removes it), as its toolbar's items do. */
324
+ function transitionGlass(glass: HTMLElement, exit: boolean): void {
325
+ const timing = baseTiming(timingFor(TOOLBAR_ANIMATION));
326
+ const gone = transitionStart(TOOLBAR_TRANSITION, exit);
327
+ const frames: Keyframe[] = [
328
+ { opacity: gone.opacity ?? 0, transform: gone.transform ?? 'none' },
329
+ { opacity: 1, transform: 'none' },
330
+ ];
331
+ if (!exit) {
332
+ glass.animate(frames, { ...timing, fill: 'backwards' });
333
+ return;
334
+ }
335
+ glass.toggleAttribute('data-sb-exiting', true);
336
+ const out = glass.animate(frames.slice().reverse(), { ...timing, fill: 'forwards' });
337
+ const done = (): void => glass.remove();
338
+ out.finished.then(done, done);
339
+ window.setTimeout(done, timing.duration + timing.delay + 100);
279
340
  }
280
341
 
281
342
  function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
@@ -434,12 +495,23 @@ function applyChrome(
434
495
  box: Rect,
435
496
  frame: Rect,
436
497
  bottomInset: number,
498
+ isNew: boolean,
499
+ changes: BoxChange[],
437
500
  ): void {
438
501
  const chrome = result.chrome.get(node.id);
439
502
  // `.listRowSeparator(.hidden)`: a list row leaves out the separator below
440
503
  // it, or above it (the previous row's line; styles.css).
441
504
  setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
442
505
  setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
506
+ // `.listRowBackground(_:)`: the row's cell paint (styles.css reads it, and
507
+ // a card with such a row leaves its own background to its rows).
508
+ const rowBackground = chrome?.['row'] ? listRowBackgroundOf(node) : undefined;
509
+ const rowPaint = rowBackground !== undefined ? styleToCSS(rowBackground as ShapeStyle, 'paint').value : '';
510
+ if (el.style.getPropertyValue('--sb-row-background') !== rowPaint) {
511
+ if (rowPaint) el.style.setProperty('--sb-row-background', rowPaint);
512
+ else el.style.removeProperty('--sb-row-background');
513
+ }
514
+ setFlag(el, 'sbRowBackground', rowPaint ? '' : null);
443
515
  // iOS 26: the separator under a list row starts at its first text (styles.css).
444
516
  const separator = chrome?.['separator'];
445
517
  const leading = separator ? `${separator.x}px` : '';
@@ -598,10 +670,20 @@ function applyChrome(
598
670
  if (content && size) {
599
671
  const s = content.style;
600
672
  const axes = String(node.props['axes'] ?? 'vertical');
601
- s.width = `${size.width}px`;
673
+ // `.scrollClipDisabled()`: the frame reaches past the viewport the
674
+ // scroll view laid out in (chrome `viewport`), by `before` and
675
+ // `after`. The content keeps its place (its margin is `before`) and
676
+ // runs `after` further, so it scrolls as far as it did and snaps
677
+ // against the viewport's edges.
678
+ const viewport = chrome?.['viewport'];
679
+ const before = viewport ? viewport.x - frame.x : 0;
680
+ const after = viewport ? frame.x + frame.width - (viewport.x + viewport.width) : 0;
681
+ s.width = `${size.width + after}px`;
602
682
  s.height = `${size.height + (axes === 'horizontal' ? 0 : bottomInset)}px`;
603
683
  s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
604
684
  s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
685
+ el.style.scrollPaddingLeft = before ? `${before}px` : '';
686
+ el.style.scrollPaddingRight = after ? `${after}px` : '';
605
687
  }
606
688
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
607
689
  bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
@@ -623,7 +705,9 @@ function applyChrome(
623
705
  bar.style.paddingTop = `${top}px`;
624
706
  bar.style.paddingBottom = `${bottom}px`;
625
707
  }
626
- syncBottomGlass(el, chrome, frame);
708
+ syncBottomGlass(result, node, el, chrome, frame, isNew, changes);
709
+ // iOS 26 in a tab view: the search field under the title (styles.css).
710
+ setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
627
711
  const largeRect = chrome?.['largeTitle'];
628
712
  if (largeTitle) {
629
713
  if (largeRect) {
package/src/main.ts CHANGED
@@ -47,12 +47,14 @@ const buildStatus = $<HTMLElement>('#build-status');
47
47
 
48
48
  const params = new URLSearchParams(location.search);
49
49
  const useMock = params.get('mock') === '1';
50
- const requestedApp = (params.get('app') ?? 'Counter').replace(/[^A-Za-z0-9_-]/g, '') || 'Counter';
50
+ // The app: `?app=`, else the site's default (`define`, node/config.ts).
51
+ const requestedApp = (params.get('app') ?? __SB_DEFAULT_APP__).replace(/[^A-Za-z0-9_-]/g, '') || __SB_DEFAULT_APP__;
51
52
 
52
53
  // Frame mode (the fake iPhone bezel, desktop and tests) or device mode (a
53
- // real phone: full screen, real safe areas, OS status bar). See deviceMode.ts;
54
- // the inline script in app/index.html pre-set the attribute with the same rule.
55
- const pageMode: PageMode = decidePageMode();
54
+ // real phone: full screen, real safe areas, OS status bar; every screen of a
55
+ // site built for a deployment). See deviceMode.ts; the inline script in
56
+ // app/index.html pre-set the attribute with the same rule.
57
+ const pageMode: PageMode = decidePageMode(location.search, window, __SB_PAGE_MODE__);
56
58
  applyPageMode(pageMode);
57
59
  const deviceMode = pageMode === 'device';
58
60
 
package/src/press.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * iOS 26's press feedback on Liquid Glass: while a finger is on a bar button
3
+ * or a glass button, the glass under it swells (scales up a little) and
4
+ * brightens; when the finger lifts it springs back. The stylesheet draws it
5
+ * (`[data-sb-pressed]`, `[data-sb-pressed-item]` in styles.css); this
6
+ * controller marks the glass under the pressed control from the pointer
7
+ * down to the pointer up, as UIKit highlights a button while it tracks a
8
+ * touch: lifting the highlight when the pointer leaves the control (plus a
9
+ * margin) and restoring it when it comes back.
10
+ *
11
+ * The glass under a control: a bar item's shared capsule (a `leading` or
12
+ * `trailing` toolbar), a bottom bar item's `.sb-bottom-glass` (the one
13
+ * under it: the glass is beside the toolbar, not around it), the back and
14
+ * More circles, the sidebar toggle, a `.glass` button, or the `.glassEffect`
15
+ * box around a button. A control on no glass (a plain or bordered button)
16
+ * keeps the stylesheet's `:active` dimming.
17
+ */
18
+
19
+ /** The controls a press can highlight the glass under. */
20
+ const PRESSABLE = '.sb-button, .sb-menu, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-picker[data-sb-style="menu"]';
21
+
22
+ /** Controls that are their own glass. */
23
+ const OWN_GLASS = '.sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-button[data-sb-style="glass"]';
24
+
25
+ /** UIKit keeps a button highlighted while the touch stays within this far outside it. */
26
+ export const PRESS_SLOP = 24;
27
+
28
+ interface Press {
29
+ pointerId: number;
30
+ control: HTMLElement;
31
+ surface: HTMLElement;
32
+ /** Where the control was on the pointer down (its bounds plus the slop hold the press). */
33
+ bounds: DOMRect;
34
+ held: boolean;
35
+ }
36
+
37
+ export class PressEffects {
38
+ private press: Press | null = null;
39
+
40
+ constructor(private readonly root: HTMLElement) {
41
+ root.addEventListener('pointerdown', this.onPointerDown, true);
42
+ }
43
+
44
+ dispose(): void {
45
+ this.root.removeEventListener('pointerdown', this.onPointerDown, true);
46
+ this.release();
47
+ }
48
+
49
+ private readonly onPointerDown = (event: PointerEvent): void => {
50
+ if (this.press || !event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return;
51
+ const control = (event.target as Element | null)?.closest<HTMLElement>(PRESSABLE);
52
+ if (!control || !this.root.contains(control) || isDisabled(control)) return;
53
+ const surface = glassSurfaceOf(control, this.root);
54
+ if (!surface) return;
55
+ this.press = { pointerId: event.pointerId, control, surface, bounds: control.getBoundingClientRect(), held: false };
56
+ this.hold(true);
57
+ window.addEventListener('pointermove', this.onPointerMove);
58
+ window.addEventListener('pointerup', this.onPointerUp);
59
+ window.addEventListener('pointercancel', this.onPointerUp);
60
+ };
61
+
62
+ private readonly onPointerMove = (event: PointerEvent): void => {
63
+ const press = this.press;
64
+ if (!press || event.pointerId !== press.pointerId) return;
65
+ const b = press.bounds;
66
+ const inside =
67
+ event.clientX >= b.left - PRESS_SLOP &&
68
+ event.clientX <= b.right + PRESS_SLOP &&
69
+ event.clientY >= b.top - PRESS_SLOP &&
70
+ event.clientY <= b.bottom + PRESS_SLOP;
71
+ this.hold(inside);
72
+ };
73
+
74
+ private readonly onPointerUp = (event: PointerEvent): void => {
75
+ if (this.press && event.pointerId === this.press.pointerId) this.release();
76
+ };
77
+
78
+ /** Marks (or unmarks) the pressed glass and the control on it. */
79
+ private hold(held: boolean): void {
80
+ const press = this.press;
81
+ if (!press || press.held === held) return;
82
+ press.held = held;
83
+ press.surface.toggleAttribute('data-sb-pressed', held);
84
+ if (press.control !== press.surface) press.control.toggleAttribute('data-sb-pressed-item', held);
85
+ }
86
+
87
+ private release(): void {
88
+ this.hold(false);
89
+ this.press = null;
90
+ window.removeEventListener('pointermove', this.onPointerMove);
91
+ window.removeEventListener('pointerup', this.onPointerUp);
92
+ window.removeEventListener('pointercancel', this.onPointerUp);
93
+ }
94
+ }
95
+
96
+ /** A control the renderer disabled (`.disabled(true)` marks the element around it). */
97
+ function isDisabled(control: HTMLElement): boolean {
98
+ return control.closest('[data-sb-disabled]') !== null || control.getAttribute('aria-disabled') === 'true';
99
+ }
100
+
101
+ /**
102
+ * The Liquid Glass under `control`, or null when it sits on none (see the
103
+ * top of the file).
104
+ */
105
+ export function glassSurfaceOf(control: HTMLElement, root: HTMLElement): HTMLElement | null {
106
+ if (control.matches(OWN_GLASS)) return control;
107
+ const toolbar = control.closest<HTMLElement>('.sb-toolbar');
108
+ if (toolbar && root.contains(toolbar)) {
109
+ const placement = toolbar.dataset['sbPlacement'];
110
+ if (placement === 'leading' || placement === 'trailing') return toolbar;
111
+ if (placement === 'bottom') return bottomGlassUnder(control, toolbar);
112
+ return null;
113
+ }
114
+ const glass = control.closest<HTMLElement>('[data-sb-glass]');
115
+ return glass && root.contains(glass) ? glass : null;
116
+ }
117
+
118
+ /** The bottom bar capsule a bottom toolbar item is drawn on: the screen's `.sb-bottom-glass` under its center. */
119
+ function bottomGlassUnder(control: HTMLElement, toolbar: HTMLElement): HTMLElement | null {
120
+ const screen = toolbar.closest<HTMLElement>('.sb-navscreen');
121
+ if (!screen) return null;
122
+ const rect = control.getBoundingClientRect();
123
+ const cx = rect.left + rect.width / 2;
124
+ const cy = rect.top + rect.height / 2;
125
+ for (const glass of screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass')) {
126
+ const g = glass.getBoundingClientRect();
127
+ if (cx >= g.left && cx <= g.right && cy >= g.top && cy <= g.bottom) return glass;
128
+ }
129
+ return null;
130
+ }
package/src/protocol.ts CHANGED
@@ -22,6 +22,8 @@ export interface RGBAColor {
22
22
 
23
23
  export type SemanticColorName =
24
24
  | 'primary'
25
+ /** `.foreground` (ForegroundStyle): the color where it is drawn (CSS `currentColor`). */
26
+ | 'foreground'
25
27
  | 'secondary'
26
28
  | 'accent'
27
29
  | 'systemBackground'
@@ -396,6 +398,12 @@ export interface Style {
396
398
  * 11 vertical) for the list row this styled element is.
397
399
  */
398
400
  listRowInsets?: EdgeInsets;
401
+ /**
402
+ * `.listRowBackground(_:)` with a `Color` (or ShapeStyle): the list row this
403
+ * styled element is (or is in) has its cell painted with it instead of the
404
+ * card's; `clear` shows the list's background.
405
+ */
406
+ listRowBackground?: ShapeStyle;
399
407
  /**
400
408
  * `.imageScale(_:)`: symbol images in the subtree (`image` with
401
409
  * `systemName`, and catalog `symbol` images) measure 0.8 / 1 / 1.25 of the
@@ -700,6 +708,8 @@ export interface TabProps {
700
708
  badge?: string;
701
709
  /** iOS 18 `Tab(role: .search)` (absent otherwise); Swift already put it last. */
702
710
  role?: 'search';
711
+ /** A catalog image (a custom symbol) for the bar item when there is no `systemImage` (absent otherwise). */
712
+ image?: string;
703
713
  }
704
714
  export type PickerStyle = 'segmented' | 'menu' | 'inline';
705
715
  /**
@@ -728,6 +738,14 @@ export interface GeometryProps {
728
738
  * only, as before.
729
739
  */
730
740
  reportsFrame?: boolean;
741
+ /**
742
+ * The Swift view the size is reported to. Swift keeps the element when
743
+ * another view takes the reader's place, and changes this: the next layout
744
+ * pass then reports the size again, unchanged or not, since the new view
745
+ * has not heard it. Also on every other element that reports its size
746
+ * (`reportsSize`).
747
+ */
748
+ reader?: number;
731
749
  }
732
750
 
733
751
  // Phase 6 kinds (docs/phase6/layout-containers.md).
@@ -842,6 +860,8 @@ export type DecorationRole = 'overlay' | 'background';
842
860
  export interface DecoratedProps {
843
861
  role: DecorationRole;
844
862
  alignment: FrameAlignment;
863
+ /** The decoration is in an `if`: aligned by its edges, its `.alignmentGuide`s unused (absent otherwise). */
864
+ conditional?: boolean;
845
865
  }
846
866
  // Phase 6 kinds (docs/phase6/chrome.md).
847
867
 
@@ -1814,6 +1834,12 @@ export interface ScrollViewProps {
1814
1834
  refreshing?: boolean;
1815
1835
  /** Phase 12 `.scrollTargetBehavior`: CSS scroll snapping on the axis. */
1816
1836
  snap?: 'viewAligned' | 'paging';
1837
+ /**
1838
+ * `.scrollClipDisabled()`: a horizontal scroll view's frame reaches the
1839
+ * screen's sides (chrome `viewport` is its own rect), so its content draws
1840
+ * past it while scrolling and snapping as before.
1841
+ */
1842
+ clipDisabled?: boolean;
1817
1843
  }
1818
1844
 
1819
1845
  export interface Style {
package/src/renderer.ts CHANGED
@@ -35,6 +35,7 @@
35
35
 
36
36
  import {
37
37
  Animator,
38
+ barToolbarOf,
38
39
  EXIT_CLONE_CLASS,
39
40
  emptyPass,
40
41
  setTransition,
@@ -58,6 +59,7 @@ import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
58
59
  import { ghostClone } from './ghost';
59
60
  import { ExternalDrops } from './dragDrop';
60
61
  import { InteractionController, type InteractionHost } from './interaction';
62
+ import { PressEffects } from './press';
61
63
  import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
62
64
  import {
63
65
  INTERACTION_KINDS,
@@ -371,6 +373,7 @@ export class Renderer {
371
373
  * swipe back, the sheet drag, pull to refresh) and arbitrates between them.
372
374
  */
373
375
  readonly interaction: InteractionController;
376
+ private readonly pressEffects: PressEffects;
374
377
  /** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
375
378
  private readonly externalDrops: ExternalDrops;
376
379
  /** `video` elements' media events, as `video` events (src/video.ts). */
@@ -820,6 +823,8 @@ export class Renderer {
820
823
  closeMenu: () => this.closeMenu(),
821
824
  };
822
825
  this.interaction = new InteractionController(host);
826
+ // iOS 26: the glass under a pressed bar button swells and springs back (src/press.ts).
827
+ this.pressEffects = new PressEffects(root);
823
828
  this.externalDrops = new ExternalDrops(host);
824
829
  root.addEventListener('change', this.onChange);
825
830
  root.addEventListener('pointerdown', this.onSliderPointerDown);
@@ -1430,6 +1435,7 @@ export class Renderer {
1430
1435
  this.root.removeEventListener('focusout', this.onFocusChange);
1431
1436
  this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
1432
1437
  this.interaction.dispose();
1438
+ this.pressEffects.dispose();
1433
1439
  this.externalDrops.dispose();
1434
1440
  this.videoEvents.dispose();
1435
1441
  this.resizeObserver?.disconnect();
@@ -1573,7 +1579,14 @@ export class Renderer {
1573
1579
  }
1574
1580
  }
1575
1581
  if (node) {
1576
- node.props = asRecord(props);
1582
+ const next = asRecord(props);
1583
+ // Another Swift view now reads this element's size (a `geometry`
1584
+ // element kept for a new page's reader): it knows no size yet, so the
1585
+ // next layout pass reports the size even when it is unchanged.
1586
+ if ((kind === 'geometry' || next['reportsSize'] === true) && next['reader'] !== node.props['reader']) {
1587
+ this.reportedGeometry.delete(id);
1588
+ }
1589
+ node.props = next;
1577
1590
  // Phase 9: the next layout re-measures this subtree and its ancestors;
1578
1591
  // a screen's title also feeds the next screen's back label.
1579
1592
  if (node.kind === 'navscreen') {
@@ -1619,10 +1632,10 @@ export class Renderer {
1619
1632
  if (stack) refreshNavStack(stack);
1620
1633
  }
1621
1634
  if (kind === 'sheet' && el.parentElement) refreshSheets(el.parentElement);
1622
- if (kind === 'tabview') refreshTabBar(el);
1635
+ if (kind === 'tabview') refreshTabBar(el, this.assets);
1623
1636
  if (kind === 'tab') {
1624
1637
  const tabview = tabViewOf(el);
1625
- if (tabview) refreshTabBar(tabview);
1638
+ if (tabview) refreshTabBar(tabview, this.assets);
1626
1639
  }
1627
1640
  // The popover shows what its control had when it opened; drop it when
1628
1641
  // the control (or, for a `menu`, anything inside it) changes.
@@ -1697,9 +1710,9 @@ export class Renderer {
1697
1710
  // Phase 8: an alert fades in (and scales from 1.1) on its own.
1698
1711
  if (el.classList.contains('sb-alert') && !wasAttached) animateAlertIn(el);
1699
1712
  if (el.classList.contains('sb-tab')) {
1700
- if (parent.classList.contains('sb-tabview')) refreshTabBar(parent);
1713
+ if (parent.classList.contains('sb-tabview')) refreshTabBar(parent, this.assets);
1701
1714
  const oldTabView = previousParent && previousParent !== container ? tabViewOf(previousParent) : null;
1702
- if (oldTabView && oldTabView !== parent) refreshTabBar(oldTabView);
1715
+ if (oldTabView && oldTabView !== parent) refreshTabBar(oldTabView, this.assets);
1703
1716
  }
1704
1717
  }
1705
1718
 
@@ -1734,7 +1747,9 @@ export class Renderer {
1734
1747
  for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
1735
1748
  let rect: DOMRect | null = null;
1736
1749
  const matched: { id: string; el: HTMLElement; rect: DOMRect }[] = [];
1737
- if (this.passAnimates !== false) {
1750
+ // A bar toolbar's items (and the toolbar) leave with a transition of
1751
+ // their own in any pass (src/animation.ts, `implicitToolbars`).
1752
+ if (this.passAnimates !== false || barToolbarOf(el, parent)) {
1738
1753
  this.passRootRect ??= this.animator.rootRect();
1739
1754
  rect = this.animator.rectOf(el, this.passRootRect);
1740
1755
  // `.matchedGeometryEffect`: where the matched boxes in the subtree were.
@@ -1766,7 +1781,7 @@ export class Renderer {
1766
1781
  }
1767
1782
  if (stack) refreshNavStack(stack);
1768
1783
  if (isSheet && parent) refreshSheets(parent);
1769
- if (tabview) refreshTabBar(tabview);
1784
+ if (tabview) refreshTabBar(tabview, this.assets);
1770
1785
  }
1771
1786
 
1772
1787
  /** Forget an element in every map; detaches its node from the tree. */
@@ -2528,7 +2543,7 @@ function clampIndex(value: unknown, count: number): number {
2528
2543
  * the selected tab: only it is visible and interactive. The layout engine
2529
2544
  * confirms the hidden set at `commit`; this keeps the DOM right in between.
2530
2545
  */
2531
- function refreshTabBar(tabview: HTMLElement): void {
2546
+ function refreshTabBar(tabview: HTMLElement, assets: AssetCatalog): void {
2532
2547
  const bar = tabview.querySelector<HTMLElement>(':scope > .sb-tabbar');
2533
2548
  if (!bar) return;
2534
2549
  const tabs = Array.from(slotOf(tabview).children).filter((c) => c.classList.contains('sb-tab')) as HTMLElement[];
@@ -2552,14 +2567,17 @@ function refreshTabBar(tabview: HTMLElement): void {
2552
2567
  item.setAttribute('aria-label', title);
2553
2568
  item.toggleAttribute('data-sb-selected', isSelected);
2554
2569
  const icon = item.querySelector<HTMLElement>(':scope > .sb-tabbar-icon');
2555
- if (icon && icon.dataset['sbSymbol'] !== symbol) {
2570
+ const asset = symbol ? '' : (tab.dataset['sbAsset'] ?? '');
2571
+ if (icon && (icon.dataset['sbSymbol'] !== symbol || (icon.dataset['sbAsset'] ?? '') !== asset)) {
2556
2572
  icon.dataset['sbSymbol'] = symbol;
2573
+ if (asset) icon.dataset['sbAsset'] = asset;
2574
+ else delete icon.dataset['sbAsset'];
2557
2575
  // iOS draws tab bar symbols in their filled form.
2558
- const svg = symbol ? renderSymbol(filledSymbolName(symbol)) : null;
2559
- if (svg) icon.replaceChildren(svg);
2576
+ const glyph = symbol ? renderSymbol(filledSymbolName(symbol)) : asset ? tabAssetIcon(asset, assets) : null;
2577
+ if (glyph) icon.replaceChildren(glyph);
2560
2578
  else icon.replaceChildren();
2561
- icon.toggleAttribute('data-sb-missing', Boolean(symbol) && !svg);
2562
- icon.hidden = !symbol;
2579
+ icon.toggleAttribute('data-sb-missing', Boolean(symbol || asset) && !glyph);
2580
+ icon.hidden = !symbol && !asset;
2563
2581
  }
2564
2582
  const label = item.querySelector<HTMLElement>(':scope > .sb-tabbar-title');
2565
2583
  if (label && label.textContent !== title) label.textContent = title;
@@ -2575,6 +2593,29 @@ function refreshTabBar(tabview: HTMLElement): void {
2575
2593
  });
2576
2594
  }
2577
2595
 
2596
+ /**
2597
+ * A tab bar item's catalog icon: a custom symbol masks the item's color, in
2598
+ * its `.fill` variant when the catalog has one (as iOS draws SF Symbols in a
2599
+ * tab bar); any other image shows as it is. Null when the catalog has neither.
2600
+ */
2601
+ function tabAssetIcon(name: string, assets: AssetCatalog): Element | null {
2602
+ const asset = assets.images[`${name}.fill`]?.kind === 'symbol' ? assets.images[`${name}.fill`] : assets.images[name];
2603
+ if (!asset) return null;
2604
+ if (asset.kind === 'symbol') {
2605
+ const mask = document.createElement('span');
2606
+ mask.className = 'sb-tabbar-mask';
2607
+ const url = `url("${asset.src}")`;
2608
+ mask.style.setProperty('mask-image', url);
2609
+ mask.style.setProperty('-webkit-mask-image', url);
2610
+ return mask;
2611
+ }
2612
+ const img = document.createElement('img');
2613
+ img.src = asset.src;
2614
+ img.alt = '';
2615
+ img.draggable = false;
2616
+ return img;
2617
+ }
2618
+
2578
2619
  // ---------------------------------------------------------------------------
2579
2620
  // Sheets
2580
2621
  // ---------------------------------------------------------------------------
@@ -2867,9 +2908,11 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
2867
2908
  break;
2868
2909
  }
2869
2910
  case 'tab': {
2870
- const { title, systemImage, badge, role } = props as TabProps;
2911
+ const { title, systemImage, badge, role, image } = props as TabProps;
2871
2912
  el.dataset['sbTitle'] = title ?? '';
2872
2913
  el.dataset['sbSymbol'] = systemImage ?? '';
2914
+ if (image) el.dataset['sbAsset'] = image;
2915
+ else delete el.dataset['sbAsset'];
2873
2916
  if (badge) el.dataset['sbBadge'] = badge;
2874
2917
  else delete el.dataset['sbBadge'];
2875
2918
  if (role) el.dataset['sbRole'] = role;