@swiftbrowser/web 0.26.2 → 0.26.4

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.
@@ -85,6 +85,8 @@ export interface Context {
85
85
  imageScale: number;
86
86
  /** Phase 7: true inside the sidebar column of a `navsplit` (a selected `navlink` row drops its chevron there). */
87
87
  splitSidebar: boolean;
88
+ /** Inside either column of a `navsplit` (on a Mac the window's unified title bar spans every column). */
89
+ splitColumn?: boolean;
88
90
  /** Inside a leading or trailing item of a navigation bar (a segmented picker there is the bar's own capsule). */
89
91
  navBarItem?: boolean;
90
92
  /** Inside an item of a bottom toolbar (a bordered or prominent button there takes the bar's sizes). */
@@ -98,6 +100,32 @@ export interface Context {
98
100
  * the symbols' widths, and with the edit-mode controls.
99
101
  */
100
102
  labelColumn?: boolean;
103
+ /**
104
+ * The Mac idiom, inside a list row's whole subtree: symbols measure the
105
+ * font's point size (not its leading) and a `Label`'s title sits 6 after
106
+ * its icon, as the Mac app draws them.
107
+ */
108
+ listCell?: boolean;
109
+ /**
110
+ * The Mac idiom: a tab view's toolbar row at the top of the window. The
111
+ * screens of a tab's navigation stack draw their bar as that row: no
112
+ * title (the tab picker is there), the back button and the items at the
113
+ * row's ends.
114
+ */
115
+ tabToolbar?: Rect;
116
+ /**
117
+ * The Mac idiom: the navigation stack that fills a sheet. Its root screen
118
+ * draws the sheet's 40 title bar (the title leading, 13 bold) and puts
119
+ * its text bar items (a cancel or confirm action) in the sheet's bottom
120
+ * bar, `actions`, when the sheet has one.
121
+ */
122
+ macSheet?: { actions: Rect | null };
123
+ /**
124
+ * The Mac idiom: a row of a form in the columns style. A control whose
125
+ * label went to the label column (a stepper's, a date picker's, a
126
+ * picker's) draws without it, at the row's leading edge.
127
+ */
128
+ formColumn?: boolean;
101
129
  /**
102
130
  * Phase 8: the y (screen coordinates) where content clears the bars above
103
131
  * it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
@@ -214,6 +242,12 @@ const LIST_SECTION_GAP = 107 / 3;
214
242
  // Below a section's footer the gap to the next section is 11 shorter.
215
243
  const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
216
244
  const LIST_CHEVRON_WIDTH = 20;
245
+ /**
246
+ * A `disclosuregroup` among a list's rows is an outline (`outlineRows`
247
+ * below): each level's rows start this much further in than the level
248
+ * above. SwiftUI's value, as yet unmeasured against the simulator.
249
+ */
250
+ const LIST_OUTLINE_INDENT = 20;
217
251
  // Phase 4.
218
252
  // iOS 26: the tab bar is a 62-tall glass capsule floating 21 above the bottom
219
253
  // edge (8 when the tab view ends above the screen bottom), its items in equal
@@ -285,6 +319,141 @@ const NAV_GLASS_PAD_H = 12;
285
319
  const NAV_GROUP_SPACING = 12;
286
320
  const NAV_RUN_GAP = 12;
287
321
  const NAV_RUN_PAD = 1;
322
+ // The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
323
+ // `macos`): the window's title bar stands in for the navigation bar. A 52
324
+ // unified title bar holds the back button, the bar items and the title,
325
+ // leading-aligned, as SwiftUI on macOS 14 lays a NavigationStack's toolbar
326
+ // out; a window with nothing in its bar but the title has the plain 28 one,
327
+ // the title centered. There are no large titles and no glass: the items
328
+ // are 28 tall, in slots 8 either side, 8 apart. On the screen's own window
329
+ // (the top of the screen, when the safe area carries a title bar) the three
330
+ // window buttons take the first 80 of the bar. Every number here is a
331
+ // first pass, to be measured against the real app (the Mac fidelity
332
+ // harness, stage 3).
333
+ const MAC_TITLE_BAR_UNIFIED = 52;
334
+ const MAC_TITLE_BAR_PLAIN = 28;
335
+ const MAC_BAR_ITEM_SIZE = 28;
336
+ const MAC_BAR_ITEM_PAD_H = 6;
337
+ const MAC_BAR_RUN_GAP = 10;
338
+ /** The window buttons' room: the first item's slot starts 90 in; a title with no item before it 2 further. */
339
+ const MAC_WINDOW_BUTTONS_ROOM = 90;
340
+ const MAC_TITLE_INSET = 2;
341
+ /** A Mac bar's trailing items end 13 before the window's edge; a tab view's toolbar's 7. */
342
+ const MAC_BAR_MARGIN_TRAILING = 13;
343
+ const MAC_TAB_TOOLBAR_MARGIN_TRAILING = 7;
344
+ // A Mac tab view's bar is a segmented control at the top of the content,
345
+ // centered in a 44 row: 24 tall, each tab a 96 segment inside a 1 inset, at
346
+ // most the width less 20 either side.
347
+ const MAC_TAB_ROW_HEIGHT = 38;
348
+ const MAC_TAB_BAR_HEIGHT = 22;
349
+ /** A tab picker's segment: its title (13 regular) and 12 either side. */
350
+ const MAC_TAB_ITEM_PAD_H = 12;
351
+ const MAC_TAB_ITEM_INSET = 1;
352
+ const MAC_TAB_BAR_MARGIN_H = 20;
353
+ // Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
354
+ // either side of its label, and every `automatic` button outside a list row
355
+ // and a bar is bordered; a checkbox is 14 square, its label 6 after it, and a
356
+ // toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
357
+ // pop-up button is 22 tall (24 in a row), its value in a bordered button
358
+ // with 8 either side.
359
+ const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
360
+ regular: { padV: 3, padH: 10, minHeight: 22 },
361
+ mini: { padV: 1, padH: 6, minHeight: 16, textStyle: 'caption' },
362
+ small: { padV: 2, padH: 8, minHeight: 19, textStyle: 'subheadline' },
363
+ large: { padV: 5, padH: 14, minHeight: 28 },
364
+ extraLarge: { padV: 7, padH: 18, minHeight: 34 },
365
+ };
366
+ const MAC_CHECKBOX_SIZE = 14;
367
+ const MAC_CHECKBOX_GAP = 6;
368
+ const MAC_PICKER_MENU_HEIGHT = 22;
369
+ // A Mac's segmented control is 22 tall with 13-point regular text, its
370
+ // label (which iOS drops) 10 before it.
371
+ const MAC_SEGMENTED_HEIGHT = 22;
372
+ const MAC_SEGMENTED_FONT_SIZE = 13;
373
+ const MAC_SEGMENTED_LABEL_GAP = 10;
374
+ const MAC_PICKER_MENU_ROW_HEIGHT = 24;
375
+ const MAC_PICKER_POPUP_PAD = 8;
376
+ // Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
377
+ // rows full width with 16 beside their content, at least 24 tall (28 in a
378
+ // sidebar) with 4 (6) above and below their content, a bordered button's 3;
379
+ // every row has a separator under it. A section's header (11 bold) starts
380
+ // 7 below the top, 5 into the 22 between sections after the first; a rule
381
+ // across the whole list sits 8 under it and the rows 10 under the rule. A
382
+ // footer (11) sits 8 under the last row, with a rule 5 under it. A
383
+ // sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
384
+ const MAC_LIST_ROW_MIN_HEIGHT = 24;
385
+ const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 28;
386
+ const MAC_LIST_ROW_VPAD = 4;
387
+ const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
388
+ const MAC_LIST_BUTTON_ROW_VPAD = 3;
389
+ const MAC_LIST_CELL_PADDING = 16;
390
+ /** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
391
+ const MAC_LIST_LABEL_GAP = 6;
392
+ const MAC_LIST_INSET_SIDEBAR = 10;
393
+ const MAC_LIST_TOP = 6;
394
+ const MAC_LIST_BOTTOM = 8;
395
+ const MAC_LIST_SECTION_GAP = 22;
396
+ const MAC_LIST_HEADER_TOP = 4;
397
+ /** Under a Mac header: 8 to its rule and the rule; the first section's rows start 10 further down. */
398
+ const MAC_LIST_HEADER_GAP = 9;
399
+ const MAC_LIST_FIRST_ROWS_GAP = 10;
400
+ const MAC_LIST_FOOTER_GAP = 8;
401
+ // A Mac's Form (measured in the Mac app): the columns style, with no
402
+ // insets of its own and centered vertically in the room it gets. The labels
403
+ // (13) right-aligned in a column as wide as the widest, from the leading
404
+ // edge; the controls in a column 10 after it that runs to the trailing
405
+ // edge; a section header and a footer (13 regular, the label color) in the
406
+ // controls column; 8 under a header, rows 7 apart, sections touching, a
407
+ // header 4 under the rows before it (right under a footer), a footer 2
408
+ // under the last row.
409
+ const MAC_FORM_LABEL_GAP = 10;
410
+ const MAC_FORM_HEADER_GAP = 8;
411
+ const MAC_FORM_HEADER_TOP = 4;
412
+ const MAC_FORM_ROW_GAP = 7;
413
+ const MAC_FORM_FOOTER_GAP = 2;
414
+ /** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
415
+ const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
416
+ /** Under a Mac footer: 5 to its rule, the rule. */
417
+ const MAC_LIST_FOOTER_BOTTOM = 6;
418
+ const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
419
+ // A Mac's search field is in the toolbar, at its trailing end: 22 tall, 180
420
+ // wide (less when the bar is narrow), 16 from the edge. A sheet is a
421
+ // window-modal panel that drops from the title bar, 480 × 400 at most,
422
+ // centered, with no grabber.
423
+ const MAC_SEARCH_FIELD_WIDTH = 180;
424
+ const MAC_SEARCH_FIELD_HEIGHT = 22;
425
+ // A Mac's sheet (measured in the Mac app): a 470 panel centered in the
426
+ // window, its 40 title bar (the title 13 bold at 16) over the content at its
427
+ // ideal height, and a 60 bar at the bottom with the text actions (cancel,
428
+ // confirm) as 22 push buttons at its trailing end, 16 in and 8 apart; at
429
+ // least 40 clear of the window's edges.
430
+ const MAC_SHEET_WIDTH = 470;
431
+ const MAC_SHEET_TITLE_BAR = 40;
432
+ const MAC_SHEET_ACTIONS_BAR = 60;
433
+ const MAC_SHEET_ACTIONS_INSET = 16;
434
+ const MAC_SHEET_ACTIONS_GAP = 8;
435
+ const MAC_SHEET_MARGIN = 40;
436
+ // A Mac's text field is 22 tall whatever its style; a stepper's control is
437
+ // the 13 × 22 pair of arrows (up over down) 4 after the label; an alert is
438
+ // a 260 panel, its title and message centered 16 in, its buttons 22-tall
439
+ // push buttons 16 in, side by side 12 apart for two, stacked 8 apart otherwise.
440
+ const MAC_TEXTFIELD_HEIGHT = 22;
441
+ const MAC_STEPPER_WIDTH = 13;
442
+ const MAC_STEPPER_HEIGHT = 22;
443
+ const MAC_STEPPER_GAP = 4;
444
+ // A Mac's slider is a 4 track with a 20 round knob in a 20 row; a compact
445
+ // date picker's date and time are 22-tall bordered fields 6 apart with 6
446
+ // beside their text; a menu outside a bar or a list row is a pull-down
447
+ // button, 22 tall with 10 beside its label and the chevron's 16 after it.
448
+ const MAC_SLIDER_HEIGHT = 20;
449
+ const MAC_SLIDER_TRACK_HEIGHT = 4;
450
+ const MAC_SLIDER_THUMB = 20;
451
+ const MAC_DATEPICKER_HEIGHT = 22;
452
+ const MAC_DATEPICKER_CAPSULE_PAD_H = 6;
453
+ const MAC_DATEPICKER_CAPSULE_GAP = 6;
454
+ const MAC_PULLDOWN_HEIGHT = 22;
455
+ const MAC_PULLDOWN_PAD_H = 10;
456
+ const MAC_PULLDOWN_CHEVRON = 16;
288
457
 
289
458
  /**
290
459
  * The children an item draws in the bar: a menu or context menu draws its
@@ -309,11 +478,11 @@ function isSymbolItem(item: LayoutNode): boolean {
309
478
  return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
310
479
  }
311
480
 
312
- /** A bar item's slot in its glass capsule. */
313
- function navSlot(item: LayoutNode, width: number): number {
481
+ /** A bar item's slot in its glass capsule (`glass` square for a symbol, else its width and `pad` either side, at least `glass`). */
482
+ function navSlot(item: LayoutNode, width: number, glass = NAV_GLASS_SIZE, pad = NAV_GLASS_PAD_H): number {
314
483
  if (isSegmentedItem(item)) return width + 2 * BAR_SEGMENTED_MARGIN;
315
- if (isSymbolItem(item) && width <= NAV_GLASS_SIZE) return NAV_GLASS_SIZE;
316
- return Math.max(NAV_GLASS_SIZE, width + 2 * NAV_GLASS_PAD_H);
484
+ if (isSymbolItem(item) && width <= glass) return glass;
485
+ return Math.max(glass, width + 2 * pad);
317
486
  }
318
487
  // iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
319
488
  // bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
@@ -457,7 +626,8 @@ const TEXTEDITOR_INSET = { horizontal: 5, vertical: 8 };
457
626
  * `alert` style (iOS 26): a 320 wide card centered in the safe area, its
458
627
  * title and message leading-aligned 30 in, its buttons 46 tall capsules 16
459
628
  * in from the card's sides, side by side 8 apart for two, stacked 8 apart
460
- * otherwise.
629
+ * otherwise. `ALERT_METRICS` gathers them for `placeAlert`; `MAC_ALERT_METRICS`
630
+ * is the Mac's panel (MAC_* above).
461
631
  */
462
632
  const ALERT_WIDTH = 320;
463
633
  const ALERT_PAD_TOP = 21;
@@ -473,6 +643,58 @@ const ALERT_BUTTON_HEIGHT = 46;
473
643
  const ALERT_FIELD_HEIGHT = 36;
474
644
  const ALERT_FIELD_SPACING = 8;
475
645
  const ALERT_FIELDS_GAP = 16;
646
+ interface AlertMetrics {
647
+ width: number;
648
+ padTop: number;
649
+ padH: number;
650
+ buttonInset: number;
651
+ buttonsGap: number;
652
+ padBottom: number;
653
+ buttonSpacing: number;
654
+ rowSpacing: number;
655
+ titleGap: number;
656
+ buttonHeight: number;
657
+ fieldHeight: number;
658
+ fieldSpacing: number;
659
+ fieldsGap: number;
660
+ textAlignment: 'leading' | 'center';
661
+ }
662
+ const ALERT_METRICS: AlertMetrics = {
663
+ width: ALERT_WIDTH,
664
+ padTop: ALERT_PAD_TOP,
665
+ padH: ALERT_PAD_H,
666
+ buttonInset: ALERT_BUTTON_INSET,
667
+ buttonsGap: ALERT_BUTTONS_GAP,
668
+ padBottom: ALERT_PAD_BOTTOM,
669
+ buttonSpacing: ALERT_BUTTON_SPACING,
670
+ rowSpacing: ALERT_BUTTON_SPACING,
671
+ titleGap: ALERT_TITLE_GAP,
672
+ buttonHeight: ALERT_BUTTON_HEIGHT,
673
+ fieldHeight: ALERT_FIELD_HEIGHT,
674
+ fieldSpacing: ALERT_FIELD_SPACING,
675
+ fieldsGap: ALERT_FIELDS_GAP,
676
+ textAlignment: 'leading',
677
+ };
678
+ // Measured in the Mac app: the app icon (54, which the page draws as a
679
+ // placeholder) 25 under the top and 25 over the title, the message 9 under
680
+ // it, 28-tall buttons 17 under the message and 16 over the bottom, two
681
+ // side by side 8 apart.
682
+ const MAC_ALERT_METRICS: AlertMetrics = {
683
+ width: 260,
684
+ padTop: 104,
685
+ padH: 16,
686
+ buttonInset: 16,
687
+ buttonsGap: 17,
688
+ padBottom: 16,
689
+ buttonSpacing: 8,
690
+ rowSpacing: 8,
691
+ titleGap: 9,
692
+ buttonHeight: 28,
693
+ fieldHeight: 22,
694
+ fieldSpacing: 8,
695
+ fieldsGap: 12,
696
+ textAlignment: 'center',
697
+ };
476
698
  /** Gap between an action sheet's title and its message. */
477
699
  const DIALOG_TITLE_GAP = 4;
478
700
  /** An action sheet's button labels are inset this much from its rows. */
@@ -742,7 +964,8 @@ function cellStyle(node: LayoutNode, key: string): unknown {
742
964
  }
743
965
 
744
966
  /** The default vertical padding above and below a list row's content. */
745
- function listRowVPad(row: LayoutNode, sidebar: boolean): number {
967
+ function listRowVPad(row: LayoutNode, sidebar: boolean, mac: boolean): number {
968
+ if (mac) return row.kind === 'button' ? MAC_LIST_BUTTON_ROW_VPAD : sidebar ? MAC_LIST_SIDEBAR_ROW_VPAD : MAC_LIST_ROW_VPAD;
746
969
  switch (row.kind) {
747
970
  case 'picker':
748
971
  return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
@@ -923,6 +1146,34 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
923
1146
  return null;
924
1147
  }
925
1148
 
1149
+ /** A list's row as `outlineRows` names it: the view, how deep it sits, and the `disclosuregroup` it is the label of. */
1150
+ interface RowSource {
1151
+ node: LayoutNode;
1152
+ depth: number;
1153
+ group: LayoutNode | null;
1154
+ }
1155
+
1156
+ /**
1157
+ * The rows a section's children make. A `disclosuregroup` among them is an
1158
+ * outline, as SwiftUI lays one out in a `List`: its label (its first child)
1159
+ * is a row, with the group's chevron at the trailing edge, and while it is
1160
+ * expanded each of its content children is a row under it, a level deeper
1161
+ * (`LIST_OUTLINE_INDENT` further in), a nested group among them opening the
1162
+ * same way. Collapsed, its content is not laid out.
1163
+ */
1164
+ function outlineRows(nodes: readonly LayoutNode[], depth: number, out: RowSource[]): RowSource[] {
1165
+ for (const row of nodes) {
1166
+ if (row.kind === 'disclosuregroup') {
1167
+ const [label, ...content] = row.children;
1168
+ if (label) out.push({ node: label, depth, group: row });
1169
+ if (row.props['expanded'] === true) outlineRows(content, depth + 1, out);
1170
+ } else {
1171
+ out.push({ node: row, depth, group: null });
1172
+ }
1173
+ }
1174
+ return out;
1175
+ }
1176
+
926
1177
  /** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
927
1178
  const LIST_LABEL_ICON_CENTER = 12;
928
1179
  const LIST_LABEL_TITLE_X = 40;
@@ -1189,6 +1440,12 @@ interface ListRowPlan {
1189
1440
  edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
1190
1441
  /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
1191
1442
  separatorHidden?: { top: boolean; bottom: boolean } | null;
1443
+ /**
1444
+ * An outline's row (`outlineRows`): how deep it sits, and the
1445
+ * `disclosuregroup` it is the label of, when it is one (the group's
1446
+ * chevron and toggle go over this row).
1447
+ */
1448
+ outline?: { depth: number; group: LayoutNode | null } | null;
1192
1449
  }
1193
1450
 
1194
1451
  /** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
@@ -1209,12 +1466,28 @@ interface ListGroupPlan {
1209
1466
  footerView: ListSupplementPlan | null;
1210
1467
  card: Rect;
1211
1468
  rows: ListRowPlan[];
1469
+ /** Each `disclosuregroup` among the rows, with the rect its rows span (its label's, then its content's while expanded). */
1470
+ outlines: { group: LayoutNode; frame: Rect }[];
1471
+ }
1472
+
1473
+ /** A Mac form's label column entry: text the page draws (`layout`), or a view (`node`) placed at the rect. */
1474
+ interface FormLabelPlan {
1475
+ /** Relative to the list origin at scroll 0; a view's rect is its own size, at the column's trailing edge. */
1476
+ rect: Rect;
1477
+ layout: TextLayout | null;
1478
+ node: LayoutNode | null;
1479
+ ctx: Context;
1212
1480
  }
1213
1481
 
1214
1482
  interface ListPlan {
1215
1483
  size: Size;
1216
1484
  contentSize: Size;
1217
1485
  groups: ListGroupPlan[];
1486
+ /** The Mac idiom's columns form: its label column's entries. */
1487
+ labels?: FormLabelPlan[];
1488
+ columns?: boolean;
1489
+ /** The columns form's controls column: where it starts (relative to the list origin) and its width. */
1490
+ column?: { x: number; width: number };
1218
1491
  }
1219
1492
 
1220
1493
  interface ScrollPlan {
@@ -1416,7 +1689,7 @@ export class LayoutPass {
1416
1689
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
1417
1690
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
1418
1691
  } else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
1419
- const rect = this.formSheetRect();
1692
+ const rect = this.formSheetRect(root, ctx);
1420
1693
  this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
1421
1694
  } else if (root.kind === 'sheet') {
1422
1695
  const floats = this.sheetFloats(root);
@@ -1779,7 +2052,8 @@ export class LayoutPass {
1779
2052
  const style = str(node.props['style']);
1780
2053
  return {
1781
2054
  width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
1782
- height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
2055
+ // A Mac's field is 22 tall in every style.
2056
+ height: this.env.platform === 'macos' ? MAC_TEXTFIELD_HEIGHT : style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
1783
2057
  };
1784
2058
  }
1785
2059
  case 'shape':
@@ -1824,7 +2098,10 @@ export class LayoutPass {
1824
2098
  // GeometryReader: the proposal, 10 on unconstrained axes.
1825
2099
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1826
2100
  case 'sheet':
1827
- if (this.sheetIsForm(node)) return { width: p.width ?? this.formSheetRect().width, height: this.formSheetRect().height };
2101
+ if (this.sheetIsForm(node)) {
2102
+ const rect = this.formSheetRect(node, ctx);
2103
+ return { width: p.width ?? rect.width, height: rect.height };
2104
+ }
1828
2105
  return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
1829
2106
  // Phase 8 controls.
1830
2107
  case 'alert':
@@ -1859,12 +2136,19 @@ export class LayoutPass {
1859
2136
  }
1860
2137
  case 'menu': {
1861
2138
  // Phase 6: the label (the first child) is the only laid-out child.
2139
+ // The Mac idiom: outside a bar and a list row the menu is a pull-down
2140
+ // button around its label.
1862
2141
  const label = node.children[0];
2142
+ if (this.menuIsPullDown(ctx)) {
2143
+ const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
2144
+ const labelSize = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
2145
+ return { width: labelSize.width + 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON, height: Math.max(MAC_PULLDOWN_HEIGHT, labelSize.height) };
2146
+ }
1863
2147
  return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
1864
2148
  }
1865
2149
  case 'searchfield':
1866
- // Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field.
1867
- return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: SEARCH_FIELD_HEIGHT };
2150
+ // Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field (22 on a Mac).
2151
+ return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: this.env.platform === 'macos' ? MAC_SEARCH_FIELD_HEIGHT : SEARCH_FIELD_HEIGHT };
1868
2152
  case 'swipeactions':
1869
2153
  case 'contextmenu': {
1870
2154
  // Phase 8: the content (the first child) alone; the actions / items
@@ -2051,14 +2335,17 @@ export class LayoutPass {
2051
2335
  rowChrome['badge'] = { ...badge, x: end - chevron.width - badge.width };
2052
2336
  }
2053
2337
 
2054
- private buttonIsBordered(node: LayoutNode): boolean {
2338
+ private buttonIsBordered(node: LayoutNode, ctx: Context): boolean {
2055
2339
  const style = str(node.props['style']);
2056
- return style === 'bordered' || style === 'borderedProminent' || style === 'glass';
2340
+ if (style === 'bordered' || style === 'borderedProminent' || style === 'glass') return true;
2341
+ // The Mac idiom: a button of the automatic style is a push button too
2342
+ // (in a list row as well), except as a bar item's content.
2343
+ return this.env.platform === 'macos' && (style === null || style === 'automatic') && !ctx.navBarItem && !ctx.bottomBarItem;
2057
2344
  }
2058
2345
 
2059
2346
  private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
2060
2347
  const label = this.contentOf(node);
2061
- if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
2348
+ if (!this.buttonIsBordered(node, ctx)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
2062
2349
  const cctx = this.borderedLabelContext(node, ctx);
2063
2350
  if (ctx.bottomBarItem) {
2064
2351
  // In a bottom bar (measured in the iOS 26.2 simulator, Native
@@ -2081,7 +2368,9 @@ export class LayoutPass {
2081
2368
  }
2082
2369
 
2083
2370
  private borderedMetrics(node: LayoutNode): { padV: number; padH: number; minHeight: number; textStyle?: string } {
2084
- return BORDERED_SIZES[str(node.props['controlSize']) ?? ''] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
2371
+ const controlSize = str(node.props['controlSize']) ?? 'regular';
2372
+ if (this.env.platform === 'macos') return MAC_BORDERED_SIZES[controlSize] ?? (MAC_BORDERED_SIZES['regular'] as { padV: number; padH: number; minHeight: number });
2373
+ return BORDERED_SIZES[controlSize] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
2085
2374
  }
2086
2375
 
2087
2376
  /** A bordered button's label context: a small or mini one sets a smaller text style. */
@@ -2091,8 +2380,20 @@ export class LayoutPass {
2091
2380
  return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
2092
2381
  }
2093
2382
 
2094
- private toggleLabelProposal(p: Proposal): Proposal {
2095
- return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
2383
+ /**
2384
+ * A toggle's control: iOS's switch at the trailing edge; on a Mac a
2385
+ * checkbox before the label, or the small switch in a list row (a grouped
2386
+ * form's), at the trailing edge.
2387
+ */
2388
+ private switchMetrics(_ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
2389
+ if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
2390
+ // A Mac's toggle is a checkbox before its label, in a list row too.
2391
+ return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
2392
+ }
2393
+
2394
+ private toggleLabelProposal(ctx: Context, p: Proposal): Proposal {
2395
+ const control = this.switchMetrics(ctx);
2396
+ return { width: shrink(p.width, control.width + control.gap), height: p.height };
2096
2397
  }
2097
2398
 
2098
2399
  private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
@@ -2107,21 +2408,24 @@ export class LayoutPass {
2107
2408
  return { width: bottomSlot(label ?? node, size) + BOTTOM_TOGGLE_GROWTH - BOTTOM_TOGGLE_INSET, height: BOTTOM_TOGGLE_HEIGHT };
2108
2409
  }
2109
2410
  // sized as a bordered button, on or off
2110
- const inner: Proposal = { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
2411
+ const m = this.borderedMetrics(node);
2412
+ const inner: Proposal = { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
2111
2413
  const size = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
2112
- return { width: size.width + 2 * BORDERED_PAD_H, height: Math.max(BORDERED_MIN_HEIGHT, size.height + 2 * BORDERED_PAD_V) };
2414
+ return { width: size.width + 2 * m.padH, height: Math.max(m.minHeight, size.height + 2 * m.padV) };
2113
2415
  }
2416
+ const control = this.switchMetrics(ctx);
2114
2417
  const labelSize = label
2115
- ? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
2418
+ ? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(ctx, p))
2116
2419
  : ZERO;
2117
2420
  return {
2118
- width: finite(p.width) ? p.width : labelSize.width + TOGGLE_GAP + SWITCH_WIDTH,
2119
- height: Math.max(labelSize.height, SWITCH_HEIGHT),
2421
+ width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
2422
+ height: Math.max(labelSize.height, control.height),
2120
2423
  };
2121
2424
  }
2122
2425
 
2123
2426
  private navLinkLabelProposal(ctx: Context, p: Proposal): Proposal {
2124
- return ctx.listRow ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
2427
+ // A Mac's rows have no chevron.
2428
+ return ctx.listRow && this.env.platform !== 'macos' ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
2125
2429
  }
2126
2430
 
2127
2431
  private measureNavLink(node: LayoutNode, ctx: Context, p: Proposal): Size {
@@ -2155,14 +2459,23 @@ export class LayoutPass {
2155
2459
  return Array.isArray(detents) && str(detents[0]) === 'full';
2156
2460
  }
2157
2461
 
2158
- /** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
2462
+ /** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet; on a Mac a window-modal panel. */
2159
2463
  private sheetIsForm(node: LayoutNode): boolean {
2160
- return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
2464
+ return (this.env.screen.width >= REGULAR_WIDTH || this.env.platform === 'macos') && !this.sheetIsFull(node);
2161
2465
  }
2162
2466
 
2163
- /** The form sheet's card: UIKit's size, clamped to the safe area less a margin, centered in it. */
2164
- private formSheetRect(): Rect {
2467
+ /**
2468
+ * The form sheet's card: UIKit's size, clamped to the safe area less a
2469
+ * margin, centered in it. On a Mac the panel is 470 wide, as tall as its
2470
+ * bars and its content's ideal height, centered in the window.
2471
+ */
2472
+ private formSheetRect(node: LayoutNode, ctx: Context): Rect {
2165
2473
  const { screen, safeArea } = this.env;
2474
+ if (this.env.platform === 'macos') {
2475
+ const width = Math.max(0, Math.min(MAC_SHEET_WIDTH, screen.width - 2 * MAC_SHEET_MARGIN));
2476
+ const height = Math.max(0, Math.min(this.macSheetHeight(node, ctx, width), screen.height - 2 * MAC_SHEET_MARGIN));
2477
+ return { x: Math.round((screen.width - width) / 2), y: Math.round((screen.height - height) / 2), width, height };
2478
+ }
2166
2479
  const top = safeArea.top + FORM_SHEET_MARGIN;
2167
2480
  const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
2168
2481
  const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
@@ -2170,6 +2483,34 @@ export class LayoutPass {
2170
2483
  return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
2171
2484
  }
2172
2485
 
2486
+ /** The toolbars of a Mac sheet's root screen that go in its bottom bar: those whose items are all text buttons (a cancel or confirm action). */
2487
+ private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
2488
+ return this.navScreenParts(screen).toolbars.filter((toolbar) => {
2489
+ const placement = str(toolbar.props['placement']);
2490
+ if ((placement !== 'leading' && placement !== 'trailing') || str(toolbar.props['spacer']) !== null) return false;
2491
+ return toolbar.children.length > 0 && toolbar.children.every((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button');
2492
+ });
2493
+ }
2494
+
2495
+ /** A Mac sheet's root screen, when its content is a navigation stack. */
2496
+ private macSheetScreen(node: LayoutNode): LayoutNode | null {
2497
+ const child = this.contentOf(node);
2498
+ if (!child) return null;
2499
+ const stack = unwrapStyled(child);
2500
+ if (stack.kind !== 'navstack') return null;
2501
+ return stack.children[stack.children.length - 1] ?? null;
2502
+ }
2503
+
2504
+ /** A Mac sheet's height: its title bar, its content's ideal height at the sheet's width, and its actions bar if it has one. */
2505
+ private macSheetHeight(node: LayoutNode, ctx: Context, width: number): number {
2506
+ const screen = this.macSheetScreen(node);
2507
+ const child = this.contentOf(node);
2508
+ if (!screen) return child ? this.measure(child, this.childContext(node, ctx), { width, height: null }).height : 0;
2509
+ const content = this.navScreenParts(screen).content;
2510
+ const ideal = content ? this.measure(content, this.childContext(screen, this.childContext(node, ctx)), { width, height: null }).height : 0;
2511
+ return MAC_SHEET_TITLE_BAR + ideal + (this.macSheetActionToolbars(screen).length > 0 ? MAC_SHEET_ACTIONS_BAR : 0);
2512
+ }
2513
+
2173
2514
  /** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
2174
2515
  private sheetFloats(node: LayoutNode): boolean {
2175
2516
  if (this.sheetIsForm(node)) return false;
@@ -2239,29 +2580,37 @@ export class LayoutPass {
2239
2580
  };
2240
2581
  }
2241
2582
  if (style === 'segmented') {
2242
- const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2583
+ const mac = this.env.platform === 'macos';
2584
+ const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2243
2585
  let natural = 0;
2244
2586
  for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
2587
+ // A Mac shows the picker's label before the control (in a form's row it is in the label column).
2588
+ const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
2589
+ const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
2245
2590
  return {
2246
- size: { width: finite(p.width) ? p.width : natural, height: PICKER_SEGMENTED_HEIGHT },
2591
+ size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
2247
2592
  style: 'segmented',
2248
2593
  font,
2249
2594
  options,
2250
2595
  selected,
2251
- labelWidth: 0,
2596
+ labelWidth,
2252
2597
  valueWidth: 0,
2253
2598
  labelRow: false,
2254
2599
  };
2255
2600
  }
2256
2601
  const font = ctx.font;
2257
- const labelWidth = this.measurer.measureWidth(str(node.props['label']) ?? '', font);
2602
+ // In a Mac form's row the label is in the label column and the pop-up spans the controls column.
2603
+ const labelWidth = ctx.formColumn ? 0 : this.measurer.measureWidth(str(node.props['label']) ?? '', font);
2258
2604
  const value = options[selected] ?? '';
2259
- const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH;
2605
+ const mac = this.env.platform === 'macos';
2606
+ // The Mac's pop-up button holds the value and the chevrons in a bordered button.
2607
+ const popup = mac && (!ctx.listRow || ctx.formColumn === true);
2608
+ const valueWidth = ctx.formColumn && finite(p.width) ? p.width : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
2260
2609
  const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
2261
2610
  return {
2262
2611
  size: {
2263
2612
  width: finite(p.width) ? p.width : natural,
2264
- height: ctx.listRow ? PICKER_MENU_ROW_HEIGHT : PICKER_MENU_HEIGHT,
2613
+ height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
2265
2614
  },
2266
2615
  style: 'menu',
2267
2616
  font,
@@ -2286,9 +2635,19 @@ export class LayoutPass {
2286
2635
  const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
2287
2636
  if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
2288
2637
  else delete chrome['bar'];
2289
- const segmentWidth = Math.max(0, size.width - 2 * inset) / n;
2638
+ // The Mac's label before the control (`label`, `track` the control's own frame).
2639
+ const labelWidth = Math.min(plan.labelWidth, size.width);
2640
+ if (labelWidth > 0) {
2641
+ chrome['label'] = { x, y, width: labelWidth - MAC_SEGMENTED_LABEL_GAP, height: size.height };
2642
+ chrome['track'] = { x: x + labelWidth, y, width: Math.max(0, size.width - labelWidth), height: size.height };
2643
+ } else {
2644
+ delete chrome['label'];
2645
+ delete chrome['track'];
2646
+ }
2647
+ const trackX = x + labelWidth;
2648
+ const segmentWidth = Math.max(0, size.width - labelWidth - 2 * inset) / n;
2290
2649
  for (let i = 0; i < plan.options.length; i++) {
2291
- chrome[`segment${i}`] = { x: x + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
2650
+ chrome[`segment${i}`] = { x: trackX + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
2292
2651
  }
2293
2652
  } else if (plan.style === 'inline') {
2294
2653
  // Rows span the list row when there is one (the hit area and the
@@ -2312,7 +2671,8 @@ export class LayoutPass {
2312
2671
  const room = Math.max(0, size.width - PICKER_MENU_GAP);
2313
2672
  let labelWidth = plan.labelWidth;
2314
2673
  let valueWidth = plan.valueWidth;
2315
- if (labelWidth + valueWidth > room) {
2674
+ // In a Mac form's controls column the pop-up spans the column (its label is in the label column).
2675
+ if (!ctx.formColumn && labelWidth + valueWidth > room) {
2316
2676
  if (labelWidth <= valueWidth) {
2317
2677
  labelWidth = Math.min(labelWidth, room / 2);
2318
2678
  valueWidth = room - labelWidth;
@@ -2324,6 +2684,9 @@ export class LayoutPass {
2324
2684
  valueWidth = Math.min(valueWidth, size.width);
2325
2685
  chrome['label'] = { x, y, width: labelWidth, height: size.height };
2326
2686
  chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
2687
+ // The page draws the Mac's pop-up button around the value (the engine padded it).
2688
+ if (this.env.platform === 'macos' && (!ctx.listRow || ctx.formColumn === true)) chrome['popup'] = chrome['value'];
2689
+ else delete chrome['popup'];
2327
2690
  }
2328
2691
  this.setChrome(node, chrome);
2329
2692
  if (node.id >= 0) this.fonts.set(node.id, plan.font);
@@ -2365,6 +2728,8 @@ export class LayoutPass {
2365
2728
  if (n < 2) return [];
2366
2729
  // A disclosure group's content sits 8 under its label, as SwiftUI's does.
2367
2730
  const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
2731
+ // A Mac list row's Label (no icon column there): the title 6 after the icon.
2732
+ if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
2368
2733
  if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
2369
2734
  const gaps: number[] = [];
2370
2735
  for (let i = 0; i < n - 1; i++) {
@@ -2629,10 +2994,13 @@ export class LayoutPass {
2629
2994
  return { width: p.width ?? width, height: p.height ?? height };
2630
2995
  }
2631
2996
  const name = str(node.props['name']);
2632
- if (name === null) return square(ctx.font.leading * ctx.imageScale);
2997
+ // A symbol takes a square of the font's leading; in a Mac list row, of
2998
+ // its point size (13 in a 13-point row, measured in the Mac app).
2999
+ const symbolSide = (ctx.listCell === true ? ctx.font.size : ctx.font.leading) * ctx.imageScale;
3000
+ if (name === null) return square(symbolSide);
2633
3001
  const asset = this.env.assets?.images[name];
2634
3002
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
2635
- if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
3003
+ if (asset.kind === 'symbol') return square(symbolSide);
2636
3004
  const width = num(asset.width) ?? 0;
2637
3005
  const height = num(asset.height) ?? 0;
2638
3006
  if (node.props['resizable'] !== true) return { width, height };
@@ -2830,6 +3198,7 @@ export class LayoutPass {
2830
3198
 
2831
3199
  private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
2832
3200
  return this.plan(node, ctx, p, () => {
3201
+ if (this.env.platform === 'macos' && node.props['form'] === true) return this.macFormPlan(node, ctx, p);
2833
3202
  const { screen } = this.env;
2834
3203
  const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
2835
3204
  const layoutWidth = Number.isFinite(width) ? width : screen.width;
@@ -2842,28 +3211,43 @@ export class LayoutPass {
2842
3211
  // (cards, chevrons, 52pt rows) and only its sections' folding remains.
2843
3212
  const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
2844
3213
  // `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
2845
- const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
2846
- const inset = listStyle === 'insetGrouped' || listStyle === 'sidebar' ? LIST_INSET : 0;
3214
+ // The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
3215
+ // 11-point bold headers, a sidebar's rows inset 10 and a grouped
3216
+ // form's cards 20.
3217
+ const mac = this.env.platform === 'macos';
3218
+ const minRowHeight =
3219
+ num(node.props['minRowHeight']) ??
3220
+ (sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
3221
+ // A Mac's lists have no cards: the rows run the whole width (a
3222
+ // sidebar's inset 10).
3223
+ const inset = mac
3224
+ ? listStyle === 'sidebar'
3225
+ ? MAC_LIST_INSET_SIDEBAR
3226
+ : 0
3227
+ : listStyle === 'insetGrouped' || listStyle === 'sidebar'
3228
+ ? LIST_INSET
3229
+ : 0;
2847
3230
  const plain = listStyle === 'plain';
2848
- const listTop = plain ? 0 : LIST_TOP;
2849
- const listBottom = plain ? 0 : LIST_BOTTOM;
2850
- const sectionGap = plain ? 0 : LIST_SECTION_GAP;
3231
+ const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
3232
+ const listTop = plain ? 0 : mac ? MAC_LIST_TOP : LIST_TOP;
3233
+ const listBottom = plain ? 0 : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
3234
+ const sectionGap = plain ? 0 : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
3235
+ const headerGap = mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
2851
3236
  const cardX = inset;
2852
3237
  const cardWidth = Math.max(0, layoutWidth - 2 * inset);
2853
- const contentX = inset + LIST_CELL_PADDING;
2854
- const contentWidth = Math.max(0, layoutWidth - 2 * (inset + LIST_CELL_PADDING));
3238
+ const contentX = inset + cellPadding;
3239
+ const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
2855
3240
  // iOS 26: headers in the headline size (semibold), footers in footnote.
2856
- const headerFont = makeFont(
2857
- 'system',
2858
- textStyleSize('headline', this.env.dynamicTypeSize),
2859
- 600,
2860
- );
3241
+ const headerFont = mac
3242
+ ? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
3243
+ : makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
2861
3244
  const footerFont = makeFont(
2862
3245
  'system',
2863
- textStyleSize('footnote', this.env.dynamicTypeSize),
3246
+ textStyleSize('footnote', this.env.dynamicTypeSize, this.env.platform),
2864
3247
  400,
2865
3248
  );
2866
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
3249
+ // A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
3250
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: !mac, listCell: mac, sidebarList: str(node.props['style']) === 'sidebar' };
2867
3251
  // Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
2868
3252
  // leading control (6 in from the card's edge) and its content shifts
2869
3253
  // right by 40; a movable one a trailing grip, which narrows it by 40.
@@ -2871,20 +3255,20 @@ export class LayoutPass {
2871
3255
 
2872
3256
  // Group consecutive plain rows into an implicit section. A section's
2873
3257
  // view header and footer are not rows.
2874
- type GroupSource = { section: LayoutNode | null; rows: LayoutNode[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
3258
+ type GroupSource = { section: LayoutNode | null; rows: RowSource[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
2875
3259
  const groupsSource: GroupSource[] = [];
2876
3260
  for (const child of node.children) {
2877
3261
  if (child.kind === 'section') {
2878
3262
  // `Section(isExpanded:)`: collapsed, its rows are not laid out, in a sidebar-style list (iOS shows the rows in the other styles, whatever the binding).
2879
3263
  const collapsed = child.props['collapsible'] === true && child.props['expanded'] === false && rowCtx.sidebarList === true;
2880
- const rows = collapsed ? [] : child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
3264
+ const rows = collapsed ? [] : outlineRows(child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter'), 0, []);
2881
3265
  const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
2882
3266
  const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
2883
3267
  groupsSource.push({ section: child, rows, headerView, footerView });
2884
3268
  } else {
2885
3269
  const last = groupsSource[groupsSource.length - 1];
2886
- if (last && last.section === null) last.rows.push(child);
2887
- else groupsSource.push({ section: null, rows: [child], headerView: null, footerView: null });
3270
+ if (last && last.section === null) outlineRows([child], 0, last.rows);
3271
+ else groupsSource.push({ section: null, rows: outlineRows([child], 0, []), headerView: null, footerView: null });
2888
3272
  }
2889
3273
  }
2890
3274
  // View headers and footers: the content width, the header's and the
@@ -2913,27 +3297,29 @@ export class LayoutPass {
2913
3297
  const previous = groups[g - 1];
2914
3298
  if (previous) {
2915
3299
  const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
2916
- y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
3300
+ y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
2917
3301
  }
2918
3302
  const top = y;
2919
3303
  let header: ListGroupPlan['header'] = null;
2920
3304
  let footer: ListGroupPlan['footer'] = null;
2921
3305
  // A header sits in the gap above its card (from LIST_HEADER_LIFT
2922
3306
  // into it); the card then starts LIST_HEADER_GAP below the header.
2923
- const headerLift = plain ? 0 : LIST_HEADER_LIFT;
3307
+ const headerLift = plain || mac ? 0 : LIST_HEADER_LIFT;
2924
3308
  const headerView = supplement(source.headerView, y - headerLift, headerCtx, source.section?.props['collapsible'] === true);
2925
- if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
3309
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + headerGap - headerLift;
2926
3310
  const headerText = source.section ? str(source.section.props['header']) : null;
2927
3311
  const footerText = source.section ? str(source.section.props['footer']) : null;
2928
3312
  if (headerText !== null && headerText.length > 0) {
2929
3313
  const { layout, size } = makeText(headerText, headerFont);
3314
+ if (mac && previous) y += MAC_LIST_HEADER_TOP;
2930
3315
  const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
2931
3316
  header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
2932
- y = headerY + size.height + LIST_HEADER_GAP;
3317
+ y = headerY + size.height + headerGap + (mac && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
2933
3318
  }
2934
3319
  const cardTop = y;
2935
3320
  const rows: ListRowPlan[] = [];
2936
- for (const rowNode of source.rows) {
3321
+ for (const rowSource of source.rows) {
3322
+ const rowNode = rowSource.node;
2937
3323
  if (rowNode.kind === 'lazyspacer') {
2938
3324
  // Lazy rows not built yet: a row of no height, without a cell.
2939
3325
  const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
@@ -2946,13 +3332,19 @@ export class LayoutPass {
2946
3332
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2947
3333
  // width (plus a gap) at the trailing edge.
2948
3334
  const hints = rowHintsOf(rowNode);
2949
- const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle);
2950
- const insets: EdgeInsets = hints.insets ?? {
2951
- top: defaultVPad,
2952
- leading: LIST_CELL_PADDING,
2953
- bottom: defaultVPad,
2954
- trailing: LIST_CELL_PADDING,
2955
- };
3335
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
3336
+ // An outline's rows start LIST_OUTLINE_INDENT further in per level.
3337
+ const indent = rowSource.depth * LIST_OUTLINE_INDENT;
3338
+ const insets: EdgeInsets = hints.insets
3339
+ ? { ...hints.insets, leading: hints.insets.leading + indent }
3340
+ : {
3341
+ top: defaultVPad,
3342
+ leading: cellPadding + indent,
3343
+ bottom: defaultVPad,
3344
+ trailing: cellPadding,
3345
+ };
3346
+ // A group's label row keeps the chevron's width at its trailing edge, as a link's does (a Mac's rows have no chevron).
3347
+ const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
2956
3348
  const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
2957
3349
  const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
2958
3350
  // Phase 8 edit mode: a selectable or deletable `listrow` gains a
@@ -2964,7 +3356,7 @@ export class LayoutPass {
2964
3356
  const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
2965
3357
  const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
2966
3358
  const rowContentX = cardX + insets.leading + editShift;
2967
- const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
3359
+ const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve);
2968
3360
  const proposal: Proposal = { width: rowContentWidth, height: null };
2969
3361
  const contentSize = this.measure(rowNode, rowCtx, proposal);
2970
3362
  const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
@@ -2997,15 +3389,27 @@ export class LayoutPass {
2997
3389
  }
2998
3390
  : null,
2999
3391
  separatorHidden: hints.separatorHidden,
3392
+ outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
3000
3393
  });
3001
3394
  y += rowHeight;
3002
3395
  }
3396
+ // Each group's rect: its label's row, then the rows under it that sit deeper, up to the next at its level or above.
3397
+ const outlines: ListGroupPlan['outlines'] = [];
3398
+ rows.forEach((row, i) => {
3399
+ const group = row.outline?.group;
3400
+ if (!group) return;
3401
+ const depth = row.outline?.depth ?? 0;
3402
+ let end = i;
3403
+ while (end + 1 < rows.length && (rows[end + 1]?.outline?.depth ?? 0) > depth) end += 1;
3404
+ const last = (rows[end] as ListRowPlan).row;
3405
+ outlines.push({ group, frame: { x: row.row.x, y: row.row.y, width: row.row.width, height: last.y + last.height - row.row.y } });
3406
+ });
3003
3407
  const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
3004
3408
  if (footerText !== null && footerText.length > 0) {
3005
- y += LIST_FOOTER_GAP;
3409
+ y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
3006
3410
  const { layout, size } = makeText(footerText, footerFont);
3007
3411
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
3008
- y += size.height;
3412
+ y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
3009
3413
  }
3010
3414
  let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
3011
3415
  if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
@@ -3019,6 +3423,7 @@ export class LayoutPass {
3019
3423
  footerView,
3020
3424
  card,
3021
3425
  rows,
3426
+ outlines,
3022
3427
  });
3023
3428
  }
3024
3429
  const contentHeight = groupsSource.length === 0 ? listTop : y + listBottom;
@@ -3177,7 +3582,7 @@ export class LayoutPass {
3177
3582
  case 'button': {
3178
3583
  const label = this.contentOf(node);
3179
3584
  if (!label) return;
3180
- if (!this.buttonIsBordered(node)) {
3585
+ if (!this.buttonIsBordered(node, ctx)) {
3181
3586
  this.place(label, cctx, p, x, y);
3182
3587
  return;
3183
3588
  }
@@ -3195,6 +3600,8 @@ export class LayoutPass {
3195
3600
  this.setChrome(node, {
3196
3601
  ...existing,
3197
3602
  label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
3603
+ // The page draws the border on an automatic button the engine bordered (the Mac idiom).
3604
+ bordered: { x, y, width: size.width, height: size.height },
3198
3605
  });
3199
3606
  return;
3200
3607
  }
@@ -3216,9 +3623,10 @@ export class LayoutPass {
3216
3623
  if (isButtonToggle(node)) {
3217
3624
  // the label centered in the button (its fill, while on, the toggle's frame)
3218
3625
  if (!label) return;
3626
+ const m = this.borderedMetrics(node);
3219
3627
  const inner: Proposal = ctx.bottomBarItem
3220
3628
  ? { width: null, height: null }
3221
- : { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
3629
+ : { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
3222
3630
  const labelSize = this.measure(label, cctx, inner);
3223
3631
  const { dx, dy } = alignIn(size, labelSize, 'center');
3224
3632
  this.place(label, cctx, inner, x + dx, y + dy);
@@ -3226,25 +3634,31 @@ export class LayoutPass {
3226
3634
  this.setChrome(node, { ...existing, label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height } });
3227
3635
  return;
3228
3636
  }
3229
- const labelProposal = this.toggleLabelProposal(p);
3637
+ const control = this.switchMetrics(ctx);
3638
+ const labelProposal = this.toggleLabelProposal(ctx, p);
3230
3639
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
3231
3640
  const labelY = y + (size.height - labelSize.height) / 2;
3232
- if (label) this.place(label, cctx, labelProposal, x, labelY);
3641
+ // A checkbox leads its label; a switch trails it.
3642
+ const labelX = control.checkbox ? x + control.width + control.gap : x;
3643
+ if (label) this.place(label, cctx, labelProposal, labelX, labelY);
3233
3644
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
3645
+ const switchRect: Rect = {
3646
+ x: control.checkbox ? x : x + size.width - control.width,
3647
+ y: y + (size.height - control.height) / 2,
3648
+ width: control.width,
3649
+ height: control.height,
3650
+ };
3234
3651
  this.setChrome(node, {
3235
3652
  ...existing,
3236
3653
  label: {
3237
- x,
3654
+ x: labelX,
3238
3655
  y: labelY,
3239
- width: Math.min(labelSize.width, Math.max(0, size.width - SWITCH_WIDTH - TOGGLE_GAP)),
3656
+ width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
3240
3657
  height: labelSize.height,
3241
3658
  },
3242
- switch: {
3243
- x: x + size.width - SWITCH_WIDTH,
3244
- y: y + (size.height - SWITCH_HEIGHT) / 2,
3245
- width: SWITCH_WIDTH,
3246
- height: SWITCH_HEIGHT,
3247
- },
3659
+ switch: switchRect,
3660
+ // The page draws a checkbox at it (the Mac idiom).
3661
+ ...(control.checkbox ? { checkbox: switchRect } : {}),
3248
3662
  });
3249
3663
  return;
3250
3664
  }
@@ -3263,8 +3677,8 @@ export class LayoutPass {
3263
3677
  // Phase 7: the selected row of a `navsplit` sidebar has no chevron
3264
3678
  // (its label still reserves the chevron's width so selecting a row
3265
3679
  // never reflows it); elsewhere `selected` keeps the chevron. No row
3266
- // of a `sidebar`-style list has one (iPadOS).
3267
- if (!(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
3680
+ // of a `sidebar`-style list has one (iPadOS), nor any row on a Mac.
3681
+ if (this.env.platform !== 'macos' && !(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
3268
3682
  chrome['chevron'] = {
3269
3683
  x: x + size.width - LIST_CHEVRON_WIDTH,
3270
3684
  y,
@@ -3425,7 +3839,16 @@ export class LayoutPass {
3425
3839
  // Phase 6: the label is placed; the items are only shown in the
3426
3840
  // popover the renderer opens, so they are hidden and have no frames.
3427
3841
  const [label, ...items] = node.children;
3428
- if (label) this.place(label, cctx, p, x, y);
3842
+ if (this.menuIsPullDown(ctx)) {
3843
+ // The Mac's pull-down button: the label 10 in, centered in the 22 row, the chevron after it.
3844
+ const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
3845
+ const labelSize = label ? this.measure(label, cctx, inner) : ZERO;
3846
+ if (label) this.place(label, cctx, inner, x + MAC_PULLDOWN_PAD_H, y + (size.height - labelSize.height) / 2);
3847
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
3848
+ this.setChrome(node, { ...existing, pulldown: { x, y, width: size.width, height: size.height } });
3849
+ } else if (label) {
3850
+ this.place(label, cctx, p, x, y);
3851
+ }
3429
3852
  for (const item of items) this.hide(item, node);
3430
3853
  return;
3431
3854
  }
@@ -3530,22 +3953,226 @@ export class LayoutPass {
3530
3953
  return { p: { width: p.width, height: size.height + top + bottom }, y: y - top };
3531
3954
  }
3532
3955
 
3956
+ /** The labeled control a form row's stack is shifted to (the form's alignment guide), if it has one. */
3957
+ private macFormGuide(row: LayoutNode): LayoutNode | null {
3958
+ const inner = unwrapStyled(row);
3959
+ if (inner.kind !== 'hstack') return null;
3960
+ return inner.children.find((child) => MAC_FORM_LABELED_KINDS.includes(unwrapStyled(child).kind)) ?? null;
3961
+ }
3962
+
3963
+ /**
3964
+ * The Mac idiom's `Form` (MAC_FORM_* above, docs/notes/macos.md): the
3965
+ * columns style. A row whose control carries a label (a text field's
3966
+ * title, a picker's, a stepper's, a date picker's, a slider's) puts it
3967
+ * in the label column, right-aligned, and the control in the controls
3968
+ * column at the control's own height; a toggle, a button, text and other
3969
+ * rows go in the controls column whole. A stack row with such a control
3970
+ * in it is shifted so the control starts at the controls column, what is
3971
+ * before it reaching into the label column, as the form's alignment guide
3972
+ * does on a Mac. Headers and footers are 13-point text in the controls
3973
+ * column. No cards, no separators.
3974
+ */
3975
+ private macFormPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
3976
+ const { screen } = this.env;
3977
+ const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
3978
+ const layoutWidth = Number.isFinite(width) ? width : screen.width;
3979
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false };
3980
+ const font = rowCtx.font;
3981
+ const textFont = makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 400);
3982
+ type Row = { node: LayoutNode; label: string | null; labelNode: LayoutNode | null; guide: LayoutNode | null };
3983
+ type Source = { section: LayoutNode | null; rows: Row[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
3984
+ // The rows, each with what goes in the label column.
3985
+ const classify = (row: LayoutNode): Row => {
3986
+ const inner = unwrapStyled(row);
3987
+ switch (inner.kind) {
3988
+ case 'textfield':
3989
+ return { node: row, label: str(inner.props['placeholder']) || null, labelNode: null, guide: null };
3990
+ case 'picker':
3991
+ return { node: row, label: str(inner.props['label']) || null, labelNode: null, guide: null };
3992
+ case 'stepper':
3993
+ case 'datepicker':
3994
+ return { node: row, label: null, labelNode: inner.props['labelsHidden'] === true ? null : this.contentOf(inner), guide: null };
3995
+ case 'slider': {
3996
+ const names = Array.isArray(inner.props['labels']) ? inner.props['labels'].map(str) : [];
3997
+ const at = names.indexOf('label');
3998
+ return { node: row, label: null, labelNode: at >= 0 ? (inner.children[at] ?? null) : null, guide: null };
3999
+ }
4000
+ default:
4001
+ return { node: row, label: null, labelNode: null, guide: this.macFormGuide(row) };
4002
+ }
4003
+ };
4004
+ const sources: Source[] = [];
4005
+ for (const child of node.children) {
4006
+ if (child.kind === 'section') {
4007
+ const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter').map(classify);
4008
+ sources.push({ section: child, rows, headerView: child.children.find((row) => row.kind === 'sectionheader') ?? null, footerView: child.children.find((row) => row.kind === 'sectionfooter') ?? null });
4009
+ } else {
4010
+ const last = sources[sources.length - 1];
4011
+ if (last && last.section === null) last.rows.push(classify(child));
4012
+ else sources.push({ section: null, rows: [classify(child)], headerView: null, footerView: null });
4013
+ }
4014
+ }
4015
+ // The label column: as wide as the widest label (a view label at its ideal size).
4016
+ const labelCtx: Context = { ...this.childContext(node, ctx), stackAxis: null, listRow: false, listCell: true };
4017
+ let labelWidth = 0;
4018
+ const labelSizes = new Map<LayoutNode, Size>();
4019
+ for (const source of sources) {
4020
+ for (const row of source.rows) {
4021
+ if (row.label !== null) labelWidth = Math.max(labelWidth, this.measurer.measureWidth(row.label, font));
4022
+ else if (row.labelNode) {
4023
+ const size = this.measure(row.labelNode, labelCtx, { width: null, height: null });
4024
+ labelSizes.set(row.labelNode, size);
4025
+ labelWidth = Math.max(labelWidth, size.width);
4026
+ }
4027
+ }
4028
+ }
4029
+ labelWidth = Math.ceil(labelWidth);
4030
+ const controlX = labelWidth + MAC_FORM_LABEL_GAP;
4031
+ const controlWidth = Math.max(0, layoutWidth - controlX);
4032
+ const textCtx: Context = { ...labelCtx, font: textFont };
4033
+ const makeText = (text: string): { layout: TextLayout; size: Size } => {
4034
+ const wrapped = wrapText(text, textFont, controlWidth, null, this.measurer);
4035
+ return { layout: { font: textFont, lines: wrapped.lines, alignment: 'leading', truncated: false }, size: { width: wrapped.width, height: textHeight(wrapped.lines.length, textFont) } };
4036
+ };
4037
+ const supplement = (view: LayoutNode | null, y: number): ListSupplementPlan | null => {
4038
+ if (!view) return null;
4039
+ const proposal: Proposal = { width: controlWidth, height: null };
4040
+ const size = this.measure(view, textCtx, proposal);
4041
+ return { node: view, ctx: textCtx, proposal, rect: { x: controlX, y, width: size.width, height: size.height } };
4042
+ };
4043
+ // Built from a top, then again centered in the room when the rows are shorter than it.
4044
+ const build = (startY: number): { groups: ListGroupPlan[]; labels: FormLabelPlan[]; end: number } => {
4045
+ const groups: ListGroupPlan[] = [];
4046
+ const labels: FormLabelPlan[] = [];
4047
+ let y = startY;
4048
+ for (let g = 0; g < sources.length; g++) {
4049
+ const source = sources[g] as Source;
4050
+ const previous = groups[g - 1];
4051
+ const headerText = source.section ? str(source.section.props['header']) : null;
4052
+ const footerText = source.section ? str(source.section.props['footer']) : null;
4053
+ const hasHeader = (headerText !== null && headerText.length > 0) || (source.headerView !== null && this.measure(source.headerView, textCtx, { width: controlWidth, height: null }).height > 0);
4054
+ // A header after rows is 4 under them; right under a footer.
4055
+ if (previous && hasHeader && previous.footer === null && !(previous.footerView !== null && previous.footerView.rect.height > 0)) y += MAC_FORM_HEADER_TOP;
4056
+ const top = y;
4057
+ let header: ListGroupPlan['header'] = null;
4058
+ let footer: ListGroupPlan['footer'] = null;
4059
+ const headerView = supplement(source.headerView, y);
4060
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + MAC_FORM_HEADER_GAP;
4061
+ if (headerText !== null && headerText.length > 0) {
4062
+ const { layout, size } = makeText(headerText);
4063
+ header = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
4064
+ y += size.height + MAC_FORM_HEADER_GAP;
4065
+ }
4066
+ const cardTop = y;
4067
+ const rows: ListRowPlan[] = [];
4068
+ source.rows.forEach((row, i) => {
4069
+ if (i > 0) y += MAC_FORM_ROW_GAP;
4070
+ if (row.node.kind === 'lazyspacer') {
4071
+ const rect: Rect = { x: controlX, y, width: controlWidth, height: 0 };
4072
+ rows.push({ node: row.node, proposal: { width: controlWidth, height: 0 }, contentSize: ZERO, row: rect, content: rect, badge: null, edit: null });
4073
+ return;
4074
+ }
4075
+ // A shifted stack is proposed the whole row less the trailing inset; its place is set when the list is placed.
4076
+ // A shifted stack is proposed the whole row; its place and width are set when the list is placed.
4077
+ const proposal: Proposal = { width: row.guide ? layoutWidth : controlWidth, height: null };
4078
+ const contentSize = this.measure(row.node, rowCtx, proposal);
4079
+ const labelSize = row.labelNode ? labelSizes.get(row.labelNode) ?? ZERO : row.label !== null ? { width: this.measurer.measureWidth(row.label, font), height: font.lineHeight } : ZERO;
4080
+ const rowHeight = Math.max(contentSize.height, labelSize.height);
4081
+ // A text field's row is the field itself (the page draws the Mac's bezel on the row element); the others span the form.
4082
+ const field = unwrapStyled(row.node).kind === 'textfield';
4083
+ rows.push({
4084
+ node: row.node,
4085
+ proposal,
4086
+ contentSize,
4087
+ row: field ? { x: controlX, y, width: controlWidth, height: rowHeight } : { x: 0, y, width: layoutWidth, height: rowHeight },
4088
+ content: { x: row.guide ? 0 : controlX, y: y + (rowHeight - contentSize.height) / 2, width: contentSize.width, height: contentSize.height },
4089
+ badge: null,
4090
+ edit: null,
4091
+ separatorHidden: { top: true, bottom: true },
4092
+ });
4093
+ if (row.label !== null) {
4094
+ labels.push({
4095
+ rect: { x: 0, y: y + (rowHeight - labelSize.height) / 2, width: labelWidth, height: labelSize.height },
4096
+ layout: { font, lines: [row.label], alignment: 'trailing', truncated: false },
4097
+ node: null,
4098
+ ctx: labelCtx,
4099
+ });
4100
+ } else if (row.labelNode) {
4101
+ labels.push({
4102
+ rect: { x: labelWidth - labelSize.width, y: y + (rowHeight - labelSize.height) / 2, width: labelSize.width, height: labelSize.height },
4103
+ layout: null,
4104
+ node: row.labelNode,
4105
+ ctx: labelCtx,
4106
+ });
4107
+ }
4108
+ y += rowHeight;
4109
+ });
4110
+ // The rows' card spans the form: the label column is drawn in it too.
4111
+ const card: Rect = { x: 0, y: cardTop, width: layoutWidth, height: y - cardTop };
4112
+ if (footerText !== null && footerText.length > 0) {
4113
+ y += MAC_FORM_FOOTER_GAP;
4114
+ const { layout, size } = makeText(footerText);
4115
+ footer = { rect: { x: controlX, y, width: controlWidth, height: size.height }, layout };
4116
+ y += size.height;
4117
+ }
4118
+ let footerView = supplement(source.footerView, y + MAC_FORM_FOOTER_GAP);
4119
+ if (footerView && footerView.rect.height > 0) y += MAC_FORM_FOOTER_GAP + footerView.rect.height;
4120
+ else if (footerView) footerView = { ...footerView, rect: { ...footerView.rect, y } };
4121
+ groups.push({ section: source.section, frame: { x: 0, y: top, width: layoutWidth, height: y - top }, header, footer, headerView, footerView, card, rows, outlines: [] });
4122
+ }
4123
+ return { groups, labels, end: y };
4124
+ };
4125
+ let built = build(0);
4126
+ // Centered in the room under the bar when shorter than it (the Mac's columns form does not scroll).
4127
+ const scrollInset = this.scrollInsets.get(node);
4128
+ const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
4129
+ const room = finite(p.height) ? p.height - inset : null;
4130
+ if (room !== null && room > built.end) built = build((room - built.end) / 2);
4131
+ const contentHeight = built.end;
4132
+ return {
4133
+ size: { width, height: p.height === null ? contentHeight : p.height },
4134
+ contentSize: { width: layoutWidth, height: contentHeight },
4135
+ groups: built.groups,
4136
+ labels: built.labels,
4137
+ columns: true,
4138
+ column: { x: controlX, width: controlWidth },
4139
+ };
4140
+ }
4141
+
3533
4142
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3534
4143
  const plan = this.listPlan(node, ctx, p);
3535
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
4144
+ const columns = plan.columns === true;
4145
+ const rowCtx: Context = columns
4146
+ ? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
4147
+ : { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
4148
+ const mac = this.env.platform === 'macos';
4149
+ const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
3536
4150
  const covered = this.coveredBottom(ctx, x, y, plan.size);
3537
4151
  // Under its screen's bar (and the large title that scrolls with it), the
3538
4152
  // rows start below them.
3539
4153
  const scrollInset = this.scrollInsets.get(node);
3540
4154
  const listChrome: Record<string, Rect> = {};
4155
+ const listTexts: Record<string, TextLayout> = {};
3541
4156
  if (covered) listChrome['bottomInset'] = covered;
3542
4157
  if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
3543
4158
  if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
3544
- if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
3545
4159
  const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
3546
4160
  if (node.id >= 0) this.contentSizes.set(node.id, { width: plan.contentSize.width, height: plan.contentSize.height + inset });
3547
4161
  y += inset;
3548
4162
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
4163
+ // The Mac idiom's columns form: the label column (chrome `formLabel<i>`
4164
+ // with its text, or a view placed there) and the flag for the page.
4165
+ if (columns) {
4166
+ listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
4167
+ (plan.labels ?? []).forEach((label, i) => {
4168
+ if (label.layout) {
4169
+ listChrome[`formLabel${i}`] = offset(label.rect);
4170
+ listTexts[`formLabel${i}`] = label.layout;
4171
+ }
4172
+ });
4173
+ }
4174
+ if (Object.keys(listChrome).length > 0) this.setChrome(node, listChrome);
4175
+ if (node.id >= 0 && Object.keys(listTexts).length > 0) this.chromeText.set(node.id, listTexts);
3549
4176
  for (const group of plan.groups) {
3550
4177
  if (group.section) {
3551
4178
  const chrome: Record<string, Rect> = { rows: offset(group.card) };
@@ -3568,7 +4195,7 @@ export class LayoutPass {
3568
4195
  if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
3569
4196
  }
3570
4197
  if (group.section.id >= 0) {
3571
- this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
4198
+ this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize, this.env.platform), 400));
3572
4199
  if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
3573
4200
  }
3574
4201
  // A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
@@ -3578,6 +4205,25 @@ export class LayoutPass {
3578
4205
  }
3579
4206
  }
3580
4207
  }
4208
+ // An outline's `disclosuregroup` is not placed through `place` (its label
4209
+ // and content are the rows): its frame is the rect its rows span, which
4210
+ // is its box too (`row`, so the page lays its rows out from it), its
4211
+ // content is hidden while collapsed, and what a pass before hid is shown.
4212
+ for (const outline of group.outlines) {
4213
+ const g = outline.group;
4214
+ const previouslyHidden = this.hiddenBy.get(g);
4215
+ if (previouslyHidden) {
4216
+ for (const id of previouslyHidden) {
4217
+ this.hidden.delete(id);
4218
+ this.touched.add(id);
4219
+ }
4220
+ this.hiddenBy.delete(g);
4221
+ }
4222
+ const frame = offset(outline.frame);
4223
+ this.setFrame(g, frame.x, frame.y, frame);
4224
+ this.setChrome(g, { row: frame });
4225
+ if (g.props['expanded'] !== true) for (const child of g.children.slice(1)) this.hide(child, g);
4226
+ }
3581
4227
  for (const row of group.rows) {
3582
4228
  // Set the row chrome before placing so a navlink can merge its own keys.
3583
4229
  const chrome: Record<string, Rect> = { row: offset(row.row) };
@@ -3598,15 +4244,31 @@ export class LayoutPass {
3598
4244
  if (row.edit.listRow === row.node) Object.assign(chrome, controls);
3599
4245
  else this.setChrome(row.edit.listRow, controls);
3600
4246
  }
3601
- const rowX = x + row.content.x;
4247
+ let rowX = x + row.content.x;
3602
4248
  const rowY = y + row.content.y;
4249
+ // A columns form's shifted stack: placed, then moved so its labeled
4250
+ // control starts at the controls column (the form's alignment guide).
4251
+ // Placed once to find the control's offset in it, then proposed the
4252
+ // width that ends it at the trailing edge, from where the control
4253
+ // lands at the column.
4254
+ const guide = columns ? this.macFormGuide(row.node) : null;
4255
+ let proposal = row.proposal;
4256
+ if (guide && plan.column) {
4257
+ this.place(row.node, rowCtx, row.proposal, rowX, rowY);
4258
+ const placed = this.frames.get(unwrapStyled(guide).id);
4259
+ if (placed) {
4260
+ const offset = placed.x - rowX;
4261
+ rowX = x + plan.column.x - offset;
4262
+ proposal = { width: Math.max(0, plan.size.width - (plan.column.x - offset)), height: row.proposal.height };
4263
+ }
4264
+ }
3603
4265
  // A row that is skipped keeps the chrome it set itself when it was
3604
4266
  // placed (a link's chevron and label); only the list's keys are new.
3605
- const own = this.placedAlready(row.node, rowCtx, row.proposal, rowX, rowY) && row.node.id >= 0
4267
+ const own = this.placedAlready(row.node, rowCtx, proposal, rowX, rowY) && row.node.id >= 0
3606
4268
  ? this.chrome.get(row.node.id)
3607
4269
  : undefined;
3608
4270
  this.setChrome(row.node, own ? { ...own, ...chrome } : chrome);
3609
- this.place(row.node, rowCtx, row.proposal, rowX, rowY);
4271
+ this.place(row.node, rowCtx, proposal, rowX, rowY);
3610
4272
  if (row.badge) this.badgeBeforeChevron(row.node);
3611
4273
  // iOS 26: the separator under a row starts where its first text
3612
4274
  // starts (after a Label's icon), else at the row's content.
@@ -3616,6 +4278,20 @@ export class LayoutPass {
3616
4278
  const leading = (textX ?? rowX) - (x + row.row.x);
3617
4279
  placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
3618
4280
  }
4281
+ // A group's label row: the group's toggle lies over it, its chevron at the row's trailing edge (as a link's; none on a Mac).
4282
+ const outlineGroup = row.outline?.group;
4283
+ if (outlineGroup && outlineGroup.id >= 0) {
4284
+ const rowRect = offset(row.row);
4285
+ const groupChrome: Record<string, Rect> = { ...this.chrome.get(outlineGroup.id), toggle: rowRect };
4286
+ if (!mac) groupChrome['chevron'] = { x: rowRect.x + rowRect.width - cellPadding - LIST_CHEVRON_WIDTH, y: rowRect.y, width: LIST_CHEVRON_WIDTH, height: rowRect.height };
4287
+ this.setChrome(outlineGroup, groupChrome);
4288
+ }
4289
+ }
4290
+ }
4291
+ // The label column's views (a stepper's, a slider's, a date picker's label) go where the plan put them, after the rows (which do not draw them).
4292
+ if (columns) {
4293
+ for (const label of plan.labels ?? []) {
4294
+ if (label.node) this.place(label.node, label.ctx, { width: null, height: null }, x + label.rect.x, y + label.rect.y);
3619
4295
  }
3620
4296
  }
3621
4297
  }
@@ -3682,11 +4358,32 @@ export class LayoutPass {
3682
4358
  // area. `.bottomBar` hidden: the bottom toolbars draw nothing.
3683
4359
  const barHidden = node.props['navigationBarHidden'] === true;
3684
4360
  const bottomBarHidden = node.props['bottomBarHidden'] === true;
4361
+ const parts = this.navScreenParts(node);
4362
+ // The Mac idiom (MAC_* above): the title bar in place of the bar, unified
4363
+ // (52) when anything but the title is in it, plain (28) otherwise; no
4364
+ // large titles. The status bar portion is the window's title bar, which
4365
+ // the unified bar takes over; the row is the whole bar.
4366
+ const mac = this.env.platform === 'macos';
4367
+ const hasBarItems = parts.toolbars.some((t) => {
4368
+ const placement = str(t.props['placement']);
4369
+ return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
4370
+ });
4371
+ const searchInBar = mac && parts.search !== null && !barHidden;
4372
+ // A Mac tab view's toolbar row (the window's top): the tab's root screen
4373
+ // draws its bar as that row, without a title (the tab picker is there).
4374
+ const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
4375
+ // A Mac sheet's root screen: the sheet's 40 title bar, its text actions in the sheet's bottom bar.
4376
+ const sheetBar = mac && depth === 0 && cctx.macSheet !== undefined ? cctx.macSheet : undefined;
4377
+ const actionToolbars = sheetBar?.actions ? this.macSheetActionToolbars(node) : [];
4378
+ const unified = mac && tabToolbar === undefined && sheetBar === undefined && (depth > 0 || hasBarItems || searchInBar || (cctx.barLeading ?? 0) > 0 || cctx.splitColumn === true);
4379
+ const rowHeight = mac ? (tabToolbar ? tabToolbar.height : sheetBar ? MAC_SHEET_TITLE_BAR : unified ? MAC_TITLE_BAR_UNIFIED : MAC_TITLE_BAR_PLAIN) : NAV_BAR_HEIGHT;
4380
+ const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
4381
+ const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
4382
+ const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
3685
4383
  // A compact height (a phone on its side) has no large titles, whatever the
3686
4384
  // display mode, as UIKit: the title is the bar's inline one.
3687
- const large = !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
3688
- const barHeight = barHidden ? statusBar : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3689
- const parts = this.navScreenParts(node);
4385
+ const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
4386
+ const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3690
4387
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3691
4388
  const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3692
4389
  const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
@@ -3712,9 +4409,9 @@ export class LayoutPass {
3712
4409
  : 0;
3713
4410
  // In a tab view (a tab bar covers the bottom) the field goes under the
3714
4411
  // title, as iOS 26 places it; elsewhere it floats at the bottom.
3715
- const searchOnTop = parts.search !== null && cctx.bottomBar > 0;
4412
+ const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
3716
4413
  const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
3717
- const searchBottom = parts.search && !searchOnTop ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
4414
+ const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3718
4415
  // The on-screen keyboard (device mode) covers the screen's bottom. A
3719
4416
  // bottom search field rises above it, as iOS 26 docks it on the
3720
4417
  // keyboard, so what is typed into it can be seen; the bars stay put,
@@ -3726,20 +4423,26 @@ export class LayoutPass {
3726
4423
  const keyboard = keyboardBarShown ? KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_GAP + (keyboardAlone > 0 ? keyboardAlone : reachesBottom ? safeArea.bottom : 0) : keyboardAlone;
3727
4424
  const searchLift = searchBottom > 0 ? keyboard : 0;
3728
4425
  const contentTop = barHeight + largeHeight + searchRow;
4426
+ const actionsBar = sheetBar?.actions ? sheetBar.actions.height : 0;
3729
4427
  const content: Rect = {
3730
4428
  x,
3731
4429
  y: y + contentTop,
3732
4430
  width: size.width,
3733
- height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
4431
+ height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom) - actionsBar),
3734
4432
  };
3735
4433
  const chrome: Record<string, Rect> = { content };
3736
4434
  if (!barHidden) {
3737
4435
  chrome['bar'] = { x: barX, y, width: barWidth, height: barHeight };
3738
- // The 44 row the bar's items sit in, under the status bar.
3739
- chrome['barRow'] = { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT };
4436
+ // The 44 row the bar's items sit in, under the status bar (on a Mac the
4437
+ // title bar's row, which ends at the bar's bottom).
4438
+ chrome['barRow'] = { x, y: mac ? y + barHeight - rowHeight : y + statusBar, width: size.width, height: rowHeight };
4439
+ // The page styles a unified title bar's title (leading-aligned, larger).
4440
+ if (unified) chrome['unified'] = chrome['barRow'];
4441
+ // The page styles a Mac sheet's title (leading-aligned, 13 bold).
4442
+ if (sheetBar) chrome['sheetBar'] = chrome['barRow'];
3740
4443
  }
3741
4444
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
3742
- if (parts.search) {
4445
+ if (parts.search && !searchInBar) {
3743
4446
  const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
3744
4447
  chrome['searchField'] = {
3745
4448
  x: x + marginH,
@@ -3758,12 +4461,19 @@ export class LayoutPass {
3758
4461
  chrome['keyboardBar'] = { x: x + KEYBOARD_BAR_MARGIN_H, y: y + size.height - keyboard, width: Math.max(0, size.width - 2 * KEYBOARD_BAR_MARGIN_H), height: KEYBOARD_BAR_HEIGHT };
3759
4462
  }
3760
4463
  const title = str(node.props['title']) ?? '';
3761
- const titleFont = makeFont('system', textStyleSize('headline', dynamicTypeSize), 600);
3762
- const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize), 700);
3763
- const backFont = makeFont('system', textStyleSize('body', dynamicTypeSize), 400);
4464
+ // `.navigationSubtitle`: a second line under the title in a Mac's unified
4465
+ // title bar (11 regular, secondary); after the title in a plain one.
4466
+ const { platform } = this.env;
4467
+ const subtitle = mac ? (str(node.props['subtitle']) ?? '') : '';
4468
+ const shownTitle = subtitle.length > 0 && !unified && !sheetBar ? `${title} — ${subtitle}` : title;
4469
+ const subtitleFont = makeFont('system', textStyleSize('subheadline', dynamicTypeSize, platform), 400);
4470
+ // A unified title bar's title is the window title (15 semibold on macOS 14); a plain bar's and iOS's is the headline.
4471
+ const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
4472
+ const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
4473
+ const backFont = makeFont('system', textStyleSize('body', dynamicTypeSize, platform), 400);
3764
4474
 
3765
4475
  // Toolbar items at their ideal size, grouped per toolbar.
3766
- const rowY = y + statusBar;
4476
+ const rowY = (chrome['barRow'] ?? { y: y + statusBar }).y;
3767
4477
  interface Group {
3768
4478
  toolbar: LayoutNode;
3769
4479
  placement: string;
@@ -3779,15 +4489,34 @@ export class LayoutPass {
3779
4489
  ? []
3780
4490
  : parts.toolbars.filter((t) => {
3781
4491
  const placement = placementOf(t);
3782
- return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
4492
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t) && !actionToolbars.includes(t);
4493
+ });
4494
+ // A Mac sheet's actions: 22 push buttons at the trailing end of the
4495
+ // sheet's bottom bar, the trailing placement's (confirm) last, 8 apart.
4496
+ if (sheetBar?.actions) {
4497
+ const actions = sheetBar.actions;
4498
+ let cursor = actions.x + actions.width - MAC_SHEET_ACTIONS_INSET;
4499
+ const ordered = [...actionToolbars.filter((t) => placementOf(t) === 'trailing'), ...actionToolbars.filter((t) => placementOf(t) === 'leading')];
4500
+ for (const toolbar of ordered) {
4501
+ const itemCtx: Context = { ...this.childContext(toolbar, cctx), navBarItem: false };
4502
+ let first = cursor;
4503
+ let last = cursor;
4504
+ [...toolbar.children].reverse().forEach((item) => {
4505
+ const itemSize = this.measure(item, itemCtx, { width: null, height: null });
4506
+ this.place(item, itemCtx, { width: null, height: null }, cursor - itemSize.width, actions.y + (actions.height - itemSize.height) / 2);
4507
+ first = cursor - itemSize.width;
4508
+ cursor = first - MAC_SHEET_ACTIONS_GAP;
3783
4509
  });
4510
+ this.setFrame(toolbar, first, actions.y, { width: Math.max(0, last - first), height: actions.height });
4511
+ }
4512
+ }
3784
4513
  const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
3785
4514
  const groups: Group[] = barToolbars.map((toolbar) => {
3786
4515
  const itemCtx = this.childContext(toolbar, cctx);
3787
4516
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
3788
4517
  const placement = str(toolbar.props['placement']) ?? 'trailing';
3789
4518
  if (placement === 'leading' || placement === 'trailing') {
3790
- const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
4519
+ const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
3791
4520
  return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
3792
4521
  }
3793
4522
  const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
@@ -3811,7 +4540,7 @@ export class LayoutPass {
3811
4540
  const runWidth = (run: Run): number => {
3812
4541
  const slots = run.groups.flatMap((g) => g.slots ?? []);
3813
4542
  if (slots.length === 0) return 0;
3814
- const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * NAV_RUN_GAP;
4543
+ const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
3815
4544
  return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
3816
4545
  };
3817
4546
  for (const toolbar of parts.toolbars) {
@@ -3868,7 +4597,7 @@ export class LayoutPass {
3868
4597
  const itemCtx = this.childContext(toolbar, cctx);
3869
4598
  return toolbar.children.map((item) => {
3870
4599
  const itemSize = this.measure(item, itemCtx, { width: null, height: null });
3871
- return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
4600
+ return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width, glass, glassPad) };
3872
4601
  });
3873
4602
  });
3874
4603
  // Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
@@ -3895,26 +4624,38 @@ export class LayoutPass {
3895
4624
  }
3896
4625
  return out;
3897
4626
  };
3898
- inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - NAV_GLASS_SIZE - NAV_GROUP_SPACING);
4627
+ inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - NAV_GROUP_SPACING);
3899
4628
  }
3900
4629
  const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
3901
4630
 
3902
4631
  // Back button, iOS 26: the chevron alone in a glass circle (the previous
3903
4632
  // title no longer shows).
3904
4633
  const backLabel = '';
3905
- const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
4634
+ // A Mac window's own title bar (the top of the screen, a title bar in the
4635
+ // safe area) starts with the window buttons; the items follow them.
4636
+ const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
4637
+ const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
4638
+ const glassY = rowY + (rowHeight - glass) / 2;
3906
4639
  if (depth > 0 && !barHidden) {
3907
- chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
4640
+ chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
3908
4641
  }
3909
4642
 
3910
4643
  // Place the toolbar groups and remember how much of the row they occupy.
3911
4644
  const back = chrome['back'];
3912
4645
  let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
3913
- let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
4646
+ let trailingEdge = x + size.width - (mac ? (tabToolbar ? MAC_TAB_TOOLBAR_MARGIN_TRAILING : MAC_BAR_MARGIN_TRAILING) : NAV_ITEM_MARGIN);
4647
+ // On a Mac the search field is the trailing-most item of the title bar
4648
+ // (chrome `searchBar` tells the page to draw it as the toolbar's field).
4649
+ if (searchInBar) {
4650
+ const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
4651
+ chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_SEARCH_FIELD_HEIGHT) / 2, width, height: MAC_SEARCH_FIELD_HEIGHT };
4652
+ chrome['searchBar'] = chrome['searchField'];
4653
+ trailingEdge -= width + NAV_GROUP_SPACING;
4654
+ }
3914
4655
  // The More button is the trailing-most item.
3915
4656
  if (collapsedSecondary.length > 0) {
3916
- chrome['more'] = { x: trailingEdge - NAV_GLASS_SIZE, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
3917
- trailingEdge -= NAV_GLASS_SIZE + NAV_GROUP_SPACING;
4657
+ chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
4658
+ trailingEdge -= glass + NAV_GROUP_SPACING;
3918
4659
  }
3919
4660
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
3920
4661
  const itemCtx = this.childContext(group.toolbar, cctx);
@@ -3937,7 +4678,7 @@ export class LayoutPass {
3937
4678
  const placeRun = (run: Run, startX: number): void => {
3938
4679
  const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
3939
4680
  if (!run.bare) {
3940
- chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: rowY, width: Math.max(0, run.width - 2 * margin), height: NAV_BAR_HEIGHT };
4681
+ chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
3941
4682
  }
3942
4683
  const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
3943
4684
  let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
@@ -3947,11 +4688,17 @@ export class LayoutPass {
3947
4688
  group.toolbar.children.forEach((item, i) => {
3948
4689
  const itemSize = group.sizes[i] as Size;
3949
4690
  const slot = group.slots?.[i] ?? itemSize.width;
3950
- this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (NAV_BAR_HEIGHT - itemSize.height) / 2);
3951
- cursor += slot + NAV_RUN_GAP;
4691
+ this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
4692
+ // A Mac toolbar's text button is a bordered 28 button at its slot
4693
+ // (chrome `bordered`, drawn by the page at that rect); an icon is bare.
4694
+ if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
4695
+ const existing = this.chrome.get(item.id);
4696
+ this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
4697
+ }
4698
+ cursor += slot + runGap;
3952
4699
  });
3953
- const last = group.toolbar.children.length > 0 ? cursor - NAV_RUN_GAP : first;
3954
- this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
4700
+ const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
4701
+ this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
3955
4702
  }
3956
4703
  };
3957
4704
  for (const run of runs.filter((r) => r.placement === 'leading')) {
@@ -3970,8 +4717,8 @@ export class LayoutPass {
3970
4717
  for (const group of groups) {
3971
4718
  if (group.placement !== 'principal') continue;
3972
4719
  const startX = x + (size.width - group.total) / 2;
3973
- this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
3974
- placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
4720
+ this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: rowHeight });
4721
+ placeGroup(group, startX, rowY, rowHeight, NAV_ITEM_SPACING);
3975
4722
  }
3976
4723
  // The inline secondary items, right-aligned before the trailing groups,
3977
4724
  // each toolbar framed around its own; the collapsed ones are hidden (the
@@ -3983,12 +4730,12 @@ export class LayoutPass {
3983
4730
  for (const [i, entry] of inlineSecondary.entries()) {
3984
4731
  cursor += secondaryGap(inlineSecondary[i - 1], entry);
3985
4732
  const itemCtx = this.childContext(entry.toolbar, cctx);
3986
- this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (NAV_BAR_HEIGHT - entry.size.height) / 2);
4733
+ this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (rowHeight - entry.size.height) / 2);
3987
4734
  const extent = extents.get(entry.toolbar);
3988
4735
  extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
3989
4736
  cursor += entry.slot;
3990
4737
  }
3991
- for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: NAV_BAR_HEIGHT });
4738
+ for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
3992
4739
  trailingEdge -= total + NAV_GROUP_SPACING;
