@swiftbrowser/web 0.2.0 → 0.3.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.
@@ -79,6 +79,12 @@ export interface Context {
79
79
  * from here.
80
80
  */
81
81
  safeTop: number;
82
+ /**
83
+ * The inset a sheet gives the bar of the navigation stack that fills it
84
+ * (its grabber row, or iOS 26's room above a sheet's bar), as the status
85
+ * bar does at the root. Only the stack's own screens see it.
86
+ */
87
+ barInset?: number;
82
88
  /**
83
89
  * Phase 12: the size of the nearest container (`containerRelativeFrame`):
84
90
  * the screen at the root, a scroll view's viewport inside one.
@@ -97,11 +103,23 @@ const DEFAULT_SPACER_MIN = 8;
97
103
  /** Phase 5: the square an `image` whose catalog name is unknown measures. */
98
104
  const UNKNOWN_IMAGE_SIDE = 44;
99
105
  const SHAPE_DEFAULT = 10;
100
- const BORDERED_PAD_V = 7;
101
- const BORDERED_PAD_H = 14;
106
+ // iOS 26 metrics (measured in the iOS 26.2 simulator, Examples/Fidelity): a
107
+ // bordered button is 34 tall (50 at `.controlSize(.large)`), a switch 63 × 28.
108
+ const BORDERED_PAD_V = 6;
109
+ const BORDERED_PAD_H = 12;
102
110
  const BORDERED_MIN_HEIGHT = 34;
103
- const SWITCH_WIDTH = 51;
104
- const SWITCH_HEIGHT = 31;
111
+ /**
112
+ * A bordered button's padding, least height and (smaller sizes) label text
113
+ * style per `controlSize` (props); `regular` is the constants above.
114
+ */
115
+ const BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
116
+ mini: { padV: 3, padH: 8, minHeight: 24, textStyle: 'footnote' },
117
+ small: { padV: 4, padH: 10, minHeight: 28, textStyle: 'subheadline' },
118
+ large: { padV: 14, padH: 20, minHeight: 50 },
119
+ extraLarge: { padV: 18, padH: 24, minHeight: 58 },
120
+ };
121
+ const SWITCH_WIDTH = 63;
122
+ const SWITCH_HEIGHT = 28;
105
123
  const TOGGLE_GAP = 8;
106
124
  const TEXTFIELD_DEFAULT_WIDTH = 200;
107
125
  const TEXTFIELD_HEIGHT_ROUNDED = 34;
@@ -109,25 +127,47 @@ const TEXTFIELD_HEIGHT_PLAIN = 22;
109
127
  /** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
110
128
  const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
111
129
  const NAV_BAR_HEIGHT = 44;
112
- const LARGE_TITLE_HEIGHT = 52;
130
+ // iOS 26: the large title's row is 62 (52 before).
131
+ const LARGE_TITLE_HEIGHT = 62;
113
132
  /** Chevron glyph plus its gap, in px. */
114
- const BACK_CHEVRON_WIDTH = 24;
115
133
  const SHEET_TOP_GAP = 10;
116
134
  const SHEET_GRABBER_ROW = 20;
117
135
  const SHEET_GRABBER: Size = { width: 36, height: 5 };
118
136
  const SHEET_GRABBER_TOP = 8;
137
+ // iOS 26: a sheet at a partial detent (medium, a height or a fraction)
138
+ // floats 8 from the screen's sides and 15 above its bottom; the medium card
139
+ // is 433 tall. Its grabber shows only when it has more than one detent; its
140
+ // content then starts 14 below the card's top.
141
+ const SHEET_FLOAT_INSET_H = 8;
142
+ const SHEET_FLOAT_INSET_BOTTOM = 15;
143
+ const SHEET_MEDIUM_HEIGHT = 433;
144
+ // iOS 26: without a grabber a sheet's content (and a navigation bar's row)
145
+ // starts 16 below its top.
146
+ const SHEET_CONTENT_TOP = 16;
119
147
  const LIST_INSET = 16;
120
148
  const LIST_CELL_PADDING = 16;
121
- const LIST_ROW_VPAD = 11;
122
- const LIST_ROW_MIN_HEIGHT = 44;
123
- const LIST_TOP = 20;
149
+ // iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity): rows are
150
+ // at least 52 tall with 13 above and below their content; a section's header
151
+ // is a sentence-case headline-sized line that starts 9 into the 35 gap
152
+ // between cards and ends 10 above its card.
153
+ const LIST_ROW_VPAD = 13;
154
+ const LIST_ROW_MIN_HEIGHT = 52;
155
+ const LIST_TOP = 18;
124
156
  const LIST_BOTTOM = 20;
125
- const LIST_HEADER_GAP = 8;
126
- const LIST_FOOTER_GAP = 8;
157
+ const LIST_HEADER_LIFT = 9;
158
+ const LIST_HEADER_GAP = 10;
159
+ const LIST_FOOTER_GAP = 6;
127
160
  const LIST_SECTION_GAP = 35;
128
161
  const LIST_CHEVRON_WIDTH = 20;
129
162
  // Phase 4.
130
- const TAB_BAR_HEIGHT = 49;
163
+ // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
164
+ // and 22 above the bottom edge (8 when the tab view ends above the screen
165
+ // bottom), its items in equal slots inside a 4 inset.
166
+ const TAB_BAR_HEIGHT = 62;
167
+ const TAB_BAR_MARGIN_H = 21;
168
+ const TAB_BAR_MARGIN_BOTTOM = 22;
169
+ const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
170
+ const TAB_BAR_ITEM_INSET = 4;
131
171
  const PICKER_SEGMENTED_HEIGHT = 32;
132
172
  const PICKER_SEGMENT_PAD = 20;
133
173
  const PICKER_SEGMENT_FONT_SIZE = 13;
@@ -139,6 +179,9 @@ const PICKER_MENU_GAP = 12;
139
179
  // Phase 6.
140
180
  const GAUGE_BAR_HEIGHT = 4;
141
181
  const GAUGE_LABEL_GAP = 4;
182
+ /** A ProgressView's label to its bar, and its bar to its current value label. */
183
+ const PROGRESS_LABEL_GAP = 8;
184
+ const PROGRESS_VALUE_GAP = 6;
142
185
  const GAUGE_DEFAULT_WIDTH = 100;
143
186
  /** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
144
187
  const GAUGE_RING_SIZE = 58;
@@ -153,12 +196,22 @@ const PICKER_INLINE_CHECK_WIDTH = 28;
153
196
  const BADGE_GAP = 8;
154
197
  const NAV_ITEM_SPACING = 16;
155
198
  const NAV_ITEM_MARGIN = 16;
199
+ // iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
200
+ // and the bar's leading and trailing items sit in glass capsules 44 tall,
201
+ // each item in a slot at least 44 wide (an icon: a circle; text: 12 either
202
+ // side), the groups 8 apart.
203
+ const NAV_GLASS_SIZE = 44;
204
+ const NAV_GLASS_PAD_H = 12;
205
+ const NAV_GROUP_SPACING = 8;
156
206
  const BOTTOM_BAR_HEIGHT = 49;
157
207
  const SEARCH_FIELD_HEIGHT = 36;
158
208
  const SEARCH_FIELD_MARGIN_H = 16;
159
- const SEARCH_FIELD_PAD_V = 8;
160
- /** 8 above the 36pt field and 8 below: what a search field adds to the bar. */
161
- const SEARCH_AREA_HEIGHT = SEARCH_FIELD_HEIGHT + 2 * SEARCH_FIELD_PAD_V;
209
+ // iOS 26: a navigation stack's search field is a 48-tall glass capsule
210
+ // floating 16 from the sides and 28 above the screen bottom (8 above a view
211
+ // that ends higher); the content stops above it.
212
+ const SEARCH_BOTTOM_HEIGHT = 48;
213
+ const SEARCH_BOTTOM_MARGIN = 28;
214
+ const SEARCH_BOTTOM_MARGIN_INSET = 8;
162
215
  const SEARCH_FIELD_DEFAULT_WIDTH = 200;
