@swiftbrowser/web 0.3.0 → 0.4.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.
@@ -19,7 +19,7 @@
19
19
  * those.
20
20
  */
21
21
 
22
- import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textStyleSize } from './typography';
22
+ import { FONT_FAMILIES, defaultFont, makeFont, resolveFont, textHeight, textStyleSize } from './typography';
23
23
  import { wrapRuns, wrapText, type TextRunInput } from './text';
24
24
  import { formatPickerDate, formatPickerTime, sliderFraction } from './controls';
25
25
  import { videoSize } from './video';
@@ -65,13 +65,33 @@ export interface Context {
65
65
  /**
66
66
  * Height of the bar covering the bottom of the screen above the subtree
67
67
  * (a `tabview`'s bar plus the bottom safe area), 0 outside tabs. Scrolling
68
- * views that reach the screen bottom inset their content by it.
68
+ * views that reach the screen bottom inset their content by it. Inside a
69
+ * `navscreen` with a bottom toolbar or search field, the room they take
70
+ * (up from `bottomEdge`).
69
71
  */
70
72
  bottomBar: number;
73
+ /** Where the bar `bottomBar` measures ends: the screen's bottom unless set (a `navscreen`'s bottom). */
74
+ bottomEdge?: number | undefined;
75
+ /**
76
+ * iOS 26: inside a `navscreen` whose content is not itself a list or scroll
77
+ * view, its bar (the band from the screen's top to `top`, `x` to `x + width`):
78
+ * a vertical list or scroll view whose top edge touches it across the
79
+ * screen runs up under it (`scrollEdgeGrowth`).
80
+ */
81
+ barAbove?: { top: number; height: number; x: number; width: number } | undefined;
71
82
  /** Phase 6 `imageScale`: symbol images measure this fraction of the line height (1 by default). */
72
83
  imageScale: number;
73
84
  /** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
74
85
  splitSidebar: boolean;
86
+ /** Rows of a `sidebar`-style list (iPadOS): its links have no chevron. */
87
+ sidebarList?: boolean;
88
+ /**
89
+ * Inside a list row (the whole subtree): a `Label` lays out in iOS's icon
90
+ * column, its icon centred 28 from the card's edge and its title 40 past
91
+ * the content edge, so titles and separators line up across rows whatever
92
+ * the symbols' widths, and with the edit-mode controls.
93
+ */
94
+ labelColumn?: boolean;
75
95
  /**
76
96
  * Phase 8: the y (screen coordinates) where content clears the bars above
77
97
  * it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
@@ -85,6 +105,12 @@ export interface Context {
85
105
  * bar does at the root. Only the stack's own screens see it.
86
106
  */
87
107
  barInset?: number;
108
+ /**
109
+ * The room a split view's sidebar toggle takes at the leading edge of the
110
+ * bar row of the column it sits in (that column's stack's screens shift
111
+ * their back button and leading items past it).
112
+ */
113
+ barLeading?: number;
88
114
  /**
89
115
  * Phase 12: the size of the nearest container (`containerRelativeFrame`):
90
116
  * the screen at the root, a scroll view's viewport inside one.
@@ -127,6 +153,9 @@ const TEXTFIELD_HEIGHT_PLAIN = 22;
127
153
  /** A vertical `roundedBorder` field's text inset: 7 + the 1px border across, (34 - 22) / 2 down. */
128
154
  const TEXTFIELD_ROUNDED_INSET = { horizontal: 8, vertical: 6 };
129
155
  const NAV_BAR_HEIGHT = 44;
156
+ // iOS 26: under an inline title the content starts 10 below the bar's 44
157
+ // row (measured in the iOS 26.2 simulator); in a sheet it does not.
158
+ const NAV_BAR_INLINE_BOTTOM = 10;
130
159
  // iOS 26: the large title's row is 62 (52 before).
131
160
  const LARGE_TITLE_HEIGHT = 62;
132
161
  /** Chevron glyph plus its gap, in px. */
@@ -146,18 +175,29 @@ const SHEET_MEDIUM_HEIGHT = 433;
146
175
  const SHEET_CONTENT_TOP = 16;
147
176
  const LIST_INSET = 16;
148
177
  const LIST_CELL_PADDING = 16;
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;
178
+ // iOS 26 (measured in the iOS 26.2 simulator, Examples/Fidelity, in thirds
179
+ // of a point): rows are at least 52 tall with 15 above and below their text;
180
+ // a section's header is a sentence-case headline-sized line that starts 8
181
+ // into the 35.67 gap between cards and ends 10 above its card; a footer is
182
+ // 7.33 below its card.
183
+ const LIST_ROW_VPAD = 15;
184
+ // A row holding just a toggle or a stepper has 10 above and below it (so
185
+ // both come out 52 tall), a segmented picker 14.5 (61).
186
+ const LIST_ROW_CONTROL_VPAD = 10;
187
+ const LIST_ROW_SEGMENTED_VPAD = 14.5;
154
188
  const LIST_ROW_MIN_HEIGHT = 52;
189
+ // iPadOS sidebar rows (`sidebar` style): at least 44 tall, 11 above and
190
+ // below their text.
191
+ const LIST_SIDEBAR_ROW_MIN_HEIGHT = 44;
192
+ const LIST_SIDEBAR_ROW_VPAD = 11;
155
193
  const LIST_TOP = 18;
156
194
  const LIST_BOTTOM = 20;
157
- const LIST_HEADER_LIFT = 9;
195
+ const LIST_HEADER_LIFT = 8;
158
196
  const LIST_HEADER_GAP = 10;
159
- const LIST_FOOTER_GAP = 6;
160
- const LIST_SECTION_GAP = 35;
197
+ const LIST_FOOTER_GAP = 22 / 3;
198
+ const LIST_SECTION_GAP = 107 / 3;
199
+ // Below a section's footer the gap to the next section is 11 shorter.
200
+ const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
161
201
  const LIST_CHEVRON_WIDTH = 20;
162
202
  // Phase 4.
163
203
  // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
@@ -219,15 +259,19 @@ const NAVSPLIT_SIDEBAR_WIDTH = 320;
219
259
  /** The sidebar never takes more than this share of the split view's width. */
220
260
  const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
221
261
  const NAVSPLIT_SEPARATOR_WIDTH = 1;
262
+ /** `.inspector`: the trailing column's default width, and at most this share of the width. */
263
+ const INSPECTOR_WIDTH = 320;
264
+ const INSPECTOR_MAX_FRACTION = 0.5;
222
265
  const EPSILON = 1e-6;
223
266
  // Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
224
267
  /** The segmented −/+ control of a `stepper`, trailing. */
225
268
  const STEPPER_WIDTH = 94;
226
269
  const STEPPER_HEIGHT = 32;
227
270
  const STEPPER_GAP = 8;
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;
271
+ /** A `slider` is 31 high (its label row, when present, sits above). */
272
+ // iOS 26: a 6pt track and a 38 × 24 pill knob in a 31 row (a form row
273
+ // holding one is 61: 31 + 2 × 15).
274
+ const SLIDER_HEIGHT = 31;
231
275
  const SLIDER_TRACK_HEIGHT = 6;
232
276
  const SLIDER_THUMB = 38;
233
277
  const SLIDER_THUMB_HEIGHT = 24;
@@ -460,6 +504,19 @@ function cellStyle(node: LayoutNode, key: string): unknown {
460
504
  return undefined;
461
505
  }
462
506
 
507
+ /** The default vertical padding above and below a list row's content. */
508
+ function listRowVPad(row: LayoutNode, sidebar: boolean): number {
509
+ switch (row.kind) {
510
+ case 'picker':
511
+ return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
512
+ case 'toggle':
513
+ case 'stepper':
514
+ return LIST_ROW_CONTROL_VPAD;
515
+ default:
516
+ return sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD;
517
+ }
518
+ }
519
+
463
520
  /** `gridCellColumns` of a grid cell (at least 1). */
464
521
  function gridSpanOf(node: LayoutNode): number {
465
522
  const span = num(cellStyle(node, 'gridCellColumns'));
@@ -472,6 +529,20 @@ function gridAnchorOf(node: LayoutNode): [number, number] | null {
472
529
  return point ? [point.x, point.y] : null;
473
530
  }
474
531
 
532
+ /** `gridColumnAlignment` of a grid cell as a horizontal unit anchor. */
533
+ function gridColumnAnchorOf(node: LayoutNode): number | null {
534
+ switch (str(cellStyle(node, 'gridColumnAlignment'))) {
535
+ case 'leading':
536
+ return 0;
537
+ case 'center':
538
+ return 0.5;
539
+ case 'trailing':
540
+ return 1;
541
+ default:
542
+ return null;
543
+ }
544
+ }
545
+
475
546
  /** Vertical anchor of a `gridrow` alignment (null → the grid's). */
476
547
  function rowAnchor(alignment: string | null, gridAlignment: string): number {
477
548
  switch (alignment) {
@@ -576,7 +647,7 @@ function rowHintsOf(node: LayoutNode): RowHints {
576
647
  }
577
648
 
578
649
  /** Phase 8 wrappers whose first child is the row content they stand for. */
579
- const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture']);
650
+ const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture', 'draggable', 'dropdestination']);
580
651
 
581
652
  /**
582
653
  * Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
@@ -592,8 +663,32 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
592
663
  return null;
593
664
  }
594
665
 
595
- /** Phase 8 edit mode: the width of a `listrow`'s leading control, by which its content shifts right. */
666
+ /** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
667
+ const LIST_LABEL_ICON_CENTER = 12;
668
+ const LIST_LABEL_TITLE_X = 40;
669
+
670
+ /**
671
+ * A `Label` in its default style: an hstack of the marked icon (`labelIcon`
672
+ * on its `styled` wrapper) then the title. In a list row it takes iOS's icon
673
+ * column instead of the stack's spacing.
674
+ */
675
+ function labelColumnOf(node: LayoutNode): { icon: LayoutNode; title: LayoutNode } | null {
676
+ if (node.kind !== 'hstack' || node.children.length !== 2) return null;
677
+ const [icon, title] = node.children as [LayoutNode, LayoutNode];
678
+ const style = icon.kind === 'styled' ? (icon.props['style'] as Record<string, unknown> | undefined) : undefined;
679
+ if (style?.['labelIcon'] !== true) return null;
680
+ return { icon, title };
681
+ }
682
+
683
+ /** Phase 8 edit mode: the width of a `listrow`'s leading control (the selection circle / minus) and of a movable row's trailing grip. */
596
684
  export const LIST_EDIT_CONTROL_WIDTH = 44;
685
+ /**
686
+ * Edit mode: how far a control sits in from the card's edge (its 22 pt glyph
687
+ * is centred 28 from the edge, as iOS draws it) and by how much the content
688
+ * shifts right of the leading control, or narrows for the trailing grip.
689
+ */
690
+ export const LIST_EDIT_CONTROL_INSET = 6;
691
+ export const LIST_EDIT_CONTROL_SHIFT = 40;
597
692
 
598
693
  /** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
599
694
  function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
@@ -601,7 +696,7 @@ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
601
696
  const size = font.size * scale;
602
697
  const lineHeight = font.lineHeight * scale;
603
698
  const px = (v: number): string => `${Math.round(v * 100) / 100}px`;
604
- return { ...font, size, lineHeight, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
699
+ return { ...font, size, lineHeight, leading: font.leading * scale, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
605
700
  }
606
701
 
607
702
  // ---------------------------------------------------------------------------
@@ -814,8 +909,12 @@ interface ListRowPlan {
814
909
  content: Rect;
815
910
  /** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
816
911
  badge: { rect: Rect; layout: TextLayout } | null;
817
- /** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
818
- edit: { listRow: LayoutNode; control: Rect } | null;
912
+ /**
913
+ * Phase 8 edit mode: the `listrow` the row is, its 44px leading control
914
+ * rect (a selectable or deletable row) and its 44px trailing grip rect (a
915
+ * movable row), relative to the list origin.
916
+ */
917
+ edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
819
918
  /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
820
919
  separatorHidden?: { top: boolean; bottom: boolean } | null;
821
920
  }
@@ -892,6 +991,8 @@ interface GridPlan {
892
991
  size: Size;
893
992
  columns: number[];
894
993
  columnX: number[];
994
+ /** Horizontal unit anchor per column from a cell's `gridColumnAlignment` (null → the grid's). */
995
+ columnAnchors: (number | null)[];
895
996
  rows: GridRowPlan[];
896
997
  }
897
998
 
@@ -971,11 +1072,13 @@ export class LayoutPass {
971
1072
  /** Previous screen title per navscreen node, for the back button label. */
972
1073
  private readonly previousTitles = new Map<LayoutNode, string>();
973
1074
  /**
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`.
1075
+ * A navigation screen's root list or vertical scroll view starts at the top
1076
+ * of the screen and scrolls under its bar (`bar` tall): its content starts
1077
+ * below the bar and the large title row that scrolls with it, if any.
1078
+ * `nested`: a scroll view deeper in the screen's content that touches the
1079
+ * bar (`scrollEdgeGrowth`), rather than the screen's own content.
977
1080
  */
978
- private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
1081
+ private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
979
1082
 
980
1083
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
981
1084
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -1020,10 +1123,17 @@ export class LayoutPass {
1020
1123
  const { screen, safeArea } = this.env;
1021
1124
  const ctx = this.rootContext;
1022
1125
  for (const root of roots) {
1023
- if (root.kind === 'navstack' || root.kind === 'tabview' || root.kind === 'navsplit' || root.kind === 'alert') {
1024
- // All three ignore the safe areas: the tab bar and navigation bars
1126
+ if (
1127
+ root.kind === 'navstack' ||
1128
+ root.kind === 'tabview' ||
1129
+ root.kind === 'navsplit' ||
1130
+ root.kind === 'inspector' ||
1131
+ root.kind === 'alert'
1132
+ ) {
1133
+ // These ignore the safe areas: the tab bar and navigation bars
1025
1134
  // extend into them and the content is inset instead (a `navsplit`
1026
- // hands the horizontal insets to its columns, see `placeNavSplit`).
1135
+ // hands the horizontal insets to its columns, see `placeNavSplit`;
1136
+ // an `inspector` its trailing one to its panel).
1027
1137
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
1028
1138
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
1029
1139
  } else if (root.kind === 'sheet') {
@@ -1038,6 +1148,14 @@ export class LayoutPass {
1038
1148
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
1039
1149
  height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
1040
1150
  };
1151
+ if (this.env.contentHeight) {
1152
+ // An island: the root takes its content's height and sits at the top.
1153
+ const proposal: Proposal = { width: safe.width, height: null };
1154
+ const size = this.measure(root, ctx, proposal);
1155
+ const { dx } = alignIn(safe, size, 'center');
1156
+ this.place(root, ctx, proposal, safeArea.leading + dx, safeArea.top);
1157
+ continue;
1158
+ }
1041
1159
  const proposal: Proposal = { width: safe.width, height: safe.height };
1042
1160
  const size = this.measure(root, ctx, proposal);
1043
1161
  const { dx, dy } = alignIn(safe, size, 'center');
@@ -1047,6 +1165,32 @@ export class LayoutPass {
1047
1165
  return this.finish();
1048
1166
  }
1049
1167
 
1168
+ /**
1169
+ * A `host` element (src/hostContent.ts): the page's DOM, measured by the
1170
+ * host measurer. `fill` takes the proposal and the content's own size on an
1171
+ * unconstrained axis; `content` takes the content's ideal width capped by
1172
+ * the proposal and its height at that width, like text. Without a measurer
1173
+ * (headless) it is a 10×10 placeholder, like a shape with no proposal.
1174
+ */
1175
+ private measureHost(node: LayoutNode, p: Proposal): Size {
1176
+ const props = node.props as { sizing?: string; content?: string };
1177
+ const bounded = (value: number | null): number | null => (value !== null && Number.isFinite(value) ? value : null);
1178
+ const width = bounded(p.width);
1179
+ const height = bounded(p.height);
1180
+ const measure = this.measurer.measureHost?.bind(this.measurer);
1181
+ if (!measure || typeof props.content !== 'string') {
1182
+ return { width: width ?? SHAPE_DEFAULT, height: height ?? SHAPE_DEFAULT };
1183
+ }
1184
+ if (props.sizing === 'fill') {
1185
+ if (width !== null && height !== null) return { width, height };
1186
+ const natural = measure(props.content, width);
1187
+ return { width: width ?? natural.width, height: height ?? natural.height };
1188
+ }
1189
+ const ideal = measure(props.content, null);
1190
+ if (width === null || ideal.width <= width) return ideal;
1191
+ return { width, height: measure(props.content, width).height };
1192
+ }
1193
+
1050
1194
  private finish(): LayoutResult {
1051
1195
  // Only what this run produced needs snapping; the rest was snapped when
1052
1196
  // it was produced.
@@ -1208,10 +1352,20 @@ export class LayoutPass {
1208
1352
  return { ...ctx, stackAxis: 'vertical', listRow: false };
1209
1353
  case 'hstack':
1210
1354
  return { ...ctx, stackAxis: 'horizontal', listRow: false };
1355
+ case 'list':
1356
+ case 'scrollview':
1357
+ case 'sheet': {
1358
+ // A bar above is the outermost scroll view's to run under; what a
1359
+ // scroll view or a sheet holds is clear of it.
1360
+ if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
1361
+ return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
1362
+ }
1211
1363
  case 'gesture':
1212
1364
  case 'swipeactions':
1213
1365
  case 'contextmenu':
1214
1366
  case 'listrow':
1367
+ case 'draggable':
1368
+ case 'dropdestination':
1215
1369
  // Phase 8 wrappers are transparent: a `navlink` under them is still
1216
1370
  // the list row (its chevron, its selected fill).
1217
1371
  return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
@@ -1292,8 +1446,11 @@ export class LayoutPass {
1292
1446
  return { width: finite(p.width) ? p.width : 0, height: 1 };
1293
1447
  case 'vstack':
1294
1448
  return this.stackPlan(node, ctx, p, 'vertical').size;
1295
- case 'hstack':
1449
+ case 'hstack': {
1450
+ const label = ctx.labelColumn ? labelColumnOf(node) : null;
1451
+ if (label) return this.labelColumnPlan(label, this.childContext(node, ctx), p).size;
1296
1452
  return this.stackPlan(node, ctx, p, 'horizontal').size;
1453
+ }
1297
1454
  case 'zstack':
1298
1455
  return this.measureZStack(node, ctx, p);
1299
1456
  case 'styled':
@@ -1310,8 +1467,15 @@ export class LayoutPass {
1310
1467
  height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
1311
1468
  };
1312
1469
  }
1313
- case 'color':
1314
1470
  case 'shape':
1471
+ if (node.props['shape'] === 'circle') {
1472
+ // SwiftUI's `Circle` is the largest square in the proposal (so in a
1473
+ // stack it gives the leftover width to its siblings).
1474
+ // An open axis counts as SHAPE_DEFAULT, as for the other shapes.
1475
+ return square(Math.min(p.width ?? SHAPE_DEFAULT, p.height ?? SHAPE_DEFAULT));
1476
+ }
1477
+ return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1478
+ case 'color':
1315
1479
  case 'canvas':
1316
1480
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1317
1481
  case 'video':
@@ -1328,6 +1492,8 @@ export class LayoutPass {
1328
1492
  case 'tabview':
1329
1493
  case 'tab':
1330
1494
  case 'navsplit':
1495
+ case 'inspector':
1496
+ case 'inspectorpanel':
1331
1497
  return {
1332
1498
  width: p.width ?? this.env.screen.width,
1333
1499
  height: p.height ?? this.env.screen.height,
@@ -1336,6 +1502,8 @@ export class LayoutPass {
1336
1502
  return this.viewThatFitsPlan(node, ctx, p).size;
1337
1503
  case 'picker':
1338
1504
  return this.pickerPlan(node, ctx, p).size;
1505
+ case 'host':
1506
+ return this.measureHost(node, p);
1339
1507
  case 'geometry':
1340
1508
  // GeometryReader: the proposal, 10 on unconstrained axes.
1341
1509
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
@@ -1393,6 +1561,8 @@ export class LayoutPass {
1393
1561
  case 'gesture':
1394
1562
  case 'listrow':
1395
1563
  case 'tapgesture':
1564
+ case 'draggable':
1565
+ case 'dropdestination':
1396
1566
  default: {
1397
1567
  // `tapgesture` (its child's size), `toolbar` outside a navscreen,
1398
1568
  // `section` outside a list and unknown kinds: lay the children out as a vstack.
@@ -1415,7 +1585,7 @@ export class LayoutPass {
1415
1585
  const min = Math.max(1, ctx.minLines ?? 1);
1416
1586
  const max = Math.max(min, ctx.lineLimit ?? Infinity);
1417
1587
  const lines = Math.min(Math.max(wrapped.lines.length, min), max);
1418
- return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
1588
+ return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
1419
1589
  }
1420
1590
 
1421
1591
  private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
@@ -1425,7 +1595,7 @@ export class LayoutPass {
1425
1595
  const text = str(node.props['text']) ?? '';
1426
1596
  const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
1427
1597
  return {
1428
- size: { width: wrapped.width, height: wrapped.lines.length * ctx.font.lineHeight },
1598
+ size: { width: wrapped.width, height: textHeight(wrapped.lines.length, ctx.font) },
1429
1599
  layout: {
1430
1600
  font: ctx.font,
1431
1601
  lines: wrapped.lines,
@@ -1464,16 +1634,16 @@ export class LayoutPass {
1464
1634
  };
1465
1635
  }
1466
1636
 
1467
- /** An image in a text: a bitmap or remote image at its size, a symbol a square of the line height. */
1637
+ /** An image in a text: a bitmap or remote image at its size, a symbol its font's symbol square. */
1468
1638
  private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
1469
1639
  if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
1470
1640
  return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
1471
1641
  }
1472
1642
  const name = str(props['name']);
1473
- if (name === null) return square(font.lineHeight);
1643
+ if (name === null) return square(font.leading);
1474
1644
  const asset = this.env.assets?.images[name];
1475
1645
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1476
- if (asset.kind === 'symbol') return square(font.lineHeight);
1646
+ if (asset.kind === 'symbol') return square(font.leading);
1477
1647
  return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
1478
1648
  }
1479
1649
 
@@ -1733,6 +1903,25 @@ export class LayoutPass {
1733
1903
  * even split gave Ice Cubes' quoted posts half their card). With a `null`
1734
1904
  * axis proposal every child is proposed `null` and the stack sizes to the sum.
1735
1905
  */
1906
+ /** A list row's `Label` in iOS's icon column: the icon at its own size, the title in what is left past 40. */
1907
+ private labelColumnPlan(
1908
+ label: { icon: LayoutNode; title: LayoutNode },
1909
+ cctx: Context,
1910
+ p: Proposal,
1911
+ ): { size: Size; icon: Size; title: Size; iconProposal: Proposal; titleProposal: Proposal } {
1912
+ const iconProposal: Proposal = { width: null, height: p.height };
1913
+ const icon = this.measure(label.icon, cctx, iconProposal);
1914
+ const titleProposal: Proposal = { width: finite(p.width) ? Math.max(0, p.width - LIST_LABEL_TITLE_X) : p.width, height: p.height };
1915
+ const title = this.measure(label.title, cctx, titleProposal);
1916
+ return {
1917
+ size: { width: LIST_LABEL_TITLE_X + title.width, height: Math.max(icon.height, title.height) },
1918
+ icon,
1919
+ title,
1920
+ iconProposal,
1921
+ titleProposal,
1922
+ };
1923
+ }
1924
+
1736
1925
  private layoutStack(
1737
1926
  children: readonly LayoutNode[],
1738
1927
  cctx: Context,
@@ -1905,10 +2094,10 @@ export class LayoutPass {
1905
2094
  return { width: p.width ?? width, height: p.height ?? height };
1906
2095
  }
1907
2096
  const name = str(node.props['name']);
1908
- if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
2097
+ if (name === null) return square(ctx.font.leading * ctx.imageScale);
1909
2098
  const asset = this.env.assets?.images[name];
1910
2099
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1911
- if (asset.kind === 'symbol') return square(ctx.font.lineHeight * ctx.imageScale);
2100
+ if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
1912
2101
  const width = num(asset.width) ?? 0;
1913
2102
  const height = num(asset.height) ?? 0;
1914
2103
  if (node.props['resizable'] !== true) return { width, height };
@@ -2111,11 +2300,13 @@ export class LayoutPass {
2111
2300
  const layoutWidth = Number.isFinite(width) ? width : screen.width;
2112
2301
  // `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
2113
2302
  // sections are full width. `plain` also drops the top/bottom gaps and
2114
- // the space between sections.
2303
+ // the space between sections. `sidebar` (iPadOS) is laid out like
2304
+ // `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
2115
2305
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
2116
- // `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
2117
- const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
2118
- const inset = listStyle === 'insetGrouped' ? LIST_INSET : 0;
2306
+ const sidebarStyle = listStyle === 'sidebar';
2307
+ // `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
2308
+ const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
2309
+ const inset = listStyle === 'insetGrouped' || sidebarStyle ? LIST_INSET : 0;
2119
2310
  const plain = listStyle === 'plain';
2120
2311
  const listTop = plain ? 0 : LIST_TOP;
2121
2312
  const listBottom = plain ? 0 : LIST_BOTTOM;
@@ -2135,9 +2326,10 @@ export class LayoutPass {
2135
2326
  textStyleSize('footnote', this.env.dynamicTypeSize),
2136
2327
  400,
2137
2328
  );
2138
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2139
- // Phase 8: in edit mode every `listrow` gains a 44 leading control and
2140
- // its content shifts right by as much.
2329
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
2330
+ // Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
2331
+ // leading control (6 in from the card's edge) and its content shifts
2332
+ // right by 40; a movable one a trailing grip, which narrows it by 40.
2141
2333
  const editing = node.props['editing'] === true;
2142
2334
 
2143
2335
  // Group consecutive plain rows into an implicit section. A section's
@@ -2173,12 +2365,16 @@ export class LayoutPass {
2173
2365
  const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
2174
2366
  return {
2175
2367
  layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
2176
- size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
2368
+ size: { width: wrapped.width, height: textHeight(wrapped.lines.length, font) },
2177
2369
  };
2178
2370
  };
2179
2371
  for (let g = 0; g < groupsSource.length; g++) {
2180
2372
  const source = groupsSource[g] as GroupSource;
2181
- if (g > 0) y += sectionGap;
2373
+ const previous = groups[g - 1];
2374
+ if (previous) {
2375
+ const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
2376
+ y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
2377
+ }
2182
2378
  const top = y;
2183
2379
  let header: ListGroupPlan['header'] = null;
2184
2380
  let footer: ListGroupPlan['footer'] = null;
@@ -2205,13 +2401,12 @@ export class LayoutPass {
2205
2401
  continue;
2206
2402
  }
2207
2403
  // 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
2404
+ // a menu, 44 per option inline); a toggle, stepper or segmented
2405
+ // picker has its own vertical padding, other rows 13. Phase 6
2210
2406
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2211
2407
  // width (plus a gap) at the trailing edge.
2212
2408
  const hints = rowHintsOf(rowNode);
2213
- const unwrappedRow = unwrapStyled(rowNode);
2214
- const defaultVPad = unwrappedRow.kind === 'picker' && str(unwrappedRow.props['style']) !== 'segmented' ? 0 : LIST_ROW_VPAD;
2409
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
2215
2410
  const insets: EdgeInsets = hints.insets ?? {
2216
2411
  top: defaultVPad,
2217
2412
  leading: LIST_CELL_PADDING,
@@ -2220,10 +2415,16 @@ export class LayoutPass {
2220
2415
  };
2221
2416
  const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
2222
2417
  const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
2418
+ // Phase 8 edit mode: a selectable or deletable `listrow` gains a
2419
+ // leading control and its content shifts right; a movable one a
2420
+ // trailing grip, which narrows its content.
2223
2421
  const listRow = editing ? listRowOf(rowNode) : null;
2224
- const editShift = listRow ? LIST_EDIT_CONTROL_WIDTH : 0;
2422
+ const hasControl = listRow !== null && (listRow.props['selectable'] === true || listRow.props['deletable'] === true);
2423
+ const hasGrip = listRow !== null && listRow.props['movable'] === true;
2424
+ const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
2425
+ const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
2225
2426
  const rowContentX = cardX + insets.leading + editShift;
2226
- const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
2427
+ const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
2227
2428
  const proposal: Proposal = { width: rowContentWidth, height: null };
2228
2429
  const contentSize = this.measure(rowNode, rowCtx, proposal);
2229
2430
  const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
@@ -2246,7 +2447,15 @@ export class LayoutPass {
2246
2447
  layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
2247
2448
  }
2248
2449
  : null,
2249
- edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
2450
+ edit: listRow
2451
+ ? {
2452
+ listRow,
2453
+ control: hasControl ? { x: cardX + LIST_EDIT_CONTROL_INSET, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } : null,
2454
+ reorder: hasGrip
2455
+ ? { x: cardX + cardWidth - LIST_EDIT_CONTROL_INSET - LIST_EDIT_CONTROL_WIDTH, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight }
2456
+ : null,
2457
+ }
2458
+ : null,
2250
2459
  separatorHidden: hints.separatorHidden,
2251
2460
  });
2252
2461
  y += rowHeight;
@@ -2316,6 +2525,11 @@ export class LayoutPass {
2316
2525
  }
2317
2526
  this.hiddenBy.delete(node);
2318
2527
  }
2528
+ const grown = this.scrollEdgeGrowth(node, ctx, p, x, y);
2529
+ if (grown) {
2530
+ p = grown.p;
2531
+ y = grown.y;
2532
+ }
2319
2533
  const size = this.measure(node, ctx, p);
2320
2534
  this.setFrame(node, x, y, size);
2321
2535
  const cctx = this.childContext(node, ctx);
@@ -2337,9 +2551,18 @@ export class LayoutPass {
2337
2551
  case 'shape':
2338
2552
  case 'canvas':
2339
2553
  case 'video':
2554
+ case 'host':
2340
2555
  return;
2341
2556
  case 'vstack':
2342
2557
  case 'hstack': {
2558
+ const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
2559
+ if (label) {
2560
+ // iOS's list icon column: the icon centred at 12, the title at 40.
2561
+ const plan = this.labelColumnPlan(label, cctx, p);
2562
+ this.place(label.icon, cctx, plan.iconProposal, x + LIST_LABEL_ICON_CENTER - plan.icon.width / 2, y + (size.height - plan.icon.height) / 2);
2563
+ this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
2564
+ return;
2565
+ }
2343
2566
  const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
2344
2567
  const plan = this.stackPlan(node, ctx, p, axis);
2345
2568
  const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
@@ -2458,8 +2681,9 @@ export class LayoutPass {
2458
2681
  };
2459
2682
  // Phase 7: the selected row of a `navsplit` sidebar has no chevron
2460
2683
  // (its label still reserves the chevron's width so selecting a row
2461
- // never reflows it); elsewhere `selected` keeps the chevron.
2462
- if (!(node.props['selected'] === true && ctx.splitSidebar)) {
2684
+ // never reflows it); elsewhere `selected` keeps the chevron. No row
2685
+ // of a `sidebar`-style list has one (iPadOS).
2686
+ if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
2463
2687
  chrome['chevron'] = {
2464
2688
  x: x + size.width - LIST_CHEVRON_WIDTH,
2465
2689
  y,
@@ -2479,18 +2703,20 @@ export class LayoutPass {
2479
2703
  // centered on the other axes when smaller than the viewport.
2480
2704
  const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2481
2705
  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;
2706
+ // Under its screen's bar (and the large title that scrolls with it),
2707
+ // the content starts below them.
2708
+ const inset = axes.vertical ? this.scrollInsets.get(node) : undefined;
2484
2709
  const content: Rect = {
2485
2710
  x: x + dx,
2486
- y: y + dy + (titleRect?.height ?? 0),
2711
+ y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
2487
2712
  width: plan.contentSize.width,
2488
2713
  height: plan.contentSize.height,
2489
2714
  };
2490
2715
  const covered = this.coveredBottom(ctx, x, y, size);
2491
2716
  const scrollChrome: Record<string, Rect> = { content };
2492
2717
  if (covered) scrollChrome['bottomInset'] = covered;
2493
- if (titleRect) scrollChrome['largeTitle'] = titleRect;
2718
+ if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
2719
+ if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
2494
2720
  this.setChrome(node, scrollChrome);
2495
2721
  if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2496
2722
  return;
@@ -2514,6 +2740,12 @@ export class LayoutPass {
2514
2740
  case 'navsplit':
2515
2741
  this.placeNavSplit(node, cctx, x, y, size);
2516
2742
  return;
2743
+ case 'inspector':
2744
+ this.placeInspector(node, cctx, x, y, size);
2745
+ return;
2746
+ case 'inspectorpanel':
2747
+ this.placeInspectorPanel(node, cctx, x, y, size);
2748
+ return;
2517
2749
  case 'viewthatfits': {
2518
2750
  // Phase 7: only the chosen candidate is placed (at the origin, the
2519
2751
  // element is its size); the others have no frame and are hidden.
@@ -2609,8 +2841,10 @@ export class LayoutPass {
2609
2841
  case 'gesture':
2610
2842
  case 'listrow':
2611
2843
  case 'tapgesture':
2844
+ case 'draggable':
2845
+ case 'dropdestination':
2612
2846
  default: {
2613
- // `tapgesture`, `gesture`, `listrow` (child at the same origin), `toolbar` outside a navscreen,
2847
+ // `tapgesture`, `gesture`, `listrow`, `draggable`, `dropdestination` (child at the same origin), `toolbar` outside a navscreen,
2614
2848
  // `tab` outside a tabview, `section` outside a list and unknown kinds.
2615
2849
  const content = this.contentOf(node);
2616
2850
  if (content) this.place(content, cctx, p, x, y);
@@ -2619,26 +2853,71 @@ export class LayoutPass {
2619
2853
  }
2620
2854
  }
2621
2855
 
2622
- /** The part of a scrolling view's frame that a tab bar covers (chrome `bottomInset`). */
2856
+ /**
2857
+ * The part of a scrolling view's frame that a tab bar (or its screen's
2858
+ * bottom toolbar or search field) covers (chrome `bottomInset`).
2859
+ */
2623
2860
  private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
2624
2861
  if (ctx.bottomBar <= 0) return null;
2625
2862
  const bottom = y + size.height;
2626
- if (bottom < this.env.screen.height - EPSILON) return null;
2863
+ if (bottom < (ctx.bottomEdge ?? this.env.screen.height) - EPSILON) return null;
2627
2864
  const height = Math.min(size.height, ctx.bottomBar);
2628
2865
  return { x, y: bottom - height, width: size.width, height };
2629
2866
  }
2630
2867
 
2868
+ /**
2869
+ * iOS 26: a vertical list or scroll view deeper in a screen's content
2870
+ * whose top edge touches the screen's bar across the screen (`barAbove`)
2871
+ * runs up under it, and one whose bottom edge touches a bar covering the
2872
+ * bottom (`bottomBar`: a tab bar, a bottom toolbar or search field) runs
2873
+ * down under that, as the screen's own scroll view does: the proposal and
2874
+ * frame grow by the bar (the parent's layout is unchanged, as for
2875
+ * `.ignoresSafeArea`) and the content is inset by it (chrome `topInset` /
2876
+ * `bottomInset`), so it scrolls under the bar, where the scroll edge
2877
+ * effect shows (styles.css). Null when nothing grows.
2878
+ */
2879
+ private scrollEdgeGrowth(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): { p: Proposal; y: number } | null {
2880
+ if (node.kind !== 'list' && !(node.kind === 'scrollview' && this.scrollAxes(node).vertical)) return null;
2881
+ const inset = this.scrollInsets.get(node);
2882
+ const bar = ctx.barAbove;
2883
+ // The screen's own scroll view is inset by `placeNavScreen` (no bar above
2884
+ // here); a nested one that no longer touches the bar loses its inset.
2885
+ if (inset?.nested) this.scrollInsets.delete(node);
2886
+ if (!finite(p.height)) return null;
2887
+ const size = this.measure(node, ctx, p);
2888
+ let top = 0;
2889
+ if (
2890
+ bar &&
2891
+ !(inset && !inset.nested) &&
2892
+ Math.abs(y - bar.top) <= EPSILON &&
2893
+ x <= bar.x + EPSILON &&
2894
+ x + size.width >= bar.x + bar.width - EPSILON
2895
+ ) {
2896
+ top = bar.height;
2897
+ this.scrollInsets.set(node, { bar: top, largeTitle: null, nested: true });
2898
+ }
2899
+ let bottom = 0;
2900
+ if (ctx.bottomBar > 0) {
2901
+ const edge = ctx.bottomEdge ?? this.env.screen.height;
2902
+ if (Math.abs(y + size.height - (edge - ctx.bottomBar)) <= EPSILON) bottom = ctx.bottomBar;
2903
+ }
2904
+ if (top === 0 && bottom === 0) return null;
2905
+ return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
2906
+ }
2907
+
2631
2908
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2632
2909
  const plan = this.listPlan(node, ctx, p);
2633
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2910
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
2634
2911
  const covered = this.coveredBottom(ctx, x, y, plan.size);
2635
- // Under a large title that scrolls with it, the rows start below it.
2636
- const titleRect = this.titleInsets.get(node);
2912
+ // Under its screen's bar (and the large title that scrolls with it), the
2913
+ // rows start below them.
2914
+ const scrollInset = this.scrollInsets.get(node);
2637
2915
  const listChrome: Record<string, Rect> = {};
2638
2916
  if (covered) listChrome['bottomInset'] = covered;
2639
- if (titleRect) listChrome['largeTitle'] = titleRect;
2917
+ if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
2918
+ if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
2640
2919
  if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
2641
- const inset = titleRect?.height ?? 0;
2920
+ const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
2642
2921
  if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
2643
2922
  y += inset;
2644
2923
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
@@ -2678,8 +2957,11 @@ export class LayoutPass {
2678
2957
  // Phase 8: the edit control belongs to the `listrow`, which is the
2679
2958
  // row itself or sits under its wrappers (its own place() keeps chrome).
2680
2959
  if (row.edit) {
2681
- if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
2682
- else this.setChrome(row.edit.listRow, { editControl: offset(row.edit.control) });
2960
+ const controls: Record<string, Rect> = {};
2961
+ if (row.edit.control) controls['editControl'] = offset(row.edit.control);
2962
+ if (row.edit.reorder) controls['reorderControl'] = offset(row.edit.reorder);
2963
+ if (row.edit.listRow === row.node) Object.assign(chrome, controls);
2964
+ else this.setChrome(row.edit.listRow, controls);
2683
2965
  }
2684
2966
  const rowX = x + row.content.x;
2685
2967
  const rowY = y + row.content.y;
@@ -2732,7 +3014,7 @@ export class LayoutPass {
2732
3014
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
2733
3015
  const depth = num(node.props['depth']) ?? 0;
2734
3016
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2735
- const barHeight = statusBar + NAV_BAR_HEIGHT;
3017
+ const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
2736
3018
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2737
3019
  const parts = this.navScreenParts(node);
2738
3020
  const reachesBottom = y + size.height >= screen.height - EPSILON;
@@ -2750,6 +3032,8 @@ export class LayoutPass {
2750
3032
  };
2751
3033
  const chrome: Record<string, Rect> = {
2752
3034
  bar: { x, y, width: size.width, height: barHeight },
3035
+ // The 44 row the bar's items sit in, under the status bar.
3036
+ barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
2753
3037
  content,
2754
3038
  };
2755
3039
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
@@ -2799,13 +3083,14 @@ export class LayoutPass {
2799
3083
  // Back button, iOS 26: the chevron alone in a glass circle (the previous
2800
3084
  // title no longer shows).
2801
3085
  const backLabel = '';
3086
+ const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
2802
3087
  if (depth > 0) {
2803
- chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
3088
+ chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
2804
3089
  }
2805
3090
 
2806
3091
  // Place the toolbar groups and remember how much of the row they occupy.
2807
3092
  const back = chrome['back'];
2808
- let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
3093
+ let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
2809
3094
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2810
3095
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2811
3096
  const itemCtx = this.childContext(group.toolbar, cctx);
@@ -2876,7 +3161,11 @@ export class LayoutPass {
2876
3161
  // A large title that scrolls away collapses into the inline title, which
2877
3162
  // the renderer shows once it has (`data-sb-collapsed`).
2878
3163
  if ((!large || titleScrolls) && !principal) {
2879
- const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
3164
+ const leftUsed = Math.max(
3165
+ back ? back.x + back.width - x : 0,
3166
+ leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
3167
+ cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
3168
+ );
2880
3169
  const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
2881
3170
  const side = Math.max(leftUsed, rightUsed) + 8;
2882
3171
  const available = Math.max(0, size.width - 2 * side);
@@ -2905,23 +3194,46 @@ export class LayoutPass {
2905
3194
 
2906
3195
  const child = parts.content;
2907
3196
  if (!child) return;
3197
+ // The room a bottom toolbar or search field takes; scroll views run under
3198
+ // it (iOS 26), other content stops above it.
3199
+ const bottomCover = Math.max(bottomBarHeight, searchBottom);
2908
3200
  // Under a tab bar the bar (which includes the bottom safe area) is what
2909
3201
  // non-scrolling content must clear; a bottom toolbar already excluded it.
2910
3202
  const bottomInset =
2911
3203
  bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2912
3204
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2913
3205
  // large title / search field), which is transparent over it.
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);
3206
+ const contentCtx: Context = {
3207
+ ...cctx,
3208
+ safeTop: content.y,
3209
+ barInset: 0,
3210
+ barLeading: 0,
3211
+ barAbove: undefined,
3212
+ ...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
3213
+ };
3214
+ const scrollsUnderBar =
3215
+ scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
3216
+ if (scroller && scrollsUnderBar) {
3217
+ // iOS 26: the scroll view starts at the top of the screen and its
3218
+ // content scrolls under the bar (and the large title moves with it);
3219
+ // the content starts below them. It runs to the bottom of the screen,
3220
+ // under a bottom toolbar or search field too.
3221
+ const top = content.y - y;
3222
+ this.scrollInsets.set(scroller, {
3223
+ bar: barHeight,
3224
+ largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
3225
+ });
3226
+ this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
2921
3227
  return;
2922
3228
  }
2923
- if (scroller) this.titleInsets.delete(scroller);
2924
- if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
3229
+ if (scroller) this.scrollInsets.delete(scroller);
3230
+ // A scroll view deeper in the content that touches the bar runs under it
3231
+ // (`scrollEdgeGrowth`); not under a large title, which stays put then.
3232
+ // One that reaches the bottom of the content runs down under all the
3233
+ // content clears there.
3234
+ if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
3235
+ if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
3236
+ if (scroller && scroller.kind === 'scrollview') {
2925
3237
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2926
3238
  return;
2927
3239
  }
@@ -2968,7 +3280,7 @@ export class LayoutPass {
2968
3280
  }
2969
3281
  this.setChrome(node, chrome);
2970
3282
  const selected = readSelected(node, n);
2971
- const tabCtx: Context = { ...cctx, bottomBar: barHeight };
3283
+ const tabCtx: Context = { ...cctx, bottomBar: barHeight, bottomEdge: undefined, barAbove: undefined };
2972
3284
  tabs.forEach((tab, i) => {
2973
3285
  this.setFrame(tab, x, y, size);
2974
3286
  if (i !== selected) {
@@ -3026,6 +3338,12 @@ export class LayoutPass {
3026
3338
  * to the detail column (when the split view touches those screen edges):
3027
3339
  * the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
3028
3340
  * navstacks start / end at them.
3341
+ *
3342
+ * iPadOS: a sidebar toggle (a 44 glass circle, chrome `toggle`) sits at the
3343
+ * leading edge of the bar row, in the sidebar's bar or, with the sidebar
3344
+ * hidden (`sidebarVisible: false`), in the detail's; that column's screens
3345
+ * start their back button and leading items after it. A hidden sidebar
3346
+ * slides off the leading edge and the detail column takes the width.
3029
3347
  */
3030
3348
  private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3031
3349
  const { screen, safeArea } = this.env;
@@ -3033,27 +3351,81 @@ export class LayoutPass {
3033
3351
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3034
3352
  const usable = Math.max(0, size.width - leading - trailing);
3035
3353
  const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
3036
- const separatorX = x + leading + sidebarWidth;
3037
- const detailX = separatorX + NAVSPLIT_SEPARATOR_WIDTH;
3354
+ const visible = node.props['sidebarVisible'] !== false;
3355
+ // Hidden, the sidebar and its separator sit just off the leading edge.
3356
+ const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
3357
+ const separatorX = sidebarX + sidebarWidth;
3358
+ const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
3038
3359
  const detailWidth = Math.max(0, x + size.width - trailing - detailX);
3360
+ const statusBar = y <= 0 ? safeArea.top : 0;
3039
3361
  const chrome: Record<string, Rect> = {
3040
- sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
3362
+ sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
3041
3363
  separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
3042
- detail: { x: detailX, y, width: Math.max(0, x + size.width - detailX), height: size.height },
3364
+ detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
3365
+ toggle: { x: x + leading + NAV_ITEM_MARGIN, y: y + statusBar, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE },
3043
3366
  };
3044
3367
  this.setChrome(node, chrome);
3368
+ const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
3045
3369
  const [sidebar, detail, ...rest] = node.children;
3046
3370
  if (sidebar) {
3047
3371
  const proposal: Proposal = { width: sidebarWidth, height: size.height };
3048
- this.place(sidebar, { ...cctx, splitSidebar: true }, proposal, x + leading, y);
3372
+ this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
3049
3373
  }
3050
3374
  if (detail) {
3051
3375
  const proposal: Proposal = { width: detailWidth, height: size.height };
3052
- this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
3376
+ this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
3053
3377
  }
3054
3378
  for (const extra of rest) this.hide(extra, node);
3055
3379
  }
3056
3380
 
3381
+ /**
3382
+ * `.inspector` (regular width): the children are the content, then the
3383
+ * `inspectorpanel`. The panel is a trailing column, 320 wide (its
3384
+ * content's `inspectorColumnWidth`, at most half the width) plus the
3385
+ * trailing safe-area inset when it touches that edge, with a 1px gap
3386
+ * before it (the panel draws the hairline there); the content is proposed the rest (each content child is
3387
+ * centered in it, as a root view is). With `presented: false` the panel
3388
+ * sits just off the trailing edge (so it slides) and the content takes the
3389
+ * whole width.
3390
+ */
3391
+ private placeInspector(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3392
+ const { screen, safeArea } = this.env;
3393
+ const last = node.children[node.children.length - 1];
3394
+ const panel = last && last.kind === 'inspectorpanel' ? last : null;
3395
+ const main = panel ? node.children.slice(0, -1) : node.children;
3396
+ const presented = node.props['presented'] === true;
3397
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3398
+ const requested = panel?.children[0] ? num(cellStyle(panel.children[0], 'inspectorColumnWidth')) : null;
3399
+ const columnWidth = Math.min(requested ?? INSPECTOR_WIDTH, size.width * INSPECTOR_MAX_FRACTION);
3400
+ const panelWidth = columnWidth + trailing;
3401
+ const panelX = presented ? x + size.width - panelWidth : x + size.width + NAVSPLIT_SEPARATOR_WIDTH;
3402
+ const mainWidth = presented ? Math.max(0, panelX - NAVSPLIT_SEPARATOR_WIDTH - x) : size.width;
3403
+ const area: Size = { width: mainWidth, height: size.height };
3404
+ for (const child of main) {
3405
+ const childSize = this.measure(child, cctx, area);
3406
+ const { dx, dy } = alignIn(area, childSize, 'center');
3407
+ this.place(child, cctx, area, x + dx, y + dy);
3408
+ }
3409
+ if (panel) this.place(panel, cctx, { width: panelWidth, height: size.height }, panelX, y);
3410
+ }
3411
+
3412
+ /**
3413
+ * The inspector column: its content below the status bar (at the top of
3414
+ * the screen) and clear of the trailing inset, top-aligned and centered
3415
+ * horizontally (a list or form fills it).
3416
+ */
3417
+ private placeInspectorPanel(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3418
+ const { screen, safeArea } = this.env;
3419
+ const top = y <= 0 ? safeArea.top : 0;
3420
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3421
+ const area: Size = { width: Math.max(0, size.width - trailing), height: Math.max(0, size.height - top) };
3422
+ for (const child of node.children) {
3423
+ const childSize = this.measure(child, cctx, area);
3424
+ const { dx } = alignIn(area, childSize, 'center');
3425
+ this.place(child, cctx, area, x + dx, y + top);
3426
+ }
3427
+ }
3428
+
3057
3429
  /**
3058
3430
  * Phase 7 `ViewThatFits`: the first child whose ideal size fits the
3059
3431
  * proposal on the restricted axes (a nil or infinite proposal axis always
@@ -3305,7 +3677,7 @@ export class LayoutPass {
3305
3677
  const wrapped = wrapText(text, font, width, null, this.measurer);
3306
3678
  return {
3307
3679
  layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
3308
- height: wrapped.lines.length * font.lineHeight,
3680
+ height: textHeight(wrapped.lines.length, font),
3309
3681
  };
3310
3682
  };
3311
3683
  const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
@@ -3613,8 +3985,12 @@ export class LayoutPass {
3613
3985
  * whose non-spanning cells are all flexible (or that has none) is flexible;
3614
3986
  * the others take the largest ideal width of their non-spanning cells. The
3615
3987
  * flexible columns share what is left of the proposal width equally (their
3616
- * ideal width with a nil proposal). Row heights come from measuring every
3617
- * cell at its cell width; a non-`gridrow` child is one full-span cell.
3988
+ * ideal width with a nil proposal). A non-`gridrow` child is one full-span
3989
+ * cell; one wider than the columns (a `Divider` takes the whole proposal)
3990
+ * widens the grid, every column by the same amount (unless it has
3991
+ * `gridCellUnsizedAxes(.horizontal)`). Row heights come from
3992
+ * measuring every cell at its cell width. A cell's `gridColumnAlignment`
3993
+ * aligns its whole column.
3618
3994
  */
3619
3995
  private gridPlan(node: LayoutNode, ctx: Context, p: Proposal): GridPlan {
3620
3996
  return this.plan(node, p, () => {
@@ -3677,6 +4053,30 @@ export class LayoutPass {
3677
4053
  if (flexible[j]) columns[j] = flexibleCount > 0 ? remaining / flexibleCount : 0;
3678
4054
  }
3679
4055
  }
4056
+ // Full-span children wider than the columns widen every column alike.
4057
+ const fullProposal: Proposal = { width: finite(p.width) ? p.width : null, height: null };
4058
+ let fullWidth = 0;
4059
+ for (const source of sources) {
4060
+ if (!source.full) continue;
4061
+ const cctx = this.gridCellContext(node, null, ctx);
4062
+ for (const cell of source.cells) {
4063
+ if (cellStyle(cell.node, 'gridCellUnsizedAxes') === true) continue;
4064
+ fullWidth = Math.max(fullWidth, this.measure(cell.node, cctx, fullProposal).width);
4065
+ }
4066
+ }
4067
+ const columnsWidth = columns.reduce((sum, w) => sum + w, 0) + hSpacing * (columnCount - 1);
4068
+ if (fullWidth > columnsWidth + EPSILON) {
4069
+ const extra = (fullWidth - columnsWidth) / columnCount;
4070
+ for (let j = 0; j < columnCount; j++) columns[j] = (columns[j] as number) + extra;
4071
+ }
4072
+ const columnAnchors = new Array<number | null>(columnCount).fill(null);
4073
+ for (const source of sources) {
4074
+ if (source.full) continue;
4075
+ for (const cell of source.cells) {
4076
+ const anchor = cell.span === 1 ? gridColumnAnchorOf(cell.node) : null;
4077
+ if (anchor !== null && columnAnchors[cell.column] === null) columnAnchors[cell.column] = anchor;
4078
+ }
4079
+ }
3680
4080
  const columnX = new Array<number>(columnCount).fill(0);
3681
4081
  for (let j = 1; j < columnCount; j++) {
3682
4082
  columnX[j] = (columnX[j - 1] as number) + (columns[j - 1] as number) + hSpacing;
@@ -3713,7 +4113,7 @@ export class LayoutPass {
3713
4113
  y += height + vSpacing;
3714
4114
  }
3715
4115
  const gridHeight = rows.length > 0 ? y - vSpacing : 0;
3716
- return { size: { width: gridWidth, height: gridHeight }, columns, columnX, rows };
4116
+ return { size: { width: gridWidth, height: gridHeight }, columns, columnX, columnAnchors, rows };
3717
4117
  });
3718
4118
  }
3719
4119
 
@@ -3727,7 +4127,8 @@ export class LayoutPass {
3727
4127
  const rowY = rowAnchor(row.alignment, gridAlignment);
3728
4128
  for (const cell of row.cells) {
3729
4129
  const box: Size = { width: cell.width, height: row.height };
3730
- const [ax, ay] = gridAnchorOf(cell.node) ?? [gridX, rowY];
4130
+ const columnX = cell.full ? null : (plan.columnAnchors[cell.column] ?? null);
4131
+ const [ax, ay] = gridAnchorOf(cell.node) ?? [columnX ?? gridX, rowY];
3731
4132
  const dx = safeOffset(box.width, cell.size.width, ax);
3732
4133
  const dy = safeOffset(box.height, cell.size.height, ay);
3733
4134
  const cellX = cell.full ? 0 : (plan.columnX[cell.column] ?? 0);