3993
4740
  }
3994
4741
  for (const toolbar of secondaryToolbars) {
@@ -4046,7 +4793,28 @@ export class LayoutPass {
4046
4793
  const titleScrolls = large && scroller !== null;
4047
4794
  // A large title that scrolls away collapses into the inline title, which
4048
4795
  // the renderer shows once it has (`data-sb-collapsed`).
4049
- if ((!large || titleScrolls) && !principal && !barHidden) {
4796
+ if (tabToolbar) {
4797
+ // The tab picker stands where the title would be.
4798
+ } else if ((unified || sheetBar) && !principal && !barHidden) {
4799
+ // The unified title bar's title (and a Mac sheet's): leading-aligned
4800
+ // after the back button and the leading items (2 past the window
4801
+ // buttons' room when nothing is before it), cut to the room before
4802
+ // the trailing ones.
4803
+ const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
4804
+ const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
4805
+ const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
4806
+ const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
4807
+ if (subtitle.length > 0) {
4808
+ // The title and the subtitle stacked, centered in the row.
4809
+ const block = titleFont.lineHeight + subtitleFont.lineHeight;
4810
+ const top = rowY + (rowHeight - block) / 2;
4811
+ chrome['title'] = { x: titleX, y: top, width: titleWidth, height: titleFont.lineHeight };
4812
+ const subtitleWidth = Math.min(Math.ceil(this.measurer.measureWidth(subtitle, subtitleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
4813
+ chrome['subtitle'] = { x: titleX, y: top + titleFont.lineHeight, width: subtitleWidth, height: subtitleFont.lineHeight };
4814
+ } else {
4815
+ chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4816
+ }
4817
+ } else if ((!large || titleScrolls) && !principal && !barHidden) {
4050
4818
  const leftUsed = Math.max(
4051
4819
  back ? back.x + back.width - x : 0,
4052
4820
  leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
@@ -4061,21 +4829,22 @@ export class LayoutPass {
4061
4829
  const roomEnd = x + size.width - rightUsed - 8;
4062
4830
  const compactCap = screen.width < REGULAR_WIDTH ? Math.floor(size.width * 0.56) : Infinity;
4063
4831
  const titleWidth = Math.min(
4064
- Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001),
4832
+ Math.ceil(this.measurer.measureWidth(shownTitle, titleFont) - 0.001),
4065
4833
  compactCap,
4066
4834
  Math.floor(Math.max(0, roomEnd - roomStart)),
4067
4835
  );
4068
4836
  const centered = x + (size.width - titleWidth) / 2;
4069
4837
  const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
4070
- chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
4838
+ chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4071
4839
  }
4072
4840
  this.setChrome(node, chrome);
4073
4841
 
4074
4842
  if (node.id >= 0) {
4075
4843
  this.fonts.set(node.id, large ? largeTitleFont : titleFont);
4076
4844
  const texts: Record<string, TextLayout> = {
4077
- title: { font: titleFont, lines: [title], alignment: 'center', truncated: false },
4845
+ title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
4078
4846
  };
4847
+ if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
4079
4848
  if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
4080
4849
  if (large) {
4081
4850
  const wrapped = wrapText(title, largeTitleFont, size.width - 32, 1, this.measurer);
@@ -4324,6 +5093,10 @@ export class LayoutPass {
4324
5093
  */
4325
5094
  private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
4326
5095
  const { screen } = this.env;
5096
+ if (this.env.platform === 'macos') {
5097
+ this.placeMacTabView(node, cctx, x, y, size);
5098
+ return;
5099
+ }
4327
5100
  const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
4328
5101
  const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
4329
5102
  const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
@@ -4363,6 +5136,64 @@ export class LayoutPass {
4363
5136
  });
4364
5137
  }
4365
5138
 
5139
+ /**
5140
+ * The Mac idiom's tab view (MAC_TAB_* above): the bar is a segmented
5141
+ * control centered in a 44 row at the top of the content, below the
5142
+ * window's title bar when the tab view is at the top of the screen; the
5143
+ * content is the rest, and the selected tab fills it (its own `navstack`
5144
+ * reserves no status bar there: the row is above it). Chrome `top` marks
5145
+ * the bar as the top one for the page.
5146
+ */
5147
+ private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
5148
+ const { safeArea } = this.env;
5149
+ // At the top of the window the row is the window's toolbar (38, the
5150
+ // title bar included); lower down, a 38 row at the top of the content.
5151
+ const atTop = y <= 0 && safeArea.top > 0;
5152
+ const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
5153
+ const tabs = node.children;
5154
+ const n = tabs.length;
5155
+ // Each segment is its title (13 regular) and 12 either side.
5156
+ const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
5157
+ const itemWidths = tabs.map((tab) => this.measurer.measureWidth(str(tab.props['title']) ?? '', font) + 2 * MAC_TAB_ITEM_PAD_H);
5158
+ const natural = itemWidths.reduce((sum, w) => sum + w, 0) + 2 * MAC_TAB_ITEM_INSET;
5159
+ const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
5160
+ const scale = natural > 0 ? width / natural : 1;
5161
+ const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height - topInset));
5162
+ const bar: Rect = { x: x + (size.width - width) / 2, y: y + topInset + (rowHeight - MAC_TAB_BAR_HEIGHT) / 2, width, height: Math.min(MAC_TAB_BAR_HEIGHT, rowHeight) };
5163
+ const toolbar: Rect = { x, y: y + topInset, width: size.width, height: rowHeight };
5164
+ const contentTop = topInset + rowHeight;
5165
+ const content: Rect = { x, y: y + contentTop, width: size.width, height: Math.max(0, size.height - contentTop) };
5166
+ const chrome: Record<string, Rect> = { bar, content, top: bar };
5167
+ let itemX = bar.x + MAC_TAB_ITEM_INSET;
5168
+ for (let i = 0; i < n; i++) {
5169
+ const itemWidth = (itemWidths[i] as number) * scale;
5170
+ chrome[`item${i}`] = { x: itemX, y: bar.y + MAC_TAB_ITEM_INSET, width: itemWidth, height: Math.max(0, bar.height - 2 * MAC_TAB_ITEM_INSET) };
5171
+ itemX += itemWidth;
5172
+ }
5173
+ this.setChrome(node, chrome);
5174
+ const selected = readSelected(node, n);
5175
+ const tabCtx: Context = { ...cctx, bottomEdge: undefined, barAbove: undefined };
5176
+ const contentSize: Size = { width: content.width, height: content.height };
5177
+ tabs.forEach((tab, i) => {
5178
+ if (i !== selected) {
5179
+ this.setFrame(tab, content.x, content.y, contentSize);
5180
+ this.hide(tab, node);
5181
+ return;
5182
+ }
5183
+ const child = this.contentOf(tab);
5184
+ // A navigation stack at the window's top fills the tab: its root
5185
+ // screen draws the toolbar row as its bar (`tabToolbar`), with the
5186
+ // picker over it; anything else starts under the row.
5187
+ if (atTop && child && unwrapStyled(child).kind === 'navstack') {
5188
+ this.setFrame(tab, x, y, size);
5189
+ this.placeTabContent(tab, this.childContext(tab, { ...tabCtx, tabToolbar: toolbar }), x, y, size, 0);
5190
+ return;
5191
+ }
5192
+ this.setFrame(tab, content.x, content.y, contentSize);
5193
+ this.placeTabContent(tab, this.childContext(tab, tabCtx), content.x, content.y, contentSize, 0);
5194
+ });
5195
+ }
5196
+
4366
5197
  /**
4367
5198
  * The selected tab's content, like a navscreen's: a `navstack` fills the
4368
5199
  * whole tab (it reserves the status bar itself and its lists run under the
@@ -4430,7 +5261,7 @@ export class LayoutPass {
4430
5261
  const leading = x <= 0 ? safeArea.leading : 0;
4431
5262
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
4432
5263
  const usable = Math.max(0, size.width - leading - trailing);
4433
- const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
5264
+ const sidebarWidth = Math.min(this.env.platform === 'macos' ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
4434
5265
  const visible = node.props['sidebarVisible'] !== false;
4435
5266
  // Hidden, the sidebar and its separator sit just off the leading edge.
4436
5267
  const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
@@ -4438,22 +5269,29 @@ export class LayoutPass {
4438
5269
  const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
4439
5270
  const detailWidth = Math.max(0, x + size.width - trailing - detailX);
4440
5271
  const statusBar = y <= 0 ? safeArea.top : 0;
5272
+ // On a Mac the toggle is a 28 button centered in the unified title bar's
5273
+ // row (which the columns' screens lay out: `placeNavScreen`), after the
5274
+ // window buttons when the split view is at the top of the screen.
5275
+ const mac = this.env.platform === 'macos';
5276
+ const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
5277
+ const toggleX = x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
5278
+ const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
4441
5279
  const chrome: Record<string, Rect> = {
4442
5280
  sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
4443
5281
  separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
4444
5282
  detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
4445
- toggle: { x: x + leading + NAV_ITEM_MARGIN, y: y + statusBar, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE },
5283
+ toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
4446
5284
  };
4447
5285
  this.setChrome(node, chrome);
4448
- const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
5286
+ const toggleRoom = toggleSize + NAV_GROUP_SPACING;
4449
5287
  const [sidebar, detail, ...rest] = node.children;
4450
5288
  if (sidebar) {
4451
5289
  const proposal: Proposal = { width: sidebarWidth, height: size.height };
4452
- this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
5290
+ this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
4453
5291
  }
4454
5292
  if (detail) {
4455
5293
  const proposal: Proposal = { width: detailWidth, height: size.height };
4456
- this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
5294
+ this.place(detail, { ...cctx, splitSidebar: false, splitColumn: true, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
4457
5295
  }
4458
5296
  for (const extra of rest) this.hide(extra, node);
4459
5297
  }
@@ -4577,16 +5415,31 @@ export class LayoutPass {
4577
5415
  }
4578
5416
 
4579
5417
  private stepperLabelProposal(p: Proposal): Proposal {
4580
- return { width: shrink(p.width, STEPPER_WIDTH + STEPPER_GAP), height: p.height };
5418
+ const control = this.stepperMetrics();
5419
+ return { width: shrink(p.width, control.width + control.gap), height: p.height };
4581
5420
  }
4582
5421
 
4583
5422
  /** Like a toggle: the label leads, the 94 × 32 control trails; at least 32 high. */
5423
+ /** The Mac idiom: a menu outside a bar and a list row is a pull-down button. */
5424
+ private menuIsPullDown(ctx: Context): boolean {
5425
+ return this.env.platform === 'macos' && !ctx.listRow && !ctx.navBarItem && !ctx.bottomBarItem;
5426
+ }
5427
+
5428
+ /** The stepper's control: iOS's segmented −/+, or the Mac's 13 × 22 arrows. */
5429
+ private stepperMetrics(): { width: number; height: number; gap: number; mac: boolean } {
5430
+ if (this.env.platform === 'macos') return { width: MAC_STEPPER_WIDTH, height: MAC_STEPPER_HEIGHT, gap: MAC_STEPPER_GAP, mac: true };
5431
+ return { width: STEPPER_WIDTH, height: STEPPER_HEIGHT, gap: STEPPER_GAP, mac: false };
5432
+ }
5433
+
4584
5434
  private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
4585
5435
  const label = this.contentOf(node);
5436
+ const control = this.stepperMetrics();
5437
+ // A Mac form's row: the label is in the label column; the control alone.
5438
+ if (ctx.formColumn) return { width: control.width, height: control.height };
4586
5439
  const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
4587
5440
  return {
4588
- width: finite(p.width) ? p.width : labelSize.width + STEPPER_GAP + STEPPER_WIDTH,
4589
- height: Math.max(labelSize.height, STEPPER_HEIGHT),
5441
+ width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
5442
+ height: Math.max(labelSize.height, control.height),
4590
5443
  };
4591
5444
  }
4592
5445
 
@@ -4594,24 +5447,32 @@ export class LayoutPass {
4594
5447
  const cctx = this.childContext(node, ctx);
4595
5448
  const label = this.contentOf(node);
4596
5449
  const labelProposal = this.stepperLabelProposal(p);
4597
- const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
5450
+ const labelSize = label && !ctx.formColumn ? this.measure(label, cctx, labelProposal) : ZERO;
4598
5451
  const labelY = y + (size.height - labelSize.height) / 2;
4599
- if (label) this.place(label, cctx, labelProposal, x, labelY);
4600
- const controlX = x + size.width - STEPPER_WIDTH;
4601
- const controlY = y + (size.height - STEPPER_HEIGHT) / 2;
4602
- const half = STEPPER_WIDTH / 2;
5452
+ // A Mac form's row: the label is in the label column (the form places it), the control at the leading edge.
5453
+ if (label && !ctx.formColumn) this.place(label, cctx, labelProposal, x, labelY);
5454
+ const control = this.stepperMetrics();
5455
+ const controlX = ctx.formColumn ? x : x + size.width - control.width;
5456
+ const controlY = y + (size.height - control.height) / 2;
4603
5457
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
5458
+ // iOS: − and + side by side; a Mac: up (increment) over down.
5459
+ const half = control.mac ? control.height / 2 : control.width / 2;
4604
5460
  this.setChrome(node, {
4605
5461
  ...existing,
4606
5462
  label: {
4607
5463
  x,
4608
5464
  y: labelY,
4609
- width: Math.min(labelSize.width, Math.max(0, size.width - STEPPER_WIDTH - STEPPER_GAP)),
5465
+ width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
4610
5466
  height: labelSize.height,
4611
5467
  },
4612
- control: { x: controlX, y: controlY, width: STEPPER_WIDTH, height: STEPPER_HEIGHT },
4613
- decrement: { x: controlX, y: controlY, width: half, height: STEPPER_HEIGHT },
4614
- increment: { x: controlX + half, y: controlY, width: half, height: STEPPER_HEIGHT },
5468
+ control: { x: controlX, y: controlY, width: control.width, height: control.height },
5469
+ decrement: control.mac
5470
+ ? { x: controlX, y: controlY + half, width: control.width, height: half }
5471
+ : { x: controlX, y: controlY, width: half, height: control.height },
5472
+ increment: control.mac
5473
+ ? { x: controlX, y: controlY, width: control.width, height: half }
5474
+ : { x: controlX + half, y: controlY, width: half, height: control.height },
5475
+ ...(control.mac ? { arrows: { x: controlX, y: controlY, width: control.width, height: control.height } } : {}),
4615
5476
  });
4616
5477
  }
4617
5478
 
@@ -4622,6 +5483,12 @@ export class LayoutPass {
4622
5483
  * names the slider for VoiceOver): the page hides it and names the slider
4623
5484
  * from it (`aria-labelledby`). Children are named by `labels` in order.
4624
5485
  */
5486
+ /** The slider's row, track and knob: iOS 26's pill knob, or the Mac's round one. */
5487
+ private sliderMetrics(): { height: number; track: number; thumb: number; thumbHeight: number; mac: boolean } {
5488
+ if (this.env.platform === 'macos') return { height: MAC_SLIDER_HEIGHT, track: MAC_SLIDER_TRACK_HEIGHT, thumb: MAC_SLIDER_THUMB, thumbHeight: MAC_SLIDER_THUMB, mac: true };
5489
+ return { height: SLIDER_HEIGHT, track: SLIDER_TRACK_HEIGHT, thumb: SLIDER_THUMB, thumbHeight: SLIDER_THUMB_HEIGHT, mac: false };
5490
+ }
5491
+
4625
5492
  private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
4626
5493
  return this.plan(node, ctx, p, () => {
4627
5494
  const width = finite(p.width) ? p.width : SLIDER_DEFAULT_WIDTH;
@@ -4638,43 +5505,47 @@ export class LayoutPass {
4638
5505
  });
4639
5506
  const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
4640
5507
  const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
4641
- return { size: { width, height: SLIDER_HEIGHT }, label, min, max, minSize, maxSize, cctx };
5508
+ return { size: { width, height: this.sliderMetrics().height }, label, min, max, minSize, maxSize, cctx };
4642
5509
  });
4643
5510
  }
4644
5511
 
4645
5512
  private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
4646
5513
  const plan = this.sliderPlan(node, ctx, p);
4647
5514
  const { cctx } = plan;
4648
- if (plan.label) this.hide(plan.label, node);
5515
+ // The label is not drawn (a Mac form places it in its label column).
5516
+ if (plan.label && !ctx.formColumn) this.hide(plan.label, node);
5517
+ const m = this.sliderMetrics();
4649
5518
  const rowY = y;
4650
5519
  let trackX = x;
4651
5520
  let trackEnd = x + size.width;
4652
5521
  if (plan.min) {
4653
- this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (SLIDER_HEIGHT - plan.minSize.height) / 2);
5522
+ this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
4654
5523
  trackX += plan.minSize.width + SLIDER_LABEL_GAP;
4655
5524
  }
4656
5525
  if (plan.max) {
4657
- this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (SLIDER_HEIGHT - plan.maxSize.height) / 2);
5526
+ this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (m.height - plan.maxSize.height) / 2);
4658
5527
  trackEnd -= plan.maxSize.width + SLIDER_LABEL_GAP;
