@swiftbrowser/web 0.2.0 → 0.3.1

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,53 @@ 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
+ // A row holding just a toggle or a stepper has 10 above and below it (so
155
+ // both come out 52 tall); a segmented picker has 14 (60 tall).
156
+ const LIST_ROW_CONTROL_VPAD = 10;
157
+ const LIST_ROW_SEGMENTED_VPAD = 14;
158
+ const LIST_ROW_MIN_HEIGHT = 52;
159
+ const LIST_TOP = 18;
124
160
  const LIST_BOTTOM = 20;
125
- const LIST_HEADER_GAP = 8;
126
- const LIST_FOOTER_GAP = 8;
161
+ const LIST_HEADER_LIFT = 9;
162
+ const LIST_HEADER_GAP = 10;
163
+ const LIST_FOOTER_GAP = 6;
127
164
  const LIST_SECTION_GAP = 35;
165
+ // Below a section's footer the gap to the next section is 12 shorter.
166
+ const LIST_SECTION_GAP_AFTER_FOOTER = 23;
128
167
  const LIST_CHEVRON_WIDTH = 20;
129
168
  // Phase 4.
130
- const TAB_BAR_HEIGHT = 49;
169
+ // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
170
+ // and 22 above the bottom edge (8 when the tab view ends above the screen
171
+ // bottom), its items in equal slots inside a 4 inset.
172
+ const TAB_BAR_HEIGHT = 62;
173
+ const TAB_BAR_MARGIN_H = 21;
174
+ const TAB_BAR_MARGIN_BOTTOM = 22;
175
+ const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
176
+ const TAB_BAR_ITEM_INSET = 4;
131
177
  const PICKER_SEGMENTED_HEIGHT = 32;
132
178
  const PICKER_SEGMENT_PAD = 20;
133
179
  const PICKER_SEGMENT_FONT_SIZE = 13;
@@ -139,6 +185,9 @@ const PICKER_MENU_GAP = 12;
139
185
  // Phase 6.
140
186
  const GAUGE_BAR_HEIGHT = 4;
141
187
  const GAUGE_LABEL_GAP = 4;
188
+ /** A ProgressView's label to its bar, and its bar to its current value label. */
189
+ const PROGRESS_LABEL_GAP = 8;
190
+ const PROGRESS_VALUE_GAP = 6;
142
191
  const GAUGE_DEFAULT_WIDTH = 100;
143
192
  /** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
144
193
  const GAUGE_RING_SIZE = 58;
@@ -153,12 +202,22 @@ const PICKER_INLINE_CHECK_WIDTH = 28;
153
202
  const BADGE_GAP = 8;
154
203
  const NAV_ITEM_SPACING = 16;
155
204
  const NAV_ITEM_MARGIN = 16;
205
+ // iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
206
+ // and the bar's leading and trailing items sit in glass capsules 44 tall,
207
+ // each item in a slot at least 44 wide (an icon: a circle; text: 12 either
208
+ // side), the groups 8 apart.
209
+ const NAV_GLASS_SIZE = 44;
210
+ const NAV_GLASS_PAD_H = 12;
211
+ const NAV_GROUP_SPACING = 8;
156
212
  const BOTTOM_BAR_HEIGHT = 49;
157
213
  const SEARCH_FIELD_HEIGHT = 36;
158
214
  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;
215
+ // iOS 26: a navigation stack's search field is a 48-tall glass capsule
216
+ // floating 16 from the sides and 28 above the screen bottom (8 above a view
217
+ // that ends higher); the content stops above it.
218
+ const SEARCH_BOTTOM_HEIGHT = 48;
219
+ const SEARCH_BOTTOM_MARGIN = 28;
220
+ const SEARCH_BOTTOM_MARGIN_INSET = 8;
162
221
  const SEARCH_FIELD_DEFAULT_WIDTH = 200;
163
222
  const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
164
223
  // Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
@@ -172,10 +231,12 @@ const EPSILON = 1e-6;
172
231
  const STEPPER_WIDTH = 94;
173
232
  const STEPPER_HEIGHT = 32;
174
233
  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;
234
+ /** A `slider` is 35 high (its label row, when present, sits above). */
235
+ // iOS 26: a 6pt track and a 38 × 24 pill knob in a 35 row.
236
+ const SLIDER_HEIGHT = 35;
237
+ const SLIDER_TRACK_HEIGHT = 6;
238
+ const SLIDER_THUMB = 38;
239
+ const SLIDER_THUMB_HEIGHT = 24;
179
240
  const SLIDER_DEFAULT_WIDTH = 200;
180
241
  /** Gap between a min / max value label and the track. */
181
242
  const SLIDER_LABEL_GAP = 8;
@@ -186,14 +247,26 @@ const DATEPICKER_CAPSULE_GAP = 8;
186
247
  const DATEPICKER_GAP = 8;
187
248
  /** A `texteditor` fills the proposal; 100 high when proposed nil. */
188
249
  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;
250
+ /**
251
+ * `alert` style (iOS 26): a 320 wide card centered in the safe area, its
252
+ * title and message leading-aligned 30 in, its buttons 46 tall capsules 16
253
+ * in from the card's sides, side by side 8 apart for two, stacked 8 apart
254
+ * otherwise.
255
+ */
256
+ const ALERT_WIDTH = 320;
257
+ const ALERT_PAD_TOP = 21;
258
+ const ALERT_PAD_H = 30;
259
+ const ALERT_BUTTON_INSET = 16;
260
+ const ALERT_BUTTONS_GAP = 20;
261
+ const ALERT_PAD_BOTTOM = 16;
262
+ const ALERT_BUTTON_SPACING = 8;
194
263
  /** Gap between the alert title and its message. */
195
- const ALERT_TITLE_GAP = 4;
196
- const ALERT_BUTTON_HEIGHT = 44;
264
+ const ALERT_TITLE_GAP = 6;
265
+ const ALERT_BUTTON_HEIGHT = 46;
266
+ /** Gap between an action sheet's title and its message. */
267
+ const DIALOG_TITLE_GAP = 4;
268
+ /** An action sheet's button labels are inset this much from its rows. */
269
+ const DIALOG_LABEL_INSET = 16;
197
270
  /** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
198
271
  const DIALOG_MARGIN = 8;
199
272
  const DIALOG_BUTTON_HEIGHT = 57;
@@ -393,6 +466,19 @@ function cellStyle(node: LayoutNode, key: string): unknown {
393
466
  return undefined;
394
467
  }
395
468
 
469
+ /** The default vertical padding above and below a list row's content. */
470
+ function listRowVPad(row: LayoutNode): number {
471
+ switch (row.kind) {
472
+ case 'picker':
473
+ return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
474
+ case 'toggle':
475
+ case 'stepper':
476
+ return LIST_ROW_CONTROL_VPAD;
477
+ default:
478
+ return LIST_ROW_VPAD;
479
+ }
480
+ }
481
+
396
482
  /** `gridCellColumns` of a grid cell (at least 1). */
