@swiftbrowser/web 0.1.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.
Files changed (99) hide show
  1. package/README.md +141 -63
  2. package/app/index.html +21 -10
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +12 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +68 -3
  69. package/src/landing.ts +2 -2
  70. package/src/largeTitle.ts +79 -0
  71. package/src/layout/chart.ts +9 -3
  72. package/src/layout/engine.ts +681 -174
  73. package/src/layout/index.ts +3 -1
  74. package/src/layout/text.ts +217 -1
  75. package/src/layout/types.ts +31 -0
  76. package/src/layout/typography.ts +24 -7
  77. package/src/layout/video.ts +24 -0
  78. package/src/layoutDom.ts +66 -6
  79. package/src/main.ts +127 -4
  80. package/src/media.ts +132 -0
  81. package/src/mock.ts +4 -3
  82. package/src/notifications.ts +109 -0
  83. package/src/pathShape.ts +156 -0
  84. package/src/protocol.ts +782 -13
  85. package/src/renderer.ts +603 -44
  86. package/src/richText.ts +65 -0
  87. package/src/scanner.ts +234 -0
  88. package/src/scrollTo.ts +123 -0
  89. package/src/styles.css +521 -100
  90. package/src/styles.ts +61 -0
  91. package/src/swiftData.ts +128 -0
  92. package/src/symbols.ts +658 -28
  93. package/src/translation.ts +126 -0
  94. package/src/video.ts +299 -0
  95. package/src/vite-env.d.ts +6 -0
  96. package/src/wasm.ts +227 -32
  97. package/src/webAuth.ts +267 -0
  98. package/src/webSocket.ts +148 -0
  99. package/src/zoneinfo.ts +269 -0
@@ -20,8 +20,9 @@
20
20
  */
21
21
 
22
22
  import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textStyleSize } from './typography';
23
- import { wrapText } from './text';
23
+ import { wrapRuns, wrapText, type TextRunInput } from './text';
24
24
  import { formatPickerDate, formatPickerTime, sliderFraction } from './controls';