4659
5528
  }
4660
5529
  const trackWidth = Math.max(0, trackEnd - trackX);
4661
- const trackY = rowY + (SLIDER_HEIGHT - SLIDER_TRACK_HEIGHT) / 2;
5530
+ const trackY = rowY + (m.height - m.track) / 2;
4662
5531
  const fraction = sliderFraction(num(node.props['value']) ?? 0, num(node.props['min']) ?? 0, num(node.props['max']) ?? 1);
4663
5532
  // The thumb travels inside the track: its center runs from half a thumb
4664
5533
  // past the track's start to half a thumb before its end.
4665
- const travel = Math.max(0, trackWidth - SLIDER_THUMB);
4666
- const thumbCenter = trackX + SLIDER_THUMB / 2 + fraction * travel;
5534
+ const travel = Math.max(0, trackWidth - m.thumb);
5535
+ const thumbCenter = trackX + m.thumb / 2 + fraction * travel;
4667
5536
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
4668
5537
  this.setChrome(node, {
4669
5538
  ...existing,
4670
- track: { x: trackX, y: trackY, width: trackWidth, height: SLIDER_TRACK_HEIGHT },
4671
- fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: SLIDER_TRACK_HEIGHT },
5539
+ track: { x: trackX, y: trackY, width: trackWidth, height: m.track },
5540
+ fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: m.track },
4672
5541
  thumb: {
4673
- x: thumbCenter - SLIDER_THUMB / 2,
4674
- y: rowY + (SLIDER_HEIGHT - SLIDER_THUMB_HEIGHT) / 2,
4675
- width: SLIDER_THUMB,
4676
- height: SLIDER_THUMB_HEIGHT,
5542
+ x: thumbCenter - m.thumb / 2,
5543
+ y: rowY + (m.height - m.thumbHeight) / 2,
5544
+ width: m.thumb,
5545
+ height: m.thumbHeight,
4677
5546
  },
