@swiftbrowser/web 0.3.1 → 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,24 +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;
154
184
  // A row holding just a toggle or a stepper has 10 above and below it (so
155
- // both come out 52 tall); a segmented picker has 14 (60 tall).
185
+ // both come out 52 tall), a segmented picker 14.5 (61).
156
186
  const LIST_ROW_CONTROL_VPAD = 10;
157
- const LIST_ROW_SEGMENTED_VPAD = 14;
187
+ const LIST_ROW_SEGMENTED_VPAD = 14.5;
158
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;
159
193
  const LIST_TOP = 18;
160
194
  const LIST_BOTTOM = 20;
161
- const LIST_HEADER_LIFT = 9;
195
+ const LIST_HEADER_LIFT = 8;
162
196
  const LIST_HEADER_GAP = 10;
163
- const LIST_FOOTER_GAP = 6;
164
- const LIST_SECTION_GAP = 35;
165
- // Below a section's footer the gap to the next section is 12 shorter.
166
- const LIST_SECTION_GAP_AFTER_FOOTER = 23;
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;
167
201
  const LIST_CHEVRON_WIDTH = 20;
168
202
  // Phase 4.
169
203
  // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
@@ -225,15 +259,19 @@ const NAVSPLIT_SIDEBAR_WIDTH = 320;
225
259
  /** The sidebar never takes more than this share of the split view's width. */
226
260
  const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
227
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;
228
265
  const EPSILON = 1e-6;
229
266
  // Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
230
267
  /** The segmented −/+ control of a `stepper`, trailing. */
231
268
  const STEPPER_WIDTH = 94;
232
269
  const STEPPER_HEIGHT = 32;
233
270
  const STEPPER_GAP = 8;
234
- /** A `slider` is 35 high (its label row, when present, sits above). */
235
- // iOS 26: a 6pt track and a 38 × 24 pill knob in a 35 row.
236
- const SLIDER_HEIGHT = 35;
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;
237
275
  const SLIDER_TRACK_HEIGHT = 6;
238
276
  const SLIDER_THUMB = 38;
239
277
  const SLIDER_THUMB_HEIGHT = 24;
@@ -467,7 +505,7 @@ function cellStyle(node: LayoutNode, key: string): unknown {
467
505
  }
468
506
 
469
507
  /** The default vertical padding above and below a list row's content. */