397
483
  function gridSpanOf(node: LayoutNode): number {
398
484
  const span = num(cellStyle(node, 'gridCellColumns'));
@@ -405,6 +491,20 @@ function gridAnchorOf(node: LayoutNode): [number, number] | null {
405
491
  return point ? [point.x, point.y] : null;
406
492
  }
407
493
 
494
+ /** `gridColumnAlignment` of a grid cell as a horizontal unit anchor. */
495
+ function gridColumnAnchorOf(node: LayoutNode): number | null {
496
+ switch (str(cellStyle(node, 'gridColumnAlignment'))) {
497
+ case 'leading':
498
+ return 0;
499
+ case 'center':
500
+ return 0.5;
501
+ case 'trailing':
502
+ return 1;
503
+ default:
504
+ return null;
505
+ }
506
+ }
507
+
408
508
  /** Vertical anchor of a `gridrow` alignment (null → the grid's). */
409
509
  function rowAnchor(alignment: string | null, gridAlignment: string): number {
410
510
  switch (alignment) {
@@ -643,6 +743,11 @@ function alignChild(container: Size, child: Size, alignment: string, node: Layou
643
743
  return offsets;
644
744
  }
645
745
 
746
+ /** A `Spacer` (a lazy stack's too): a stack sizes it after its other children. */
747
+ function isSpacer(node: LayoutNode): boolean {
748
+ return node.kind === 'spacer' || node.kind === 'lazyspacer';
749
+ }
750
+
646
751
  function safeOffset(container: number, child: number, anchor: number): number {
647
752
  if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
648
753
  return (container - child) * anchor;
@@ -820,6 +925,8 @@ interface GridPlan {
820
925
  size: Size;
821
926
  columns: number[];
822
927
  columnX: number[];
928
+ /** Horizontal unit anchor per column from a cell's `gridColumnAlignment` (null → the grid's). */
929
+ columnAnchors: (number | null)[];
823
930
  rows: GridRowPlan[];
824
931
  }
825
932
 
@@ -844,6 +951,8 @@ interface GaugePlan {
844
951
  /** Height of the label row (0 without labels). */
845
952
  labelRow: number;
846
953
  labelCtx: Context;
954
+ /** A ProgressView's label context (the gauge's own font, not the caption). */
955
+ labelOwnCtx?: Context;
847
956
  /** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
848
957
  ring?: { center: LayoutNode | null; centerSize: Size };
849
958
  }
@@ -896,6 +1005,12 @@ export class LayoutPass {
896
1005
  readonly layouts = new Map<number, LayoutRecord>();
897
1006
  /** Previous screen title per navscreen node, for the back button label. */
898
1007
  private readonly previousTitles = new Map<LayoutNode, string>();
1008
+ /**
1009
+ * A large-title screen's root list or vertical scroll view starts under the
1010
+ * bar and insets its content by the title row, which scrolls with it
1011
+ * (iOS): the inset, by scroll node, set by `placeNavScreen`.
1012
+ */
1013
+ private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
899
1014
 
900
1015
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
901
1016
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -947,9 +1062,12 @@ export class LayoutPass {
947
1062
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
948
1063
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
949
1064
  } else if (root.kind === 'sheet') {
950
- const proposal: Proposal = { width: screen.width, height: null };
1065
+ const floats = this.sheetFloats(root);
1066
+ const insetH = floats ? SHEET_FLOAT_INSET_H : 0;
1067
+ const insetBottom = floats ? SHEET_FLOAT_INSET_BOTTOM : 0;
1068
+ const proposal: Proposal = { width: screen.width - 2 * insetH, height: null };
951
1069
  const size = this.measure(root, ctx, proposal);
952
- this.place(root, ctx, proposal, 0, screen.height - size.height);
1070
+ this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
953
1071
  } else {
954
1072
  const safe: Size = {
955
1073
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
@@ -1227,8 +1345,15 @@ export class LayoutPass {
1227
1345
  height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
1228
1346
  };
1229
1347
  }
1230
- case 'color':
1231
1348
  case 'shape':
1349
+ if (node.props['shape'] === 'circle') {
1350
+ // SwiftUI's `Circle` is the largest square in the proposal (so in a
1351
+ // stack it gives the leftover width to its siblings).
1352
+ // An open axis counts as SHAPE_DEFAULT, as for the other shapes.
1353
+ return square(Math.min(p.width ?? SHAPE_DEFAULT, p.height ?? SHAPE_DEFAULT));
1354
+ }
1355
+ return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1356
+ case 'color':
1232
1357
  case 'canvas':
1233
1358
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1234
1359
  case 'video':
@@ -1428,19 +1553,31 @@ export class LayoutPass {
1428
1553
 
1429
1554
  private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
1430
1555
  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;
1556
+ if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
1557
+ const cctx = this.borderedLabelContext(node, ctx);
1558
+ const { padV, padH, minHeight } = this.borderedMetrics(node);
1433
1559
  const inner: Proposal = {
1434
- width: shrink(p.width, 2 * BORDERED_PAD_H),
1435
- height: shrink(p.height, 2 * BORDERED_PAD_V),
1560
+ width: shrink(p.width, 2 * padH),
1561
+ height: shrink(p.height, 2 * padV),
1436
1562
  };
1437
1563
  const size = label ? this.measure(label, cctx, inner) : ZERO;
1438
1564
  return {
1439
- width: size.width + 2 * BORDERED_PAD_H,
1440
- height: Math.max(BORDERED_MIN_HEIGHT, size.height + 2 * BORDERED_PAD_V),
1565
+ width: size.width + 2 * padH,
1566
+ height: Math.max(minHeight, size.height + 2 * padV),
1441
1567
  };
1442
1568
  }
1443
1569
 
1570
+ private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
1571
+ return BORDERED_SIZES[str(node.props['controlSize']) ?? ''] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
1572
+ }
1573
+
1574
+ /** A bordered button's label context: a small or mini one sets a smaller text style. */
1575
+ private borderedLabelContext(node: LayoutNode, ctx: Context): Context {
1576
+ const cctx = this.childContext(node, ctx);
1577
+ const textStyle = this.borderedMetrics(node).textStyle;
1578
+ return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
1579
+ }
1580
+
1444
1581
  private toggleLabelProposal(p: Proposal): Proposal {
1445
1582
  return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
1446
1583
  }
@@ -1473,7 +1610,7 @@ export class LayoutPass {
1473
1610
  const detents = node.props['detents'];
1474
1611
  const first = Array.isArray(detents) ? str(detents[0]) : null;
1475
1612
  const { screen, safeArea } = this.env;
1476
- if (first === 'medium') return screen.height / 2;
1613
+ if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
1477
1614
  // `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
1478
1615
  const large = screen.height - safeArea.top - SHEET_TOP_GAP;
1479
1616
  if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
@@ -1489,6 +1626,19 @@ export class LayoutPass {
1489
1626
  return Array.isArray(detents) && str(detents[0]) === 'full';
1490
1627
  }
1491
1628
 
1629
+ /** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
1630
+ private sheetFloats(node: LayoutNode): boolean {
1631
+ const detents = node.props['detents'];
1632
+ const first = Array.isArray(detents) ? str(detents[0]) : null;
1633
+ return first === 'medium' || first?.startsWith('height:') === true || first?.startsWith('fraction:') === true;
1634
+ }
1635
+
1636
+ /** iOS 26 shows the grabber only for a sheet with more than one detent. */
1637
+ private sheetHasGrabber(node: LayoutNode): boolean {
1638
+ const detents = node.props['detents'];
1639
+ return Array.isArray(detents) && detents.length > 1 && !this.sheetIsFull(node);
1640
+ }
1641
+
1492
1642
  // -------------------------------------------------------------------------
1493
1643
  // Picker (Phase 4)
1494
1644
  // -------------------------------------------------------------------------
@@ -1615,11 +1765,15 @@ export class LayoutPass {
1615
1765
  * least flexible first. A child is offered `remaining / childrenLeft`, where
1616
1766
  * `remaining` excludes the minimum sizes of every lower-priority child and
1617
1767
  * `childrenLeft` counts only the unsized children of its own priority group
1618
- * with *finite* flexibility: infinitely flexible children (spacers, colors,
1768
+ * with *finite* flexibility: infinitely flexible children (colors,
1619
1769
  * `maxWidth: infinity` frames, text fields) only reserve their minimum and
1620
1770
  * 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.
1771
+ * `Spacer` never makes its sibling text wrap. Spacers come after those: they
1772
+ * reserve their minimum while the other infinitely flexible children share,
1773
+ * and take what is left (iOS gives `HStack { VStack { … }.frame(maxWidth:
1774
+ * .infinity); Spacer() }`'s stack all but the spacer's 8 points, where an
1775
+ * even split gave Ice Cubes' quoted posts half their card). With a `null`
1776
+ * axis proposal every child is proposed `null` and the stack sizes to the sum.
1623
1777
  */
1624
1778
  private layoutStack(
1625
1779
  children: readonly LayoutNode[],
@@ -1646,6 +1800,7 @@ export class LayoutPass {
1646
1800
  const mins = new Array<number>(n);
1647
1801
  const flex = new Array<number>(n);
1648
1802
  const priorities = new Array<number>(n);
1803
+ const spacers = children.map(isSpacer);
1649
1804
  const minProposal = makeProposal(axis, 0, cross);
1650
1805
  const maxProposal = makeProposal(axis, Infinity, cross);
1651
1806
  for (let i = 0; i < n; i++) {
@@ -1663,6 +1818,7 @@ export class LayoutPass {
1663
1818
  const fa = flex[a] as number;
1664
1819
  const fb = flex[b] as number;
1665
1820
  if (fa !== fb) return fa < fb ? -1 : 1;
1821
+ if (spacers[a] !== spacers[b]) return spacers[a] ? 1 : -1;
1666
1822
  return a - b;
1667
1823
  });
1668
1824
 
@@ -1679,10 +1835,15 @@ export class LayoutPass {
1679
1835
  let finiteLeft = 0;
1680
1836
  let infiniteLeft = 0;
1681
1837
  let infiniteMin = 0;
1838
+ let spacersLeft = 0;
1839
+ let spacerMin = 0;
1682
1840
  for (let k = i; k < end; k++) {
1683
1841
  const index = order[k] as number;
1684
1842
  if (Number.isFinite(flex[index])) finiteLeft++;
1685
- else {
1843
+ else if (spacers[index]) {
1844
+ spacersLeft++;
1845
+ spacerMin += mins[index] as number;
1846
+ } else {
1686
1847
  infiniteLeft++;
1687
1848
  infiniteMin += mins[index] as number;
1688
1849
  }
@@ -1690,16 +1851,21 @@ export class LayoutPass {
1690
1851
  for (let k = i; k < end; k++) {
1691
1852
  const index = order[k] as number;
1692
1853
  const isInfinite = !Number.isFinite(flex[index]);
1854
+ const isSpacerChild = isInfinite && spacers[index] === true;
1693
1855
  let offer: number;
1694
1856
  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;
1857
+ else if (isSpacerChild) offer = Math.max(0, remaining - lowerMin) / spacersLeft;
1858
+ else if (isInfinite) offer = Math.max(0, remaining - lowerMin - spacerMin) / infiniteLeft;
1859
+ else offer = Math.max(0, remaining - lowerMin - infiniteMin - spacerMin) / finiteLeft;
1697
1860
  const proposal = makeProposal(axis, offer, cross);
1698
1861
  const size = this.measure(children[index] as LayoutNode, cctx, proposal);
1699
1862
  childProposals[index] = proposal;
1700
1863
  childSizes[index] = size;
1701
1864
  if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
1702
- if (isInfinite) {
1865
+ if (isSpacerChild) {
1866
+ spacersLeft--;
1867
+ spacerMin -= mins[index] as number;
1868
+ } else if (isInfinite) {
1703
1869
  infiniteLeft--;
1704
1870
  infiniteMin -= mins[index] as number;
1705
1871
  } else finiteLeft--;
@@ -1989,7 +2155,7 @@ export class LayoutPass {
1989
2155
  // sections are full width. `plain` also drops the top/bottom gaps and
1990
2156
  // the space between sections.
1991
2157
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
1992
- // `.environment(\.defaultMinListRowHeight, _)`; 44 by default.
2158
+ // `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
1993
2159
  const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
1994
2160
  const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
1995
2161
  const plain = listStyle === 'plain';
@@ -2000,7 +2166,13 @@ export class LayoutPass {
2000
2166
  const cardWidth = Math.max(0, layoutWidth - 2 * inset);
2001
2167
  const contentX = inset + LIST_CELL_PADDING;
2002
2168
  const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
2169
+ // iOS 26: headers in the headline size (semibold), footers in footnote.
2003
2170
  const headerFont = makeFont(
2171
+ 'system',
2172
+ textStyleSize('headline', this.env.dynamicTypeSize),
2173
+ 600,
2174
+ );
2175
+ const footerFont = makeFont(
2004
2176
  'system',
2005
2177
  textStyleSize('footnote', this.env.dynamicTypeSize),
2006
2178
  400,
@@ -2026,38 +2198,48 @@ export class LayoutPass {
2026
2198
  else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
2027
2199
  }
2028
2200
  }
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 => {
2201
+ // View headers and footers: the content width, the header's and the
2202
+ // footer's font.
2203
+ const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
2204
+ const footerCtx: Context = { ...headerCtx, font: footerFont };
2205
+ const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
2032
2206
  if (!view) return null;
2033
2207
  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 } };
2208
+ const size = this.measure(view, ctx, proposal);
2209
+ return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2036
2210
  };
2037
2211
 
2038
2212
  const groups: ListGroupPlan[] = [];
2039
2213
  let y = listTop;
2040
- const makeText = (text: string): { layout: TextLayout; size: Size } => {
2041
- const wrapped = wrapText(text.toUpperCase(), headerFont, contentWidth, null, this.measurer);
2214
+ const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
2215
+ const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
2042
2216
  return {
2043
- layout: { font: headerFont, lines: wrapped.lines, alignment: 'leading', truncated: false },
2044
- size: { width: wrapped.width, height: wrapped.lines.length * headerFont.lineHeight },
2217
+ layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
2218
+ size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
2045
2219
  };
2046
2220
  };
2047
2221
  for (let g = 0; g < groupsSource.length; g++) {
2048
2222
  const source = groupsSource[g] as GroupSource;
2049
- if (g > 0) y += sectionGap;
2223
+ const previous = groups[g - 1];
2224
+ if (previous) {
2225
+ const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
2226
+ y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
2227
+ }
2050
2228
  const top = y;
2051
2229
  let header: ListGroupPlan['header'] = null;
2052
2230
  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;
2231
+ // A header sits in the gap above its card (from LIST_HEADER_LIFT
2232
+ // into it); the card then starts LIST_HEADER_GAP below the header.
2233
+ const headerLift = plain ? 0 : LIST_HEADER_LIFT;
2234
+ const headerView = supplement(source.headerView, y - headerLift, headerCtx);
2235
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
2055
2236
  const headerText = source.section ? str(source.section.props['header']) : null;
2056
2237
  const footerText = source.section ? str(source.section.props['footer']) : null;
2057
2238
  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;
2239
+ const { layout, size } = makeText(headerText, headerFont);
2240
+ const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
2241
+ header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
2242
+ y = headerY + size.height + LIST_HEADER_GAP;
2061
2243
  }
2062
2244
  const cardTop = y;
2063
2245
  const rows: ListRowPlan[] = [];
@@ -2068,12 +2250,13 @@ export class LayoutPass {
2068
2250
  rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
2069
2251
  continue;
2070
2252
  }
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
2253
+ // A menu or inline picker already reports its row height (44 for
2254
+ // a menu, 44 per option inline); a toggle, stepper or segmented
2255
+ // picker has its own vertical padding, other rows 13. Phase 6
2073
2256
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2074
2257
  // width (plus a gap) at the trailing edge.
2075
2258
  const hints = rowHintsOf(rowNode);
2076
- const defaultVPad = unwrapStyled(rowNode).kind === 'picker' ? 0 : LIST_ROW_VPAD;
2259
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode));
2077
2260
  const insets: EdgeInsets = hints.insets ?? {
2078
2261
  top: defaultVPad,
2079
2262
  leading: LIST_CELL_PADDING,
@@ -2116,11 +2299,11 @@ export class LayoutPass {
2116
2299
  const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
2117
2300
  if (footerText !== null && footerText.length > 0) {
2118
2301
  y += LIST_FOOTER_GAP;
2119
- const { layout, size } = makeText(footerText);
2302
+ const { layout, size } = makeText(footerText, footerFont);
2120
2303
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
2121
2304
  y += size.height;
2122
2305
  }
2123
- let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP);
2306
+ let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
2124
2307
  if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
2125
2308
  else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
2126
2309
  groups.push({
@@ -2265,13 +2448,15 @@ export class LayoutPass {
2265
2448
  this.place(label, cctx, p, x, y);
2266
2449
  return;
2267
2450
  }
2451
+ const metrics = this.borderedMetrics(node);
2452
+ const labelCtx = this.borderedLabelContext(node, ctx);
2268
2453
  const inner: Proposal = {
2269
- width: shrink(p.width, 2 * BORDERED_PAD_H),
2270
- height: shrink(p.height, 2 * BORDERED_PAD_V),
2454
+ width: shrink(p.width, 2 * metrics.padH),
2455
+ height: shrink(p.height, 2 * metrics.padV),
2271
2456
  };
2272
- const labelSize = this.measure(label, cctx, inner);
2457
+ const labelSize = this.measure(label, labelCtx, inner);
2273
2458
  const { dx, dy } = alignIn(size, labelSize, 'center');
2274
- this.place(label, cctx, inner, x + dx, y + dy);
2459
+ this.place(label, labelCtx, inner, x + dx, y + dy);
2275
2460
  // Keep the list row chrome (`row`, `badge`) a list set before placing.
2276
2461
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2277
2462
  this.setChrome(node, {
@@ -2339,14 +2524,19 @@ export class LayoutPass {
2339
2524
  // centered on the other axes when smaller than the viewport.
2340
2525
  const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2341
2526
  const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
2527
+ // Under a large title that scrolls with it, the content starts below it.
2528
+ const titleRect = axes.vertical ? this.titleInsets.get(node) : undefined;
2342
2529
  const content: Rect = {
2343
2530
  x: x + dx,
2344
- y: y + dy,
2531
+ y: y + dy + (titleRect?.height ?? 0),
2345
2532
  width: plan.contentSize.width,
2346
2533
  height: plan.contentSize.height,
2347
2534
  };
2348
2535
  const covered = this.coveredBottom(ctx, x, y, size);
2349
- this.setChrome(node, covered ? { content, bottomInset: covered } : { content });
2536
+ const scrollChrome: Record<string, Rect> = { content };
2537
+ if (covered) scrollChrome['bottomInset'] = covered;
2538
+ if (titleRect) scrollChrome['largeTitle'] = titleRect;
2539
+ this.setChrome(node, scrollChrome);
2350
2540
  if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2351
2541
  return;
2352
2542
  }
@@ -2486,9 +2676,16 @@ export class LayoutPass {
2486
2676
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2487
2677
  const plan = this.listPlan(node, ctx, p);
2488
2678
  const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2489
- if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
2490
2679
  const covered = this.coveredBottom(ctx, x, y, plan.size);
2491
- if (covered) this.setChrome(node, { bottomInset: covered });
2680
+ // Under a large title that scrolls with it, the rows start below it.
2681
+ const titleRect = this.titleInsets.get(node);
2682
+ const listChrome: Record<string, Rect> = {};
2683
+ if (covered) listChrome['bottomInset'] = covered;
2684
+ if (titleRect) listChrome['largeTitle'] = titleRect;
2685
+ if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
2686
+ const inset = titleRect?.height ?? 0;
2687
+ if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
2688
+ y += inset;
2492
2689
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
2493
2690
  for (const group of plan.groups) {
2494
2691
  if (group.section) {
@@ -2538,40 +2735,63 @@ export class LayoutPass {
2538
2735
  : undefined;
2539
2736
  this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
2540
2737
  this.place(row.node, rowCtx, row.proposal, rowX, rowY);
2738
+ // iOS 26: the separator under a row starts where its first text
2739
+ // starts (after a Label's icon), else at the row's content.
2740
+ const placed = row.node.id >= 0 ? this.chrome.get(row.node.id) : undefined;
2741
+ if (placed) {
2742
+ const textX = this.firstTextX(row.node);
2743
+ const leading = (textX ?? rowX) - (x + row.row.x);
2744
+ placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
2745
+ }
2541
2746
  }
2542
2747
  }
2543
2748
  }
2544
2749
 
2750
+ /** The x of the first text (in tree order) placed under `node`, if any. */
2751
+ private firstTextX(node: LayoutNode): number | null {
2752
+ if (node.kind === 'text' && node.id >= 0) {
2753
+ const frame = this.frames.get(node.id);
2754
+ if (frame && frame.width > 0) return frame.x;
2755
+ }
2756
+ for (const child of node.children) {
2757
+ const x = this.firstTextX(child);
2758
+ if (x !== null) return x;
2759
+ }
2760
+ return null;
2761
+ }
2762
+
2545
2763
  /**
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
2764
+ * The navigation bar is the status bar plus a 44 row (plus 62 for a large
2765
+ * title); a Phase 6 `searchfield` is a capsule floating above the bottom
2766
+ * (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
2767
+ * content is proposed the rest. Toolbar
2768
+ * items are measured at their ideal size and laid out in iOS 26 glass
2769
+ * capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
2551
2770
  * trailing edge, `principal` centered in place of the title) or spread
2552
2771
  * evenly across the bottom bar. The inline title (`chrome.title`) is
2553
2772
  * centered and truncated to the room the bar items leave.
2554
2773
  */
2555
2774
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2556
2775
  const { safeArea, screen, dynamicTypeSize } = this.env;
2557
- const statusBar = y <= 0 ? safeArea.top : 0;
2776
+ const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
2558
2777
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
2559
2778
  const depth = num(node.props['depth']) ?? 0;
2560
2779
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2561
2780
  const barHeight = statusBar + NAV_BAR_HEIGHT;
2562
2781
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2563
2782
  const parts = this.navScreenParts(node);
2564
- const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
2565
2783
  const reachesBottom = y + size.height >= screen.height - EPSILON;
2566
2784
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
2567
2785
  const bottomBarHeight =
2568
2786
  bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
2569
- const contentTop = barHeight + largeHeight + searchHeight;
2787
+ const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
2788
+ const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
2789
+ const contentTop = barHeight + largeHeight;
2570
2790
  const content: Rect = {
2571
2791
  x,
2572
2792
  y: y + contentTop,
2573
2793
  width: size.width,
2574
- height: Math.max(0, size.height - contentTop - bottomBarHeight),
2794
+ height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
2575
2795
  };
2576
2796
  const chrome: Record<string, Rect> = {
2577
2797
  bar: { x, y, width: size.width, height: barHeight },
@@ -2581,9 +2801,9 @@ export class LayoutPass {
2581
2801
  if (parts.search) {
2582
2802
  chrome['searchField'] = {
2583
2803
  x: x + SEARCH_FIELD_MARGIN_H,
2584
- y: y + barHeight + largeHeight + SEARCH_FIELD_PAD_V,
2804
+ y: y + size.height - searchBottom,
2585
2805
  width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
2586
- height: SEARCH_FIELD_HEIGHT,
2806
+ height: SEARCH_BOTTOM_HEIGHT,
2587
2807
  };
2588
2808
  }
2589
2809
  if (bottomBarHeight > 0) {
@@ -2600,60 +2820,56 @@ export class LayoutPass {
2600
2820
  toolbar: LayoutNode;
2601
2821
  placement: string;
2602
2822
  sizes: Size[];
2823
+ /** Leading and trailing groups: each item's glass slot width. */
2824
+ slots: number[] | null;
2603
2825
  total: number;
2604
2826
  }
2605
2827
  const groups: Group[] = parts.toolbars.map((toolbar) => {
2606
2828
  const itemCtx = this.childContext(toolbar, cctx);
2607
2829
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
2830
+ const placement = str(toolbar.props['placement']) ?? 'trailing';
2831
+ if (placement === 'leading' || placement === 'trailing') {
2832
+ const slots = sizes.map((s) => Math.max(NAV_GLASS_SIZE, s.width + 2 * NAV_GLASS_PAD_H));
2833
+ return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
2834
+ }
2608
2835
  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 };
2836
+ return { toolbar, placement, sizes, slots: null, total };
2610
2837
  });
2611
2838
  const groupWidth = (placement: string): number =>
2612
- groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_ITEM_SPACING, 0);
2839
+ groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_GROUP_SPACING, 0);
2613
2840
  const leadingWidth = groupWidth('leading');
2614
2841
  const trailingWidth = groupWidth('trailing');
2615
2842
  const principal = groups.some((g) => g.placement === 'principal');
2616
2843
 
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 = '';
2844
+ // Back button, iOS 26: the chevron alone in a glass circle (the previous
2845
+ // title no longer shows).
2846
+ const backLabel = '';
2620
2847
  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 };
2848
+ chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
2636
2849
  }
2637
2850
 
2638
2851
  // Place the toolbar groups and remember how much of the row they occupy.
2639
2852
  const back = chrome['back'];
2640
- let leadingCursor = back ? back.x + back.width + 8 : x + NAV_ITEM_MARGIN;
2853
+ let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
2641
2854
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2642
2855
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2643
2856
  const itemCtx = this.childContext(group.toolbar, cctx);
2644
2857
  let cursor = startX;
2645
2858
  group.toolbar.children.forEach((item, i) => {
2646
2859
  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;
2860
+ // In a glass slot the item is centered in it.
2861
+ const slot = group.slots?.[i];
2862
+ const itemX = slot !== undefined ? cursor + (slot - itemSize.width) / 2 : cursor;
2863
+ this.place(item, itemCtx, { width: null, height: null }, itemX, top + (rowHeight - itemSize.height) / 2);
2864
+ cursor += (slot ?? itemSize.width) + spacing;
2649
2865
  });
2650
2866
  };
2651
2867
  for (const group of groups) {
2652
2868
  switch (group.placement) {
2653
2869
  case 'leading': {
2654
2870
  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;
2871
+ placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
2872
+ leadingCursor += group.total + NAV_GROUP_SPACING;
2657
2873
  break;
2658
2874
  }
2659
2875
  case 'principal': {
@@ -2678,15 +2894,17 @@ export class LayoutPass {
2678
2894
  default: {
2679
2895
  const startX = trailingEdge - group.total;
2680
2896
  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;
2897
+ placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
2898
+ trailingEdge = startX - NAV_GROUP_SPACING;
2683
2899
  break;
2684
2900
  }
2685
2901
  }
2686
2902
  }
2687
2903
  if (parts.search) {
2904
+ // The field is the whole capsule (its own measure is a 36pt field).
2688
2905
  const field = chrome['searchField'] as Rect;
2689
2906
  this.place(parts.search, cctx, { width: field.width, height: field.height }, field.x, field.y);
2907
+ this.setFrame(parts.search, field.x, field.y, { width: field.width, height: field.height });
2690
2908
  }
2691
2909
 
2692
2910
  // The inline title: centered, no wider than 56% of the bar, and clear of
@@ -2695,9 +2913,16 @@ export class LayoutPass {
2695
2913
  // takes up to a pixel off a fractional width at a fractional origin, and
2696
2914
  // the title truncates with an ellipsis ("Animati…") when its box is any
2697
2915
  // 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;
2916
+ const scroller = parts.content ? unwrapStyled(parts.content) : null;
2917
+ const titleScrolls =
2918
+ large &&
2919
+ scroller !== null &&
2920
+ (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
2921
+ // A large title that scrolls away collapses into the inline title, which
2922
+ // the renderer shows once it has (`data-sb-collapsed`).
2923
+ if ((!large || titleScrolls) && !principal) {
2924
+ const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
2925
+ const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
2701
2926
  const side = Math.max(leftUsed, rightUsed) + 8;
2702
2927
  const available = Math.max(0, size.width - 2 * side);
2703
2928
  const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
@@ -2731,8 +2956,17 @@ export class LayoutPass {
2731
2956
  bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2732
2957
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2733
2958
  // 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') {
2959
+ const contentCtx: Context = { ...cctx, safeTop: content.y, barInset: 0 };
2960
+ if (scroller && titleScrolls) {
2961
+ // The scroll view starts under the bar; its content starts under the
2962
+ // large title, which moves with it.
2963
+ const top = content.y - largeHeight;
2964
+ this.titleInsets.set(scroller, chrome['largeTitle'] as Rect);
2965
+ this.place(child, contentCtx, { width: content.width, height: content.height + largeHeight }, content.x, top);
2966
+ return;
2967
+ }
2968
+ if (scroller) this.titleInsets.delete(scroller);
2969
+ if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
2736
2970
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2737
2971
  return;
2738
2972
  }
@@ -2744,25 +2978,38 @@ export class LayoutPass {
2744
2978
  }
2745
2979
 
2746
2980
  /**
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`.
2981
+ * The tab bar (iOS 26) is a glass capsule floating over the bottom of the
2982
+ * content (TAB_BAR_* above), with one equal-width item per tab; what it
2983
+ * covers (the capsule and the gap under it) is the content's bottom inset.
2984
+ * Every `tab` gets the tab view's frame; only the selected one lays its
2985
+ * content out, the others are reported in `hidden`.
2751
2986
  */
2752
2987
  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 };
2988
+ const { screen } = this.env;
2989
+ const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
2990
+ const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
2991
+ const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
2992
+ const bar: Rect = {
2993
+ x: x + TAB_BAR_MARGIN_H,
2994
+ y: y + size.height - barHeight,
2995
+ width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
2996
+ height: capsuleHeight,
2997
+ };
2757
2998
  const chrome: Record<string, Rect> = {
2758
2999
  bar,
2759
3000
  content: { x, y, width: size.width, height: size.height - barHeight },
2760
3001
  };
2761
3002
  const tabs = node.children;
2762
3003
  const n = tabs.length;
3004
+ const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
2763
3005
  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) };
3006
+ const itemWidth = inner / n;
3007
+ chrome[`item${i}`] = {
3008
+ x: bar.x + TAB_BAR_ITEM_INSET + i * itemWidth,
3009
+ y: bar.y + TAB_BAR_ITEM_INSET,
3010
+ width: itemWidth,
3011
+ height: Math.max(0, capsuleHeight - 2 * TAB_BAR_ITEM_INSET),
3012
+ };
2766
3013
  }
2767
3014
  this.setChrome(node, chrome);
2768
3015
  const selected = readSelected(node, n);
@@ -3008,9 +3255,9 @@ export class LayoutPass {
3008
3255
  fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
3009
3256
  thumb: {
3010
3257
  x: thumbCenter - SLIDER_THUMB / 2,
3011
- y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB) / 2,
3258
+ y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
3012
3259
  width: SLIDER_THUMB,
3013
- height: SLIDER_THUMB,
3260
+ height: SLIDER_THUMB_HEIGHT,
3014
3261
  },
3015
3262
  });
3016
3263
  }
@@ -3080,9 +3327,10 @@ export class LayoutPass {
3080
3327
  }
3081
3328
 
3082
3329
  /**
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.
3330
+ * `alert` style (iOS 26): a card centered in the safe area with the title
3331
+ * (headline) and the message (subheadline), leading-aligned, and capsule
3332
+ * buttons: two side by side (a `cancel` one first), otherwise stacked; no
3333
+ * buttons → an `ok` rect the renderer draws.
3086
3334
  * `dialog` style: an action sheet of 57 rows in a card 8 from the screen
3087
3335
  * edges, above a separate card for the `cancel` button, 8 above the bottom
3088
3336
  * safe area; the title and message are a footnote caption on top. Button
@@ -3098,40 +3346,45 @@ export class LayoutPass {
3098
3346
  const texts: Record<string, TextLayout> = {};
3099
3347
  const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
3100
3348
 
3101
- const centered = (text: string, font: ResolvedFont, width: number): { layout: TextLayout; height: number } => {
3349
+ const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
3102
3350
  const wrapped = wrapText(text, font, width, null, this.measurer);
3103
3351
  return {
3104
- layout: { font, lines: wrapped.lines, alignment: 'center', truncated: wrapped.truncated ?? false },
3352
+ layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
3105
3353
  height: wrapped.lines.length * font.lineHeight,
3106
3354
  };
3107
3355
  };
3356
+ const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
3108
3357
 
3109
3358
  if (style === 'alert') {
3110
3359
  const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
3111
3360
  const textWidth = cardWidth - 2 * ALERT_PAD_H;
3112
3361
  const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
3113
- const messageFont = resolveFont({ textStyle: 'footnote' }, ctx.font, this.env);
3362
+ const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
3114
3363
  // Heights first (the card is centered), then the rects.
3115
3364
  let h = ALERT_PAD_TOP;
3116
3365
  let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
3117
3366
  let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
3118
3367
  if (title.length > 0) {
3119
- const block = centered(title, titleFont, textWidth);
3120
- titleBlock = { ...block, top: h };
3121
- h += block.height;
3368
+ const b = block(title, titleFont, textWidth, 'leading');
3369
+ titleBlock = { ...b, top: h };
3370
+ h += b.height;
3122
3371
  }
3123
3372
  if (message.length > 0) {
3124
3373
  if (titleBlock) h += ALERT_TITLE_GAP;
3125
- const block = centered(message, messageFont, textWidth);
3126
- messageBlock = { ...block, top: h };
3127
- h += block.height;
3374
+ const b = block(message, messageFont, textWidth, 'leading');
3375
+ messageBlock = { ...b, top: h };
3376
+ h += b.height;
3128
3377
  }
3129
- h += ALERT_PAD_BOTTOM;
3378
+ h += ALERT_BUTTONS_GAP;
3130
3379
  const headerHeight = h;
3131
3380
  const buttons = node.children;
3132
3381
  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 };
3382
+ h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
3383
+ // Centered in the safe area, as iOS does.
3384
+ const { safeArea: inset } = this.env;
3385
+ const top = y + (y <= 0 ? inset.top : 0);
3386
+ const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
3387
+ const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
3135
3388
  chrome['card'] = card;
3136
3389
  if (titleBlock) {
3137
3390
  chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
@@ -3142,18 +3395,28 @@ export class LayoutPass {
3142
3395
  texts['message'] = messageBlock.layout;
3143
3396
  }
3144
3397
  const buttonsTop = card.y + headerHeight;
3398
+ const buttonsX = card.x + ALERT_BUTTON_INSET;
3399
+ const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
3145
3400
  if (buttons.length === 0) {
3146
- chrome['ok'] = { x: card.x, y: buttonsTop, width: cardWidth, height: ALERT_BUTTON_HEIGHT };
3401
+ chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
3147
3402
  } 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 };
3403
+ // A `cancel` button goes first (leading), as iOS orders them.
3404
+ const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
3405
+ const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
3406
+ ordered.forEach((button, slot) => {
3407
+ const i = buttons.indexOf(button as LayoutNode);
3408
+ const rect: Rect = { x: buttonsX + slot * (half + ALERT_BUTTON_SPACING), y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
3151
3409
  chrome[`button${i}`] = rect;
3152
- this.placeAlertButton(button, cctx, rect);
3410
+ this.placeAlertButton(button as LayoutNode, cctx, rect);
3153
3411
  });
3154
3412
  } else {
3155
3413
  buttons.forEach((button, i) => {
3156
- const rect: Rect = { x: card.x, y: buttonsTop + i * ALERT_BUTTON_HEIGHT, width: cardWidth, height: ALERT_BUTTON_HEIGHT };
3414
+ const rect: Rect = {
3415
+ x: buttonsX,
3416
+ y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
3417
+ width: buttonsWidth,
3418
+ height: ALERT_BUTTON_HEIGHT,
3419
+ };
3157
3420
  chrome[`button${i}`] = rect;
3158
3421
  this.placeAlertButton(button, cctx, rect);
3159
3422
  });
@@ -3176,7 +3439,7 @@ export class LayoutPass {
3176
3439
  h += block.height;
3177
3440
  }
3178
3441
  if (message.length > 0) {
3179
- if (titleBlock) h += ALERT_TITLE_GAP;
3442
+ if (titleBlock) h += DIALOG_TITLE_GAP;
3180
3443
  const block = centered(message, messageFont, textWidth);
3181
3444
  messageBlock = { ...block, top: h };
3182
3445
  h += block.height;
@@ -3194,7 +3457,7 @@ export class LayoutPass {
3194
3457
  const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
3195
3458
  chrome['cancelCard'] = cancelCard;
3196
3459
  chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
3197
- this.placeAlertButton(cancel, buttonCtx, cancelCard);
3460
+ this.placeAlertButton(cancel, buttonCtx, cancelCard, true);
3198
3461
  cardBottom = cancelCard.y - DIALOG_CARD_GAP;
3199
3462
  }
3200
3463
  const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
@@ -3214,7 +3477,7 @@ export class LayoutPass {
3214
3477
  others.forEach((button, i) => {
3215
3478
  const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
3216
3479
  chrome[`button${node.children.indexOf(button)}`] = rect;
3217
- this.placeAlertButton(button, buttonCtx, rect);
3480
+ this.placeAlertButton(button, buttonCtx, rect, true);
3218
3481
  });
3219
3482
  }
3220
3483
  this.setChrome(node, chrome);
@@ -3222,10 +3485,11 @@ export class LayoutPass {
3222
3485
  }
3223
3486
 
3224
3487
  /** 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';
3488
+ private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect, boldCancel = false): void {
3489
+ const proposal: Proposal = { width: Math.max(0, rect.width - 2 * DIALOG_LABEL_INSET), height: rect.height };
3490
+ // An action sheet's `cancel` button is bold: measure (and so draw) its
3491
+ // label semibold. iOS 26 alerts draw it regular.
3492
+ const cancel = boldCancel && str(unwrapStyled(child).props['role']) === 'cancel';
3229
3493
  const inherited = this.childContext(child, ctx);
3230
3494
  const cctx: Context = {
3231
3495
  ...inherited,
@@ -3250,9 +3514,16 @@ export class LayoutPass {
3250
3514
  }
3251
3515
 
3252
3516
  private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3253
- // Phase 8: a `full` sheet (`.fullScreenCover`) has no grabber row.
3517
+ // A grabber gets its row; without one the content starts a little below
3518
+ // the sheet's top (iOS 26), except in a `full` one (Phase 8,
3519
+ // `.fullScreenCover`). A navigation stack fills the sheet from its top
3520
+ // instead, its bar taking that room (`barInset`), so its background
3521
+ // reaches the sheet's edge; a list or scroll view fills it below the
3522
+ // grabber row.
3523
+ const grabber = this.sheetHasGrabber(node);
3524
+ const floats = this.sheetFloats(node);
3254
3525
  const full = this.sheetIsFull(node);
3255
- const grabberRow = full ? 0 : SHEET_GRABBER_ROW;
3526
+ const grabberRow = grabber ? SHEET_GRABBER_ROW : full ? 0 : SHEET_CONTENT_TOP;
3256
3527
  const content: Rect = {
3257
3528
  x,
3258
3529
  y: y + grabberRow,
@@ -3260,7 +3531,8 @@ export class LayoutPass {
3260
3531
  height: Math.max(0, size.height - grabberRow),
3261
3532
  };
3262
3533
  const chrome: Record<string, Rect> = { content };
3263
- if (!full) {
3534
+ if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
3535
+ if (grabber) {
3264
3536
  chrome['grabber'] = {
3265
3537
  x: x + (size.width - SHEET_GRABBER.width) / 2,
3266
3538
  y: y + SHEET_GRABBER_TOP,
@@ -3273,8 +3545,14 @@ export class LayoutPass {
3273
3545
  if (!child) return;
3274
3546
  const proposal: Proposal = { width: content.width, height: content.height };
3275
3547
  const kind = unwrapStyled(child).kind;
3276
- if (kind === 'list' || kind === 'scrollview' || kind === 'navstack') {
3277
- this.place(child, cctx, proposal, content.x, content.y);
3548
+ if (kind === 'navstack') {
3549
+ this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
3550
+ return;
3551
+ }
3552
+ if (kind === 'list' || kind === 'scrollview') {
3553
+ // A scrolling view fills the sheet below the grabber row, if any.
3554
+ const top = grabber ? SHEET_GRABBER_ROW : 0;
3555
+ this.place(child, cctx, { width: size.width, height: size.height - top }, x, y + top);
3278
3556
  return;
3279
3557
  }
3280
3558
  const childSize = this.measure(child, cctx, proposal);
@@ -3380,8 +3658,12 @@ export class LayoutPass {
3380
3658
  * whose non-spanning cells are all flexible (or that has none) is flexible;
3381
3659
  * the others take the largest ideal width of their non-spanning cells. The
3382
3660
  * flexible columns share what is left of the proposal width equally (their
3383
- * ideal width with a nil proposal). Row heights come from measuring every
3384
- * cell at its cell width; a non-`gridrow` child is one full-span cell.
3661
+ * ideal width with a nil proposal). A non-`gridrow` child is one full-span
3662
+ * cell; one wider than the columns (a `Divider` takes the whole proposal)
3663
+ * widens the grid, every column by the same amount (unless it has
3664
+ * `gridCellUnsizedAxes(.horizontal)`). Row heights come from
3665
+ * measuring every cell at its cell width. A cell's `gridColumnAlignment`
3666
+ * aligns its whole column.
3385
3667
  */
3386
3668
  private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
3387
3669
  return this.plan(node, p, () => {
@@ -3444,6 +3726,30 @@ export class LayoutPass {
3444
3726
  if (flexible[j]) columns[j] = flexibleCount > 0 ? remaining / flexibleCount : 0;
3445
3727
  }
3446
3728
  }
3729
+ // Full-span children wider than the columns widen every column alike.
3730
+ const fullProposal: Proposal = { width: finite(p.width) ? p.width : null, height: null };
3731
+ let fullWidth = 0;
3732
+ for (const source of sources) {
3733
+ if (!source.full) continue;
3734
+ const cctx = this.gridCellContext(node, null, ctx);
3735
+ for (const cell of source.cells) {
3736
+ if (cellStyle(cell.node, 'gridCellUnsizedAxes') === true) continue;
3737
+ fullWidth = Math.max(fullWidth, this.measure(cell.node, cctx, fullProposal).width);
3738
+ }
3739
+ }
3740
+ const columnsWidth = columns.reduce((sum, w) => sum + w, 0) + hSpacing * (columnCount - 1);
3741
+ if (fullWidth > columnsWidth + EPSILON) {
3742
+ const extra = (fullWidth - columnsWidth) / columnCount;
3743
+ for (let j = 0; j < columnCount; j++) columns[j] = (columns[j] as number) + extra;
3744
+ }
3745
+ const columnAnchors = new Array<number | null>(columnCount).fill(null);
3746
+ for (const source of sources) {
3747
+ if (source.full) continue;
3748
+ for (const cell of source.cells) {
3749
+ const anchor = cell.span === 1 ? gridColumnAnchorOf(cell.node) : null;
3750
+ if (anchor !== null && columnAnchors[cell.column] === null) columnAnchors[cell.column] = anchor;
3751
+ }
3752
+ }
3447
3753
  const columnX = new Array<number>(columnCount).fill(0);
3448
3754
  for (let j = 1; j < columnCount; j++) {
3449
3755
  columnX[j] = (columnX[j - 1] as number) + (columns[j - 1] as number) + hSpacing;
@@ -3480,7 +3786,7 @@ export class LayoutPass {
3480
3786
  y += height + vSpacing;
3481
3787
  }
3482
3788
  const gridHeight = rows.length > 0 ? y - vSpacing : 0;
3483
- return { size: { width: gridWidth, height: gridHeight }, columns, columnX, rows };
3789
+ return { size: { width: gridWidth, height: gridHeight }, columns, columnX, columnAnchors, rows };
3484
3790
  });
3485
3791
  }
3486
3792
 
@@ -3494,7 +3800,8 @@ export class LayoutPass {
3494
3800
  const rowY = rowAnchor(row.alignment, gridAlignment);
3495
3801
  for (const cell of row.cells) {
3496
3802
  const box: Size = { width: cell.width, height: row.height };
3497
- const [ax, ay] = gridAnchorOf(cell.node) ?? [gridX, rowY];
3803
+ const columnX = cell.full ? null : (plan.columnAnchors[cell.column] ?? null);
3804
+ const [ax, ay] = gridAnchorOf(cell.node) ?? [columnX ?? gridX, rowY];
3498
3805
  const dx = safeOffset(box.width, cell.size.width, ax);
3499
3806
  const dy = safeOffset(box.height, cell.size.height, ay);
3500
3807
  const cellX = cell.full ? 0 : (plan.columnX[cell.column] ?? 0);
@@ -3642,6 +3949,15 @@ export class LayoutPass {
3642
3949
  return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
3643
3950
  }
3644
3951
  const proposal: Proposal = { width, height: null };
3952
+ if (style === 'progress') {
3953
+ // A ProgressView: the label in the context font above the bar, the
3954
+ // current value label (caption) under it.
3955
+ const labelSize = label ? this.measure(label, cctx, proposal) : ZERO;
3956
+ const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
3957
+ const labelRow = label ? labelSize.height + PROGRESS_LABEL_GAP : 0;
3958
+ const height = labelRow + GAUGE_BAR_HEIGHT + (current ? PROGRESS_VALUE_GAP + currentSize.height : 0);
3959
+ return { size: { width, height }, label, current, labelSize, currentSize, labelRow, labelCtx, labelOwnCtx: cctx };
3960
+ }
3645
3961
  const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
3646
3962
  const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
3647
3963
  const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
@@ -3665,6 +3981,17 @@ export class LayoutPass {
3665
3981
  return;
3666
3982
  }
3667
3983
  const proposal: Proposal = { width: size.width, height: null };
3984
+ if (str(node.props['style']) === 'progress') {
3985
+ if (plan.label) this.place(plan.label, plan.labelOwnCtx ?? plan.labelCtx, proposal, x, y);
3986
+ const barY = y + plan.labelRow;
3987
+ if (plan.current) this.place(plan.current, plan.labelCtx, proposal, x, barY + GAUGE_BAR_HEIGHT + PROGRESS_VALUE_GAP);
3988
+ const fraction = Math.min(1, Math.max(0, num(node.props['value']) ?? 0));
3989
+ this.setChrome(node, {
3990
+ track: { x, y: barY, width: size.width, height: GAUGE_BAR_HEIGHT },
3991
+ fill: { x, y: barY, width: size.width * fraction, height: GAUGE_BAR_HEIGHT },
3992
+ });
3993
+ return;
3994
+ }
3668
3995
  if (plan.label) {
3669
3996
  this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
3670
3997
  }