5547
+ // The page draws the Mac's round knob (styles.css).
5548
+ ...(m.mac ? { knob: { x: thumbCenter - m.thumb / 2, y: rowY + (m.height - m.thumbHeight) / 2, width: m.thumb, height: m.thumbHeight } } : {}),
4678
5549
  });
4679
5550
  }
4680
5551
 
@@ -4691,17 +5562,21 @@ export class LayoutPass {
4691
5562
  const timeZone = str(node.props['timeZone']) ?? undefined;
4692
5563
  const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds, timeZone);
4693
5564
  const timeText = components === 'date' ? null : formatPickerTime(seconds, timeZone);
5565
+ const mac = this.env.platform === 'macos';
5566
+ const capsulePad = mac ? MAC_DATEPICKER_CAPSULE_PAD_H : DATEPICKER_CAPSULE_PAD_H;
5567
+ const capsuleGap = mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP;
4694
5568
  const capsule = (text: string | null): number =>
4695
- text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * DATEPICKER_CAPSULE_PAD_H;
5569
+ text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * capsulePad;
4696
5570
  const dateWidth = capsule(dateText);
4697
5571
  const timeWidth = capsule(timeText);
4698
- const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? DATEPICKER_CAPSULE_GAP : 0);
4699
- const label = node.props['labelsHidden'] === true ? null : this.contentOf(node);
5572
+ const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
5573
+ // A Mac form's row: the label is in the label column; the fields alone.
5574
+ const label = node.props['labelsHidden'] === true || ctx.formColumn ? null : this.contentOf(node);
4700
5575
  const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
