@swiftbrowser/web 0.3.1 → 0.5.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,30 +175,38 @@ 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
- // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
170
- // and 22 above the bottom edge (8 when the tab view ends above the screen
171
- // bottom), its items in equal slots inside a 4 inset.
203
+ // iOS 26: the tab bar is a 62-tall glass capsule floating 22 above the bottom
204
+ // edge (8 when the tab view ends above the screen bottom), its items in equal
205
+ // slots inside a 4 inset. It is as wide as its tabs (268/3 each, measured in
206
+ // the 26.2 simulator: two tabs make a 186.67 capsule), centered, and at most
207
+ // the width less 21 either side (four tabs fill that on an iPhone).
172
208
  const TAB_BAR_HEIGHT = 62;
209
+ const TAB_BAR_ITEM_WIDTH = 268 / 3;
173
210
  const TAB_BAR_MARGIN_H = 21;
174
211
  const TAB_BAR_MARGIN_BOTTOM = 22;
175
212
  const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
@@ -209,7 +246,18 @@ const NAV_ITEM_MARGIN = 16;
209
246
  const NAV_GLASS_SIZE = 44;
210
247
  const NAV_GLASS_PAD_H = 12;
211
248
  const NAV_GROUP_SPACING = 8;
212
- const BOTTOM_BAR_HEIGHT = 49;
249
+ // iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
250
+ // bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
251
+ // screen bottom (as the search field does) and 28 in from the sides.
252
+ // Consecutive items share a capsule: an icon takes a 50 slot, text its width
253
+ // and 18 either side, 6 between slots; a lone icon is a 48 circle.
254
+ const BOTTOM_GLASS_HEIGHT = 48;
255
+ const BOTTOM_GLASS_MARGIN_H = 28;
256
+ const BOTTOM_ICON_SLOT = 50;
257
+ const BOTTOM_TEXT_PAD_H = 18;
258
+ const BOTTOM_ITEM_GAP = 6;
259
+ /** A fixed `ToolbarSpacer` between two bottom groups. */
260
+ const BOTTOM_FIXED_SPACER = 8;
213
261
  const SEARCH_FIELD_HEIGHT = 36;
214
262
  const SEARCH_FIELD_MARGIN_H = 16;
215
263
  // iOS 26: a navigation stack's search field is a 48-tall glass capsule
@@ -225,15 +273,19 @@ const NAVSPLIT_SIDEBAR_WIDTH = 320;
225
273
  /** The sidebar never takes more than this share of the split view's width. */
226
274
  const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
227
275
  const NAVSPLIT_SEPARATOR_WIDTH = 1;
276
+ /** `.inspector`: the trailing column's default width, and at most this share of the width. */
277
+ const INSPECTOR_WIDTH = 320;
278
+ const INSPECTOR_MAX_FRACTION = 0.5;
228
279
  const EPSILON = 1e-6;
229
280
  // Phase 8 (docs/ops-protocol.md, "Phase 8: interaction": alerts and controls).
230
281
  /** The segmented −/+ control of a `stepper`, trailing. */
231
282
  const STEPPER_WIDTH = 94;
232
283
  const STEPPER_HEIGHT = 32;
233
284
  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;
285
+ /** A `slider` is 31 high (its label row, when present, sits above). */
286
+ // iOS 26: a 6pt track and a 38 × 24 pill knob in a 31 row (a form row
287
+ // holding one is 61: 31 + 2 × 15).
288
+ const SLIDER_HEIGHT = 31;
237
289
  const SLIDER_TRACK_HEIGHT = 6;
238
290
  const SLIDER_THUMB = 38;
239
291
  const SLIDER_THUMB_HEIGHT = 24;
@@ -467,7 +519,7 @@ function cellStyle(node: LayoutNode, key: string): unknown {
467
519
  }
468
520
 
469
521
  /** The default vertical padding above and below a list row's content. */