163
216
  const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
164
217
  // Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
@@ -172,10 +225,12 @@ const EPSILON = 1e-6;
172
225
  const STEPPER_WIDTH = 94;
173
226
  const STEPPER_HEIGHT = 32;
174
227
  const STEPPER_GAP = 8;
175
- /** A `slider` is 44 high (its label row, when present, sits above). */
176
- const SLIDER_HEIGHT = 44;
177
- const SLIDER_TRACK_HEIGHT = 4;
178
- const SLIDER_THUMB = 27;
228
+ /** A `slider` is 35 high (its label row, when present, sits above). */
229
+ // iOS 26: a 6pt track and a 38 × 24 pill knob in a 35 row.
230
+ const SLIDER_HEIGHT = 35;
231
+ const SLIDER_TRACK_HEIGHT = 6;
232
+ const SLIDER_THUMB = 38;
233
+ const SLIDER_THUMB_HEIGHT = 24;
179
234
  const SLIDER_DEFAULT_WIDTH = 200;
180
235
  /** Gap between a min / max value label and the track. */
181
236
  const SLIDER_LABEL_GAP = 8;
@@ -186,14 +241,26 @@ const DATEPICKER_CAPSULE_GAP = 8;
186
241
  const DATEPICKER_GAP = 8;
187
242
  /** A `texteditor` fills the proposal; 100 high when proposed nil. */
188
243
  const TEXTEDITOR_DEFAULT_HEIGHT = 100;
189
- /** `alert` style: a 270 wide centered card. */
190
- const ALERT_WIDTH = 270;
191
- const ALERT_PAD_TOP = 20;
192
- const ALERT_PAD_H = 16;
193
- const ALERT_PAD_BOTTOM = 20;
244
+ /**
245
+ * `alert` style (iOS 26): a 320 wide card centered in the safe area, its
246
+ * title and message leading-aligned 30 in, its buttons 46 tall capsules 16
247
+ * in from the card's sides, side by side 8 apart for two, stacked 8 apart
248
+ * otherwise.
249
+ */
250
+ const ALERT_WIDTH = 320;
251
+ const ALERT_PAD_TOP = 21;
252
+ const ALERT_PAD_H = 30;
253
+ const ALERT_BUTTON_INSET = 16;
254
+ const ALERT_BUTTONS_GAP = 20;
255
+ const ALERT_PAD_BOTTOM = 16;
256
+ const ALERT_BUTTON_SPACING = 8;
194
257
  /** Gap between the alert title and its message. */