4701
5576
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
4702
- const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
5577
+ const width = ctx.formColumn ? capsulesWidth : finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
4703
5578
  return {
4704
- size: { width, height: Math.max(labelSize.height, DATEPICKER_HEIGHT) },
5579
+ size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
4705
5580
  label,
4706
5581
  labelProposal,
4707
5582
  labelSize,
@@ -4715,13 +5590,15 @@ export class LayoutPass {
4715
5590
 
4716
5591
  private placeDatePicker(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
4717
5592
  const plan = this.datePickerPlan(node, ctx, p);
4718
- if (!plan.label) {
4719
- // `labelsHidden`: the label child exists but is not shown.
5593
+ if (!plan.label && (!ctx.formColumn || node.props['labelsHidden'] === true)) {
5594
+ // `labelsHidden`: the label child exists but is not shown (a Mac form places it in its label column).
4720
5595
  for (const child of node.children) this.hide(child, node);
4721
5596
  }
4722
5597
  const labelY = y + (size.height - plan.labelSize.height) / 2;
4723
5598
  if (plan.label) this.place(plan.label, plan.cctx, plan.labelProposal, x, labelY);
4724
- const capsuleY = y + (size.height - DATEPICKER_HEIGHT) / 2;
5599
+ const mac = this.env.platform === 'macos';
5600
+ const capsuleHeight = mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT;
5601
+ const capsuleY = y + (size.height - capsuleHeight) / 2;
4725
5602
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
4726
5603
  const chrome: Record<string, Rect> = {
4727
5604
  ...existing,
@@ -4734,12 +5611,14 @@ export class LayoutPass {
4734
5611
  };
4735
5612
  let capsuleX = x + size.width - plan.capsulesWidth;
4736
5613
  if (plan.dateWidth > 0) {
4737
- chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: DATEPICKER_HEIGHT };
4738
- capsuleX += plan.dateWidth + DATEPICKER_CAPSULE_GAP;
5614
+ chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: capsuleHeight };
5615
+ capsuleX += plan.dateWidth + (mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP);
4739
5616
  }
4740
5617
  if (plan.timeWidth > 0) {
4741
- chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: DATEPICKER_HEIGHT };
5618
+ chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: capsuleHeight };
4742
5619
  }
5620
+ // The page draws the Mac's bordered fields (styles.css).
5621
+ if (mac) chrome['fields'] = { x: x + size.width - plan.capsulesWidth, y: capsuleY, width: plan.capsulesWidth, height: capsuleHeight };
4743
5622
  this.setChrome(node, chrome);
4744
5623
  }