25
+ import { videoSize } from './video';
25
26
  import {
26
27
  CHART_LEGEND_GAP,
27
28
  CHART_LEGEND_GLYPH,
@@ -78,11 +79,22 @@ export interface Context {
78
79
  * from here.
79
80
  */
80
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;
81
88
  /**
82
89
  * Phase 12: the size of the nearest container (`containerRelativeFrame`):
83
90
  * the screen at the root, a scroll view's viewport inside one.
84
91
  */
85
92
  container: Size;
93
+ /**
94
+ * The lower bound of the enclosing `.lineLimit(2...4)` range (`lineLimitMin`):
95
+ * a vertical text field is at least this many lines tall. Absent for 1.
96
+ */
97
+ minLines?: number;
86
98
  }
87
99
 
88
100
  // Constants from the spec.
@@ -91,35 +103,71 @@ const DEFAULT_SPACER_MIN = 8;
91
103
  /** Phase 5: the square an `image` whose catalog name is unknown measures. */
92
104
  const UNKNOWN_IMAGE_SIDE = 44;
93
105
  const SHAPE_DEFAULT = 10;
94
- const BORDERED_PAD_V = 7;
95
- 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;
96
110
  const BORDERED_MIN_HEIGHT = 34;
97
- const SWITCH_WIDTH = 51;
98
- 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;
99
123
  const TOGGLE_GAP = 8;
100
124
  const TEXTFIELD_DEFAULT_WIDTH = 200;
101
125
  const TEXTFIELD_HEIGHT_ROUNDED = 34;
102
126
  const TEXTFIELD_HEIGHT_PLAIN = 22;
127
+ /** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
128
+ const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
103
129
  const NAV_BAR_HEIGHT = 44;
104
- const LARGE_TITLE_HEIGHT = 52;
130
+ // iOS 26: the large title's row is 62 (52 before).
131
+ const LARGE_TITLE_HEIGHT = 62;
105
132
  /** Chevron glyph plus its gap, in px. */
106
- const BACK_CHEVRON_WIDTH = 24;
107
133
  const SHEET_TOP_GAP = 10;
108
134
  const SHEET_GRABBER_ROW = 20;
109
135
  const SHEET_GRABBER: Size = { width: 36, height: 5 };
110
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;
111
147
  const LIST_INSET = 16;
112
148
  const LIST_CELL_PADDING = 16;
113
- const LIST_ROW_VPAD = 11;
114
- const LIST_ROW_MIN_HEIGHT = 44;
115
- 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;
116
156
  const LIST_BOTTOM = 20;
117
- const LIST_HEADER_GAP = 8;
118
- const LIST_FOOTER_GAP = 8;
157
+ const LIST_HEADER_LIFT = 9;
158
+ const LIST_HEADER_GAP = 10;
159
+ const LIST_FOOTER_GAP = 6;
119
160
  const LIST_SECTION_GAP = 35;
120
161
  const LIST_CHEVRON_WIDTH = 20;
121
162
  // Phase 4.
122
- 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;
123
171
  const PICKER_SEGMENTED_HEIGHT = 32;
124
172
  const PICKER_SEGMENT_PAD = 20;
125
173
  const PICKER_SEGMENT_FONT_SIZE = 13;
@@ -131,7 +179,14 @@ const PICKER_MENU_GAP = 12;
131
179
  // Phase 6.
132
180
  const GAUGE_BAR_HEIGHT = 4;
133
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;
134
185
  const GAUGE_DEFAULT_WIDTH = 100;
186
+ /** A circular gauge's side (`accessoryCircular` / `accessoryCircularCapacity`). */
187
+ const GAUGE_RING_SIZE = 58;
188
+ /** The square inside the ring its label is proposed, as a fraction of the side. */
189
+ const GAUGE_RING_INNER = 0.62;
135
190
  const GRID_ITEM_DEFAULT_MIN = 10;
136
191
  // Phase 6 (docs/phase6/chrome.md).
137
192
  const PICKER_INLINE_ROW_HEIGHT = 44;
@@ -141,12 +196,22 @@ const PICKER_INLINE_CHECK_WIDTH = 28;
141
196
  const BADGE_GAP = 8;
142
197
  const NAV_ITEM_SPACING = 16;
143
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;
144
206
  const BOTTOM_BAR_HEIGHT = 49;
145
207
  const SEARCH_FIELD_HEIGHT = 36;
146
208
  const SEARCH_FIELD_MARGIN_H = 16;
147
- const SEARCH_FIELD_PAD_V = 8;
148
- /** 8 above the 36pt field and 8 below: what a search field adds to the bar. */
149
- 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;
150
215
  const SEARCH_FIELD_DEFAULT_WIDTH = 200;
151
216
  const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
152
217
  // Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
@@ -160,10 +225,12 @@ const EPSILON = 1e-6;
160
225
  const STEPPER_WIDTH = 94;
161
226
  const STEPPER_HEIGHT = 32;
162
227
  const STEPPER_GAP = 8;
163
- /** A `slider` is 44 high (its label row, when present, sits above). */
164
- const SLIDER_HEIGHT = 44;
165
- const SLIDER_TRACK_HEIGHT = 4;
166
- 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;
167
234
  const SLIDER_DEFAULT_WIDTH = 200;
168
235
  /** Gap between a min / max value label and the track. */
169
236
  const SLIDER_LABEL_GAP = 8;
@@ -174,14 +241,26 @@ const DATEPICKER_CAPSULE_GAP = 8;
174
241
  const DATEPICKER_GAP = 8;
175
242
  /** A `texteditor` fills the proposal; 100 high when proposed nil. */
176
243
  const TEXTEDITOR_DEFAULT_HEIGHT = 100;
177
- /** `alert` style: a 270 wide centered card. */
178
- const ALERT_WIDTH = 270;
179
- const ALERT_PAD_TOP = 20;
180
- const ALERT_PAD_H = 16;
181
- 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;
182
257
  /** Gap between the alert title and its message. */
183
- const ALERT_TITLE_GAP = 4;
184
- 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;
185
264
  /** `dialog` style (action sheet): cards inset 8 from the screen edges and the bottom safe area. */
186
265
  const DIALOG_MARGIN = 8;
187
266
  const DIALOG_BUTTON_HEIGHT = 57;
@@ -457,6 +536,8 @@ interface RowHints {
457
536
  badge: string | null;
458
537
  /** `.listRowInsets(_:)`, or null for the defaults. */
459
538
  insets: EdgeInsets | null;
539
+ /** `.listRowSeparator(.hidden, edges:)`: the edges whose separator the row hides. */
540
+ separatorHidden: { top: boolean; bottom: boolean } | null;
460
541
  }
461
542
 
462
543
  /**
@@ -464,7 +545,7 @@ interface RowHints {
464
545
  * outermost wins, like `layoutPriority`): `badge` and `listRowInsets`.
465
546
  */
466
547
  function rowHintsOf(node: LayoutNode): RowHints {
467
- const hints: RowHints = { badge: null, insets: null };
548
+ const hints: RowHints = { badge: null, insets: null, separatorHidden: null };
468
549
  let current: LayoutNode | undefined = node;
469
550
  // `NavigationLink(value:) { OrderRow().badge(n) }`: the hints sit on the
470
551
  // link's label, which is the row's content. Phase 8 wrappers (`listrow`,
@@ -477,6 +558,11 @@ function rowHintsOf(node: LayoutNode): RowHints {
477
558
  if (badge !== null && badge.length > 0) hints.badge = badge;
478
559
  }
479
560
  if (hints.insets === null) hints.insets = readInsets(style['listRowInsets']);
561
+ const separator = style['listRowSeparator'];
562
+ if (hints.separatorHidden === null && typeof separator === 'object' && separator !== null) {
563
+ const edges = separator as Record<string, unknown>;
564
+ hints.separatorHidden = { top: edges['top'] === true, bottom: edges['bottom'] === true };
565
+ }
480
566
  current = current.children.length === 1 ? current.children[0] : undefined;
481
567
  } else if (current.kind === 'navlink' && current.children.length === 1) {
482
568
  current = current.children[0];
@@ -581,6 +667,54 @@ function alignIn(container: Size, child: Size, alignment: string): { dx: number;
581
667
  };
582
668
  }
583
669
 
670
+ /** The guide names a pair of anchors aligns by: `leading`/`center`/`trailing` and `top`/`center`/`bottom`. */
671
+ function guideNames([ax, ay]: [number, number]): [string, string] {
672
+ return [ax === 0 ? 'leading' : ax === 1 ? 'trailing' : 'center', ay === 0 ? 'top' : ay === 1 ? 'bottom' : 'center'];
673
+ }
674
+
675
+ /**
676
+ * The guide `name` on `axis` a view moved with `.alignmentGuide`, as
677
+ * `[a, b, c]` (the guide is `a + b·width + c·height`): set on the view or on
678
+ * a `styled` wrapper inside it (the modifiers between change nothing here).
679
+ */
680
+ export function explicitGuide(node: LayoutNode, axis: 'horizontal' | 'vertical', name: string): [number, number, number] | null {
681
+ let current: LayoutNode | undefined = node;
682
+ for (let depth = 0; current && current.kind === 'styled' && depth < 32; depth++) {
683
+ const guide = (current.props['style'] as { alignmentGuide?: { axis?: string; name?: string; value?: unknown } } | undefined)?.alignmentGuide;
684
+ if (guide && guide.axis === axis && guide.name === name && Array.isArray(guide.value) && guide.value.length === 3) {
685
+ const [a, b, c] = guide.value.map((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)) as [number, number, number];
686
+ return [a, b, c];
687
+ }
688
+ current = current.children.length === 1 ? current.children[0] : undefined;
689
+ }
690
+ return null;
691
+ }
692
+
693
+ function guideValue([a, b, c]: [number, number, number], size: Size): number {
694
+ return a + b * size.width + c * size.height;
695
+ }
696
+
697
+ /**
698
+ * `alignIn` for a child that may have moved its guides (`.alignmentGuide`):
699
+ * on an axis where it did, its guide meets the container's guide for the
700
+ * same alignment (0, the middle or the end); elsewhere it aligns as before.
701
+ */
702
+ function alignChild(container: Size, child: Size, alignment: string, node: LayoutNode): { dx: number; dy: number } {
703
+ const anchors = alignmentAnchors(alignment);
704
+ const [horizontal, vertical] = guideNames(anchors);
705
+ const offsets = alignIn(container, child, alignment);
706
+ const gx = explicitGuide(node, 'horizontal', horizontal);
707
+ const gy = explicitGuide(node, 'vertical', vertical);
708
+ if (gx && Number.isFinite(container.width)) offsets.dx = container.width * anchors[0] - guideValue(gx, child);
709
+ if (gy && Number.isFinite(container.height)) offsets.dy = container.height * anchors[1] - guideValue(gy, child);
710
+ return offsets;
711
+ }
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
+
584
718
  function safeOffset(container: number, child: number, anchor: number): number {
585
719
  if (!Number.isFinite(container) || !Number.isFinite(child)) return 0;
586
720
  return (container - child) * anchor;
@@ -682,6 +816,17 @@ interface ListRowPlan {
682
816
  badge: { rect: Rect; layout: TextLayout } | null;
683
817
  /** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
684
818
  edit: { listRow: LayoutNode; control: Rect } | null;
819
+ /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
820
+ separatorHidden?: { top: boolean; bottom: boolean } | null;
821
+ }
822
+
823
+ /** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
824
+ interface ListSupplementPlan {
825
+ node: LayoutNode;
826
+ ctx: Context;
827
+ proposal: Proposal;
828
+ /** Relative to the list origin at scroll 0. */
829
+ rect: Rect;
685
830
  }
686
831
 
687
832
  interface ListGroupPlan {
@@ -689,6 +834,8 @@ interface ListGroupPlan {
689
834
  frame: Rect;
690
835
  header: { rect: Rect; layout: TextLayout } | null;
691
836
  footer: { rect: Rect; layout: TextLayout } | null;
837
+ headerView: ListSupplementPlan | null;
838
+ footerView: ListSupplementPlan | null;
692
839
  card: Rect;
693
840
  rows: ListRowPlan[];
694
841
  }
@@ -769,6 +916,10 @@ interface GaugePlan {
769
916
  /** Height of the label row (0 without labels). */
770
917
  labelRow: number;
771
918
  labelCtx: Context;
919
+ /** A ProgressView's label context (the gauge's own font, not the caption). */
920
+ labelOwnCtx?: Context;
921
+ /** A circular gauge: the view in the middle of the ring (the current value label, else the label) and its size. */
922
+ ring?: { center: LayoutNode | null; centerSize: Size };
772
923
  }
773
924
 
774
925
  /** Phase 7: one `chartitem` child of a chart, measured at its ideal size. */
@@ -819,6 +970,12 @@ export class LayoutPass {
819
970
  readonly layouts = new Map<number, LayoutRecord>();
820
971
  /** Previous screen title per navscreen node, for the back button label. */
821
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>();
822
979
 
823
980
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
824
981
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -870,9 +1027,12 @@ export class LayoutPass {
870
1027
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
871
1028
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
872
1029
  } else if (root.kind === 'sheet') {
873
- 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 };
874
1034
  const size = this.measure(root, ctx, proposal);
875
- this.place(root, ctx, proposal, 0, screen.height - size.height);
1035
+ this.place(root, ctx, proposal, insetH, screen.height - insetBottom - size.height);
876
1036
  } else {
877
1037
  const safe: Size = {
878
1038
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
@@ -1030,7 +1190,11 @@ export class LayoutPass {
1030
1190
  if (font !== ctx.font) next = { ...next, font };
1031
1191
  if ('lineLimit' in style) {
1032
1192
  const limit = num(style['lineLimit']);
1193
+ const min = num(style['lineLimitMin']);
1033
1194
  next = { ...next, lineLimit: limit !== null && limit >= 1 ? Math.floor(limit) : null };
1195
+ // A new limit replaces the whole range, lower bound included.
1196
+ if (min !== null && min > 1) next.minLines = Math.floor(min);
1197
+ else delete next.minLines;
1034
1198
  }
1035
1199
  const alignment = str(style['multilineTextAlignment']);
1036
1200
  if (alignment === 'leading' || alignment === 'center' || alignment === 'trailing') {
@@ -1120,6 +1284,9 @@ export class LayoutPass {
1120
1284
  return this.measureImage(node, ctx, p);
1121
1285
  case 'spacer':
1122
1286
  return this.measureSpacer(node, ctx, p);
1287
+ case 'lazyspacer':
1288
+ // Lazy rows not built yet: no space (the page builds them as it nears).
1289
+ return ZERO;
1123
1290
  case 'divider':
1124
1291
  if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
1125
1292
  return { width: finite(p.width) ? p.width : 0, height: 1 };
@@ -1136,6 +1303,7 @@ export class LayoutPass {
1136
1303
  case 'toggle':
1137
1304
  return this.measureToggle(node, ctx, p);
1138
1305
  case 'textfield': {
1306
+ if (node.props['axis'] === 'vertical') return this.measureMultilineField(node, ctx, p);
1139
1307
  const style = str(node.props['style']);
1140
1308
  return {
1141
1309
  width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
@@ -1144,7 +1312,11 @@ export class LayoutPass {
1144
1312
  }
1145
1313
  case 'color':
1146
1314
  case 'shape':
1315
+ case 'canvas':
1147
1316
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1317
+ case 'video':
1318
+ // Fills the proposal; an open axis follows the video's aspect ratio.
1319
+ return videoSize(node.props, p);
1148
1320
  case 'scrollview':
1149
1321
  return this.scrollPlan(node, ctx, p).size;
1150
1322
  case 'list':
@@ -1230,8 +1402,26 @@ export class LayoutPass {
1230
1402
  }
1231
1403
  }
1232
1404
 
1405
+ /**
1406
+ * A vertical `textfield`: the proposed width (200 when unconstrained) and
1407
+ * as many lines as its text (or, empty, its placeholder) wraps to in the
1408
+ * context font, between the range's `minLines` and `lineLimit`.
1409
+ */
1410
+ private measureMultilineField(node: LayoutNode, ctx: Context, p: Proposal): Size {
1411
+ const inset = str(node.props['style']) === 'roundedBorder' ? TEXTFIELD_ROUNDED_INSET : { horizontal: 0, vertical: 0 };
1412
+ const width = finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH;
1413
+ const text = str(node.props['text']) || str(node.props['placeholder']) || '';
1414
+ const wrapped = wrapText(text, ctx.font, Math.max(1, width - 2 * inset.horizontal), null, this.measurer);
1415
+ const min = Math.max(1, ctx.minLines ?? 1);
1416
+ const max = Math.max(min, ctx.lineLimit ?? Infinity);
1417
+ const lines = Math.min(Math.max(wrapped.lines.length, min), max);
1418
+ return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
1419
+ }
1420
+
1233
1421
  private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
1234
1422
  return this.plan(node, p, () => {
1423
+ const runs = node.props['runs'];
1424
+ if (Array.isArray(runs)) return this.richTextPlan(runs, ctx, p);
1235
1425
  const text = str(node.props['text']) ?? '';
1236
1426
  const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
1237
1427
  return {
@@ -1246,6 +1436,47 @@ export class LayoutPass {
1246
1436
  });
1247
1437
  }
1248
1438
 
1439
+ /**
1440
+ * Rich text: each run's font (the text's font, changed by the run's), its
1441
+ * kerning, and an inline image's size; wrapped as one paragraph flow.
1442
+ */
1443
+ private richTextPlan(runs: unknown[], ctx: Context, p: Proposal): TextPlan {
1444
+ const inputs: TextRunInput[] = runs.map((value) => {
1445
+ const run = obj(value) ?? {};
1446
+ let font = resolveFont(run['font'], ctx.font, this.env);
1447
+ if (run['italic'] === true) font = { ...font, css: `italic ${font.css}` };
1448
+ const kerning = num(run['kerning']) ?? 0;
1449
+ const image = obj(run['image']);
1450
+ if (image) return { image: this.inlineImageSize(image, font), font, kerning };
1451
+ return { text: str(run['text']) ?? '', font, kerning };
1452
+ });
1453
+ const wrapped = wrapRuns(inputs, ctx.font, p.width, ctx.lineLimit, this.measurer);
1454
+ const lines = wrapped.lines.map((line) => line.map((fragment) => fragment.text ?? '\uFFFC').join(''));
1455
+ return {
1456
+ size: { width: wrapped.width, height: wrapped.heights.reduce((total, height) => total + height, 0) },
1457
+ layout: {
1458
+ font: ctx.font,
1459
+ lines,
1460
+ alignment: ctx.textAlignment,
1461
+ truncated: wrapped.truncated,
1462
+ rich: { lines: wrapped.lines, heights: wrapped.heights, fonts: inputs.map((input) => input.font) },
1463
+ },
1464
+ };
1465
+ }
1466
+
1467
+ /** An image in a text: a bitmap or remote image at its size, a symbol a square of the line height. */
1468
+ private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
1469
+ if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
1470
+ return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
1471
+ }
1472
+ const name = str(props['name']);
1473
+ if (name === null) return square(font.lineHeight);
1474
+ const asset = this.env.assets?.images[name];
1475
+ if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1476
+ if (asset.kind === 'symbol') return square(font.lineHeight);
1477
+ return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
1478
+ }
1479
+
1249
1480
  private measureSpacer(node: LayoutNode, ctx: Context, p: Proposal): Size {
1250
1481
  const min = num(node.props['minLength']);
1251
1482
  if (ctx.stackAxis === null) {
@@ -1280,19 +1511,31 @@ export class LayoutPass {
1280
1511
 
1281
1512
  private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
1282
1513
  const label = this.contentOf(node);
1283
- const cctx = this.childContext(node, ctx);
1284
- 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);
1285
1517
  const inner: Proposal = {
1286
- width: shrink(p.width, 2 * BORDERED_PAD_H),
1287
- height: shrink(p.height, 2 * BORDERED_PAD_V),
1518
+ width: shrink(p.width, 2 * padH),
1519
+ height: shrink(p.height, 2 * padV),
1288
1520
  };
1289
1521
  const size = label ? this.measure(label, cctx, inner) : ZERO;
1290
1522
  return {
1291
- width: size.width + 2 * BORDERED_PAD_H,
1292
- 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),
1293
1525
  };
1294
1526
  }
1295
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
+
1296
1539
  private toggleLabelProposal(p: Proposal): Proposal {
1297
1540
  return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
1298
1541
  }
@@ -1325,7 +1568,11 @@ export class LayoutPass {
1325
1568
  const detents = node.props['detents'];
1326
1569
  const first = Array.isArray(detents) ? str(detents[0]) : null;
1327
1570
  const { screen, safeArea } = this.env;
1328
- if (first === 'medium') return screen.height / 2;
1571
+ if (first === 'medium') return Math.min(SHEET_MEDIUM_HEIGHT, screen.height - safeArea.top - SHEET_TOP_GAP);
1572
+ // `.height(h)` / `.fraction(f)` detents, at most as tall as `large`.
1573
+ const large = screen.height - safeArea.top - SHEET_TOP_GAP;
1574
+ if (first?.startsWith('height:')) return Math.min(large, Number(first.slice(7)) || large);
1575
+ if (first?.startsWith('fraction:')) return Math.min(large, screen.height * (Number(first.slice(9)) || 1));
1329
1576
  // Phase 8: `full` (`.fullScreenCover`) covers the whole screen.
1330
1577
  if (first === 'full') return screen.height;
1331
1578
  return screen.height - safeArea.top - SHEET_TOP_GAP;
@@ -1337,6 +1584,19 @@ export class LayoutPass {
1337
1584
  return Array.isArray(detents) && str(detents[0]) === 'full';
1338
1585
  }
1339
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
+
1340
1600
  // -------------------------------------------------------------------------
1341
1601
  // Picker (Phase 4)
1342
1602
  // -------------------------------------------------------------------------
@@ -1463,11 +1723,15 @@ export class LayoutPass {
1463
1723
  * least flexible first. A child is offered `remaining / childrenLeft`, where
1464
1724
  * `remaining` excludes the minimum sizes of every lower-priority child and
1465
1725
  * `childrenLeft` counts only the unsized children of its own priority group
1466
- * with *finite* flexibility: infinitely flexible children (spacers, colors,
1726
+ * with *finite* flexibility: infinitely flexible children (colors,
1467
1727
  * `maxWidth: infinity` frames, text fields) only reserve their minimum and
1468
1728
  * share whatever is left once everything else is sized, which is why a
1469
- * `Spacer` never makes its sibling text wrap. With a `null` axis proposal
1470
- * 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.
1471
1735
  */
1472
1736
  private layoutStack(
1473
1737
  children: readonly LayoutNode[],
@@ -1494,6 +1758,7 @@ export class LayoutPass {
1494
1758
  const mins = new Array<number>(n);
1495
1759
  const flex = new Array<number>(n);
1496
1760
  const priorities = new Array<number>(n);
1761
+ const spacers = children.map(isSpacer);
1497
1762
  const minProposal = makeProposal(axis, 0, cross);
1498
1763
  const maxProposal = makeProposal(axis, Infinity, cross);
1499
1764
  for (let i = 0; i < n; i++) {
@@ -1511,6 +1776,7 @@ export class LayoutPass {
1511
1776
  const fa = flex[a] as number;
1512
1777
  const fb = flex[b] as number;
1513
1778
  if (fa !== fb) return fa < fb ? -1 : 1;
1779
+ if (spacers[a] !== spacers[b]) return spacers[a] ? 1 : -1;
1514
1780
  return a - b;
1515
1781
  });
1516
1782
 
@@ -1527,10 +1793,15 @@ export class LayoutPass {
1527
1793
  let finiteLeft = 0;
1528
1794
  let infiniteLeft = 0;
1529
1795
  let infiniteMin = 0;
1796
+ let spacersLeft = 0;
1797
+ let spacerMin = 0;
1530
1798
  for (let k = i; k < end; k++) {
1531
1799
  const index = order[k] as number;
1532
1800
  if (Number.isFinite(flex[index])) finiteLeft++;
1533
- else {
1801
+ else if (spacers[index]) {
1802
+ spacersLeft++;
1803
+ spacerMin += mins[index] as number;
1804
+ } else {
1534
1805
  infiniteLeft++;
1535
1806
  infiniteMin += mins[index] as number;
1536
1807
  }
@@ -1538,16 +1809,21 @@ export class LayoutPass {
1538
1809
  for (let k = i; k < end; k++) {
1539
1810
  const index = order[k] as number;
1540
1811
  const isInfinite = !Number.isFinite(flex[index]);
1812
+ const isSpacerChild = isInfinite && spacers[index] === true;
1541
1813
  let offer: number;
1542
1814
  if (!Number.isFinite(remaining)) offer = Infinity;
1543
- else if (isInfinite) offer = Math.max(0, remaining - lowerMin) / infiniteLeft;
1544
- 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;
1545
1818
  const proposal = makeProposal(axis, offer, cross);
1546
1819
  const size = this.measure(children[index] as LayoutNode, cctx, proposal);
1547
1820
  childProposals[index] = proposal;
1548
1821
  childSizes[index] = size;
1549
1822
  if (Number.isFinite(remaining)) remaining = Math.max(0, remaining - axisOf(size, axis));
1550
- if (isInfinite) {
1823
+ if (isSpacerChild) {
1824
+ spacersLeft--;
1825
+ spacerMin -= mins[index] as number;
1826
+ } else if (isInfinite) {
1551
1827
  infiniteLeft--;
1552
1828
  infiniteMin -= mins[index] as number;
1553
1829
  } else finiteLeft--;
@@ -1582,12 +1858,27 @@ export class LayoutPass {
1582
1858
  const anchors = alignmentAnchors(alignment);
1583
1859
  const crossAnchor = axis === 'horizontal' ? anchors[1] : anchors[0];
1584
1860
  const crossSize = crossOf(size, axis);
1861
+ // `.alignmentGuide`: the children's guides for the stack's alignment
1862
+ // line up. A child's guide is its explicit one, else the default (none,
1863
+ // half or all of its cross size); the line is the stack's own guide, or
1864
+ // further along when a guide reaches past it (the children move with it).
1865
+ const guideAxis = axis === 'horizontal' ? 'vertical' : 'horizontal';
1866
+ const guideName = guideNames(anchors)[axis === 'horizontal' ? 1 : 0];
1867
+ const guides = children.map((child, i) => {
1868
+ const explicit = explicitGuide(child, guideAxis, guideName);
1869
+ return explicit ? guideValue(explicit, plan.childSizes[i] as Size) : null;
1870
+ });
1871
+ const line = guides.some((g) => g !== null) && Number.isFinite(crossSize)
1872
+ ? Math.max(crossSize * crossAnchor, ...guides.filter((g): g is number => g !== null))
1873
+ : null;
1585
1874
  let cursor = 0;
1586
1875
  for (let i = 0; i < children.length; i++) {
1587
1876
  const child = children[i] as LayoutNode;
1588
1877
  const childSize = plan.childSizes[i] as Size;
1589
1878
  const proposal = plan.childProposals[i] as Proposal;
1590
- const crossOffset = safeOffset(crossSize, crossOf(childSize, axis), crossAnchor);
1879
+ const crossOffset = line === null
1880
+ ? safeOffset(crossSize, crossOf(childSize, axis), crossAnchor)
1881
+ : line - (guides[i] ?? crossOf(childSize, axis) * crossAnchor);
1591
1882
  if (axis === 'horizontal') this.place(child, cctx, proposal, x + cursor, y + crossOffset);
1592
1883
  else this.place(child, cctx, proposal, x + crossOffset, y + cursor);
1593
1884
  cursor += axisOf(childSize, axis) + spacing;
@@ -1606,6 +1897,13 @@ export class LayoutPass {
1606
1897
  * fixed 44×44 so the placeholder is visible.
1607
1898
  */
1608
1899
  private measureImage(node: LayoutNode, ctx: Context, p: Proposal): Size {
1900
+ if (typeof node.props['url'] === 'string' || typeof node.props['bitmap'] === 'object') {
1901
+ // Phase 13: a remote image carries its own point size, as does a bitmap.
1902
+ const width = num(node.props['width']) ?? 0;
1903
+ const height = num(node.props['height']) ?? 0;
1904
+ if (node.props['resizable'] !== true) return { width, height };
1905
+ return { width: p.width ?? width, height: p.height ?? height };
1906
+ }
1609
1907
  const name = str(node.props['name']);
1610
1908
  if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
1611
1909
  const asset = this.env.assets?.images[name];
@@ -1815,6 +2113,8 @@ export class LayoutPass {
1815
2113
  // sections are full width. `plain` also drops the top/bottom gaps and
1816
2114
  // the space between sections.
1817
2115
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
2116
+ // `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
2117
+ const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
1818
2118
  const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
1819
2119
  const plain = listStyle === 'plain';
1820
2120
  const listTop = plain ? 0 : LIST_TOP;
@@ -1824,7 +2124,13 @@ export class LayoutPass {
1824
2124
  const cardWidth = Math.max(0, layoutWidth - 2 * inset);
1825
2125
  const contentX = inset + LIST_CELL_PADDING;
1826
2126
  const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
2127
+ // iOS 26: headers in the headline size (semibold), footers in footnote.
1827
2128
  const headerFont = makeFont(
2129
+ 'system',
2130
+ textStyleSize('headline', this.env.dynamicTypeSize),
2131
+ 600,
2132
+ );
2133
+ const footerFont = makeFont(
1828
2134
  'system',
1829
2135
  textStyleSize('footnote', this.env.dynamicTypeSize),
1830
2136
  400,
@@ -1834,49 +2140,78 @@ export class LayoutPass {
1834
2140
  // its content shifts right by as much.
1835
2141
  const editing = node.props['editing'] === true;
1836
2142
 
1837
- // Group consecutive plain rows into an implicit section.
1838
- const groupsSource: { section: LayoutNode | null; rows: LayoutNode[] }[] = [];
2143
+ // Group consecutive plain rows into an implicit section. A section's
2144
+ // view header and footer are not rows.
2145
+ type GroupSource = { section: LayoutNode | null; rows: LayoutNode[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
2146
+ const groupsSource: GroupSource[] = [];
1839
2147
  for (const child of node.children) {
1840
2148
  if (child.kind === 'section') {
1841
- groupsSource.push({ section: child, rows: child.children });
2149
+ const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
2150
+ const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
2151
+ const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
2152
+ groupsSource.push({ section: child, rows, headerView, footerView });
1842
2153
  } else {
1843
2154
  const last = groupsSource[groupsSource.length - 1];
1844
2155
  if (last && last.section === null) last.rows.push(child);
1845
- else groupsSource.push({ section: null, rows: [child] });
2156
+ else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
1846
2157
  }
1847
2158
  }
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 => {
2164
+ if (!view) return null;
2165
+ const proposal: Proposal = { width: contentWidth, height: null };
2166
+ const size = this.measure(view, ctx, proposal);
2167
+ return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2168
+ };
1848
2169
 
1849
2170
  const groups: ListGroupPlan[] = [];
1850
2171
  let y = listTop;
1851
- const makeText = (text: string): { layout: TextLayout; size: Size } => {
1852
- 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);
1853
2174
  return {
1854
- layout: { font: headerFont, lines: wrapped.lines, alignment: 'leading', truncated: false },
1855
- 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 },
1856
2177
  };
1857
2178
  };
1858
2179
  for (let g = 0; g < groupsSource.length; g++) {
1859
- const source = groupsSource[g] as { section: LayoutNode | null; rows: LayoutNode[] };
2180
+ const source = groupsSource[g] as GroupSource;
1860
2181
  if (g > 0) y += sectionGap;
1861
2182
  const top = y;
1862
2183
  let header: ListGroupPlan['header'] = null;
1863
2184
  let footer: ListGroupPlan['footer'] = null;
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;
1864
2190
  const headerText = source.section ? str(source.section.props['header']) : null;
1865
2191
  const footerText = source.section ? str(source.section.props['footer']) : null;
1866
2192
  if (headerText !== null && headerText.length > 0) {
1867
- const { layout, size } = makeText(headerText);
1868
- header = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
1869
- 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;
1870
2197
  }
1871
2198
  const cardTop = y;
1872
2199
  const rows: ListRowPlan[] = [];
1873
2200
  for (const rowNode of source.rows) {
1874
- // A picker already reports its row height (44 for a menu, 44 per
1875
- // option inline); other rows get the 11px vertical padding. Phase 6
2201
+ if (rowNode.kind === 'lazyspacer') {
2202
+ // Lazy rows not built yet: a row of no height, without a cell.
2203
+ const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
2204
+ rows.push({ node: rowNode, proposal: { width: cardWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
2205
+ continue;
2206
+ }
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
1876
2210
  // `listRowInsets` replaces all four insets; a `badge` reserves its
1877
2211
  // width (plus a gap) at the trailing edge.
1878
2212
  const hints = rowHintsOf(rowNode);
1879
- 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;
1880
2215
  const insets: EdgeInsets = hints.insets ?? {
1881
2216
  top: defaultVPad,
1882
2217
  leading: LIST_CELL_PADDING,
@@ -1891,7 +2226,7 @@ export class LayoutPass {
1891
2226
  const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
1892
2227
  const proposal: Proposal = { width: rowContentWidth, height: null };
1893
2228
  const contentSize = this.measure(rowNode, rowCtx, proposal);
1894
- const rowHeight = Math.max(LIST_ROW_MIN_HEIGHT, contentSize.height + insets.top + insets.bottom);
2229
+ const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
1895
2230
  const row: Rect = { x: cardX, y, width: cardWidth, height: rowHeight };
1896
2231
  rows.push({
1897
2232
  node: rowNode,
@@ -1912,21 +2247,27 @@ export class LayoutPass {
1912
2247
  }
1913
2248
  : null,
1914
2249
  edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
2250
+ separatorHidden: hints.separatorHidden,
1915
2251
  });
1916
2252
  y += rowHeight;
1917
2253
  }
1918
2254
  const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
1919
2255
  if (footerText !== null && footerText.length > 0) {
1920
2256
  y += LIST_FOOTER_GAP;
1921
- const { layout, size } = makeText(footerText);
2257
+ const { layout, size } = makeText(footerText, footerFont);
1922
2258
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
1923
2259
  y += size.height;
1924
2260
  }
2261
+ let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
2262
+ if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
2263
+ else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
1925
2264
  groups.push({
1926
2265
  section: source.section,
1927
2266
  frame: { x: cardX, y: top, width: cardWidth, height: y - top },
1928
2267
  header,
1929
2268
  footer,
2269
+ headerView,
2270
+ footerView,
1930
2271
  card,
1931
2272
  rows,
1932
2273
  });
@@ -1944,15 +2285,21 @@ export class LayoutPass {
1944
2285
  // Place
1945
2286
  // -------------------------------------------------------------------------
1946
2287
 
1947
- place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
1948
- // Phase 9: placed before, same proposal, same origin, nothing in the
1949
- // subtree invalidated since: the last run's output still holds.
1950
- const key = proposalKey(p);
2288
+ /**
2289
+ * Phase 9: placed before, same proposal, same origin, nothing in the
2290
+ * subtree invalidated since: the last run's output still holds.
2291
+ */
2292
+ private placedAlready(node: LayoutNode, p: Proposal, x: number, y: number): boolean {
1951
2293
  const last = this.placed.get(node);
1952
- if (last && last.key === key && last.x === x && last.y === y) {
2294
+ return last !== undefined && last.key === proposalKey(p) && last.x === x && last.y === y;
2295
+ }
2296
+
2297
+ place(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2298
+ if (this.placedAlready(node, p, x, y)) {
1953
2299
  this.stats.skipped += 1;
1954
2300
  return;
1955
2301
  }
2302
+ const key = proposalKey(p);
1956
2303
  this.placed.set(node, { key, x, y });
1957
2304
  this.stats.placed += 1;
1958
2305
  if (node.id >= 0) {
@@ -1979,11 +2326,17 @@ export class LayoutPass {
1979
2326
  case 'image':
1980
2327
  if (node.id >= 0) this.fonts.set(node.id, scaledFont(ctx.font, ctx.imageScale));
1981
2328
  return;
2329
+ case 'textfield':
2330
+ // A vertical field draws its text in the context font (one-line fields in the body font).
2331
+ if (node.props['axis'] === 'vertical' && node.id >= 0) this.fonts.set(node.id, ctx.font);
2332
+ return;
1982
2333
  case 'spacer':
2334
+ case 'lazyspacer':
1983
2335
  case 'divider':
1984
- case 'textfield':
1985
2336
  case 'color':
1986
2337
  case 'shape':
2338
+ case 'canvas':
2339
+ case 'video':
1987
2340
  return;
1988
2341
  case 'vstack':
1989
2342
  case 'hstack': {
@@ -1998,7 +2351,7 @@ export class LayoutPass {
1998
2351
  const alignment = str(node.props['alignment']) ?? 'center';
1999
2352
  for (const child of node.children) {
2000
2353
  const childSize = this.measure(child, cctx, p);
2001
- const { dx, dy } = alignIn(size, childSize, alignment);
2354
+ const { dx, dy } = alignChild(size, childSize, alignment, child);
2002
2355
  this.place(child, cctx, p, x + dx, y + dy);
2003
2356
  }
2004
2357
  return;
@@ -2038,7 +2391,7 @@ export class LayoutPass {
2038
2391
  );
2039
2392
  return;
2040
2393
  }
2041
- const { dx, dy } = alignIn(box, plan.childSize, plan.alignment);
2394
+ const { dx, dy } = alignChild(box, plan.childSize, plan.alignment, child);
2042
2395
  this.place(child, cctx, plan.childProposal, x + box.x + dx, y + box.y + dy);
2043
2396
  }
2044
2397
  return;
@@ -2050,13 +2403,15 @@ export class LayoutPass {
2050
2403
  this.place(label, cctx, p, x, y);
2051
2404
  return;
2052
2405
  }
2406
+ const metrics = this.borderedMetrics(node);
2407
+ const labelCtx = this.borderedLabelContext(node, ctx);
2053
2408
  const inner: Proposal = {
2054
- width: shrink(p.width, 2 * BORDERED_PAD_H),
2055
- height: shrink(p.height, 2 * BORDERED_PAD_V),
2409
+ width: shrink(p.width, 2 * metrics.padH),
2410
+ height: shrink(p.height, 2 * metrics.padV),
2056
2411
  };
2057
- const labelSize = this.measure(label, cctx, inner);
2412
+ const labelSize = this.measure(label, labelCtx, inner);
2058
2413
  const { dx, dy } = alignIn(size, labelSize, 'center');
2059
- this.place(label, cctx, inner, x + dx, y + dy);
2414
+ this.place(label, labelCtx, inner, x + dx, y + dy);
2060
2415
  // Keep the list row chrome (`row`, `badge`) a list set before placing.
2061
2416
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
2062
2417
  this.setChrome(node, {
@@ -2124,14 +2479,19 @@ export class LayoutPass {
2124
2479
  // centered on the other axes when smaller than the viewport.
2125
2480
  const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2126
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;
2127
2484
  const content: Rect = {
2128
2485
  x: x + dx,
2129
- y: y + dy,
2486
+ y: y + dy + (titleRect?.height ?? 0),
2130
2487
  width: plan.contentSize.width,
2131
2488
  height: plan.contentSize.height,
2132
2489
  };
2133
2490
  const covered = this.coveredBottom(ctx, x, y, size);
2134
- 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);
2135
2495
  if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2136
2496
  return;
2137
2497
  }
@@ -2208,7 +2568,7 @@ export class LayoutPass {
2208
2568
  const proposal: Proposal = { width: size.width, height: size.height };
2209
2569
  const decorationSize = this.measure(decoration, cctx, proposal);
2210
2570
  const alignment = str(node.props['alignment']) ?? 'center';
2211
- const { dx, dy } = alignIn(size, decorationSize, alignment);
2571
+ const { dx, dy } = alignChild(size, decorationSize, alignment, decoration);
2212
2572
  this.place(decoration, cctx, proposal, x + dx, y + dy);
2213
2573
  }
2214
2574
  return;
@@ -2271,9 +2631,16 @@ export class LayoutPass {
2271
2631
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2272
2632
  const plan = this.listPlan(node, ctx, p);
2273
2633
  const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2274
- if (node.id >= 0) this.contentSizes.set(node.id, plan.contentSize);
2275
2634
  const covered = this.coveredBottom(ctx, x, y, plan.size);
2276
- 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;
2277
2644
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
2278
2645
  for (const group of plan.groups) {
2279
2646
  if (group.section) {
@@ -2289,6 +2656,9 @@ export class LayoutPass {
2289
2656
  }
2290
2657
  this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
2291
2658
  this.setChrome(group.section, chrome);
2659
+ for (const view of [group.headerView, group.footerView]) {
2660
+ if (view) this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
2661
+ }
2292
2662
  if (group.section.id >= 0) {
2293
2663
  this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
2294
2664
  if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
@@ -2297,6 +2667,10 @@ export class LayoutPass {
2297
2667
  for (const row of group.rows) {
2298
2668
  // Set the row chrome before placing so a navlink can merge its own keys.
2299
2669
  const chrome: Record<string, Rect> = { row: offset(row.row) };
2670
+ // `.listRowSeparator(.hidden)`: flags the renderer turns into data
2671
+ // attributes on the row element (its separator line is CSS).
2672
+ if (row.separatorHidden?.top) chrome['separatorTopHidden'] = offset(row.row);
2673
+ if (row.separatorHidden?.bottom) chrome['separatorBottomHidden'] = offset(row.row);
2300
2674
  if (row.badge) {
2301
2675
  chrome['badge'] = offset(row.badge.rect);
2302
2676
  if (row.node.id >= 0) this.chromeText.set(row.node.id, { badge: row.badge.layout });
@@ -2307,42 +2681,72 @@ export class LayoutPass {
2307
2681
  if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
2308
2682
  else this.setChrome(row.edit.listRow, { editControl: offset(row.edit.control) });
2309
2683
  }
2310
- this.setChrome(row.node, chrome);
2311
- this.place(row.node, rowCtx, row.proposal, x + row.content.x, y + row.content.y);
2684
+ const rowX = x + row.content.x;
2685
+ const rowY = y + row.content.y;
2686
+ // A row that is skipped keeps the chrome it set itself when it was
2687
+ // placed (a link's chevron and label); only the list's keys are new.
2688
+ const own = this.placedAlready(row.node, row.proposal, rowX, rowY) && row.node.id >= 0
2689
+ ? this.chrome.get(row.node.id)
2690
+ : undefined;
2691
+ this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
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
+ }
2312
2701
  }
2313
2702
  }
2314
2703
  }
2315
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
+
2316
2718
  /**
2317
- * The navigation bar is the status bar plus a 44 row (plus 52 for a large
2318
- * title, plus 52 for a Phase 6 `searchfield`); a `bottom` toolbar is a 49
2319
- * bar plus the bottom safe area; the content is proposed the rest. Toolbar
2320
- * items are measured at their ideal size and laid out as a 16-spaced hstack
2321
- * 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
2322
2725
  * trailing edge, `principal` centered in place of the title) or spread
2323
2726
  * evenly across the bottom bar. The inline title (`chrome.title`) is
2324
2727
  * centered and truncated to the room the bar items leave.
2325
2728
  */
2326
2729
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2327
2730
  const { safeArea, screen, dynamicTypeSize } = this.env;
2328
- const statusBar = y <= 0 ? safeArea.top : 0;
2731
+ const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
2329
2732
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
2330
2733
  const depth = num(node.props['depth']) ?? 0;
2331
2734
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2332
2735
  const barHeight = statusBar + NAV_BAR_HEIGHT;
2333
2736
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2334
2737
  const parts = this.navScreenParts(node);
2335
- const searchHeight = parts.search ? SEARCH_AREA_HEIGHT : 0;
2336
2738
  const reachesBottom = y + size.height >= screen.height - EPSILON;
2337
2739
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
2338
2740
  const bottomBarHeight =
2339
2741
  bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
2340
- 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;
2341
2745
  const content: Rect = {
2342
2746
  x,
2343
2747
  y: y + contentTop,
2344
2748
  width: size.width,
2345
- height: Math.max(0, size.height - contentTop - bottomBarHeight),
2749
+ height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
2346
2750
  };
2347
2751
  const chrome: Record<string, Rect> = {
2348
2752
  bar: { x, y, width: size.width, height: barHeight },
@@ -2352,9 +2756,9 @@ export class LayoutPass {
2352
2756
  if (parts.search) {
2353
2757
  chrome['searchField'] = {
2354
2758
  x: x + SEARCH_FIELD_MARGIN_H,
2355
- y: y + barHeight + largeHeight + SEARCH_FIELD_PAD_V,
2759
+ y: y + size.height - searchBottom,
2356
2760
  width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
2357
- height: SEARCH_FIELD_HEIGHT,
2761
+ height: SEARCH_BOTTOM_HEIGHT,
2358
2762
  };
2359
2763
  }
2360
2764
  if (bottomBarHeight > 0) {
@@ -2371,60 +2775,56 @@ export class LayoutPass {
2371
2775
  toolbar: LayoutNode;
2372
2776
  placement: string;
2373
2777
  sizes: Size[];
2778
+ /** Leading and trailing groups: each item's glass slot width. */
2779
+ slots: number[] | null;
2374
2780
  total: number;
2375
2781
  }
2376
2782
  const groups: Group[] = parts.toolbars.map((toolbar) => {
2377
2783
  const itemCtx = this.childContext(toolbar, cctx);
2378
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
+ }
2379
2790
  const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
2380
- return { toolbar, placement: str(toolbar.props['placement']) ?? 'trailing', sizes, total };
2791
+ return { toolbar, placement, sizes, slots: null, total };
2381
2792
  });
2382
2793
  const groupWidth = (placement: string): number =>
2383
- 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);
2384
2795
  const leadingWidth = groupWidth('leading');
2385
2796
  const trailingWidth = groupWidth('trailing');
2386
2797
  const principal = groups.some((g) => g.placement === 'principal');
2387
2798
 
2388
- // Back button, like iOS: the previous title when it fits beside the
2389
- // centered inline title, otherwise "Back", otherwise the chevron alone.
2390
- let backLabel = '';
2799
+ // Back button, iOS 26: the chevron alone in a glass circle (the previous
2800
+ // title no longer shows).
2801
+ const backLabel = '';
2391
2802
  if (depth > 0) {
2392
- const inlineTitleWidth = large || principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
2393
- const available =
2394
- (large ? size.width - BACK_CHEVRON_WIDTH - 16 - 44 : (size.width - inlineTitleWidth) / 2 - 8 - 12) - leadingWidth;
2395
- const candidates = [this.previousTitles.get(node) ?? '', 'Back', ''];
2396
- let width = BACK_CHEVRON_WIDTH + 8;
2397
- for (const candidate of candidates) {
2398
- const labelWidth = candidate ? this.measurer.measureWidth(candidate, backFont) : 0;
2399
- const needed = 8 + BACK_CHEVRON_WIDTH + labelWidth + 8;
2400
- if (candidate === '' || needed <= available) {
2401
- backLabel = candidate;
2402
- width = needed;
2403
- break;
2404
- }
2405
- }
2406
- 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 };
2407
2804
  }
2408
2805
 
2409
2806
  // Place the toolbar groups and remember how much of the row they occupy.
2410
2807
  const back = chrome['back'];
2411
- 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;
2412
2809
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2413
2810
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2414
2811
  const itemCtx = this.childContext(group.toolbar, cctx);
2415
2812
  let cursor = startX;
2416
2813
  group.toolbar.children.forEach((item, i) => {
2417
2814
  const itemSize = group.sizes[i] as Size;
2418
- this.place(item, itemCtx, { width: null, height: null }, cursor, top + (rowHeight - itemSize.height) / 2);
2419
- 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;
2420
2820
  });
2421
2821
  };
2422
2822
  for (const group of groups) {
2423
2823
  switch (group.placement) {
2424
2824
  case 'leading': {
2425
2825
  this.setFrame(group.toolbar, leadingCursor, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2426
- placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2427
- leadingCursor += group.total + NAV_ITEM_SPACING;
2826
+ placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
2827
+ leadingCursor += group.total + NAV_GROUP_SPACING;
2428
2828
  break;
2429
2829
  }
2430
2830
  case 'principal': {
@@ -2449,25 +2849,38 @@ export class LayoutPass {
2449
2849
  default: {
2450
2850
  const startX = trailingEdge - group.total;
2451
2851
  this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
2452
- placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2453
- trailingEdge = startX - NAV_ITEM_SPACING;
2852
+ placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
2853
+ trailingEdge = startX - NAV_GROUP_SPACING;
2454
2854
  break;
2455
2855
  }
2456
2856
  }
2457
2857
  }
2458
2858
  if (parts.search) {
2859
+ // The field is the whole capsule (its own measure is a 36pt field).
2459
2860
  const field = chrome['searchField'] as Rect;
2460
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 });
2461
2863
  }
2462
2864
 
2463
2865
  // The inline title: centered, no wider than 56% of the bar, and clear of
2464
- // whatever the back button and the bar items occupy on either side.
2465
- if (!large && !principal) {
2466
- const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_ITEM_SPACING - x : 0);
2467
- const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_ITEM_SPACING) : 0;
2866
+ // whatever the back button and the bar items occupy on either side. Its
2867
+ // width is rounded up to whole pixels: snapping floors both edges, which
2868
+ // takes up to a pixel off a fractional width at a fractional origin, and
2869
+ // the title truncates with an ellipsis ("Animati…") when its box is any
2870
+ // narrower than its text.
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;
2468
2881
  const side = Math.max(leftUsed, rightUsed) + 8;
2469
2882
  const available = Math.max(0, size.width - 2 * side);
2470
- const titleWidth = Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56, available);
2883
+ const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
2471
2884
  chrome['title'] = { x: x + (size.width - titleWidth) / 2, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
2472
2885
  }
2473
2886
  this.setChrome(node, chrome);
@@ -2498,8 +2911,17 @@ export class LayoutPass {
2498
2911
  bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2499
2912
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2500
2913
  // large title / search field), which is transparent over it.
2501
- const contentCtx: Context = { ...cctx, safeTop: content.y };
2502
- 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')) {
2503
2925
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2504
2926
  return;
2505
2927
  }
@@ -2511,25 +2933,38 @@ export class LayoutPass {
2511
2933
  }
2512
2934
 
2513
2935
  /**
2514
- * The tab bar is 49 plus the bottom safe area (when the tab view reaches
2515
- * the screen bottom), with one equal-width item per tab. Every `tab` gets
2516
- * the tab view's frame; only the selected one lays its content out, the
2517
- * 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`.
2518
2941
  */
2519
2942
  private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2520
- const { screen, safeArea } = this.env;
2521
- const bottomSafe = y + size.height >= screen.height - EPSILON ? safeArea.bottom : 0;
2522
- const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + bottomSafe);
2523
- 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
+ };
2524
2953
  const chrome: Record<string, Rect> = {
2525
2954
  bar,
2526
2955
  content: { x, y, width: size.width, height: size.height - barHeight },
2527
2956
  };
2528
2957
  const tabs = node.children;
2529
2958
  const n = tabs.length;
2959
+ const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
2530
2960
  for (let i = 0; i < n; i++) {
2531
- const itemWidth = size.width / n;
2532
- 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
+ };
2533
2968
  }
2534
2969
  this.setChrome(node, chrome);
2535
2970
  const selected = readSelected(node, n);
@@ -2775,9 +3210,9 @@ export class LayoutPass {
2775
3210
  fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
2776
3211
  thumb: {
2777
3212
  x: thumbCenter - SLIDER_THUMB / 2,
2778
- y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB) / 2,
3213
+ y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
2779
3214
  width: SLIDER_THUMB,
2780
- height: SLIDER_THUMB,
3215
+ height: SLIDER_THUMB_HEIGHT,
2781
3216
  },
2782
3217
  });
2783
3218
  }
@@ -2847,9 +3282,10 @@ export class LayoutPass {
2847
3282
  }
2848
3283
 
2849
3284
  /**
2850
- * `alert` style: a 270 wide card centered on the screen with the title
2851
- * (headline), the message (footnote) and the buttons: two side by side,
2852
- * 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.
2853
3289
  * `dialog` style: an action sheet of 57 rows in a card 8 from the screen
2854
3290
  * edges, above a separate card for the `cancel` button, 8 above the bottom
2855
3291
  * safe area; the title and message are a footnote caption on top. Button
@@ -2865,40 +3301,45 @@ export class LayoutPass {
2865
3301
  const texts: Record<string, TextLayout> = {};
2866
3302
  const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
2867
3303
 
2868
- 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 } => {
2869
3305
  const wrapped = wrapText(text, font, width, null, this.measurer);
2870
3306
  return {
2871
- layout: { font, lines: wrapped.lines, alignment: 'center', truncated: wrapped.truncated ?? false },
3307
+ layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
2872
3308
  height: wrapped.lines.length * font.lineHeight,
2873
3309
  };
2874
3310
  };
3311
+ const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
2875
3312
 
2876
3313
  if (style === 'alert') {
2877
3314
  const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
2878
3315
  const textWidth = cardWidth - 2 * ALERT_PAD_H;
2879
3316
  const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
2880
- const messageFont = resolveFont({ textStyle: 'footnote' }, ctx.font, this.env);
3317
+ const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
2881
3318
  // Heights first (the card is centered), then the rects.
2882
3319
  let h = ALERT_PAD_TOP;
2883
3320
  let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
2884
3321
  let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
2885
3322
  if (title.length > 0) {
2886
- const block = centered(title, titleFont, textWidth);
2887
- titleBlock = { ...block, top: h };
2888
- h += block.height;
3323
+ const b = block(title, titleFont, textWidth, 'leading');
3324
+ titleBlock = { ...b, top: h };
3325
+ h += b.height;
2889
3326
  }
2890
3327
  if (message.length > 0) {
2891
3328
  if (titleBlock) h += ALERT_TITLE_GAP;
2892
- const block = centered(message, messageFont, textWidth);
2893
- messageBlock = { ...block, top: h };
2894
- h += block.height;
3329
+ const b = block(message, messageFont, textWidth, 'leading');
3330
+ messageBlock = { ...b, top: h };
3331
+ h += b.height;
2895
3332
  }
2896
- h += ALERT_PAD_BOTTOM;
3333
+ h += ALERT_BUTTONS_GAP;
2897
3334
  const headerHeight = h;
2898
3335
  const buttons = node.children;
2899
3336
  const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
2900
- h += rows * ALERT_BUTTON_HEIGHT;
2901
- 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 };
2902
3343
  chrome['card'] = card;
2903
3344
  if (titleBlock) {
2904
3345
  chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
@@ -2909,18 +3350,28 @@ export class LayoutPass {
2909
3350
  texts['message'] = messageBlock.layout;
2910
3351
  }
2911
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);
2912
3355
  if (buttons.length === 0) {
2913
- 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 };
2914
3357
  } else if (buttons.length === 2) {
2915
- const half = cardWidth / 2;
2916
- buttons.forEach((button, i) => {
2917
- 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 };
2918
3364
  chrome[`button${i}`] = rect;
2919
- this.placeAlertButton(button, cctx, rect);
3365
+ this.placeAlertButton(button as LayoutNode, cctx, rect);
2920
3366
  });
2921
3367
  } else {
2922
3368
  buttons.forEach((button, i) => {
2923
- 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
+ };
2924
3375
  chrome[`button${i}`] = rect;
2925
3376
  this.placeAlertButton(button, cctx, rect);
2926
3377
  });
@@ -2943,7 +3394,7 @@ export class LayoutPass {
2943
3394
  h += block.height;
2944
3395
  }
2945
3396
  if (message.length > 0) {
2946
- if (titleBlock) h += ALERT_TITLE_GAP;
3397
+ if (titleBlock) h += DIALOG_TITLE_GAP;
2947
3398
  const block = centered(message, messageFont, textWidth);
2948
3399
  messageBlock = { ...block, top: h };
2949
3400
  h += block.height;
@@ -2961,7 +3412,7 @@ export class LayoutPass {
2961
3412
  const cancelCard: Rect = { x: cardX, y: bottom - DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
2962
3413
  chrome['cancelCard'] = cancelCard;
2963
3414
  chrome[`button${node.children.indexOf(cancel)}`] = cancelCard;
2964
- this.placeAlertButton(cancel, buttonCtx, cancelCard);
3415
+ this.placeAlertButton(cancel, buttonCtx, cancelCard, true);
2965
3416
  cardBottom = cancelCard.y - DIALOG_CARD_GAP;
2966
3417
  }
2967
3418
  const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
@@ -2981,7 +3432,7 @@ export class LayoutPass {
2981
3432
  others.forEach((button, i) => {
2982
3433
  const rect: Rect = { x: card.x, y: buttonsTop + i * DIALOG_BUTTON_HEIGHT, width: cardWidth, height: DIALOG_BUTTON_HEIGHT };
2983
3434
  chrome[`button${node.children.indexOf(button)}`] = rect;
2984
- this.placeAlertButton(button, buttonCtx, rect);
3435
+ this.placeAlertButton(button, buttonCtx, rect, true);
2985
3436
  });
2986
3437
  }
2987
3438
  this.setChrome(node, chrome);
@@ -2989,10 +3440,11 @@ export class LayoutPass {
2989
3440
  }
2990
3441
 
2991
3442
  /** An alert button fills its row; its label is centered in it (any other child is centered as is). */
2992
- private placeAlertButton(child: LayoutNode, ctx: Context, rect: Rect): void {
2993
- const proposal: Proposal = { width: Math.max(0, rect.width - 2 * ALERT_PAD_H), height: rect.height };
2994
- // A `cancel` button is bold: measure (and so draw) its label semibold.
2995
- 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';
2996
3448
  const inherited = this.childContext(child, ctx);
2997
3449
  const cctx: Context = {
2998
3450
  ...inherited,
@@ -3017,9 +3469,16 @@ export class LayoutPass {
3017
3469
  }
3018
3470
 
3019
3471
  private placeSheet(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3020
- // 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);
3021
3480
  const full = this.sheetIsFull(node);
3022
- const grabberRow = full ? 0 : SHEET_GRABBER_ROW;
3481
+ const grabberRow = grabber ? SHEET_GRABBER_ROW : full ? 0 : SHEET_CONTENT_TOP;
3023
3482
  const content: Rect = {
3024
3483
  x,
3025
3484
  y: y + grabberRow,
@@ -3027,7 +3486,8 @@ export class LayoutPass {
3027
3486
  height: Math.max(0, size.height - grabberRow),
3028
3487
  };
3029
3488
  const chrome: Record<string, Rect> = { content };
3030
- if (!full) {
3489
+ if (floats) chrome['floating'] = { x, y, width: size.width, height: size.height };
3490
+ if (grabber) {
3031
3491
  chrome['grabber'] = {
3032
3492
  x: x + (size.width - SHEET_GRABBER.width) / 2,
3033
3493
  y: y + SHEET_GRABBER_TOP,
@@ -3040,8 +3500,14 @@ export class LayoutPass {
3040
3500
  if (!child) return;
3041
3501
  const proposal: Proposal = { width: content.width, height: content.height };
3042
3502
  const kind = unwrapStyled(child).kind;
3043
- if (kind === 'list' || kind === 'scrollview' || kind === 'navstack') {
3044
- 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);
3045
3511
  return;
3046
3512
  }
3047
3513
  const childSize = this.measure(child, cctx, proposal);
@@ -3389,7 +3855,9 @@ export class LayoutPass {
3389
3855
  /**
3390
3856
  * A linear gauge: width = proposal (100 when none), a 4px bar below an
3391
3857
  * optional label row (label leading, current value label trailing, both in
3392
- * the `caption` text style) with a 4px gap. Circular styles render linear.
3858
+ * the `caption` text style) with a 4px gap. A circular gauge is a 58-point
3859
+ * square, whatever the proposal, with the current value label (else the
3860
+ * label) centered in the ring, proposed the square inside it.
3393
3861
  */
3394
3862
  private gaugePlan(node: LayoutNode, ctx: Context, p: Proposal): GaugePlan {
3395
3863
  return this.plan(node, p, () => {
@@ -3399,7 +3867,23 @@ export class LayoutPass {
3399
3867
  let index = 0;
3400
3868
  const label = node.props['hasLabel'] === true ? (node.children[index++] ?? null) : null;
3401
3869
  const current = node.props['hasCurrentValueLabel'] === true ? (node.children[index++] ?? null) : null;
3870
+ const style = str(node.props['style']);
3871
+ if (style === 'accessoryCircular' || style === 'accessoryCircularCapacity') {
3872
+ const center = current ?? label;
3873
+ const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
3874
+ const centerSize = center ? this.measure(center, labelCtx, { width: inner, height: inner }) : ZERO;
3875
+ return { size: square(GAUGE_RING_SIZE), label, current, labelSize: ZERO, currentSize: ZERO, labelRow: 0, labelCtx, ring: { center, centerSize } };
3876
+ }
3402
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
+ }
3403
3887
  const labelSize = label ? this.measure(label, labelCtx, proposal) : ZERO;
3404
3888
  const currentSize = current ? this.measure(current, labelCtx, proposal) : ZERO;
3405
3889
  const labelRow = label || current ? Math.max(labelSize.height, currentSize.height) : 0;
@@ -3410,7 +3894,30 @@ export class LayoutPass {
3410
3894
 
3411
3895
  private placeGauge(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
3412
3896
  const plan = this.gaugePlan(node, ctx, p);
3897
+ if (plan.ring) {
3898
+ const { center, centerSize } = plan.ring;
3899
+ const inner = Math.floor(GAUGE_RING_SIZE * GAUGE_RING_INNER);
3900
+ if (center) {
3901
+ const { dx, dy } = alignIn(size, centerSize, 'center');
3902
+ this.place(center, plan.labelCtx, { width: inner, height: inner }, x + dx, y + dy);
3903
+ }
3904
+ // The other label is not shown (SwiftUI shows one in the middle).
3905
+ for (const other of [plan.label, plan.current]) if (other && other !== center) this.hide(other, node);
3906
+ this.setChrome(node, { ring: { x, y, width: size.width, height: size.height } });
3907
+ return;
3908
+ }
3413
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
+ }
3414
3921
  if (plan.label) {
3415
3922
  this.place(plan.label, plan.labelCtx, proposal, x, y + (plan.labelRow - plan.labelSize.height) / 2);
3416
3923
  }
@@ -3464,13 +3971,13 @@ export class LayoutPass {
3464
3971
  const axisInput = (axis: unknown, fallback: string, views: ChartItemPlan[], key: 'x0' | 'y0'): ChartAxisInput | null => {
3465
3972
  const a = obj(axis);
3466
3973
  if (!a) return null;
3467
- const labels: { at: number; width: number }[] = [];
3974
+ const labels: ChartAxisInput['labels'] = [];
3468
3975
  const rawLabels = Array.isArray(a['labels']) ? (a['labels'] as unknown[]) : [];
3469
3976
  for (const raw of rawLabels) {
3470
3977
  const label = obj(raw);
3471
3978
  if (!label) continue;
3472
3979
  const text = str(label['text']) ?? '';
3473
- labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text) });
3980
+ labels.push({ at: num(label['at']) ?? 0, width: measureWidth(text), leading: label['anchor'] === 'leading' });
3474
3981
  }
3475
3982
  return {
3476
3983
  position: (str(a['position']) ?? fallback) as ChartAxisInput['position'],