195
- const ALERT_TITLE_GAP = 4;
196
- const ALERT_BUTTON_HEIGHT = 44;
258
+ const ALERT_TITLE_GAP = 6;
259
+ const ALERT_BUTTON_HEIGHT = 46;
260
+ /** Gap between an action sheet's title and its message. */
261
+ const DIALOG_TITLE_GAP = 4;
262
+ /** An action sheet's button labels are inset this much from its rows. */
263
+ const DIALOG_LABEL_INSET = 16;
197
264
  /** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
198
265
  const DIALOG_MARGIN = 8;
199
266
  const DIALOG_BUTTON_HEIGHT = 57;
@@ -643,6 +710,11 @@ function alignChild(container: Size, child: Size, alignment: string, node: Layou
643
710
  return offsets;
644
711
  }
645
712
 
713
+ /** A `Spacer` (a lazy stack's too): a stack sizes it after its other children. */
714
+ function isSpacer(node: LayoutNode): boolean {
715
+ return node.kind === 'spacer' || node.kind === 'lazyspacer';
716
+ }
717
+
646
718
  function safeOffset(container: number, child: number, anchor: number): number {
647
719
  if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
648
720
  return (container - child) * anchor;
@@ -844,6 +916,8 @@ interface GaugePlan {
844
916
  /** Height of the label row (0 without labels). */
845
917
  labelRow: number;
846
918
  labelCtx: Context;
919
+ /** A ProgressView's label context (the gauge's own font, not the caption). */
920
+ labelOwnCtx?: Context;
847
921
  /** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
848
922
  ring?: { center: LayoutNode | null; centerSize: Size };
849
923
  }
@@ -896,6 +970,12 @@ export class LayoutPass {
896
970
  readonly layouts = new Map<number, LayoutRecord>();
897
971
  /** Previous screen title per navscreen node, for the back button label. */
898
972
  private readonly previousTitles = new Map<LayoutNode, string>();
973
+ /**
974
+ * A large-title screen's root list or vertical scroll view starts under the
975
+ * bar and insets its content by the title row, which scrolls with it
976
+ * (iOS): the inset, by scroll node, set by `placeNavScreen`.
977
+ */
978
+ private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
899
979
 
900
980
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
901
981
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -947,9 +1027,12 @@ export class LayoutPass {
947
1027
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
948
1028
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
949
1029
  } else if (root.kind === 'sheet') {
950
- const proposal: Proposal = { width: screen.width, height: null };
1030
+ const floats = this.sheetFloats(root);
1031
+ const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
1032
+ const insetBottom = floats ? SHEET_FLOAT_INSET_BOTTOM : 0;
1033
+ const proposal: Proposal = { width: screen.width - 2 * insetH, height: null };
951
1034
  const size = this.measure(root, ctx, proposal);
952
- this.place(root, ctx, proposal, 0, screen.height - size.height);
1035
+ this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
953
1036
  } else {
954
1037
  const safe: Size = {
955
1038
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
@@ -1428,19 +1511,31 @@ export class LayoutPass {
1428
1511
 
1429
1512
  private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
1430
1513
  const label = this.contentOf(node);
1431
- const cctx = this.childContext(node, ctx);
1432
- if (!this.buttonIsBordered(node)) return label ? this.measure(label, cctx, p) : ZERO;
1514
+ if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
1515
+ const cctx = this.borderedLabelContext(node, ctx);
1516
+ const { padV, padH, minHeight } = this.borderedMetrics(node);
1433
1517
  const inner: Proposal = {
1434
- width: shrink(p.width, 2 * BORDERED_PAD_H),
1435
- height: shrink(p.height, 2 * BORDERED_PAD_V),
1518
+ width: shrink(p.width, 2 * padH),
1519
+ height: shrink(p.height, 2 * padV),
1436
1520
  };
1437
1521
  const size = label ? this.measure(label, cctx, inner) : ZERO;
1438
1522
  return {
1439
- width: size.width + 2 * BORDERED_PAD_H,
1440
- height: Math.max(BORDERED_MIN_HEIGHT, size.height + 2 * BORDERED_PAD_V),
1523
+ width: size.width + 2 * padH,
1524
+ height: Math.max(minHeight, size.height + 2 * padV),
1441
1525
  };
1442
1526
  }
1443
1527
 
1528
+ private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
1529
+ return BORDERED_SIZES[str(node.props['controlSize']) ?? ''] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
1530
+ }
1531
+
1532
+ /** A bordered button's label context: a small or mini one sets a smaller text style. */
1533
+ private borderedLabelContext(node: LayoutNode, ctx: Context): Context {
1534
+ const cctx = this.childContext(node, ctx);
1535
+ const textStyle = this.borderedMetrics(node).textStyle;
1536
+ return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
1537
+ }
1538
+
1444
1539
  private toggleLabelProposal(p: Proposal): Proposal {
1445
1540
  return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
1446
1541
  }
@@ -1473,7 +1568,7 @@ export class LayoutPass {
1473
1568
  const detents = node.props['detents'];
1474
1569
  const first = Array.isArray(detents) ? str(detents[0]) : null;
1475
1570
  const { screen, safeArea } = this.env;
1476
- if (first === 'medium') return screen.height / 2;
1571
+ if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
1477
1572
  // `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
1478
1573
  const large = screen.height - safeArea.top - SHEET_TOP_GAP;
1479
1574
  if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
@@ -1489,6 +1584,19 @@ export class LayoutPass {
1489
1584
  return Array.isArray(detents) && str(detents[0]) === 'full';
1490
1585
  }
1491
1586
 
1587
+ /** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
1588
+ private sheetFloats(node: LayoutNode): boolean {
1589
+ const detents = node.props['detents'];
1590
+ const first = Array.isArray(detents) ? str(detents[0]) : null;
1591
+ return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
1592
+ }
1593
+
1594
+ /** iOS 26 shows the grabber only for a sheet with more than one detent. */
1595
+ private sheetHasGrabber(node: LayoutNode): boolean {
1596
+ const detents = node.props['detents'];
1597
+ return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
1598
+ }
1599
+
1492
1600
  // -------------------------------------------------------------------------
1493
1601
  // Picker (Phase 4)
1494
1602
  // -------------------------------------------------------------------------
@@ -1615,11 +1723,15 @@ export class LayoutPass {
1615
1723
  * least flexible first. A child is offered `remaining / childrenLeft`, where
1616
1724
  * `remaining` excludes the minimum sizes of every lower-priority child and
1617
1725
  * `childrenLeft` counts only the unsized children of its own priority group
1618
- * with *finite* flexibility: infinitely flexible children (spacers, colors,
1726
+ * with *finite* flexibility: infinitely flexible children (colors,
1619
1727
  * `maxWidth: infinity` frames, text fields) only reserve their minimum and
1620
1728
  * share whatever is left once everything else is sized, which is why a
1621
- * `Spacer` never makes its sibling text wrap. With a `null` axis proposal
1622
- * every child is proposed `null` and the stack sizes to the sum.
1729
+ * `Spacer` never makes its sibling text wrap. Spacers come after those: they
1730
+ * reserve their minimum while the other infinitely flexible children share,
1731
+ * and take what is left (iOS gives `HStack { VStack { … }.frame(maxWidth:
1732
+ * .infinity); Spacer() }`'s stack all but the spacer's 8 points, where an
1733
+ * even split gave Ice Cubes' quoted posts half their card). With a `null`
1734
+ * axis proposal every child is proposed `null` and the stack sizes to the sum.
1623
1735
  */
1624
1736
  private layoutStack(
1625
1737
  children: readonly LayoutNode[],
@@ -1646,6 +1758,7 @@ export class LayoutPass {
1646
1758
  const mins = new Array<number>(n);
1647
1759
  const flex = new Array<number>(n);
1648
1760
  const priorities = new Array<number>(n);
1761
+ const spacers = children.map(isSpacer);
1649
1762
  const minProposal = makeProposal(axis, 0, cross);
1650
1763
  const maxProposal = makeProposal(axis, Infinity, cross);
1651
1764
  for (let i = 0; i < n; i++) {
@@ -1663,6 +1776,7 @@ export class LayoutPass {
1663
1776
  const fa = flex[a] as number;
1664
1777
  const fb = flex[b] as number;
1665
1778
  if (fa !== fb) return fa < fb ? -1 : 1;
1779
+ if (spacers[a] !== spacers[b]) return spacers[a] ? 1 : -1;
1666
1780
  return a - b;
1667
1781
  });
1668
1782
 
@@ -1679,10 +1793,15 @@ export class LayoutPass {
1679
1793
  let finiteLeft = 0;
1680
1794
  let infiniteLeft = 0;
1681
1795
  let infiniteMin = 0;
1796
+ let spacersLeft = 0;
1797
+ let spacerMin = 0;
1682
1798
  for (let k = i; k < end; k++) {
1683
1799
  const index = order[k] as number;
1684
1800
  if (Number.isFinite(flex[index])) finiteLeft++;
1685
- else {
1801
+ else if (spacers[index]) {
1802
+ spacersLeft++;
1803
+ spacerMin += mins[index] as number;
1804
+ } else {
1686
1805
  infiniteLeft++;
1687
1806
  infiniteMin += mins[index] as number;
1688
1807
  }
@@ -1690,16 +1809,21 @@ export class LayoutPass {
1690
1809
  for (let k = i; k < end; k++) {
1691
1810
  const index = order[k] as number;
1692
1811
  const isInfinite = !Number.isFinite(flex[index]);
1812
+ const isSpacerChild = isInfinite && spacers[index] === true;
1693
1813
  let offer: number;
1694
1814
  if (!Number.isFinite(remaining)) offer = Infinity;
1695
- else if (isInfinite) offer = Math.max(0, remaining - lowerMin) / infiniteLeft;
1696
- else offer = Math.max(0, remaining - lowerMin - infiniteMin) / finiteLeft;
1815
+ else if (isSpacerChild) offer = Math.max(0, remaining - lowerMin) / spacersLeft;
1816
+ else if (isInfinite) offer = Math.max(0, remaining - lowerMin - spacerMin) / infiniteLeft;
1817
+ else offer = Math.max(0, remaining - lowerMin - infiniteMin - spacerMin) / finiteLeft;
1697
1818
  const proposal = makeProposal(axis, offer, cross);
1698
1819
  const size = this.measure(children[index] as LayoutNode, cctx, proposal);
1699
1820
  childProposals[index] = proposal;
1700
1821
  childSizes[index] = size;
1701
1822
  if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
1702
- if (isInfinite) {
1823
+ if (isSpacerChild) {
1824
+ spacersLeft--;
1825
+ spacerMin -= mins[index] as number;
1826
+ } else if (isInfinite) {
1703
1827
  infiniteLeft--;
1704
1828
  infiniteMin -= mins[index] as number;
1705
1829
  } else finiteLeft--;
@@ -1989,7 +2113,7 @@ export class LayoutPass {
1989
2113
  // sections are full width. `plain` also drops the top/bottom gaps and
1990
2114
  // the space between sections.
1991
2115
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
1992
- // `.environment(\.defaultMinListRowHeight, _)`; 44 by default.
2116
+ // `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
1993
2117
  const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
1994
2118
  const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
1995
2119
  const plain = listStyle === 'plain';
@@ -2000,7 +2124,13 @@ export class LayoutPass {
2000
2124
  const cardWidth = Math.max(0, layoutWidth - 2 * inset);
2001
2125
  const contentX = inset + LIST_CELL_PADDING;
2002
2126
  const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
2127
+ // iOS 26: headers in the headline size (semibold), footers in footnote.
2003
2128
  const headerFont = makeFont(
2129
+ 'system',
2130
+ textStyleSize('headline', this.env.dynamicTypeSize),
2131
+ 600,
2132
+ );
2133
+ const footerFont = makeFont(
2004
2134
  'system',
2005
2135
  textStyleSize('footnote', this.env.dynamicTypeSize),
2006
2136
  400,
@@ -2026,22 +2156,24 @@ export class LayoutPass {
2026
2156
  else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
2027
2157
  }
2028
2158
  }
2029
- // View headers and footers: the content width, the footnote font.
2030
- const supplementCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
2031
- const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
2159
+ // View headers and footers: the content width, the header's and the
2160
+ // footer's font.
2161
+ const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
2162
+ const footerCtx: Context = { ...headerCtx, font: footerFont };
2163
+ const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
2032
2164
  if (!view) return null;
2033
2165
  const proposal: Proposal = { width: contentWidth, height: null };
2034
- const size = this.measure(view, supplementCtx, proposal);
2035
- return { node: view, ctx: supplementCtx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2166
+ const size = this.measure(view, ctx, proposal);
2167
+ return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2036
2168
  };
2037
2169
 
2038
2170
  const groups: ListGroupPlan[] = [];
2039
2171
  let y = listTop;
2040
- const makeText = (text: string): { layout: TextLayout; size: Size } => {
2041
- const wrapped = wrapText(text.toUpperCase(), headerFont, contentWidth, null, this.measurer);
2172
+ const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
2173
+ const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
2042
2174
  return {
2043
- layout: { font: headerFont, lines: wrapped.lines, alignment: 'leading', truncated: false },
2044
- size: { width: wrapped.width, height: wrapped.lines.length * headerFont.lineHeight },
2175
+ layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
2176
+ size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
2045
2177
  };
2046
2178
  };
2047
2179
  for (let g = 0; g < groupsSource.length; g++) {
@@ -2050,14 +2182,18 @@ export class LayoutPass {
2050
2182
  const top = y;
2051
2183
  let header: ListGroupPlan['header'] = null;
2052
2184
  let footer: ListGroupPlan['footer'] = null;
2053
- const headerView = supplement(source.headerView, y);
2054
- if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP;
2185
+ // A header sits in the gap above its card (from LIST_HEADER_LIFT
2186
+ // into it); the card then starts LIST_HEADER_GAP below the header.
2187
+ const headerLift = plain ? 0 : LIST_HEADER_LIFT;
2188
+ const headerView = supplement(source.headerView, y - headerLift, headerCtx);
2189
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
2055
2190
  const headerText = source.section ? str(source.section.props['header']) : null;
2056
2191
  const footerText = source.section ? str(source.section.props['footer']) : null;
2057
2192
  if (headerText !== null && headerText.length > 0) {
2058
- const { layout, size } = makeText(headerText);
2059
- header = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
2060
- y += size.height + LIST_HEADER_GAP;
2193
+ const { layout, size } = makeText(headerText, headerFont);
2194
+ const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
2195
+ header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
2196
+ y = headerY + size.height + LIST_HEADER_GAP;
2061
2197
  }
2062
2198
  const cardTop = y;
2063
2199
  const rows: ListRowPlan[] = [];
@@ -2068,12 +2204,14 @@ export class LayoutPass {
2068
2204
  rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
2069
2205
  continue;
2070
2206
  }
2071
- // A picker already reports its row height (44 for a menu, 44 per
2072
- // option inline); other rows get the 11px vertical padding. Phase 6
2207
+ // A menu or inline picker already reports its row height (44 for
2208
+ // a menu, 44 per option inline); other rows (a segmented picker
2209
+ // too) get the 13px vertical padding. Phase 6
2073
2210
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2074
2211
  // width (plus a gap) at the trailing edge.
2075
2212
  const hints = rowHintsOf(rowNode);
2076
- const defaultVPad = unwrapStyled(rowNode).kind === 'picker' ? 0 : LIST_ROW_VPAD;
2213
+ const unwrappedRow = unwrapStyled(rowNode);
2214
+ const defaultVPad = unwrappedRow.kind === 'picker' && str(unwrappedRow.props['style']) !== 'segmented' ? 0 : LIST_ROW_VPAD;
2077
2215
  const insets: EdgeInsets = hints.insets ?? {
2078
2216
  top: defaultVPad,
2079
2217
  leading: LIST_CELL_PADDING,
@@ -2116,11 +2254,11 @@ export class LayoutPass {
2116
2254
  const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
2117
2255
  if (footerText !== null && footerText.length > 0) {
2118
2256
  y += LIST_FOOTER_GAP;
2119
- const { layout, size } = makeText(footerText);
2257
+ const { layout, size } = makeText(footerText, footerFont);
2120
2258
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
2121
2259
  y += size.height;
2122
2260
  }
2123
- let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP);
2261
+ let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
2124
2262
  if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
2125
2263
  else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
2126
2264
  groups.push({
@@ -2265,13 +2403,15 @@ export class LayoutPass {
2265
2403
  this.place(label, cctx, p, x, y);
2266
2404
  return;
2267
2405
  }
2406
+ const metrics = this.borderedMetrics(node);
2407
+ const labelCtx = this.borderedLabelContext(node, ctx);
2268
2408
  const inner: Proposal = {
2269
- width: shrink(p.width, 2 * BORDERED_PAD_H),
2270
- height: shrink(p.height, 2 * BORDERED_PAD_V),
2409
+ width: shrink(p.width, 2 * metrics.padH),
2410
+ height: shrink(p.height, 2 * metrics.padV),
2271
2411
  };
2272
- const labelSize = this.measure(label, cctx, inner);
2412
+ const labelSize = this.measure(label, labelCtx, inner);
2273
2413
  const { dx, dy } = alignIn(size, labelSize, 'center');
2274
- this.place(label, cctx, inner, x + dx, y + dy);
2414
+ this.place(label, labelCtx, inner, x + dx, y + dy);
2275
2415
  // Keep the list row chrome (`row`, `badge`) a list set before placing.
2276
2416
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2277
2417
  this.setChrome(node, {
@@ -2339,14 +2479,19 @@ export class LayoutPass {
2339
2479
  // centered on the other axes when smaller than the viewport.
2340
2480
  const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2341
2481
  const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
2482
+ // Under a large title that scrolls with it, the content starts below it.
2483
+ const titleRect = axes.vertical ? this.titleInsets.get(node) : undefined;
2342
2484
  const content: Rect = {
2343
2485
  x: x + dx,
2344
- y: y + dy,
2486
+ y: y + dy + (titleRect?.height ?? 0),
2345
2487
  width: plan.contentSize.width,
2346
2488
  height: plan.contentSize.height,
2347
2489
  };
2348
2490
  const covered = this.coveredBottom(ctx, x, y, size);
2349
- this.setChrome(node, covered ? { content, bottomInset: covered } : { content });
2491
+ const scrollChrome: Record<string, Rect> = { content };
2492
+ if (covered) scrollChrome['bottomInset'] = covered;
2493
+ if (titleRect) scrollChrome['largeTitle'] = titleRect;
2494
+ this.setChrome(node, scrollChrome);
2350
2495
  if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2351
2496
  return;
2352
2497
  }
@@ -2486,9 +2631,16 @@ export class LayoutPass {
2486
2631
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2487
2632
  const plan = this.listPlan(node, ctx, p);
2488
2633
  const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2489
- if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
2490
2634
  const covered = this.coveredBottom(ctx, x, y, plan.size);
2491
- if (covered) this.setChrome(node, { bottomInset: covered });
2635
+ // Under a large title that scrolls with it, the rows start below it.
2636
+ const titleRect = this.titleInsets.get(node);
2637
+ const listChrome: Record<string, Rect> = {};
2638
+ if (covered) listChrome['bottomInset'] = covered;
2639
+ if (titleRect) listChrome['largeTitle'] = titleRect;
2640
+ if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
2641
+ const inset = titleRect?.height ?? 0;
2642
+ if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
2643
+ y += inset;
2492
2644
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
2493
2645
  for (const group of plan.groups) {
2494
2646
  if (group.section) {
@@ -2538,40 +2690,63 @@ export class LayoutPass {
2538
2690
  : undefined;
2539
2691
  this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
2540
2692
  this.place(row.node, rowCtx, row.proposal, rowX, rowY);
2693
+ // iOS 26: the separator under a row starts where its first text
2694
+ // starts (after a Label's icon), else at the row's content.
2695
+ const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
2696
+ if (placed) {
2697
+ const textX = this.firstTextX(row.node);
2698
+ const leading = (textX ?? rowX) - (x + row.row.x);
2699
+ placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
2700
+ }
2541
2701
  }
2542
2702
  }
2543
2703
  }
2544
2704
 
2705
+ /** The x of the first text (in tree order) placed under `node`, if any. */
2706
+ private firstTextX(node: LayoutNode): number | null {
2707
+ if (node.kind === 'text' && node.id >= 0) {
2708
+ const frame = this.frames.get(node.id);
2709
+ if (frame && frame.width > 0) return frame.x;
2710
+ }
2711
+ for (const child of node.children) {
2712
+ const x = this.firstTextX(child);
2713
+ if (x !== null) return x;
2714
+ }
2715
+ return null;
2716
+ }
2717
+
2545
2718
  /**
2546
- * The navigation bar is the status bar plus a 44 row (plus 52 for a large
2547
- * title, plus 52 for a Phase 6 `searchfield`); a `bottom` toolbar is a 49
2548
- * bar plus the bottom safe area; the content is proposed the rest. Toolbar
2549
- * items are measured at their ideal size and laid out as a 16-spaced hstack
2550
- * in the 44 row (`leading` after the back button, `trailing` 16 from the
2719
+ * The navigation bar is the status bar plus a 44 row (plus 62 for a large
2720
+ * title); a Phase 6 `searchfield` is a capsule floating above the bottom
2721
+ * (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
2722
+ * content is proposed the rest. Toolbar
2723
+ * items are measured at their ideal size and laid out in iOS 26 glass
2724
+ * capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
2551
2725
  * trailing edge, `principal` centered in place of the title) or spread
2552
2726
  * evenly across the bottom bar. The inline title (`chrome.title`) is
2553
2727
  * centered and truncated to the room the bar items leave.
2554
2728
  */
2555
2729
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2556
2730
  const { safeArea, screen, dynamicTypeSize } = this.env;
2557
- const statusBar = y <= 0 ? safeArea.top : 0;
2731
+ const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
2558
2732
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
2559
2733
  const depth = num(node.props['depth']) ?? 0;
2560
2734
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2561
2735
  const barHeight = statusBar + NAV_BAR_HEIGHT;
2562
2736
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2563
2737
  const parts = this.navScreenParts(node);
2564
- const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
2565
2738
  const reachesBottom = y + size.height >= screen.height - EPSILON;
2566
2739
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
2567
2740
  const bottomBarHeight =
2568
2741
  bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
2569
- const contentTop = barHeight + largeHeight + searchHeight;
2742
+ const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
2743
+ const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
2744
+ const contentTop = barHeight + largeHeight;
2570
2745
  const content: Rect = {
2571
2746
  x,
2572
2747
  y: y + contentTop,
2573
2748
  width: size.width,
2574
- height: Math.max(0, size.height - contentTop - bottomBarHeight),
2749
+ height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
2575
2750
  };
2576
2751
  const chrome: Record<string, Rect> = {
2577
2752
  bar: { x, y, width: size.width, height: barHeight },
@@ -2581,9 +2756,9 @@ export class LayoutPass {
2581
2756
  if (parts.search) {
2582
2757
  chrome['searchField'] = {
2583
2758
  x: x + SEARCH_FIELD_MARGIN_H,
2584
- y: y + barHeight + largeHeight + SEARCH_FIELD_PAD_V,
2759
+ y: y + size.height - searchBottom,
2585
2760
  width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
2586
- height: SEARCH_FIELD_HEIGHT,
2761
+ height: SEARCH_BOTTOM_HEIGHT,
2587
2762
  };
2588
2763
  }
2589
2764
  if (bottomBarHeight > 0) {
@@ -2600,60 +2775,56 @@ export class LayoutPass {
2600
2775
  toolbar: LayoutNode;
2601
2776
  placement: string;
2602
2777
  sizes: Size[];
2778
+ /** Leading and trailing groups: each item's glass slot width. */
2779
+ slots: number[] | null;
2603
2780
  total: number;
2604
2781
  }
2605
2782
  const groups: Group[] = parts.toolbars.map((toolbar) => {
2606
2783
  const itemCtx = this.childContext(toolbar, cctx);
2607
2784
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
2785
+ const placement = str(toolbar.props['placement']) ?? 'trailing';
2786
+ if (placement === 'leading' || placement === 'trailing') {
2787
+ const slots = sizes.map((s) => Math.max(NAV_GLASS_SIZE, s.width + 2 * NAV_GLASS_PAD_H));
2788
+ return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
2789
+ }
2608
2790
  const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
2609
- return { toolbar, placement: str(toolbar.props['placement']) ?? 'trailing', sizes, total };
2791
+ return { toolbar, placement, sizes, slots: null, total };
2610
2792
  });
2611
2793
  const groupWidth = (placement: string): number =>
2612
- groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_ITEM_SPACING, 0);
2794
+ groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_GROUP_SPACING, 0);
2613
2795
  const leadingWidth = groupWidth('leading');
2614
2796
  const trailingWidth = groupWidth('trailing');
2615
2797
  const principal = groups.some((g) => g.placement === 'principal');
2616
2798
 
2617
- // Back button, like iOS: the previous title when it fits beside the
2618
- // centered inline title, otherwise "Back", otherwise the chevron alone.
2619
- let backLabel = '';
2799
+ // Back button, iOS 26: the chevron alone in a glass circle (the previous
2800
+ // title no longer shows).
2801
+ const backLabel = '';
2620
2802
  if (depth > 0) {
2621
- const inlineTitleWidth = large || principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
2622
- const available =
2623
- (large ? size.width - BACK_CHEVRON_WIDTH - 16 - 44 : (size.width - inlineTitleWidth) / 2 - 8 - 12) - leadingWidth;
2624
- const candidates = [this.previousTitles.get(node) ?? '', 'Back', ''];
2625
- let width = BACK_CHEVRON_WIDTH + 8;
2626
- for (const candidate of candidates) {
2627
- const labelWidth = candidate ? this.measurer.measureWidth(candidate, backFont) : 0;
2628
- const needed = 8 + BACK_CHEVRON_WIDTH + labelWidth + 8;
2629
- if (candidate === '' || needed <= available) {
2630
- backLabel = candidate;
2631
- width = needed;
2632
- break;
2633
- }
2634
- }
2635
- chrome['back'] = { x: x + 8, y: rowY, width, height: NAV_BAR_HEIGHT };
2803
+ chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
2636
2804
  }
2637
2805
 
2638
2806
  // Place the toolbar groups and remember how much of the row they occupy.
2639
2807
  const back = chrome['back'];
2640
- let leadingCursor = back ? back.x + back.width + 8 : x + NAV_ITEM_MARGIN;
2808
+ let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
2641
2809
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2642
2810
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2643
2811
  const itemCtx = this.childContext(group.toolbar, cctx);
2644
2812
  let cursor = startX;
2645
2813
  group.toolbar.children.forEach((item, i) => {
2646
2814
  const itemSize = group.sizes[i] as Size;
2647
- this.place(item, itemCtx, { width: null, height: null }, cursor, top + (rowHeight - itemSize.height) / 2);
2648
- cursor += itemSize.width + spacing;
2815
+ // In a glass slot the item is centered in it.
2816
+ const slot = group.slots?.[i];
2817
+ const itemX = slot !== undefined ? cursor + (slot - itemSize.width) / 2 : cursor;
2818
+ this.place(item, itemCtx, { width: null, height: null }, itemX, top + (rowHeight - itemSize.height) / 2);
2819
+ cursor += (slot ?? itemSize.width) + spacing;
2649
2820
  });
2650
2821
  };
2651
2822
  for (const group of groups) {
2652
2823
  switch (group.placement) {
2653
2824
  case 'leading': {
2654
2825
  this.setFrame(group.toolbar, leadingCursor, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2655
- placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2656
- leadingCursor += group.total + NAV_ITEM_SPACING;
2826
+ placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
2827
+ leadingCursor += group.total + NAV_GROUP_SPACING;
2657
2828
  break;
2658
2829
  }
2659
2830
  case 'principal': {
@@ -2678,15 +2849,17 @@ export class LayoutPass {
2678
2849
  default: {
2679
2850
  const startX = trailingEdge - group.total;
2680
2851
  this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2681
- placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2682
- trailingEdge = startX - NAV_ITEM_SPACING;
2852
+ placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
2853
+ trailingEdge = startX - NAV_GROUP_SPACING;
2683
2854
  break;
2684
2855
  }
2685
2856
  }
2686
2857
  }
2687
2858
  if (parts.search) {
2859
+ // The field is the whole capsule (its own measure is a 36pt field).
2688
2860
  const field = chrome['searchField'] as Rect;
2689
2861
  this.place(parts.search, cctx, { width: field.width, height: field.height }, field.x, field.y);
2862
+ this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
2690
2863
  }
2691
2864
 
2692
2865
  // The inline title: centered, no wider than 56% of the bar, and clear of
@@ -2695,9 +2868,16 @@ export class LayoutPass {
2695
2868
  // takes up to a pixel off a fractional width at a fractional origin, and
2696
2869
  // the title truncates with an ellipsis ("Animati…") when its box is any
2697
2870
  // narrower than its text.
2698
- if (!large && !principal) {
2699
- const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_ITEM_SPACING - x : 0);
2700
- const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_ITEM_SPACING) : 0;
2871
+ const scroller = parts.content ? unwrapStyled(parts.content) : null;
2872
+ const titleScrolls =
2873
+ large &&
2874
+ scroller !== null &&
2875
+ (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
2876
+ // A large title that scrolls away collapses into the inline title, which
2877
+ // the renderer shows once it has (`data-sb-collapsed`).
2878
+ if ((!large || titleScrolls) && !principal) {
2879
+ const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
2880
+ const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
2701
2881
  const side = Math.max(leftUsed, rightUsed) + 8;
2702
2882
  const available = Math.max(0, size.width - 2 * side);
2703
2883
  const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
@@ -2731,8 +2911,17 @@ export class LayoutPass {
2731
2911
  bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2732
2912
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2733
2913
  // large title / search field), which is transparent over it.
2734
- const contentCtx: Context = { ...cctx, safeTop: content.y };
2735
- if (unwrapStyled(child).kind === 'list' || unwrapStyled(child).kind === 'scrollview') {
2914
+ const contentCtx: Context = { ...cctx, safeTop: content.y, barInset: 0 };
2915
+ if (scroller && titleScrolls) {
2916
+ // The scroll view starts under the bar; its content starts under the
2917
+ // large title, which moves with it.
2918
+ const top = content.y - largeHeight;
2919
+ this.titleInsets.set(scroller, chrome['largeTitle'] as Rect);
2920
+ this.place(child, contentCtx, { width: content.width, height: content.height + largeHeight }, content.x, top);
2921
+ return;
2922
+ }
2923
+ if (scroller) this.titleInsets.delete(scroller);
2924
+ if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
2736
2925
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2737
2926
  return;
2738
2927
  }
@@ -2744,25 +2933,38 @@ export class LayoutPass {
2744
2933
  }
2745
2934
 
2746
2935
  /**
2747
- * The tab bar is 49 plus the bottom safe area (when the tab view reaches
2748
- * the screen bottom), with one equal-width item per tab. Every `tab` gets
2749
- * the tab view's frame; only the selected one lays its content out, the
2750
- * others are reported in `hidden`.
2936
+ * The tab bar (iOS 26) is a glass capsule floating over the bottom of the
2937
+ * content (TAB_BAR_* above), with one equal-width item per tab; what it
2938
+ * covers (the capsule and the gap under it) is the content's bottom inset.
2939
+ * Every `tab` gets the tab view's frame; only the selected one lays its
2940
+ * content out, the others are reported in `hidden`.
2751
2941
  */
2752
2942
  private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2753
- const { screen, safeArea } = this.env;
2754
- const bottomSafe = y + size.height >= screen.height - EPSILON ? safeArea.bottom : 0;
2755
- const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + bottomSafe);
2756
- const bar: Rect = { x, y: y + size.height - barHeight, width: size.width, height: barHeight };
2943
+ const { screen } = this.env;
2944
+ const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
2945
+ const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
2946
+ const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
2947
+ const bar: Rect = {
2948
+ x: x + TAB_BAR_MARGIN_H,
2949
+ y: y + size.height - barHeight,
2950
+ width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
2951
+ height: capsuleHeight,
2952
+ };
2757
2953
  const chrome: Record<string, Rect> = {
2758
2954
  bar,
2759
2955
  content: { x, y, width: size.width, height: size.height - barHeight },
2760
2956
  };
2761
2957
  const tabs = node.children;
2762
2958
  const n = tabs.length;
2959
+ const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
2763
2960
  for (let i = 0; i < n; i++) {
2764
- const itemWidth = size.width / n;
2765
- chrome[`item${i}`] = { x: x + i * itemWidth, y: bar.y, width: itemWidth, height: Math.min(barHeight, TAB_BAR_HEIGHT) };
2961
+ const itemWidth = inner / n;
2962
+ chrome[`item${i}`] = {
2963
+ x: bar.x + TAB_BAR_ITEM_INSET + i * itemWidth,
2964
+ y: bar.y + TAB_BAR_ITEM_INSET,
2965
+ width: itemWidth,
2966
+ height: Math.max(0, capsuleHeight - 2 * TAB_BAR_ITEM_INSET),
2967
+ };
2766
2968
  }
2767
2969
  this.setChrome(node, chrome);
2768
2970
  const selected = readSelected(node, n);
@@ -3008,9 +3210,9 @@ export class LayoutPass {
3008
3210
  fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
3009
3211
  thumb: {
3010
3212
  x: thumbCenter - SLIDER_THUMB / 2,
3011
- y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB) / 2,
3213
+ y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
3012
3214
  width: SLIDER_THUMB,
3013
- height: SLIDER_THUMB,
3215
+ height: SLIDER_THUMB_HEIGHT,
3014
3216
  },
3015
3217
  });
3016
3218
  }
@@ -3080,9 +3282,10 @@ export class LayoutPass {
3080
3282
  }
3081
3283
 
3082
3284
  /**
3083
- * `alert` style: a 270 wide card centered on the screen with the title
3084
- * (headline), the message (footnote) and the buttons: two side by side,
3085
- * otherwise stacked, 44 each; no buttons → an `ok` rect the renderer draws.
3285
+ * `alert` style (iOS 26): a card centered in the safe area with the title
3286
+ * (headline) and the message (subheadline), leading-aligned, and capsule
3287
+ * buttons: two side by side (a `cancel` one first), otherwise stacked; no
3288
+ * buttons → an `ok` rect the renderer draws.
3086
3289
  * `dialog` style: an action sheet of 57 rows in a card 8 from the screen
3087
3290
  * edges, above a separate card for the `cancel` button, 8 above the bottom
3088
3291
  * safe area; the title and message are a footnote caption on top. Button
@@ -3098,40 +3301,45 @@ export class LayoutPass {
3098
3301
  const texts: Record<string, TextLayout> = {};
3099
3302
  const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
3100
3303
 
3101
- const centered = (text: string, font: ResolvedFont, width: number): { layout: TextLayout; height: number } => {
3304
+ const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
3102
3305
  const wrapped = wrapText(text, font, width, null, this.measurer);
3103
3306
  return {
3104
- layout: { font, lines: wrapped.lines, alignment: 'center', truncated: wrapped.truncated ?? false },
3307
+ layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
3105
3308
  height: wrapped.lines.length * font.lineHeight,
3106
3309
  };
3107
3310
  };
3311
+ const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
3108
3312
 
3109
3313
  if (style === 'alert') {
3110
3314
  const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
3111
3315
  const textWidth = cardWidth - 2 * ALERT_PAD_H;
3112
3316
  const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
3113
- const messageFont = resolveFont({ textStyle: 'footnote' }, ctx.font, this.env);
3317
+ const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
3114
3318
  // Heights first (the card is centered), then the rects.
3115
3319
  let h = ALERT_PAD_TOP;
3116
3320
  let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
3117
3321
  let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
3118
3322
  if (title.length > 0) {
3119
- const block = centered(title, titleFont, textWidth);
3120
- titleBlock = { ...block, top: h };
3121
- h += block.height;
3323
+ const b = block(title, titleFont, textWidth, 'leading');
3324
+ titleBlock = { ...b, top: h };
3325
+ h += b.height;
3122
3326
  }
3123
3327
  if (message.length > 0) {
3124
3328
  if (titleBlock) h += ALERT_TITLE_GAP;
3125
- const block = centered(message, messageFont, textWidth);
3126
- messageBlock = { ...block, top: h };
3127
- h += block.height;
3329
+ const b = block(message, messageFont, textWidth, 'leading');
3330
+ messageBlock = { ...b, top: h };
3331
+ h += b.height;
3128
3332
  }
3129
- h += ALERT_PAD_BOTTOM;
3333
+ h += ALERT_BUTTONS_GAP;
3130
3334
  const headerHeight = h;
3131
3335
  const buttons = node.children;
3132
3336
  const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
3133
- h += rows * ALERT_BUTTON_HEIGHT;
3134
- const card: Rect = { x: x + (size.width - cardWidth) / 2, y: y + (size.height - h) / 2, width: cardWidth, height: h };
3337
+ h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
3338
+ // Centered in the safe area, as iOS does.
3339
+ const { safeArea: inset } = this.env;
3340
+ const top = y + (y <= 0 ? inset.top : 0);
3341
+ const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
3342
+ const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
3135
3343
  chrome['card'] = card;
3136
3344
  if (titleBlock) {
3137
3345
  chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
@@ -3142,18 +3350,28 @@ export class LayoutPass {
3142
3350
  texts['message'] = messageBlock.layout;
3143
3351
  }
3144
3352
  const buttonsTop = card.y + headerHeight;
3353
+ const buttonsX = card.x + ALERT_BUTTON_INSET;
3354
+ const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
3145
3355
  if (buttons.length === 0) {
3146
- chrome['ok'] = { x: card.x, y: buttonsTop, width: cardWidth, height: ALERT_BUTTON_HEIGHT };
3356
+ chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
3147
3357
  } else if (buttons.length === 2) {
3148
- const half = cardWidth / 2;
3149
- buttons.forEach((button, i) => {
3150
- const rect: Rect = { x: card.x + i * half, y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
3358
+ // A `cancel` button goes first (leading), as iOS orders them.
3359
+ const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
3360
+ const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
3361
+ ordered.forEach((button, slot) => {
3362
+ const i = buttons.indexOf(button as LayoutNode);
3363
+ const rect: Rect = { x: buttonsX + slot * (half + ALERT_BUTTON_SPACING), y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
3151
3364
  chrome[`button${i}`] = rect;
3152
- this.placeAlertButton(button, cctx, rect);
3365
+ this.placeAlertButton(button as LayoutNode, cctx, rect);
3153
3366
  });
3154
3367
  } else {
3155
3368
  buttons.forEach((button, i) => {
3156
- const rect: Rect = { x: card.x, y: buttonsTop + i * ALERT_BUTTON_HEIGHT, width: cardWidth, height: ALERT_BUTTON_HEIGHT };
3369
+ const rect: Rect = {
3370
+ x: buttonsX,
3371
+ y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
3372
+ width: buttonsWidth,
3373
+ height: ALERT_BUTTON_HEIGHT,
3374
+ };
3157
3375
  chrome[`button${i}`] = rect;
3158
3376
  this.placeAlertButton(button, cctx, rect);
3159
3377
  });
@@ -3176,7 +3394,7 @@ export class LayoutPass {
3176
3394
  h += block.height;
3177
3395
  }
3178
3396
  if (message.length > 0) {
3179
- if (titleBlock) h += ALERT_TITLE_GAP;
3397
+ if (titleBlock) h += DIALOG_TITLE_GAP;
3180
3398
  const block = centered(message, messageFont, textWidth);
3181
3399
  messageBlock = { ...block, top: h };
3182
3400
  h += block.height;
@@ -3194,7 +3412,7 @@ export class LayoutPass {
3194
3412
  const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
3195
3413
  chrome['cancelCard'] = cancelCard;
3196
3414
  chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
3197
- this.placeAlertButton(cancel, buttonCtx, cancelCard);
3415
+ this.placeAlertButton(cancel, buttonCtx, cancelCard, true);
3198
3416
  cardBottom = cancelCard.y - DIALOG_CARD_GAP;
3199
3417
  }
3200
3418
  const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
@@ -3214,7 +3432,7 @@ export class LayoutPass {
3214
3432
  others.forEach((button, i) => {
3215
3433
  const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
3216
3434
  chrome[`button${node.children.indexOf(button)}`] = rect;
3217
- this.placeAlertButton(button, buttonCtx, rect);
3435
+ this.placeAlertButton(button, buttonCtx, rect, true);
3218
3436
  });
3219
3437
  }
3220
3438
  this.setChrome(node, chrome);
@@ -3222,10 +3440,11 @@ export class LayoutPass {
3222
3440
  }
3223
3441
 
3224
3442
  /** An alert button fills its row; its label is centered in it (any other child is centered as is). */
3225
- private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect): void {
3226
- const proposal: Proposal = { width: Math.max(0, rect.width - 2 * ALERT_PAD_H), height: rect.height };
3227
- // A `cancel` button is bold: measure (and so draw) its label semibold.
3228
- const cancel = str(unwrapStyled(child).props['role']) === 'cancel';
3443
+ private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect, boldCancel = false): void {
3444
+ const proposal: Proposal = { width: Math.max(0, rect.width - 2 * DIALOG_LABEL_INSET), height: rect.height };
3445
+ // An action sheet's `cancel` button is bold: measure (and so draw) its
3446
+ // label semibold. iOS 26 alerts draw it regular.
3447
+ const cancel = boldCancel && str(unwrapStyled(child).props['role']) === 'cancel';
3229
3448
  const inherited = this.childContext(child, ctx);
3230
3449
  const cctx: Context = {
3231
3450
  ...inherited,
@@ -3250,9 +3469,16 @@ export class LayoutPass {
3250
3469
  }
3251
3470
 
3252
3471
  private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3253
- // Phase 8: a `full` sheet (`.fullScreenCover`) has no grabber row.
3472
+ // A grabber gets its row; without one the content starts a little below
3473
+ // the sheet's top (iOS 26), except in a `full` one (Phase 8,
3474
+ // `.fullScreenCover`). A navigation stack fills the sheet from its top
3475
+ // instead, its bar taking that room (`barInset`), so its background
3476
+ // reaches the sheet's edge; a list or scroll view fills it below the
3477
+ // grabber row.
3478
+ const grabber = this.sheetHasGrabber(node);
3479
+ const floats = this.sheetFloats(node);
3254
3480
  const full = this.sheetIsFull(node);
3255
- const grabberRow = full ? 0 : SHEET_GRABBER_ROW;
3481
+ const grabberRow = grabber ? SHEET_GRABBER_ROW : full ? 0 : SHEET_CONTENT_TOP;
3256
3482
  const content: Rect = {
3257
3483
  x,
3258
3484
  y: y + grabberRow,
@@ -3260,7 +3486,8 @@ export class LayoutPass {
3260
3486
  height: Math.max(0, size.height - grabberRow),
3261
3487
  };
3262
3488
  const chrome: Record<string, Rect> = { content };
3263
- if (!full) {
3489
+ if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
3490
+ if (grabber) {
3264
3491
  chrome['grabber'] = {
3265
3492
  x: x + (size.width - SHEET_GRABBER.width) / 2,
3266
3493
  y: y + SHEET_GRABBER_TOP,
@@ -3273,8 +3500,14 @@ export class LayoutPass {
3273
3500
  if (!child) return;
3274
3501
  const proposal: Proposal = { width: content.width, height: content.height };
3275
3502
  const kind = unwrapStyled(child).kind;
3276
- if (kind === 'list' || kind === 'scrollview' || kind === 'navstack') {
3277
- this.place(child, cctx, proposal, content.x, content.y);
3503
+ if (kind === 'navstack') {
3504
+ this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
3505
+ return;
3506
+ }
3507
+ if (kind === 'list' || kind === 'scrollview') {
3508
+ // A scrolling view fills the sheet below the grabber row, if any.
3509
+ const top = grabber ? SHEET_GRABBER_ROW : 0;
3510
+ this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
3278
3511
  return;
3279
3512
  }
3280
3513
  const childSize = this.measure(child, cctx, proposal);
@@ -3642,6 +3875,15 @@ export class LayoutPass {
3642
3875
  return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
3643
3876
  }
3644
3877
  const proposal: Proposal = { width, height: null };
3878
+ if (style === 'progress') {
3879
+ // A ProgressView: the label in the context font above the bar, the
3880
+ // current value label (caption) under it.
3881
+ const labelSize = label ? this.measure(label, cctx, proposal) : ZERO;
3882
+ const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
3883
+ const labelRow = label ? labelSize.height + PROGRESS_LABEL_GAP : 0;
3884
+ const height = labelRow + GAUGE_BAR_HEIGHT + (current ? PROGRESS_VALUE_GAP + currentSize.height : 0);
3885
+ return { size: { width, height }, label, current, labelSize, currentSize, labelRow, labelCtx, labelOwnCtx: cctx };
3886
+ }
3645
3887
  const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
3646
3888
  const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
3647
3889
  const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
@@ -3665,6 +3907,17 @@ export class LayoutPass {
3665
3907
  return;
3666
3908
  }
3667
3909
  const proposal: Proposal = { width: size.width, height: null };
3910
+ if (str(node.props['style']) === 'progress') {
3911
+ if (plan.label) this.place(plan.label, plan.labelOwnCtx ?? plan.labelCtx, proposal, x, y);
3912
+ const barY = y + plan.labelRow;
3913
+ if (plan.current) this.place(plan.current, plan.labelCtx, proposal, x, barY + GAUGE_BAR_HEIGHT + PROGRESS_VALUE_GAP);
3914
+ const fraction = Math.min(1, Math.max(0, num(node.props['value']) ?? 0));
3915
+ this.setChrome(node, {
3916
+ track: { x, y: barY, width: size.width, height: GAUGE_BAR_HEIGHT },
3917
+ fill: { x, y: barY, width: size.width * fraction, height: GAUGE_BAR_HEIGHT },
3918
+ });
3919
+ return;
3920
+ }
3668
3921
  if (plan.label) {
3669
3922
  this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
3670
3923
  }