4745
5624
 
@@ -4762,13 +5641,15 @@ export class LayoutPass {
4762
5641
  const chrome: Record<string, Rect> = {};
4763
5642
  const texts: Record<string, TextLayout> = {};
4764
5643
  const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
5644
+ // The alert's metrics: iOS's card, or the Mac's panel (`data-sb-panel` for the page).
5645
+ const m = this.env.platform === 'macos' ? MAC_ALERT_METRICS : ALERT_METRICS;
4765
5646
  // `textfield` children (`.alert { TextField(...); Button(...) }`) go in
4766
5647
  // the card between the message and the buttons, in their order.
4767
5648
  const fields = node.children.filter((child) => unwrapStyled(child).kind === 'textfield');
4768
5649
  const buttons = node.children.filter((child) => !fields.includes(child));
4769
5650
  const placeFields = (top: number, left: number, width: number): void => {
4770
5651
  fields.forEach((field, i) => {
4771
- const rect: Rect = { x: left, y: top + i * (ALERT_FIELD_HEIGHT + ALERT_FIELD_SPACING), width, height: ALERT_FIELD_HEIGHT };
5652
+ const rect: Rect = { x: left, y: top + i * (m.fieldHeight + m.fieldSpacing), width, height: m.fieldHeight };
4772
5653
  chrome[`field${i}`] = rect;
4773
5654
  const proposal: Proposal = { width: rect.width, height: rect.height };
4774
5655
  const fieldSize = this.measure(field, cctx, proposal);
@@ -4776,7 +5657,7 @@ export class LayoutPass {
4776
5657
  this.setFrame(field, rect.x, rect.y, { width: rect.width, height: rect.height });
4777
5658
  });
4778
5659
  };
4779
- const fieldsHeight = fields.length > 0 ? fields.length * ALERT_FIELD_HEIGHT + (fields.length - 1) * ALERT_FIELD_SPACING : 0;
5660
+ const fieldsHeight = fields.length > 0 ? fields.length * m.fieldHeight + (fields.length - 1) * m.fieldSpacing : 0;
4780
5661
 
4781
5662
  const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
4782
5663
  const wrapped = wrapText(text, font, width, null, this.measurer);
@@ -4788,62 +5669,63 @@ export class LayoutPass {
4788
5669
  const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
4789
5670
 
4790
5671
  if (style === 'alert') {
4791
- const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
4792
- const textWidth = cardWidth - 2 * ALERT_PAD_H;
5672
+ const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
5673
+ const textWidth = cardWidth - 2 * m.padH;
4793
5674
  const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
4794
5675
  const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
4795
5676
  // Heights first (the card is centered), then the rects.
4796
- let h = ALERT_PAD_TOP;
5677
+ let h = m.padTop;
4797
5678
  let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
4798
5679
  let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
4799
5680
  if (title.length > 0) {
4800
- const b = block(title, titleFont, textWidth, 'leading');
5681
+ const b = block(title, titleFont, textWidth, m.textAlignment);
4801
5682
  titleBlock = { ...b, top: h };
4802
5683
  h += b.height;
4803
5684
  }
4804
5685
  if (message.length > 0) {
4805
- if (titleBlock) h += ALERT_TITLE_GAP;
4806
- const b = block(message, messageFont, textWidth, 'leading');
5686
+ if (titleBlock) h += m.titleGap;
5687
+ const b = block(message, messageFont, textWidth, m.textAlignment);
4807
5688
  messageBlock = { ...b, top: h };
4808
5689
  h += b.height;
4809
5690
  }
4810
5691
  let fieldsTop: number | null = null;
4811
5692
  if (fields.length > 0) {
4812
- if (titleBlock || messageBlock) h += ALERT_FIELDS_GAP;
5693
+ if (titleBlock || messageBlock) h += m.fieldsGap;
4813
5694
  fieldsTop = h;
4814
5695
  h += fieldsHeight;
4815
5696
  }
4816
- h += ALERT_BUTTONS_GAP;
5697
+ h += m.buttonsGap;
4817
5698
  const headerHeight = h;
4818
5699
  const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
4819
- h += rows * ALERT_BUTTON_HEIGHT + (rows - 1) * ALERT_BUTTON_SPACING + ALERT_PAD_BOTTOM;
5700
+ h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
4820
5701
  // Centered in the safe area, as iOS does.
4821
5702
  const { safeArea: inset } = this.env;
4822
5703
  const top = y + (y <= 0 ? inset.top : 0);
4823
5704
  const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
4824
5705
  const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
4825
5706
  chrome['card'] = card;
5707
+ if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
4826
5708
  if (titleBlock) {
4827
- chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
5709
+ chrome['title'] = { x: card.x + m.padH, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
4828
5710
  texts['title'] = titleBlock.layout;
4829
5711
  }
4830
5712
  if (messageBlock) {
4831
- chrome['message'] = { x: card.x + ALERT_PAD_H, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
5713
+ chrome['message'] = { x: card.x + m.padH, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
4832
5714
  texts['message'] = messageBlock.layout;
4833
5715
  }
4834
5716
  const buttonsTop = card.y + headerHeight;
4835
- const buttonsX = card.x + ALERT_BUTTON_INSET;
4836
- const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
5717
+ const buttonsX = card.x + m.buttonInset;
5718
+ const buttonsWidth = Math.max(0, cardWidth - 2 * m.buttonInset);
4837
5719
  if (fieldsTop !== null) placeFields(card.y + fieldsTop, buttonsX, buttonsWidth);
4838
5720
  if (buttons.length === 0) {
4839
- chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
5721
+ chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: m.buttonHeight };
4840
5722
  } else if (buttons.length === 2) {
4841
5723
  // A `cancel` button goes first (leading), as iOS orders them.
4842
5724
  const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
4843
- const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
5725
+ const half = (buttonsWidth - m.rowSpacing) / 2;
4844
5726
  ordered.forEach((button, slot) => {
4845
5727
  const i = buttons.indexOf(button as LayoutNode);
4846
- const rect: Rect = { x: buttonsX + slot * (half + ALERT_BUTTON_SPACING), y: buttonsTop, width: half, height: ALERT_BUTTON_HEIGHT };
5728
+ const rect: Rect = { x: buttonsX + slot * (half + m.rowSpacing), y: buttonsTop, width: half, height: m.buttonHeight };
4847
5729
  chrome[`button${i}`] = rect;
4848
5730
  this.placeAlertButton(button as LayoutNode, cctx, rect);
4849
5731
  });
@@ -4851,9 +5733,9 @@ export class LayoutPass {
4851
5733
  buttons.forEach((button, i) => {
4852
5734
  const rect: Rect = {
4853
5735
  x: buttonsX,
4854
- y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
5736
+ y: buttonsTop + i * (m.buttonHeight + m.buttonSpacing),
4855
5737
  width: buttonsWidth,
4856
- height: ALERT_BUTTON_HEIGHT,
5738
+ height: m.buttonHeight,
4857
5739
  };
4858
5740
  chrome[`button${i}`] = rect;
4859
5741
  this.placeAlertButton(button, cctx, rect);
@@ -4905,6 +5787,7 @@ export class LayoutPass {
4905
5787
  }
4906
5788
  const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
4907
5789
  chrome['card'] = card;
5790
+ if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
4908
5791
  if (titleBlock) {
4909
5792
  chrome['title'] = { x: card.x + DIALOG_HEADER_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
4910
5793
  texts['title'] = titleBlock.layout;
@@ -4994,6 +5877,17 @@ export class LayoutPass {
4994
5877
  const covered = Math.max(0, y + size.height - (this.env.screen.height - this.keyboard));
4995
5878
  const kind = unwrapStyled(child).kind;
4996
5879
  if (kind === 'navstack') {
5880
+ // A Mac sheet: its stack draws the sheet's title bar and puts its text
5881
+ // actions in the bottom bar (chrome `actions`, which the page draws).
5882
+ if (this.env.platform === 'macos' && this.sheetIsForm(node)) {
5883
+ const screen = this.macSheetScreen(node);
5884
+ const actions: Rect | null = screen && this.macSheetActionToolbars(screen).length > 0
5885
+ ? { x, y: y + size.height - MAC_SHEET_ACTIONS_BAR, width: size.width, height: MAC_SHEET_ACTIONS_BAR }
5886
+ : null;
5887
+ if (actions) this.setChrome(node, { ...chrome, actions });
5888
+ this.place(child, { ...cctx, barInset: 0, macSheet: { actions } }, { width: size.width, height: size.height }, x, y);
5889
+ return;
5890
+ }
4997
5891
  this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
4998
5892
  return;
4999
5893
  }