470
- function listRowVPad(row: LayoutNode): number {
508
+ function listRowVPad(row: LayoutNode, sidebar: boolean): number {
471
509
  switch (row.kind) {
472
510
  case 'picker':
473
511
  return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
@@ -475,7 +513,7 @@ function listRowVPad(row: LayoutNode): number {
475
513
  case 'stepper':
476
514
  return LIST_ROW_CONTROL_VPAD;
477
515
  default:
478
- return LIST_ROW_VPAD;
516
+ return sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD;
479
517
  }
480
518
  }
481
519
 
@@ -609,7 +647,7 @@ function rowHintsOf(node: LayoutNode): RowHints {
609
647
  }
610
648
 
611
649
  /** Phase 8 wrappers whose first child is the row content they stand for. */
612
- 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']);
613
651
 
614
652
  /**
615
653
  * Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
@@ -625,8 +663,32 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
625
663
  return null;
626
664
  }
627
665
 
628
- /** 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. */
629
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;
630
692
 
631
693
  /** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
632
694
  function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
@@ -634,7 +696,7 @@ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
634
696
  const size = font.size * scale;
635
697
  const lineHeight = font.lineHeight * scale;
636
698
  const px = (v: number): string => `${Math.round(v * 100) / 100}px`;
637
- 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]}` };
638
700
  }
639
701
 
640
702
  // ---------------------------------------------------------------------------
@@ -847,8 +909,12 @@ interface ListRowPlan {
847
909
  content: Rect;
848
910
  /** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
849
911
  badge: { rect: Rect; layout: TextLayout } | null;
850
- /** Phase 8 edit mode: the `listrow` the row is and its 44px leading control rect (relative to the list origin). */
851
- 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;
852
918
  /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
853
919
  separatorHidden?: { top: boolean; bottom: boolean } | null;
854
920
  }
@@ -1006,11 +1072,13 @@ export class LayoutPass {
1006
1072
  /** Previous screen title per navscreen node, for the back button label. */
1007
1073
  private readonly previousTitles = new Map<LayoutNode, string>();
1008
1074
  /**
1009
- * A large-title screen's root list or vertical scroll view starts under the
1010
- * bar and insets its content by the title row, which scrolls with it
1011
- * (iOS): the inset, by scroll node, set by `placeNavScreen`.
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.
1012
1080
  */
1013
- private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
1081
+ private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
1014
1082
 
1015
1083
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
1016
1084
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -1055,10 +1123,17 @@ export class LayoutPass {
1055
1123
  const { screen, safeArea } = this.env;
1056
1124
  const ctx = this.rootContext;
1057
1125
  for (const root of roots) {
1058
- if (root.kind === 'navstack' || root.kind === 'tabview' || root.kind === 'navsplit' || root.kind === 'alert') {
1059
- // 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
1060
1134
  // extend into them and the content is inset instead (a `navsplit`
1061
- // 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).
1062
1137
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
1063
1138
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
1064
1139
  } else if (root.kind === 'sheet') {
@@ -1073,6 +1148,14 @@ export class LayoutPass {
1073
1148
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
1074
1149
  height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
1075
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
+ }
1076
1159
  const proposal: Proposal = { width: safe.width, height: safe.height };
1077
1160
  const size = this.measure(root, ctx, proposal);
1078
1161
  const { dx, dy } = alignIn(safe, size, 'center');
@@ -1082,6 +1165,32 @@ export class LayoutPass {
1082
1165
  return this.finish();
1083
1166
  }
1084
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
+
1085
1194
  private finish(): LayoutResult {
1086
1195
  // Only what this run produced needs snapping; the rest was snapped when
1087
1196
  // it was produced.
@@ -1243,10 +1352,20 @@ export class LayoutPass {
1243
1352
  return { ...ctx, stackAxis: 'vertical', listRow: false };
1244
1353
  case 'hstack':
1245
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
+ }
1246
1363
  case 'gesture':
1247
1364
  case 'swipeactions':
1248
1365
  case 'contextmenu':
1249
1366
  case 'listrow':
1367
+ case 'draggable':
1368
+ case 'dropdestination':
1250
1369
  // Phase 8 wrappers are transparent: a `navlink` under them is still
1251
1370
  // the list row (its chevron, its selected fill).
1252
1371
  return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
@@ -1327,8 +1446,11 @@ export class LayoutPass {
1327
1446
  return { width: finite(p.width) ? p.width : 0, height: 1 };
1328
1447
  case 'vstack':
1329
1448
  return this.stackPlan(node, ctx, p, 'vertical').size;
1330
- 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;
1331
1452
  return this.stackPlan(node, ctx, p, 'horizontal').size;
1453
+ }
1332
1454
  case 'zstack':
1333
1455
  return this.measureZStack(node, ctx, p);
1334
1456
  case 'styled':
@@ -1370,6 +1492,8 @@ export class LayoutPass {
1370
1492
  case 'tabview':
1371
1493
  case 'tab':
1372
1494
  case 'navsplit':
1495
+ case 'inspector':
1496
+ case 'inspectorpanel':
1373
1497
  return {
1374
1498
  width: p.width ?? this.env.screen.width,
1375
1499
  height: p.height ?? this.env.screen.height,
@@ -1378,6 +1502,8 @@ export class LayoutPass {
1378
1502
  return this.viewThatFitsPlan(node, ctx, p).size;
1379
1503
  case 'picker':
1380
1504
  return this.pickerPlan(node, ctx, p).size;
1505
+ case 'host':
1506
+ return this.measureHost(node, p);
1381
1507
  case 'geometry':
1382
1508
  // GeometryReader: the proposal, 10 on unconstrained axes.
1383
1509
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
@@ -1435,6 +1561,8 @@ export class LayoutPass {
1435
1561
  case 'gesture':
1436
1562
  case 'listrow':
1437
1563
  case 'tapgesture':
1564
+ case 'draggable':
1565
+ case 'dropdestination':
1438
1566
  default: {
1439
1567
  // `tapgesture` (its child's size), `toolbar` outside a navscreen,
1440
1568
  // `section` outside a list and unknown kinds: lay the children out as a vstack.
@@ -1457,7 +1585,7 @@ export class LayoutPass {
1457
1585
  const min = Math.max(1, ctx.minLines ?? 1);
1458
1586
  const max = Math.max(min, ctx.lineLimit ?? Infinity);
1459
1587
  const lines = Math.min(Math.max(wrapped.lines.length, min), max);
1460
- return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
1588
+ return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
1461
1589
  }
1462
1590
 
1463
1591
  private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
@@ -1467,7 +1595,7 @@ export class LayoutPass {
1467
1595
  const text = str(node.props['text']) ?? '';
1468
1596
  const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
1469
1597
  return {
1470
- size: { width: wrapped.width, height: wrapped.lines.length * ctx.font.lineHeight },
1598
+ size: { width: wrapped.width, height: textHeight(wrapped.lines.length, ctx.font) },
1471
1599
  layout: {
1472
1600
  font: ctx.font,
1473
1601
  lines: wrapped.lines,
@@ -1506,16 +1634,16 @@ export class LayoutPass {
1506
1634
  };
1507
1635
  }
1508
1636
 
1509
- /** 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. */
1510
1638
  private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
1511
1639
  if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
1512
1640
  return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
1513
1641
  }
1514
1642
  const name = str(props['name']);
1515
- if (name === null) return square(font.lineHeight);
1643
+ if (name === null) return square(font.leading);
1516
1644
  const asset = this.env.assets?.images[name];
1517
1645
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1518
- if (asset.kind === 'symbol') return square(font.lineHeight);
1646
+ if (asset.kind === 'symbol') return square(font.leading);
1519
1647
  return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
1520
1648
  }
1521
1649
 
@@ -1775,6 +1903,25 @@ export class LayoutPass {
1775
1903
  * even split gave Ice Cubes' quoted posts half their card). With a `null`
1776
1904
  * axis proposal every child is proposed `null` and the stack sizes to the sum.
1777
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
+
1778
1925
  private layoutStack(
1779
1926
  children: readonly LayoutNode[],
1780
1927
  cctx: Context,
@@ -1947,10 +2094,10 @@ export class LayoutPass {
1947
2094
  return { width: p.width ?? width, height: p.height ?? height };
1948
2095
  }
1949
2096
  const name = str(node.props['name']);
1950
- if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
2097
+ if (name === null) return square(ctx.font.leading * ctx.imageScale);
1951
2098
  const asset = this.env.assets?.images[name];
1952
2099
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1953
- if (asset.kind === 'symbol') return square(ctx.font.lineHeight * ctx.imageScale);
2100
+ if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
1954
2101
  const width = num(asset.width) ?? 0;
1955
2102
  const height = num(asset.height) ?? 0;
1956
2103
  if (node.props['resizable'] !== true) return { width, height };
@@ -2153,11 +2300,13 @@ export class LayoutPass {
2153
2300
  const layoutWidth = Number.isFinite(width) ? width : screen.width;
2154
2301
  // `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
2155
2302
  // sections are full width. `plain` also drops the top/bottom gaps and
2156
- // 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).
2157
2305
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
2158
- // `.environment(\.defaultMinListRowHeight, _)`; 52 by default.
2159
- const minRowHeight = num(node.props['minRowHeight']) ?? LIST_ROW_MIN_HEIGHT;
2160
- 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;
2161
2310
  const plain = listStyle === 'plain';
2162
2311
  const listTop = plain ? 0 : LIST_TOP;
2163
2312
  const listBottom = plain ? 0 : LIST_BOTTOM;
@@ -2177,9 +2326,10 @@ export class LayoutPass {
2177
2326
  textStyleSize('footnote', this.env.dynamicTypeSize),
2178
2327
  400,
2179
2328
  );
2180
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2181
- // Phase 8: in edit mode every `listrow` gains a 44 leading control and
2182
- // 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.
2183
2333
  const editing = node.props['editing'] === true;
2184
2334
 
2185
2335
  // Group consecutive plain rows into an implicit section. A section's
@@ -2215,7 +2365,7 @@ export class LayoutPass {
2215
2365
  const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
2216
2366
  return {
2217
2367
  layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
2218
- size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
2368
+ size: { width: wrapped.width, height: textHeight(wrapped.lines.length, font) },
2219
2369
  };
2220
2370
  };
2221
2371
  for (let g = 0; g < groupsSource.length; g++) {
@@ -2256,7 +2406,7 @@ export class LayoutPass {
2256
2406
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2257
2407
  // width (plus a gap) at the trailing edge.
2258
2408
  const hints = rowHintsOf(rowNode);
2259
- const defaultVPad = listRowVPad(unwrapStyled(rowNode));
2409
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
2260
2410
  const insets: EdgeInsets = hints.insets ?? {
2261
2411
  top: defaultVPad,
2262
2412
  leading: LIST_CELL_PADDING,
@@ -2265,10 +2415,16 @@ export class LayoutPass {
2265
2415
  };
2266
2416
  const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
2267
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.
2268
2421
  const listRow = editing ? listRowOf(rowNode) : null;
2269
- 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;
2270
2426
  const rowContentX = cardX + insets.leading + editShift;
2271
- 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);
2272
2428
  const proposal: Proposal = { width: rowContentWidth, height: null };
2273
2429
  const contentSize = this.measure(rowNode, rowCtx, proposal);
2274
2430
  const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
@@ -2291,7 +2447,15 @@ export class LayoutPass {
2291
2447
  layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
2292
2448
  }
2293
2449
  : null,
2294
- 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,
2295
2459
  separatorHidden: hints.separatorHidden,
2296
2460
  });
2297
2461
  y += rowHeight;
@@ -2361,6 +2525,11 @@ export class LayoutPass {
2361
2525
  }
2362
2526
  this.hiddenBy.delete(node);
2363
2527
  }
2528
+ const grown = this.scrollEdgeGrowth(node, ctx, p, x, y);
2529
+ if (grown) {
2530
+ p = grown.p;
2531
+ y = grown.y;
2532
+ }
2364
2533
  const size = this.measure(node, ctx, p);
2365
2534
  this.setFrame(node, x, y, size);
2366
2535
  const cctx = this.childContext(node, ctx);
@@ -2382,9 +2551,18 @@ export class LayoutPass {
2382
2551
  case 'shape':
2383
2552
  case 'canvas':
2384
2553
  case 'video':
2554
+ case 'host':
2385
2555
  return;
2386
2556
  case 'vstack':
2387
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
+ }
2388
2566
  const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
2389
2567
  const plan = this.stackPlan(node, ctx, p, axis);
2390
2568
  const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
@@ -2503,8 +2681,9 @@ export class LayoutPass {
2503
2681
  };
2504
2682
  // Phase 7: the selected row of a `navsplit` sidebar has no chevron
2505
2683
  // (its label still reserves the chevron's width so selecting a row
2506
- // never reflows it); elsewhere `selected` keeps the chevron.
2507
- 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) {
2508
2687
  chrome['chevron'] = {
2509
2688
  x: x + size.width - LIST_CHEVRON_WIDTH,
2510
2689
  y,
@@ -2524,18 +2703,20 @@ export class LayoutPass {
2524
2703
  // centered on the other axes when smaller than the viewport.
2525
2704
  const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2526
2705
  const dy = axes.vertical ? 0 : safeOffset(size.height, plan.contentSize.height, 0.5);
2527
- // Under a large title that scrolls with it, the content starts below it.
2528
- const titleRect = axes.vertical ? this.titleInsets.get(node) : undefined;
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;
2529
2709
  const content: Rect = {
2530
2710
  x: x + dx,
2531
- y: y + dy + (titleRect?.height ?? 0),
2711
+ y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
2532
2712
  width: plan.contentSize.width,
2533
2713
  height: plan.contentSize.height,
2534
2714
  };
2535
2715
  const covered = this.coveredBottom(ctx, x, y, size);
2536
2716
  const scrollChrome: Record<string, Rect> = { content };
2537
2717
  if (covered) scrollChrome['bottomInset'] = covered;
2538
- 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;
2539
2720
  this.setChrome(node, scrollChrome);
2540
2721
  if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2541
2722
  return;
@@ -2559,6 +2740,12 @@ export class LayoutPass {
2559
2740
  case 'navsplit':
2560
2741
  this.placeNavSplit(node, cctx, x, y, size);
2561
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;
2562
2749
  case 'viewthatfits': {
2563
2750
  // Phase 7: only the chosen candidate is placed (at the origin, the
2564
2751
  // element is its size); the others have no frame and are hidden.
@@ -2654,8 +2841,10 @@ export class LayoutPass {
2654
2841
  case 'gesture':
2655
2842
  case 'listrow':
2656
2843
  case 'tapgesture':
2844
+ case 'draggable':
2845
+ case 'dropdestination':
2657
2846
  default: {
2658
- // `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,
2659
2848
  // `tab` outside a tabview, `section` outside a list and unknown kinds.
2660
2849
  const content = this.contentOf(node);
2661
2850
  if (content) this.place(content, cctx, p, x, y);
@@ -2664,26 +2853,71 @@ export class LayoutPass {
2664
2853
  }
2665
2854
  }
2666
2855
 
2667
- /** 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
+ */
2668
2860
  private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
2669
2861
  if (ctx.bottomBar <= 0) return null;
2670
2862
  const bottom = y + size.height;
2671
- if (bottom < this.env.screen.height - EPSILON) return null;
2863
+ if (bottom < (ctx.bottomEdge ?? this.env.screen.height) - EPSILON) return null;
2672
2864
  const height = Math.min(size.height, ctx.bottomBar);
2673
2865
  return { x, y: bottom - height, width: size.width, height };
2674
2866
  }
2675
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
+
2676
2908
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2677
2909
  const plan = this.listPlan(node, ctx, p);
2678
- 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' };
2679
2911
  const covered = this.coveredBottom(ctx, x, y, plan.size);
2680
- // Under a large title that scrolls with it, the rows start below it.
2681
- 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);
2682
2915
  const listChrome: Record<string, Rect> = {};
2683
2916
  if (covered) listChrome['bottomInset'] = covered;
2684
- 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;
2685
2919
  if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
2686
- const inset = titleRect?.height ?? 0;
2920
+ const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
2687
2921
  if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
2688
2922
  y += inset;
2689
2923
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
@@ -2723,8 +2957,11 @@ export class LayoutPass {
2723
2957
  // Phase 8: the edit control belongs to the `listrow`, which is the
2724
2958
  // row itself or sits under its wrappers (its own place() keeps chrome).
2725
2959
  if (row.edit) {
2726
- if (row.edit.listRow === row.node) chrome['editControl'] = offset(row.edit.control);
2727
- 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);
2728
2965
  }
2729
2966
  const rowX = x + row.content.x;
2730
2967
  const rowY = y + row.content.y;
@@ -2777,7 +3014,7 @@ export class LayoutPass {
2777
3014
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
2778
3015
  const depth = num(node.props['depth']) ?? 0;
2779
3016
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2780
- const barHeight = statusBar + NAV_BAR_HEIGHT;
3017
+ const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
2781
3018
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2782
3019
  const parts = this.navScreenParts(node);
2783
3020
  const reachesBottom = y + size.height >= screen.height - EPSILON;
@@ -2795,6 +3032,8 @@ export class LayoutPass {
2795
3032
  };
2796
3033
  const chrome: Record<string, Rect> = {
2797
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 },
2798
3037
  content,
2799
3038
  };
2800
3039
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
@@ -2844,13 +3083,14 @@ export class LayoutPass {
2844
3083
  // Back button, iOS 26: the chevron alone in a glass circle (the previous
2845
3084
  // title no longer shows).
2846
3085
  const backLabel = '';
3086
+ const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
2847
3087
  if (depth > 0) {
2848
- 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 };
2849
3089
  }
2850
3090
 
2851
3091
  // Place the toolbar groups and remember how much of the row they occupy.
2852
3092
  const back = chrome['back'];
2853
- 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;
2854
3094
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2855
3095
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2856
3096
  const itemCtx = this.childContext(group.toolbar, cctx);
@@ -2921,7 +3161,11 @@ export class LayoutPass {
2921
3161
  // A large title that scrolls away collapses into the inline title, which
2922
3162
  // the renderer shows once it has (`data-sb-collapsed`).
2923
3163
  if ((!large || titleScrolls) && !principal) {
2924
- 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
+ );
2925
3169
  const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
2926
3170
  const side = Math.max(leftUsed, rightUsed) + 8;
2927
3171
  const available = Math.max(0, size.width - 2 * side);
@@ -2950,23 +3194,46 @@ export class LayoutPass {
2950
3194
 
2951
3195
  const child = parts.content;
2952
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);
2953
3200
  // Under a tab bar the bar (which includes the bottom safe area) is what
2954
3201
  // non-scrolling content must clear; a bottom toolbar already excluded it.
2955
3202
  const bottomInset =
2956
3203
  bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2957
3204
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2958
3205
  // large title / search field), which is transparent over it.
2959
- const contentCtx: Context = { ...cctx, safeTop: content.y, barInset: 0 };
2960
- if (scroller && titleScrolls) {
2961
- // The scroll view starts under the bar; its content starts under the
2962
- // large title, which moves with it.
2963
- const top = content.y - largeHeight;
2964
- this.titleInsets.set(scroller, chrome['largeTitle'] as Rect);
2965
- this.place(child, contentCtx, { width: content.width, height: content.height + largeHeight }, content.x, top);
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);
2966
3227
  return;
2967
3228
  }
2968
- if (scroller) this.titleInsets.delete(scroller);
2969
- 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') {
2970
3237
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2971
3238
  return;
2972
3239
  }
@@ -3013,7 +3280,7 @@ export class LayoutPass {
3013
3280
  }
3014
3281
  this.setChrome(node, chrome);
3015
3282
  const selected = readSelected(node, n);
3016
- const tabCtx: Context = { ...cctx, bottomBar: barHeight };
3283
+ const tabCtx: Context = { ...cctx, bottomBar: barHeight, bottomEdge: undefined, barAbove: undefined };
3017
3284
  tabs.forEach((tab, i) => {
3018
3285
  this.setFrame(tab, x, y, size);
3019
3286
  if (i !== selected) {
@@ -3071,6 +3338,12 @@ export class LayoutPass {
3071
3338
  * to the detail column (when the split view touches those screen edges):
3072
3339
  * the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
3073
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.
3074
3347
  */
3075
3348
  private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3076
3349
  const { screen, safeArea } = this.env;
@@ -3078,27 +3351,81 @@ export class LayoutPass {
3078
3351
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3079
3352
  const usable = Math.max(0, size.width - leading - trailing);
3080
3353
  const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
3081
- const separatorX = x + leading + sidebarWidth;
3082
- 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;
3083
3359
  const detailWidth = Math.max(0, x + size.width - trailing - detailX);
3360
+ const statusBar = y <= 0 ? safeArea.top : 0;
3084
3361
  const chrome: Record<string, Rect> = {
3085
- sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
3362
+ sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
3086
3363
  separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
3087
- 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 },
3088
3366
  };
3089
3367
  this.setChrome(node, chrome);
3368
+ const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
3090
3369
  const [sidebar, detail, ...rest] = node.children;
3091
3370
  if (sidebar) {
3092
3371
  const proposal: Proposal = { width: sidebarWidth, height: size.height };
3093
- this.place(sidebar, { ...cctx, splitSidebar: true }, proposal, x + leading, y);
3372
+ this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
3094
3373
  }
3095
3374
  if (detail) {
3096
3375
  const proposal: Proposal = { width: detailWidth, height: size.height };
3097
- this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
3376
+ this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
3098
3377
  }
3099
3378
  for (const extra of rest) this.hide(extra, node);
3100
3379
  }
3101
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
+
3102
3429
  /**
3103
3430
  * Phase 7 `ViewThatFits`: the first child whose ideal size fits the
3104
3431
  * proposal on the restricted axes (a nil or infinite proposal axis always
@@ -3350,7 +3677,7 @@ export class LayoutPass {
3350
3677
  const wrapped = wrapText(text, font, width, null, this.measurer);
3351
3678
  return {
3352
3679
  layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
3353
- height: wrapped.lines.length * font.lineHeight,
3680
+ height: textHeight(wrapped.lines.length, font),
3354
3681
  };
3355
3682
  };
3356
3683
  const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');