470
- function listRowVPad(row: LayoutNode): number {
522
+ function listRowVPad(row: LayoutNode, sidebar: boolean): number {
471
523
  switch (row.kind) {
472
524
  case 'picker':
473
525
  return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
@@ -475,7 +527,7 @@ function listRowVPad(row: LayoutNode): number {
475
527
  case 'stepper':
476
528
  return LIST_ROW_CONTROL_VPAD;
477
529
  default:
478
- return LIST_ROW_VPAD;
530
+ return sidebar ? LIST_SIDEBAR_ROW_VPAD : LIST_ROW_VPAD;
479
531
  }
480
532
  }
481
533
 
@@ -530,7 +582,7 @@ interface GridItemSpec {
530
582
  alignment: string | null;
531
583
  }
532
584
 
533
- /** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible column. */
585
+ /** `columns` of a `lazyvgrid` or `rows` of a `lazyhgrid` (untrusted JSON); an empty list is one flexible track. */
534
586
  function readGridItems(value: unknown): GridItemSpec[] {
535
587
  const items: GridItemSpec[] = [];
536
588
  if (Array.isArray(value)) {
@@ -609,7 +661,7 @@ function rowHintsOf(node: LayoutNode): RowHints {
609
661
  }
610
662
 
611
663
  /** 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']);
664
+ const ROW_WRAPPERS: ReadonlySet<string> = new Set(['listrow', 'swipeactions', 'contextmenu', 'gesture', 'tapgesture', 'draggable', 'dropdestination']);
613
665
 
614
666
  /**
615
667
  * Phase 8: the `listrow` a list row is (or wraps, through `styled` and the
@@ -625,8 +677,32 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
625
677
  return null;
626
678
  }
627
679
 
628
- /** Phase 8 edit mode: the width of a `listrow`'s leading control, by which its content shifts right. */
680
+ /** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
681
+ const LIST_LABEL_ICON_CENTER = 12;
682
+ const LIST_LABEL_TITLE_X = 40;
683
+
684
+ /**
685
+ * A `Label` in its default style: an hstack of the marked icon (`labelIcon`
686
+ * on its `styled` wrapper) then the title. In a list row it takes iOS's icon
687
+ * column instead of the stack's spacing.
688
+ */
689
+ function labelColumnOf(node: LayoutNode): { icon: LayoutNode; title: LayoutNode } | null {
690
+ if (node.kind !== 'hstack' || node.children.length !== 2) return null;
691
+ const [icon, title] = node.children as [LayoutNode, LayoutNode];
692
+ const style = icon.kind === 'styled' ? (icon.props['style'] as Record<string, unknown> | undefined) : undefined;
693
+ if (style?.['labelIcon'] !== true) return null;
694
+ return { icon, title };
695
+ }
696
+
697
+ /** 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
698
  export const LIST_EDIT_CONTROL_WIDTH = 44;
699
+ /**
700
+ * Edit mode: how far a control sits in from the card's edge (its 22 pt glyph
701
+ * is centred 28 from the edge, as iOS draws it) and by how much the content
702
+ * shifts right of the leading control, or narrows for the trailing grip.
703
+ */
704
+ export const LIST_EDIT_CONTROL_INSET = 6;
705
+ export const LIST_EDIT_CONTROL_SHIFT = 40;
630
706
 
631
707
  /** `font` with every metric multiplied by `scale` (for `imageScale`d symbols). */
632
708
  function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
@@ -634,7 +710,7 @@ function scaledFont(font: ResolvedFont, scale: number): ResolvedFont {
634
710
  const size = font.size * scale;
635
711
  const lineHeight = font.lineHeight * scale;
636
712
  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]}` };
713
+ return { ...font, size, lineHeight, leading: font.leading * scale, css: `${font.weight} ${px(size)}/${px(lineHeight)} ${FONT_FAMILIES[font.family]}` };
638
714
  }
639
715
 
640
716
  // ---------------------------------------------------------------------------
@@ -847,8 +923,12 @@ interface ListRowPlan {
847
923
  content: Rect;
848
924
  /** Phase 6 `.badge`: trailing text in the row (rect relative to the list origin). */
849
925
  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;
926
+ /**
927
+ * Phase 8 edit mode: the `listrow` the row is, its 44px leading control
928
+ * rect (a selectable or deletable row) and its 44px trailing grip rect (a
929
+ * movable row), relative to the list origin.
930
+ */
931
+ edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
852
932
  /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
853
933
  separatorHidden?: { top: boolean; bottom: boolean } | null;
854
934
  }
@@ -1006,11 +1086,13 @@ export class LayoutPass {
1006
1086
  /** Previous screen title per navscreen node, for the back button label. */
1007
1087
  private readonly previousTitles = new Map<LayoutNode, string>();
1008
1088
  /**
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`.
1089
+ * A navigation screen's root list or vertical scroll view starts at the top
1090
+ * of the screen and scrolls under its bar (`bar` tall): its content starts
1091
+ * below the bar and the large title row that scrolls with it, if any.
1092
+ * `nested`: a scroll view deeper in the screen's content that touches the
1093
+ * bar (`scrollEdgeGrowth`), rather than the screen's own content.
1012
1094
  */
1013
- private readonly titleInsets = new WeakMap<LayoutNode, Rect>();
1095
+ private readonly scrollInsets = new WeakMap<LayoutNode, { bar: number; largeTitle: Rect | null; nested?: boolean }>();
1014
1096
 
1015
1097
  private readonly memo = new Map<LayoutNode, Map<string, unknown>>();
1016
1098
  private readonly implicit = new Map<LayoutNode, LayoutNode>();
@@ -1055,10 +1137,17 @@ export class LayoutPass {
1055
1137
  const { screen, safeArea } = this.env;
1056
1138
  const ctx = this.rootContext;
1057
1139
  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
1140
+ if (
1141
+ root.kind === 'navstack' ||
1142
+ root.kind === 'tabview' ||
1143
+ root.kind === 'navsplit' ||
1144
+ root.kind === 'inspector' ||
1145
+ root.kind === 'alert'
1146
+ ) {
1147
+ // These ignore the safe areas: the tab bar and navigation bars
1060
1148
  // extend into them and the content is inset instead (a `navsplit`
1061
- // hands the horizontal insets to its columns, see `placeNavSplit`).
1149
+ // hands the horizontal insets to its columns, see `placeNavSplit`;
1150
+ // an `inspector` its trailing one to its panel).
1062
1151
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
1063
1152
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
1064
1153
  } else if (root.kind === 'sheet') {
@@ -1073,6 +1162,14 @@ export class LayoutPass {
1073
1162
  width: Math.max(0, screen.width - safeArea.leading - safeArea.trailing),
1074
1163
  height: Math.max(0, screen.height - safeArea.top - safeArea.bottom),
1075
1164
  };
1165
+ if (this.env.contentHeight) {
1166
+ // An island: the root takes its content's height and sits at the top.
1167
+ const proposal: Proposal = { width: safe.width, height: null };
1168
+ const size = this.measure(root, ctx, proposal);
1169
+ const { dx } = alignIn(safe, size, 'center');
1170
+ this.place(root, ctx, proposal, safeArea.leading + dx, safeArea.top);
1171
+ continue;
1172
+ }
1076
1173
  const proposal: Proposal = { width: safe.width, height: safe.height };
1077
1174
  const size = this.measure(root, ctx, proposal);
1078
1175
  const { dx, dy } = alignIn(safe, size, 'center');
@@ -1082,6 +1179,32 @@ export class LayoutPass {
1082
1179
  return this.finish();
1083
1180
  }
1084
1181
 
1182
+ /**
1183
+ * A `host` element (src/hostContent.ts): the page's DOM, measured by the
1184
+ * host measurer. `fill` takes the proposal and the content's own size on an
1185
+ * unconstrained axis; `content` takes the content's ideal width capped by
1186
+ * the proposal and its height at that width, like text. Without a measurer
1187
+ * (headless) it is a 10×10 placeholder, like a shape with no proposal.
1188
+ */
1189
+ private measureHost(node: LayoutNode, p: Proposal): Size {
1190
+ const props = node.props as { sizing?: string; content?: string };
1191
+ const bounded = (value: number | null): number | null => (value !== null && Number.isFinite(value) ? value : null);
1192
+ const width = bounded(p.width);
1193
+ const height = bounded(p.height);
1194
+ const measure = this.measurer.measureHost?.bind(this.measurer);
1195
+ if (!measure || typeof props.content !== 'string') {
1196
+ return { width: width ?? SHAPE_DEFAULT, height: height ?? SHAPE_DEFAULT };
1197
+ }
1198
+ if (props.sizing === 'fill') {
1199
+ if (width !== null && height !== null) return { width, height };
1200
+ const natural = measure(props.content, width);
1201
+ return { width: width ?? natural.width, height: height ?? natural.height };
1202
+ }
1203
+ const ideal = measure(props.content, null);
1204
+ if (width === null || ideal.width <= width) return ideal;
1205
+ return { width, height: measure(props.content, width).height };
1206
+ }
1207
+
1085
1208
  private finish(): LayoutResult {
1086
1209
  // Only what this run produced needs snapping; the rest was snapped when
1087
1210
  // it was produced.
@@ -1243,10 +1366,20 @@ export class LayoutPass {
1243
1366
  return { ...ctx, stackAxis: 'vertical', listRow: false };
1244
1367
  case 'hstack':
1245
1368
  return { ...ctx, stackAxis: 'horizontal', listRow: false };
1369
+ case 'list':
1370
+ case 'scrollview':
1371
+ case 'sheet': {
1372
+ // A bar above is the outermost scroll view's to run under; what a
1373
+ // scroll view or a sheet holds is clear of it.
1374
+ if (ctx.barAbove === undefined && ctx.stackAxis === null && !ctx.listRow) return ctx;
1375
+ return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
1376
+ }
1246
1377
  case 'gesture':
1247
1378
  case 'swipeactions':
1248
1379
  case 'contextmenu':
1249
1380
  case 'listrow':
1381
+ case 'draggable':
1382
+ case 'dropdestination':
1250
1383
  // Phase 8 wrappers are transparent: a `navlink` under them is still
1251
1384
  // the list row (its chevron, its selected fill).
1252
1385
  return ctx.stackAxis === null ? ctx : { ...ctx, stackAxis: null };
@@ -1327,8 +1460,11 @@ export class LayoutPass {
1327
1460
  return { width: finite(p.width) ? p.width : 0, height: 1 };
1328
1461
  case 'vstack':
1329
1462
  return this.stackPlan(node, ctx, p, 'vertical').size;
1330
- case 'hstack':
1463
+ case 'hstack': {
1464
+ const label = ctx.labelColumn ? labelColumnOf(node) : null;
1465
+ if (label) return this.labelColumnPlan(label, this.childContext(node, ctx), p).size;
1331
1466
  return this.stackPlan(node, ctx, p, 'horizontal').size;
1467
+ }
1332
1468
  case 'zstack':
1333
1469
  return this.measureZStack(node, ctx, p);
1334
1470
  case 'styled':
@@ -1370,6 +1506,8 @@ export class LayoutPass {
1370
1506
  case 'tabview':
1371
1507
  case 'tab':
1372
1508
  case 'navsplit':
1509
+ case 'inspector':
1510
+ case 'inspectorpanel':
1373
1511
  return {
1374
1512
  width: p.width ?? this.env.screen.width,
1375
1513
  height: p.height ?? this.env.screen.height,
@@ -1378,6 +1516,8 @@ export class LayoutPass {
1378
1516
  return this.viewThatFitsPlan(node, ctx, p).size;
1379
1517
  case 'picker':
1380
1518
  return this.pickerPlan(node, ctx, p).size;
1519
+ case 'host':
1520
+ return this.measureHost(node, p);
1381
1521
  case 'geometry':
1382
1522
  // GeometryReader: the proposal, 10 on unconstrained axes.
1383
1523
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
@@ -1400,6 +1540,7 @@ export class LayoutPass {
1400
1540
  case 'grid':
1401
1541
  return this.gridPlan(node, ctx, p).size;
1402
1542
  case 'lazyvgrid':
1543
+ case 'lazyhgrid':
1403
1544
  return this.lazyGridPlan(node, ctx, p).size;
1404
1545
  case 'gauge':
1405
1546
  return this.gaugePlan(node, ctx, p).size;
@@ -1435,6 +1576,8 @@ export class LayoutPass {
1435
1576
  case 'gesture':
1436
1577
  case 'listrow':
1437
1578
  case 'tapgesture':
1579
+ case 'draggable':
1580
+ case 'dropdestination':
1438
1581
  default: {
1439
1582
  // `tapgesture` (its child's size), `toolbar` outside a navscreen,
1440
1583
  // `section` outside a list and unknown kinds: lay the children out as a vstack.
@@ -1457,7 +1600,7 @@ export class LayoutPass {
1457
1600
  const min = Math.max(1, ctx.minLines ?? 1);
1458
1601
  const max = Math.max(min, ctx.lineLimit ?? Infinity);
1459
1602
  const lines = Math.min(Math.max(wrapped.lines.length, min), max);
1460
- return { width, height: lines * ctx.font.lineHeight + 2 * inset.vertical };
1603
+ return { width, height: textHeight(lines, ctx.font) + 2 * inset.vertical };
1461
1604
  }
1462
1605
 
1463
1606
  private textPlan(node: LayoutNode, ctx: Context, p: Proposal): TextPlan {
@@ -1467,7 +1610,7 @@ export class LayoutPass {
1467
1610
  const text = str(node.props['text']) ?? '';
1468
1611
  const wrapped = wrapText(text, ctx.font, p.width, ctx.lineLimit, this.measurer);
1469
1612
  return {
1470
- size: { width: wrapped.width, height: wrapped.lines.length * ctx.font.lineHeight },
1613
+ size: { width: wrapped.width, height: textHeight(wrapped.lines.length, ctx.font) },
1471
1614
  layout: {
1472
1615
  font: ctx.font,
1473
1616
  lines: wrapped.lines,
@@ -1506,16 +1649,16 @@ export class LayoutPass {
1506
1649
  };
1507
1650
  }
1508
1651
 
1509
- /** An image in a text: a bitmap or remote image at its size, a symbol a square of the line height. */
1652
+ /** An image in a text: a bitmap or remote image at its size, a symbol its font's symbol square. */
1510
1653
  private inlineImageSize(props: Record<string, unknown>, font: ResolvedFont): Size {
1511
1654
  if (typeof props['url'] === 'string' || typeof props['bitmap'] === 'object') {
1512
1655
  return { width: num(props['width']) ?? 0, height: num(props['height']) ?? 0 };
1513
1656
  }
1514
1657
  const name = str(props['name']);
1515
- if (name === null) return square(font.lineHeight);
1658
+ if (name === null) return square(font.leading);
1516
1659
  const asset = this.env.assets?.images[name];
1517
1660
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1518
- if (asset.kind === 'symbol') return square(font.lineHeight);
1661
+ if (asset.kind === 'symbol') return square(font.leading);
1519
1662
  return { width: num(asset.width) ?? 0, height: num(asset.height) ?? 0 };
1520
1663
  }
1521
1664
 
@@ -1775,6 +1918,25 @@ export class LayoutPass {
1775
1918
  * even split gave Ice Cubes' quoted posts half their card). With a `null`
1776
1919
  * axis proposal every child is proposed `null` and the stack sizes to the sum.
1777
1920
  */
1921
+ /** A list row's `Label` in iOS's icon column: the icon at its own size, the title in what is left past 40. */
1922
+ private labelColumnPlan(
1923
+ label: { icon: LayoutNode; title: LayoutNode },
1924
+ cctx: Context,
1925
+ p: Proposal,
1926
+ ): { size: Size; icon: Size; title: Size; iconProposal: Proposal; titleProposal: Proposal } {
1927
+ const iconProposal: Proposal = { width: null, height: p.height };
1928
+ const icon = this.measure(label.icon, cctx, iconProposal);
1929
+ const titleProposal: Proposal = { width: finite(p.width) ? Math.max(0, p.width - LIST_LABEL_TITLE_X) : p.width, height: p.height };
1930
+ const title = this.measure(label.title, cctx, titleProposal);
1931
+ return {
1932
+ size: { width: LIST_LABEL_TITLE_X + title.width, height: Math.max(icon.height, title.height) },
1933
+ icon,
1934
+ title,
1935
+ iconProposal,
1936
+ titleProposal,
1937
+ };
1938
+ }
1939
+
1778
1940
  private layoutStack(
1779
1941
  children: readonly LayoutNode[],
1780
1942
  cctx: Context,
@@ -1947,10 +2109,10 @@ export class LayoutPass {
1947
2109
  return { width: p.width ?? width, height: p.height ?? height };
1948
2110
  }
1949
2111
  const name = str(node.props['name']);
1950
- if (name === null) return square(ctx.font.lineHeight * ctx.imageScale);
2112
+ if (name === null) return square(ctx.font.leading * ctx.imageScale);
1951
2113
  const asset = this.env.assets?.images[name];
1952
2114
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
1953
- if (asset.kind === 'symbol') return square(ctx.font.lineHeight * ctx.imageScale);
2115
+ if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
1954
2116
  const width = num(asset.width) ?? 0;
1955
2117
  const height = num(asset.height) ?? 0;
1956
2118
  if (node.props['resizable'] !== true) return { width, height };
@@ -2153,11 +2315,13 @@ export class LayoutPass {
2153
2315
  const layoutWidth = Number.isFinite(width) ? width : screen.width;
2154
2316
  // `insetGrouped` (default) insets the cards 16; `grouped` and `plain`
2155
2317
  // sections are full width. `plain` also drops the top/bottom gaps and
2156
- // the space between sections.
2318
+ // the space between sections. `sidebar` (iPadOS) is laid out like
2319
+ // `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
2157
2320
  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;
2321
+ const sidebarStyle = listStyle === 'sidebar';
2322
+ // `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
2323
+ const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
2324
+ const inset = listStyle === 'insetGrouped' || sidebarStyle ? LIST_INSET : 0;
2161
2325
  const plain = listStyle === 'plain';
2162
2326
  const listTop = plain ? 0 : LIST_TOP;
2163
2327
  const listBottom = plain ? 0 : LIST_BOTTOM;
@@ -2177,9 +2341,10 @@ export class LayoutPass {
2177
2341
  textStyleSize('footnote', this.env.dynamicTypeSize),
2178
2342
  400,
2179
2343
  );
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.
2344
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
2345
+ // Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
2346
+ // leading control (6 in from the card's edge) and its content shifts
2347
+ // right by 40; a movable one a trailing grip, which narrows it by 40.
2183
2348
  const editing = node.props['editing'] === true;
2184
2349
 
2185
2350
  // Group consecutive plain rows into an implicit section. A section's
@@ -2215,7 +2380,7 @@ export class LayoutPass {
2215
2380
  const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
2216
2381
  return {
2217
2382
  layout: { font, lines: wrapped.lines, alignment: 'leading', truncated: false },
2218
- size: { width: wrapped.width, height: wrapped.lines.length * font.lineHeight },
2383
+ size: { width: wrapped.width, height: textHeight(wrapped.lines.length, font) },
2219
2384
  };
2220
2385
  };
2221
2386
  for (let g = 0; g < groupsSource.length; g++) {
@@ -2256,7 +2421,7 @@ export class LayoutPass {
2256
2421
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2257
2422
  // width (plus a gap) at the trailing edge.
2258
2423
  const hints = rowHintsOf(rowNode);
2259
- const defaultVPad = listRowVPad(unwrapStyled(rowNode));
2424
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
2260
2425
  const insets: EdgeInsets = hints.insets ?? {
2261
2426
  top: defaultVPad,
2262
2427
  leading: LIST_CELL_PADDING,
@@ -2265,10 +2430,16 @@ export class LayoutPass {
2265
2430
  };
2266
2431
  const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
2267
2432
  const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
2433
+ // Phase 8 edit mode: a selectable or deletable `listrow` gains a
2434
+ // leading control and its content shifts right; a movable one a
2435
+ // trailing grip, which narrows its content.
2268
2436
  const listRow = editing ? listRowOf(rowNode) : null;
2269
- const editShift = listRow ? LIST_EDIT_CONTROL_WIDTH : 0;
2437
+ const hasControl = listRow !== null && (listRow.props['selectable'] === true || listRow.props['deletable'] === true);
2438
+ const hasGrip = listRow !== null && listRow.props['movable'] === true;
2439
+ const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
2440
+ const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
2270
2441
  const rowContentX = cardX + insets.leading + editShift;
2271
- const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift);
2442
+ const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
2272
2443
  const proposal: Proposal = { width: rowContentWidth, height: null };
2273
2444
  const contentSize = this.measure(rowNode, rowCtx, proposal);
2274
2445
  const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
@@ -2291,7 +2462,15 @@ export class LayoutPass {
2291
2462
  layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
2292
2463
  }
2293
2464
  : null,
2294
- edit: listRow ? { listRow, control: { x: cardX, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } } : null,
2465
+ edit: listRow
2466
+ ? {
2467
+ listRow,
2468
+ control: hasControl ? { x: cardX + LIST_EDIT_CONTROL_INSET, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight } : null,
2469
+ reorder: hasGrip
2470
+ ? { x: cardX + cardWidth - LIST_EDIT_CONTROL_INSET - LIST_EDIT_CONTROL_WIDTH, y, width: LIST_EDIT_CONTROL_WIDTH, height: rowHeight }
2471
+ : null,
2472
+ }
2473
+ : null,
2295
2474
  separatorHidden: hints.separatorHidden,
2296
2475
  });
2297
2476
  y += rowHeight;
@@ -2361,6 +2540,11 @@ export class LayoutPass {
2361
2540
  }
2362
2541
  this.hiddenBy.delete(node);
2363
2542
  }
2543
+ const grown = this.scrollEdgeGrowth(node, ctx, p, x, y);
2544
+ if (grown) {
2545
+ p = grown.p;
2546
+ y = grown.y;
2547
+ }
2364
2548
  const size = this.measure(node, ctx, p);
2365
2549
  this.setFrame(node, x, y, size);
2366
2550
  const cctx = this.childContext(node, ctx);
@@ -2382,9 +2566,18 @@ export class LayoutPass {
2382
2566
  case 'shape':
2383
2567
  case 'canvas':
2384
2568
  case 'video':
2569
+ case 'host':
2385
2570
  return;
2386
2571
  case 'vstack':
2387
2572
  case 'hstack': {
2573
+ const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
2574
+ if (label) {
2575
+ // iOS's list icon column: the icon centred at 12, the title at 40.
2576
+ const plan = this.labelColumnPlan(label, cctx, p);
2577
+ this.place(label.icon, cctx, plan.iconProposal, x + LIST_LABEL_ICON_CENTER - plan.icon.width / 2, y + (size.height - plan.icon.height) / 2);
2578
+ this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
2579
+ return;
2580
+ }
2388
2581
  const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
2389
2582
  const plan = this.stackPlan(node, ctx, p, axis);
2390
2583
  const spacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
@@ -2503,8 +2696,9 @@ export class LayoutPass {
2503
2696
  };
2504
2697
  // Phase 7: the selected row of a `navsplit` sidebar has no chevron
2505
2698
  // (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)) {
2699
+ // never reflows it); elsewhere `selected` keeps the chevron. No row
2700
+ // of a `sidebar`-style list has one (iPadOS).
2701
+ if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
2508
2702
  chrome['chevron'] = {
2509
2703
  x: x + size.width - LIST_CHEVRON_WIDTH,
2510
2704
  y,
@@ -2524,18 +2718,20 @@ export class LayoutPass {
2524
2718
  // centered on the other axes when smaller than the viewport.
2525
2719
  const dx = axes.horizontal ? 0 : safeOffset(size.width, plan.contentSize.width, 0.5);
2526
2720
  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;
2721
+ // Under its screen's bar (and the large title that scrolls with it),
2722
+ // the content starts below them.
2723
+ const inset = axes.vertical ? this.scrollInsets.get(node) : undefined;
2529
2724
  const content: Rect = {
2530
2725
  x: x + dx,
2531
- y: y + dy + (titleRect?.height ?? 0),
2726
+ y: y + dy + (inset ? inset.bar + (inset.largeTitle?.height ?? 0) : 0),
2532
2727
  width: plan.contentSize.width,
2533
2728
  height: plan.contentSize.height,
2534
2729
  };
2535
2730
  const covered = this.coveredBottom(ctx, x, y, size);
2536
2731
  const scrollChrome: Record<string, Rect> = { content };
2537
2732
  if (covered) scrollChrome['bottomInset'] = covered;
2538
- if (titleRect) scrollChrome['largeTitle'] = titleRect;
2733
+ if (inset) scrollChrome['topInset'] = { x, y, width: size.width, height: inset.bar };
2734
+ if (inset?.largeTitle) scrollChrome['largeTitle'] = inset.largeTitle;
2539
2735
  this.setChrome(node, scrollChrome);
2540
2736
  if (plan.content) this.place(plan.content, cctx, plan.contentProposal, content.x, content.y);
2541
2737
  return;
@@ -2559,6 +2755,12 @@ export class LayoutPass {
2559
2755
  case 'navsplit':
2560
2756
  this.placeNavSplit(node, cctx, x, y, size);
2561
2757
  return;
2758
+ case 'inspector':
2759
+ this.placeInspector(node, cctx, x, y, size);
2760
+ return;
2761
+ case 'inspectorpanel':
2762
+ this.placeInspectorPanel(node, cctx, x, y, size);
2763
+ return;
2562
2764
  case 'viewthatfits': {
2563
2765
  // Phase 7: only the chosen candidate is placed (at the origin, the
2564
2766
  // element is its size); the others have no frame and are hidden.
@@ -2591,6 +2793,7 @@ export class LayoutPass {
2591
2793
  this.placeGrid(node, ctx, p, x, y);
2592
2794
  return;
2593
2795
  case 'lazyvgrid':
2796
+ case 'lazyhgrid':
2594
2797
  this.placeLazyGrid(node, ctx, p, x, y);
2595
2798
  return;
2596
2799
  case 'gauge':
@@ -2654,8 +2857,10 @@ export class LayoutPass {
2654
2857
  case 'gesture':
2655
2858
  case 'listrow':
2656
2859
  case 'tapgesture':
2860
+ case 'draggable':
2861
+ case 'dropdestination':
2657
2862
  default: {
2658
- // `tapgesture`, `gesture`, `listrow` (child at the same origin), `toolbar` outside a navscreen,
2863
+ // `tapgesture`, `gesture`, `listrow`, `draggable`, `dropdestination` (child at the same origin), `toolbar` outside a navscreen,
2659
2864
  // `tab` outside a tabview, `section` outside a list and unknown kinds.
2660
2865
  const content = this.contentOf(node);
2661
2866
  if (content) this.place(content, cctx, p, x, y);
@@ -2664,26 +2869,71 @@ export class LayoutPass {
2664
2869
  }
2665
2870
  }
2666
2871
 
2667
- /** The part of a scrolling view's frame that a tab bar covers (chrome `bottomInset`). */
2872
+ /**
2873
+ * The part of a scrolling view's frame that a tab bar (or its screen's
2874
+ * bottom toolbar or search field) covers (chrome `bottomInset`).
2875
+ */
2668
2876
  private coveredBottom(ctx: Context, x: number, y: number, size: Size): Rect | null {
2669
2877
  if (ctx.bottomBar <= 0) return null;
2670
2878
  const bottom = y + size.height;
2671
- if (bottom < this.env.screen.height - EPSILON) return null;
2879
+ if (bottom < (ctx.bottomEdge ?? this.env.screen.height) - EPSILON) return null;
2672
2880
  const height = Math.min(size.height, ctx.bottomBar);
2673
2881
  return { x, y: bottom - height, width: size.width, height };
2674
2882
  }
2675
2883
 
2884
+ /**
2885
+ * iOS 26: a vertical list or scroll view deeper in a screen's content
2886
+ * whose top edge touches the screen's bar across the screen (`barAbove`)
2887
+ * runs up under it, and one whose bottom edge touches a bar covering the
2888
+ * bottom (`bottomBar`: a tab bar, a bottom toolbar or search field) runs
2889
+ * down under that, as the screen's own scroll view does: the proposal and
2890
+ * frame grow by the bar (the parent's layout is unchanged, as for
2891
+ * `.ignoresSafeArea`) and the content is inset by it (chrome `topInset` /
2892
+ * `bottomInset`), so it scrolls under the bar, where the scroll edge
2893
+ * effect shows (styles.css). Null when nothing grows.
2894
+ */
2895
+ private scrollEdgeGrowth(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): { p: Proposal; y: number } | null {
2896
+ if (node.kind !== 'list' && !(node.kind === 'scrollview' && this.scrollAxes(node).vertical)) return null;
2897
+ const inset = this.scrollInsets.get(node);
2898
+ const bar = ctx.barAbove;
2899
+ // The screen's own scroll view is inset by `placeNavScreen` (no bar above
2900
+ // here); a nested one that no longer touches the bar loses its inset.
2901
+ if (inset?.nested) this.scrollInsets.delete(node);
2902
+ if (!finite(p.height)) return null;
2903
+ const size = this.measure(node, ctx, p);
2904
+ let top = 0;
2905
+ if (
2906
+ bar &&
2907
+ !(inset && !inset.nested) &&
2908
+ Math.abs(y - bar.top) <= EPSILON &&
2909
+ x <= bar.x + EPSILON &&
2910
+ x + size.width >= bar.x + bar.width - EPSILON
2911
+ ) {
2912
+ top = bar.height;
2913
+ this.scrollInsets.set(node, { bar: top, largeTitle: null, nested: true });
2914
+ }
2915
+ let bottom = 0;
2916
+ if (ctx.bottomBar > 0) {
2917
+ const edge = ctx.bottomEdge ?? this.env.screen.height;
2918
+ if (Math.abs(y + size.height - (edge - ctx.bottomBar)) <= EPSILON) bottom = ctx.bottomBar;
2919
+ }
2920
+ if (top === 0 && bottom === 0) return null;
2921
+ return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
2922
+ }
2923
+
2676
2924
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
2677
2925
  const plan = this.listPlan(node, ctx, p);
2678
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true };
2926
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
2679
2927
  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);
2928
+ // Under its screen's bar (and the large title that scrolls with it), the
2929
+ // rows start below them.
2930
+ const scrollInset = this.scrollInsets.get(node);
2682
2931
  const listChrome: Record<string, Rect> = {};
2683
2932
  if (covered) listChrome['bottomInset'] = covered;
2684
- if (titleRect) listChrome['largeTitle'] = titleRect;
2933
+ if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
2934
+ if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
2685
2935
  if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
2686
- const inset = titleRect?.height ?? 0;
2936
+ const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) : 0;
2687
2937
  if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
2688
2938
  y += inset;
2689
2939
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
@@ -2723,8 +2973,11 @@ export class LayoutPass {
2723
2973
  // Phase 8: the edit control belongs to the `listrow`, which is the
2724
2974
  // row itself or sits under its wrappers (its own place() keeps chrome).
2725
2975
  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) });
2976
+ const controls: Record<string, Rect> = {};
2977
+ if (row.edit.control) controls['editControl'] = offset(row.edit.control);
2978
+ if (row.edit.reorder) controls['reorderControl'] = offset(row.edit.reorder);
2979
+ if (row.edit.listRow === row.node) Object.assign(chrome, controls);
2980
+ else this.setChrome(row.edit.listRow, controls);
2728
2981
  }
2729
2982
  const rowX = x + row.content.x;
2730
2983
  const rowY = y + row.content.y;
@@ -2763,13 +3016,14 @@ export class LayoutPass {
2763
3016
  /**
2764
3017
  * The navigation bar is the status bar plus a 44 row (plus 62 for a large
2765
3018
  * title); a Phase 6 `searchfield` is a capsule floating above the bottom
2766
- * (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
2767
- * content is proposed the rest. Toolbar
2768
- * items are measured at their ideal size and laid out in iOS 26 glass
2769
- * capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
2770
- * trailing edge, `principal` centered in place of the title) or spread
2771
- * evenly across the bottom bar. The inline title (`chrome.title`) is
2772
- * centered and truncated to the room the bar items leave.
3019
+ * (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
3020
+ * above the bottom (`placeBottomToolbars`); the content is proposed the
3021
+ * rest. Toolbar items are measured at their ideal size and laid out in iOS
3022
+ * 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
3023
+ * (`leading` after the back button, `trailing` 16 from the trailing edge,
3024
+ * `principal` centered in place of the title). A `ToolbarSpacer` (an empty
3025
+ * toolbar with `spacer`) only separates groups there. The inline title
3026
+ * (`chrome.title`) is centered and truncated to the room the bar items leave.
2773
3027
  */
2774
3028
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
2775
3029
  const { safeArea, screen, dynamicTypeSize } = this.env;
@@ -2777,14 +3031,16 @@ export class LayoutPass {
2777
3031
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
2778
3032
  const depth = num(node.props['depth']) ?? 0;
2779
3033
  const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
2780
- const barHeight = statusBar + NAV_BAR_HEIGHT;
3034
+ const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
2781
3035
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
2782
3036
  const parts = this.navScreenParts(node);
2783
3037
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3038
+ const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
2784
3039
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
2785
- const bottomBarHeight =
2786
- bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
2787
3040
  const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
3041
+ const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
3042
+ ? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
3043
+ : 0;
2788
3044
  const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
2789
3045
  const contentTop = barHeight + largeHeight;
2790
3046
  const content: Rect = {
@@ -2795,6 +3051,8 @@ export class LayoutPass {
2795
3051
  };
2796
3052
  const chrome: Record<string, Rect> = {
2797
3053
  bar: { x, y, width: size.width, height: barHeight },
3054
+ // The 44 row the bar's items sit in, under the status bar.
3055
+ barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
2798
3056
  content,
2799
3057
  };
2800
3058
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
@@ -2824,7 +3082,11 @@ export class LayoutPass {
2824
3082
  slots: number[] | null;
2825
3083
  total: number;
2826
3084
  }
2827
- const groups: Group[] = parts.toolbars.map((toolbar) => {
3085
+ // The navigation bar's groups; spacers there only separate them (the
3086
+ // groups are 8 apart anyway) and draw nothing.
3087
+ for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3088
+ const barToolbars = parts.toolbars.filter((t) => str(t.props['placement']) !== 'bottom' && !isSpacer(t));
3089
+ const groups: Group[] = barToolbars.map((toolbar) => {
2828
3090
  const itemCtx = this.childContext(toolbar, cctx);
2829
3091
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
2830
3092
  const placement = str(toolbar.props['placement']) ?? 'trailing';
@@ -2844,13 +3106,14 @@ export class LayoutPass {
2844
3106
  // Back button, iOS 26: the chevron alone in a glass circle (the previous
2845
3107
  // title no longer shows).
2846
3108
  const backLabel = '';
3109
+ const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
2847
3110
  if (depth > 0) {
2848
- chrome['back'] = { x: x + NAV_ITEM_MARGIN, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
3111
+ chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
2849
3112
  }
2850
3113
 
2851
3114
  // Place the toolbar groups and remember how much of the row they occupy.
2852
3115
  const back = chrome['back'];
2853
- let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : x + NAV_ITEM_MARGIN;
3116
+ let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
2854
3117
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
2855
3118
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
2856
3119
  const itemCtx = this.childContext(group.toolbar, cctx);
@@ -2878,19 +3141,6 @@ export class LayoutPass {
2878
3141
  placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
2879
3142
  break;
2880
3143
  }
2881
- case 'bottom': {
2882
- const bar = chrome['bottomBar'] as Rect;
2883
- this.setFrame(group.toolbar, bar.x, bar.y, bar);
2884
- const n = group.sizes.length;
2885
- const inner = Math.max(0, size.width - 2 * NAV_ITEM_MARGIN);
2886
- const itemsWidth = group.total - Math.max(0, n - 1) * NAV_ITEM_SPACING;
2887
- if (n === 1) placeGroup(group, x + (size.width - itemsWidth) / 2, bar.y, BOTTOM_BAR_HEIGHT, 0);
2888
- else if (n > 1) {
2889
- const gap = Math.max(NAV_ITEM_SPACING, (inner - itemsWidth) / (n - 1));
2890
- placeGroup(group, x + NAV_ITEM_MARGIN, bar.y, BOTTOM_BAR_HEIGHT, gap);
2891
- }
2892
- break;
2893
- }
2894
3144
  default: {
2895
3145
  const startX = trailingEdge - group.total;
2896
3146
  this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
@@ -2900,6 +3150,9 @@ export class LayoutPass {
2900
3150
  }
2901
3151
  }
2902
3152
  }
3153
+ if (bottomBarHeight > 0) {
3154
+ this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
3155
+ }
2903
3156
  if (parts.search) {
2904
3157
  // The field is the whole capsule (its own measure is a 36pt field).
2905
3158
  const field = chrome['searchField'] as Rect;
@@ -2921,7 +3174,11 @@ export class LayoutPass {
2921
3174
  // A large title that scrolls away collapses into the inline title, which
2922
3175
  // the renderer shows once it has (`data-sb-collapsed`).
2923
3176
  if ((!large || titleScrolls) && !principal) {
2924
- const leftUsed = Math.max(back ? back.x + back.width - x : 0, leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0);
3177
+ const leftUsed = Math.max(
3178
+ back ? back.x + back.width - x : 0,
3179
+ leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
3180
+ cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
3181
+ );
2925
3182
  const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
2926
3183
  const side = Math.max(leftUsed, rightUsed) + 8;
2927
3184
  const available = Math.max(0, size.width - 2 * side);
@@ -2950,23 +3207,46 @@ export class LayoutPass {
2950
3207
 
2951
3208
  const child = parts.content;
2952
3209
  if (!child) return;
3210
+ // The room a bottom toolbar or search field takes; scroll views run under
3211
+ // it (iOS 26), other content stops above it.
3212
+ const bottomCover = Math.max(bottomBarHeight, searchBottom);
2953
3213
  // Under a tab bar the bar (which includes the bottom safe area) is what
2954
3214
  // non-scrolling content must clear; a bottom toolbar already excluded it.
2955
3215
  const bottomInset =
2956
3216
  bottomBarHeight === 0 && y + size.height >= screen.height ? Math.max(safeArea.bottom, cctx.bottomBar) : 0;
2957
3217
  // Phase 8: `.ignoresSafeArea` content grows up under the bar (and the
2958
3218
  // 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);
3219
+ const contentCtx: Context = {
3220
+ ...cctx,
3221
+ safeTop: content.y,
3222
+ barInset: 0,
3223
+ barLeading: 0,
3224
+ barAbove: undefined,
3225
+ ...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
3226
+ };
3227
+ const scrollsUnderBar =
3228
+ scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
3229
+ if (scroller && scrollsUnderBar) {
3230
+ // iOS 26: the scroll view starts at the top of the screen and its
3231
+ // content scrolls under the bar (and the large title moves with it);
3232
+ // the content starts below them. It runs to the bottom of the screen,
3233
+ // under a bottom toolbar or search field too.
3234
+ const top = content.y - y;
3235
+ this.scrollInsets.set(scroller, {
3236
+ bar: barHeight,
3237
+ largeTitle: titleScrolls ? (chrome['largeTitle'] as Rect) : null,
3238
+ });
3239
+ this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
2966
3240
  return;
2967
3241
  }
2968
- if (scroller) this.titleInsets.delete(scroller);
2969
- if (scroller && (scroller.kind === 'list' || scroller.kind === 'scrollview')) {
3242
+ if (scroller) this.scrollInsets.delete(scroller);
3243
+ // A scroll view deeper in the content that touches the bar runs under it
3244
+ // (`scrollEdgeGrowth`); not under a large title, which stays put then.
3245
+ // One that reaches the bottom of the content runs down under all the
3246
+ // content clears there.
3247
+ if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
3248
+ if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
3249
+ if (scroller && scroller.kind === 'scrollview') {
2970
3250
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
2971
3251
  return;
2972
3252
  }
@@ -2977,9 +3257,101 @@ export class LayoutPass {
2977
3257
  this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
2978
3258
  }
2979
3259
 
3260
+ /**
3261
+ * iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
3262
+ * float on Liquid Glass in a 48 row at the top of the bottom bar's band
3263
+ * (`top`), 28 in from the sides. Consecutive items share a capsule; a
3264
+ * `ToolbarSpacer` (an empty toolbar with `spacer`) ends one. Flexible
3265
+ * spacers share the room the groups leave (with none the row is centered)
3266
+ * and fixed ones are 8 wide. An icon takes a 50 slot (a lone one is a 48
3267
+ * circle), text its width and 18 either side, 6 between slots; each item
3268
+ * is centered in its slot. The capsules are the screen's chrome
3269
+ * `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
3270
+ * items.
3271
+ */
3272
+ private placeBottomToolbars(
3273
+ toolbars: LayoutNode[],
3274
+ cctx: Context,
3275
+ chrome: Record<string, Rect>,
3276
+ x: number,
3277
+ top: number,
3278
+ width: number,
3279
+ ): void {
3280
+ interface Item {
3281
+ node: LayoutNode;
3282
+ toolbar: LayoutNode;
3283
+ size: Size;
3284
+ slot: number;
3285
+ }
3286
+ type Token = { kind: 'group'; items: Item[]; width: number } | { kind: 'flexible' } | { kind: 'fixed' };
3287
+ const tokens: Token[] = [];
3288
+ let open: Item[] = [];
3289
+ const close = (): void => {
3290
+ if (open.length === 0) return;
3291
+ const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
3292
+ const lone = open.length === 1 && icon(open[0] as Item);
3293
+ if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
3294
+ const slots = open.reduce((sum, item) => sum + item.slot, 0);
3295
+ tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
3296
+ open = [];
3297
+ };
3298
+ for (const toolbar of toolbars) {
3299
+ const spacer = str(toolbar.props['spacer']);
3300
+ if (spacer !== null) {
3301
+ close();
3302
+ tokens.push({ kind: spacer === 'fixed' ? 'fixed' : 'flexible' });
3303
+ continue;
3304
+ }
3305
+ const itemCtx = this.childContext(toolbar, cctx);
3306
+ for (const node of toolbar.children) {
3307
+ const size = this.measure(node, itemCtx, { width: null, height: null });
3308
+ const slot = size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
3309
+ open.push({ node, toolbar, size, slot });
3310
+ }
3311
+ }
3312
+ close();
3313
+ const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
3314
+ let used = 0;
3315
+ let flexible = 0;
3316
+ for (const token of tokens) {
3317
+ if (token.kind === 'group') used += token.width;
3318
+ else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
3319
+ else flexible += 1;
3320
+ }
3321
+ const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
3322
+ let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
3323
+ const spans = new Map<LayoutNode, { start: number; end: number }>();
3324
+ let glass = 0;
3325
+ for (const token of tokens) {
3326
+ if (token.kind === 'flexible') {
3327
+ cursor += share;
3328
+ continue;
3329
+ }
3330
+ if (token.kind === 'fixed') {
3331
+ cursor += BOTTOM_FIXED_SPACER;
3332
+ continue;
3333
+ }
3334
+ chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
3335
+ glass += 1;
3336
+ for (const item of token.items) {
3337
+ const itemCtx = this.childContext(item.toolbar, cctx);
3338
+ this.place(item.node, itemCtx, { width: null, height: null }, cursor + (item.slot - item.size.width) / 2, top + (BOTTOM_GLASS_HEIGHT - item.size.height) / 2);
3339
+ const span = spans.get(item.toolbar);
3340
+ spans.set(item.toolbar, { start: span ? span.start : cursor, end: cursor + item.slot });
3341
+ cursor += item.slot + BOTTOM_ITEM_GAP;
3342
+ }
3343
+ cursor -= BOTTOM_ITEM_GAP;
3344
+ }
3345
+ for (const toolbar of toolbars) {
3346
+ const span = spans.get(toolbar);
3347
+ if (span) this.setFrame(toolbar, span.start, top, { width: span.end - span.start, height: BOTTOM_GLASS_HEIGHT });
3348
+ }
3349
+ }
3350
+
2980
3351
  /**
2981
3352
  * The tab bar (iOS 26) is a glass capsule floating over the bottom of the
2982
- * content (TAB_BAR_* above), with one equal-width item per tab; what it
3353
+ * content (TAB_BAR_* above), as wide as its equal-width items (one per tab)
3354
+ * up to the width less the margins, centered; what it
2983
3355
  * covers (the capsule and the gap under it) is the content's bottom inset.
2984
3356
  * Every `tab` gets the tab view's frame; only the selected one lays its
2985
3357
  * content out, the others are reported in `hidden`.
@@ -2989,18 +3361,19 @@ export class LayoutPass {
2989
3361
  const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
2990
3362
  const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
2991
3363
  const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
3364
+ const tabs = node.children;
3365
+ const n = tabs.length;
3366
+ const width = Math.min(Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H), n * TAB_BAR_ITEM_WIDTH + 2 * TAB_BAR_ITEM_INSET);
2992
3367
  const bar: Rect = {
2993
- x: x + TAB_BAR_MARGIN_H,
3368
+ x: x + (size.width - width) / 2,
2994
3369
  y: y + size.height - barHeight,
2995
- width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
3370
+ width,
2996
3371
  height: capsuleHeight,
2997
3372
  };
2998
3373
  const chrome: Record<string, Rect> = {
2999
3374
  bar,
3000
3375
  content: { x, y, width: size.width, height: size.height - barHeight },
3001
3376
  };
3002
- const tabs = node.children;
3003
- const n = tabs.length;
3004
3377
  const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
3005
3378
  for (let i = 0; i < n; i++) {
3006
3379
  const itemWidth = inner / n;
@@ -3013,7 +3386,7 @@ export class LayoutPass {
3013
3386
  }
3014
3387
  this.setChrome(node, chrome);
3015
3388
  const selected = readSelected(node, n);
3016
- const tabCtx: Context = { ...cctx, bottomBar: barHeight };
3389
+ const tabCtx: Context = { ...cctx, bottomBar: barHeight, bottomEdge: undefined, barAbove: undefined };
3017
3390
  tabs.forEach((tab, i) => {
3018
3391
  this.setFrame(tab, x, y, size);
3019
3392
  if (i !== selected) {
@@ -3071,6 +3444,12 @@ export class LayoutPass {
3071
3444
  * to the detail column (when the split view touches those screen edges):
3072
3445
  * the column rects (`chrome.sidebar` / `chrome.detail`) include them, the
3073
3446
  * navstacks start / end at them.
3447
+ *
3448
+ * iPadOS: a sidebar toggle (a 44 glass circle, chrome `toggle`) sits at the
3449
+ * leading edge of the bar row, in the sidebar's bar or, with the sidebar
3450
+ * hidden (`sidebarVisible: false`), in the detail's; that column's screens
3451
+ * start their back button and leading items after it. A hidden sidebar
3452
+ * slides off the leading edge and the detail column takes the width.
3074
3453
  */
3075
3454
  private placeNavSplit(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3076
3455
  const { screen, safeArea } = this.env;
@@ -3078,27 +3457,81 @@ export class LayoutPass {
3078
3457
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3079
3458
  const usable = Math.max(0, size.width - leading - trailing);
3080
3459
  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;
3460
+ const visible = node.props['sidebarVisible'] !== false;
3461
+ // Hidden, the sidebar and its separator sit just off the leading edge.
3462
+ const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
3463
+ const separatorX = sidebarX + sidebarWidth;
3464
+ const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
3083
3465
  const detailWidth = Math.max(0, x + size.width - trailing - detailX);
3466
+ const statusBar = y <= 0 ? safeArea.top : 0;
3084
3467
  const chrome: Record<string, Rect> = {
3085
- sidebar: { x, y, width: leading + sidebarWidth, height: size.height },
3468
+ sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
3086
3469
  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 },
3470
+ detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
3471
+ toggle: { x: x + leading + NAV_ITEM_MARGIN, y: y + statusBar, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE },
3088
3472
  };
3089
3473
  this.setChrome(node, chrome);
3474
+ const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
3090
3475
  const [sidebar, detail, ...rest] = node.children;
3091
3476
  if (sidebar) {
3092
3477
  const proposal: Proposal = { width: sidebarWidth, height: size.height };
3093
- this.place(sidebar, { ...cctx, splitSidebar: true }, proposal, x + leading, y);
3478
+ this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
3094
3479
  }
3095
3480
  if (detail) {
3096
3481
  const proposal: Proposal = { width: detailWidth, height: size.height };
3097
- this.place(detail, { ...cctx, splitSidebar: false }, proposal, detailX, y);
3482
+ this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
3098
3483
  }
3099
3484
  for (const extra of rest) this.hide(extra, node);
3100
3485
  }
3101
3486
 
3487
+ /**
3488
+ * `.inspector` (regular width): the children are the content, then the
3489
+ * `inspectorpanel`. The panel is a trailing column, 320 wide (its
3490
+ * content's `inspectorColumnWidth`, at most half the width) plus the
3491
+ * trailing safe-area inset when it touches that edge, with a 1px gap
3492
+ * before it (the panel draws the hairline there); the content is proposed the rest (each content child is
3493
+ * centered in it, as a root view is). With `presented: false` the panel
3494
+ * sits just off the trailing edge (so it slides) and the content takes the
3495
+ * whole width.
3496
+ */
3497
+ private placeInspector(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3498
+ const { screen, safeArea } = this.env;
3499
+ const last = node.children[node.children.length - 1];
3500
+ const panel = last && last.kind === 'inspectorpanel' ? last : null;
3501
+ const main = panel ? node.children.slice(0, -1) : node.children;
3502
+ const presented = node.props['presented'] === true;
3503
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3504
+ const requested = panel?.children[0] ? num(cellStyle(panel.children[0], 'inspectorColumnWidth')) : null;
3505
+ const columnWidth = Math.min(requested ?? INSPECTOR_WIDTH, size.width * INSPECTOR_MAX_FRACTION);
3506
+ const panelWidth = columnWidth + trailing;
3507
+ const panelX = presented ? x + size.width - panelWidth : x + size.width + NAVSPLIT_SEPARATOR_WIDTH;
3508
+ const mainWidth = presented ? Math.max(0, panelX - NAVSPLIT_SEPARATOR_WIDTH - x) : size.width;
3509
+ const area: Size = { width: mainWidth, height: size.height };
3510
+ for (const child of main) {
3511
+ const childSize = this.measure(child, cctx, area);
3512
+ const { dx, dy } = alignIn(area, childSize, 'center');
3513
+ this.place(child, cctx, area, x + dx, y + dy);
3514
+ }
3515
+ if (panel) this.place(panel, cctx, { width: panelWidth, height: size.height }, panelX, y);
3516
+ }
3517
+
3518
+ /**
3519
+ * The inspector column: its content below the status bar (at the top of
3520
+ * the screen) and clear of the trailing inset, top-aligned and centered
3521
+ * horizontally (a list or form fills it).
3522
+ */
3523
+ private placeInspectorPanel(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3524
+ const { screen, safeArea } = this.env;
3525
+ const top = y <= 0 ? safeArea.top : 0;
3526
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3527
+ const area: Size = { width: Math.max(0, size.width - trailing), height: Math.max(0, size.height - top) };
3528
+ for (const child of node.children) {
3529
+ const childSize = this.measure(child, cctx, area);
3530
+ const { dx } = alignIn(area, childSize, 'center');
3531
+ this.place(child, cctx, area, x + dx, y + top);
3532
+ }
3533
+ }
3534
+
3102
3535
  /**
3103
3536
  * Phase 7 `ViewThatFits`: the first child whose ideal size fits the
3104
3537
  * proposal on the restricted axes (a nil or infinite proposal axis always
@@ -3350,7 +3783,7 @@ export class LayoutPass {
3350
3783
  const wrapped = wrapText(text, font, width, null, this.measurer);
3351
3784
  return {
3352
3785
  layout: { font, lines: wrapped.lines, alignment, truncated: wrapped.truncated ?? false },
3353
- height: wrapped.lines.length * font.lineHeight,
3786
+ height: textHeight(wrapped.lines.length, font),
3354
3787
  };
3355
3788
  };
3356
3789
  const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
@@ -3823,22 +4256,33 @@ export class LayoutPass {
3823
4256
  * clamped. With no width the flexible columns take the cells' largest ideal
3824
4257
  * width and an adaptive item is one column. Cells fill rows left to right.
3825
4258
  */
4259
+ /**
4260
+ * `lazyvgrid` and `lazyhgrid`. Written for the vertical grid: its columns
4261
+ * share the proposed width and its cells fill them row by row. A horizontal
4262
+ * grid is the same along the other axis (its rows share the proposed
4263
+ * height and the cells fill them column by column), so the plan's
4264
+ * `columns` are the tracks across the grid (`x`, `width`: cross axis) and
4265
+ * its `rows` the lines along it (`y`, `height`: main axis).
4266
+ */
3826
4267
  private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
3827
4268
  return this.plan(node, p, () => {
3828
- const items = readGridItems(node.props['columns']);
3829
- const rowSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
4269
+ const horizontal = node.kind === 'lazyhgrid';
4270
+ const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
4271
+ const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
4272
+ const lineSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
3830
4273
  const cctx = this.childContext(node, ctx);
3831
4274
  const cells = node.children;
4275
+ const cross = crossOf(p);
3832
4276
  let idealCell: number | null = null;
3833
4277
  const ideal = (): number => {
3834
4278
  if (idealCell === null) {
3835
4279
  idealCell = 0;
3836
- for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL).width);
4280
+ for (const cell of cells) idealCell = Math.max(idealCell, crossOf(this.measure(cell, cctx, NIL_PROPOSAL)) ?? 0);
3837
4281
  }
3838
4282
  return idealCell;
3839
4283
  };
3840
4284
  const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
3841
- if (!finite(p.width)) {
4285
+ if (!finite(cross)) {
3842
4286
  items.forEach((item, i) => {
3843
4287
  if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
3844
4288
  });
@@ -3848,7 +4292,7 @@ export class LayoutPass {
3848
4292
  let fixed = 0;
3849
4293
  for (const item of items) if (item.kind === 'fixed') fixed += item.value;
3850
4294
  const sharing = items.filter((item) => item.kind !== 'fixed').length;
3851
- const remaining = Math.max(0, p.width - fixed - gaps);
4295
+ const remaining = Math.max(0, cross - fixed - gaps);
3852
4296
  const share = sharing > 0 ? remaining / sharing : 0;
3853
4297
  let flexibleTotal = 0;
3854
4298
  let adaptiveCount = 0;
@@ -3882,9 +4326,9 @@ export class LayoutPass {
3882
4326
  });
3883
4327
  });
3884
4328
  const used = cursor;
3885
- const gridWidth = finite(p.width) ? p.width : used;
4329
+ const gridCross = finite(cross) ? cross : used;
3886
4330
  const alignment = str(node.props['alignment']) ?? 'center';
3887
- const offset = safeOffset(gridWidth, used, alignmentAnchors(alignment)[0]);
4331
+ const offset = safeOffset(gridCross, used, alignmentAnchors(alignment)[horizontal ? 1 : 0]);
3888
4332
  for (const column of columns) column.x += offset;
3889
4333
 
3890
4334
  const rows: LazyGridPlan['rows'] = [];
@@ -3896,28 +4340,31 @@ export class LayoutPass {
3896
4340
  for (let k = 0; k < perRow && start + k < cells.length; k++) {
3897
4341
  const cell = cells[start + k] as LayoutNode;
3898
4342
  const column = columns[k] as LazyColumnPlan;
3899
- const size = this.measure(cell, cctx, { width: column.width, height: null });
3900
- height = Math.max(height, size.height);
4343
+ const size = this.measure(cell, cctx, horizontal ? { width: null, height: column.width } : { width: column.width, height: null });
4344
+ height = Math.max(height, horizontal ? size.width : size.height);
3901
4345
  rowCells.push({ node: cell, column: k, size });
3902
4346
  }
3903
4347
  rows.push({ y, height, cells: rowCells });
3904
- y += height + rowSpacing;
4348
+ y += height + lineSpacing;
3905
4349
  }
3906
- const gridHeight = rows.length > 0 ? y - rowSpacing : 0;
3907
- return { size: { width: gridWidth, height: gridHeight }, columns, rows };
4350
+ const gridMain = rows.length > 0 ? y - lineSpacing : 0;
4351
+ const size = horizontal ? { width: gridMain, height: gridCross } : { width: gridCross, height: gridMain };
4352
+ return { size, columns, rows };
3908
4353
  });
3909
4354
  }
3910
4355
 
3911
4356
  private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3912
4357
  const plan = this.lazyGridPlan(node, ctx, p);
4358
+ const horizontal = node.kind === 'lazyhgrid';
3913
4359
  const cctx = this.childContext(node, ctx);
3914
4360
  const gridAlignment = str(node.props['alignment']) ?? 'center';
3915
4361
  for (const row of plan.rows) {
3916
4362
  for (const cell of row.cells) {
3917
4363
  const column = plan.columns[cell.column] as LazyColumnPlan;
3918
- const box: Size = { width: column.width, height: row.height };
4364
+ const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
3919
4365
  const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
3920
- this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
4366
+ if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
4367
+ else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
3921
4368
  }
3922
4369
  }
3923
4370
  }