@swiftbrowser/web 0.26.1 → 0.26.3

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,26 @@ 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 };
101
123
  /**
102
124
  * Phase 8: the y (screen coordinates) where content clears the bars above
103
125
  * it: the top safe area at the root, the bottom of a `navscreen`'s bar (and
@@ -214,6 +236,12 @@ const LIST_SECTION_GAP = 107 / 3;
214
236
  // Below a section's footer the gap to the next section is 11 shorter.
215
237
  const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
216
238
  const LIST_CHEVRON_WIDTH = 20;
239
+ /**
240
+ * A `disclosuregroup` among a list's rows is an outline (`outlineRows`
241
+ * below): each level's rows start this much further in than the level
242
+ * above. SwiftUI's value, as yet unmeasured against the simulator.
243
+ */
244
+ const LIST_OUTLINE_INDENT = 20;
217
245
  // Phase 4.
218
246
  // iOS 26: the tab bar is a 62-tall glass capsule floating 21 above the bottom
219
247
  // edge (8 when the tab view ends above the screen bottom), its items in equal
@@ -285,6 +313,126 @@ const NAV_GLASS_PAD_H = 12;
285
313
  const NAV_GROUP_SPACING = 12;
286
314
  const NAV_RUN_GAP = 12;
287
315
  const NAV_RUN_PAD = 1;
316
+ // The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
317
+ // `macos`): the window's title bar stands in for the navigation bar. A 52
318
+ // unified title bar holds the back button, the bar items and the title,
319
+ // leading-aligned, as SwiftUI on macOS 14 lays a NavigationStack's toolbar
320
+ // out; a window with nothing in its bar but the title has the plain 28 one,
321
+ // the title centered. There are no large titles and no glass: the items
322
+ // are 28 tall, in slots 8 either side, 8 apart. On the screen's own window
323
+ // (the top of the screen, when the safe area carries a title bar) the three
324
+ // window buttons take the first 80 of the bar. Every number here is a
325
+ // first pass, to be measured against the real app (the Mac fidelity
326
+ // harness, stage 3).
327
+ const MAC_TITLE_BAR_UNIFIED = 52;
328
+ const MAC_TITLE_BAR_PLAIN = 28;
329
+ const MAC_BAR_ITEM_SIZE = 28;
330
+ const MAC_BAR_ITEM_PAD_H = 6;
331
+ const MAC_BAR_RUN_GAP = 10;
332
+ /** The window buttons' room: the first item's slot starts 90 in; a title with no item before it 2 further. */
333
+ const MAC_WINDOW_BUTTONS_ROOM = 90;
334
+ const MAC_TITLE_INSET = 2;
335
+ /** A Mac bar's trailing items end 13 before the window's edge; a tab view's toolbar's 7. */
336
+ const MAC_BAR_MARGIN_TRAILING = 13;
337
+ const MAC_TAB_TOOLBAR_MARGIN_TRAILING = 7;
338
+ // A Mac tab view's bar is a segmented control at the top of the content,
339
+ // centered in a 44 row: 24 tall, each tab a 96 segment inside a 1 inset, at
340
+ // most the width less 20 either side.
341
+ const MAC_TAB_ROW_HEIGHT = 38;
342
+ const MAC_TAB_BAR_HEIGHT = 22;
343
+ /** A tab picker's segment: its title (13 regular) and 12 either side. */
344
+ const MAC_TAB_ITEM_PAD_H = 12;
345
+ const MAC_TAB_ITEM_INSET = 1;
346
+ const MAC_TAB_BAR_MARGIN_H = 20;
347
+ // Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
348
+ // either side of its label, and every `automatic` button outside a list row
349
+ // and a bar is bordered; a checkbox is 14 square, its label 6 after it, and a
350
+ // toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
351
+ // pop-up button is 22 tall (24 in a row), its value in a bordered button
352
+ // with 8 either side.
353
+ const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
354
+ regular: { padV: 3, padH: 10, minHeight: 22 },
355
+ mini: { padV: 1, padH: 6, minHeight: 16, textStyle: 'caption' },
356
+ small: { padV: 2, padH: 8, minHeight: 19, textStyle: 'subheadline' },
357
+ large: { padV: 5, padH: 14, minHeight: 28 },
358
+ extraLarge: { padV: 7, padH: 18, minHeight: 34 },
359
+ };
360
+ const MAC_CHECKBOX_SIZE = 14;
361
+ const MAC_CHECKBOX_GAP = 6;
362
+ const MAC_PICKER_MENU_HEIGHT = 22;
363
+ // A Mac's segmented control is 22 tall with 13-point regular text, its
364
+ // label (which iOS drops) 10 before it.
365
+ const MAC_SEGMENTED_HEIGHT = 22;
366
+ const MAC_SEGMENTED_FONT_SIZE = 13;
367
+ const MAC_SEGMENTED_LABEL_GAP = 10;
368
+ const MAC_PICKER_MENU_ROW_HEIGHT = 24;
369
+ const MAC_PICKER_POPUP_PAD = 8;
370
+ // Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
371
+ // rows full width with 16 beside their content, at least 24 tall (28 in a
372
+ // sidebar) with 4 (6) above and below their content, a bordered button's 3;
373
+ // every row has a separator under it. A section's header (11 bold) starts
374
+ // 7 below the top, 5 into the 22 between sections after the first; a rule
375
+ // across the whole list sits 8 under it and the rows 10 under the rule. A
376
+ // footer (11) sits 8 under the last row, with a rule 5 under it. A
377
+ // sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
378
+ const MAC_LIST_ROW_MIN_HEIGHT = 24;
379
+ const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 28;
380
+ const MAC_LIST_ROW_VPAD = 4;
381
+ const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
382
+ const MAC_LIST_BUTTON_ROW_VPAD = 3;
383
+ const MAC_LIST_CELL_PADDING = 16;
384
+ /** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
385
+ const MAC_LIST_LABEL_GAP = 6;
386
+ const MAC_LIST_INSET_SIDEBAR = 10;
387
+ const MAC_LIST_TOP = 6;
388
+ const MAC_LIST_BOTTOM = 8;
389
+ const MAC_LIST_SECTION_GAP = 22;
390
+ const MAC_LIST_HEADER_TOP = 4;
391
+ /** Under a Mac header: 8 to its rule and the rule; the first section's rows start 10 further down. */
392
+ const MAC_LIST_HEADER_GAP = 9;
393
+ const MAC_LIST_FIRST_ROWS_GAP = 10;
394
+ const MAC_LIST_FOOTER_GAP = 8;
395
+ /** Under a Mac footer: 5 to its rule, the rule. */
396
+ const MAC_LIST_FOOTER_BOTTOM = 6;
397
+ const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
398
+ // A Mac's search field is in the toolbar, at its trailing end: 22 tall, 180
399
+ // wide (less when the bar is narrow), 16 from the edge. A sheet is a
400
+ // window-modal panel that drops from the title bar, 480 × 400 at most,
401
+ // centered, with no grabber.
402
+ const MAC_SEARCH_FIELD_WIDTH = 180;
403
+ const MAC_SEARCH_FIELD_HEIGHT = 22;
404
+ // A Mac's sheet (measured in the Mac app): a 470 panel centered in the
405
+ // window, its 40 title bar (the title 13 bold at 16) over the content at its
406
+ // ideal height, and a 60 bar at the bottom with the text actions (cancel,
407
+ // confirm) as 22 push buttons at its trailing end, 16 in and 8 apart; at
408
+ // least 40 clear of the window's edges.
409
+ const MAC_SHEET_WIDTH = 470;
410
+ const MAC_SHEET_TITLE_BAR = 40;
411
+ const MAC_SHEET_ACTIONS_BAR = 60;
412
+ const MAC_SHEET_ACTIONS_INSET = 16;
413
+ const MAC_SHEET_ACTIONS_GAP = 8;
414
+ const MAC_SHEET_MARGIN = 40;
415
+ // A Mac's text field is 22 tall whatever its style; a stepper's control is
416
+ // the 13 × 22 pair of arrows (up over down) 4 after the label; an alert is
417
+ // a 260 panel, its title and message centered 16 in, its buttons 22-tall
418
+ // push buttons 16 in, side by side 12 apart for two, stacked 8 apart otherwise.
419
+ const MAC_TEXTFIELD_HEIGHT = 22;
420
+ const MAC_STEPPER_WIDTH = 13;
421
+ const MAC_STEPPER_HEIGHT = 22;
422
+ const MAC_STEPPER_GAP = 4;
423
+ // A Mac's slider is a 4 track with a 20 round knob in a 20 row; a compact
424
+ // date picker's date and time are 22-tall bordered fields 6 apart with 6
425
+ // beside their text; a menu outside a bar or a list row is a pull-down
426
+ // button, 22 tall with 10 beside its label and the chevron's 16 after it.
427
+ const MAC_SLIDER_HEIGHT = 20;
428
+ const MAC_SLIDER_TRACK_HEIGHT = 4;
429
+ const MAC_SLIDER_THUMB = 20;
430
+ const MAC_DATEPICKER_HEIGHT = 22;
431
+ const MAC_DATEPICKER_CAPSULE_PAD_H = 6;
432
+ const MAC_DATEPICKER_CAPSULE_GAP = 6;
433
+ const MAC_PULLDOWN_HEIGHT = 22;
434
+ const MAC_PULLDOWN_PAD_H = 10;
435
+ const MAC_PULLDOWN_CHEVRON = 16;
288
436
 
289
437
  /**
290
438
  * The children an item draws in the bar: a menu or context menu draws its
@@ -309,11 +457,11 @@ function isSymbolItem(item: LayoutNode): boolean {
309
457
  return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
310
458
  }
311
459
 
312
- /** A bar item's slot in its glass capsule. */
313
- function navSlot(item: LayoutNode, width: number): number {
460
+ /** A bar item's slot in its glass capsule (`glass` square for a symbol, else its width and `pad` either side, at least `glass`). */
461
+ function navSlot(item: LayoutNode, width: number, glass = NAV_GLASS_SIZE, pad = NAV_GLASS_PAD_H): number {
314
462
  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);
463
+ if (isSymbolItem(item) && width <= glass) return glass;
464
+ return Math.max(glass, width + 2 * pad);
317
465
  }
318
466
  // iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
319
467
  // bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
@@ -457,7 +605,8 @@ const TEXTEDITOR_INSET = { horizontal: 5, vertical: 8 };
457
605
  * `alert` style (iOS 26): a 320 wide card centered in the safe area, its
458
606
  * title and message leading-aligned 30 in, its buttons 46 tall capsules 16
459
607
  * in from the card's sides, side by side 8 apart for two, stacked 8 apart
460
- * otherwise.
608
+ * otherwise. `ALERT_METRICS` gathers them for `placeAlert`; `MAC_ALERT_METRICS`
609
+ * is the Mac's panel (MAC_* above).
461
610
  */
462
611
  const ALERT_WIDTH = 320;
463
612
  const ALERT_PAD_TOP = 21;
@@ -473,6 +622,58 @@ const ALERT_BUTTON_HEIGHT = 46;
473
622
  const ALERT_FIELD_HEIGHT = 36;
474
623
  const ALERT_FIELD_SPACING = 8;
475
624
  const ALERT_FIELDS_GAP = 16;
625
+ interface AlertMetrics {
626
+ width: number;
627
+ padTop: number;
628
+ padH: number;
629
+ buttonInset: number;
630
+ buttonsGap: number;
631
+ padBottom: number;
632
+ buttonSpacing: number;
633
+ rowSpacing: number;
634
+ titleGap: number;
635
+ buttonHeight: number;
636
+ fieldHeight: number;
637
+ fieldSpacing: number;
638
+ fieldsGap: number;
639
+ textAlignment: 'leading' | 'center';
640
+ }
641
+ const ALERT_METRICS: AlertMetrics = {
642
+ width: ALERT_WIDTH,
643
+ padTop: ALERT_PAD_TOP,
644
+ padH: ALERT_PAD_H,
645
+ buttonInset: ALERT_BUTTON_INSET,
646
+ buttonsGap: ALERT_BUTTONS_GAP,
647
+ padBottom: ALERT_PAD_BOTTOM,
648
+ buttonSpacing: ALERT_BUTTON_SPACING,
649
+ rowSpacing: ALERT_BUTTON_SPACING,
650
+ titleGap: ALERT_TITLE_GAP,
651
+ buttonHeight: ALERT_BUTTON_HEIGHT,
652
+ fieldHeight: ALERT_FIELD_HEIGHT,
653
+ fieldSpacing: ALERT_FIELD_SPACING,
654
+ fieldsGap: ALERT_FIELDS_GAP,
655
+ textAlignment: 'leading',
656
+ };
657
+ // Measured in the Mac app: the app icon (54, which the page draws as a
658
+ // placeholder) 25 under the top and 25 over the title, the message 9 under
659
+ // it, 28-tall buttons 17 under the message and 16 over the bottom, two
660
+ // side by side 8 apart.
661
+ const MAC_ALERT_METRICS: AlertMetrics = {
662
+ width: 260,
663
+ padTop: 104,
664
+ padH: 16,
665
+ buttonInset: 16,
666
+ buttonsGap: 17,
667
+ padBottom: 16,
668
+ buttonSpacing: 8,
669
+ rowSpacing: 8,
670
+ titleGap: 9,
671
+ buttonHeight: 28,
672
+ fieldHeight: 22,
673
+ fieldSpacing: 8,
674
+ fieldsGap: 12,
675
+ textAlignment: 'center',
676
+ };
476
677
  /** Gap between an action sheet's title and its message. */
477
678
  const DIALOG_TITLE_GAP = 4;
478
679
  /** An action sheet's button labels are inset this much from its rows. */
@@ -742,7 +943,8 @@ function cellStyle(node: LayoutNode, key: string): unknown {
742
943
  }
743
944
 
744
945
  /** The default vertical padding above and below a list row's content. */
745
- function listRowVPad(row: LayoutNode, sidebar: boolean): number {
946
+ function listRowVPad(row: LayoutNode, sidebar: boolean, mac: boolean): number {
947
+ if (mac) return row.kind === 'button' ? MAC_LIST_BUTTON_ROW_VPAD : sidebar ? MAC_LIST_SIDEBAR_ROW_VPAD : MAC_LIST_ROW_VPAD;
746
948
  switch (row.kind) {
747
949
  case 'picker':
748
950
  return str(row.props['style']) === 'segmented' ? LIST_ROW_SEGMENTED_VPAD : 0;
@@ -923,6 +1125,34 @@ function listRowOf(node: LayoutNode): LayoutNode | null {
923
1125
  return null;
924
1126
  }
925
1127
 
1128
+ /** A list's row as `outlineRows` names it: the view, how deep it sits, and the `disclosuregroup` it is the label of. */
1129
+ interface RowSource {
1130
+ node: LayoutNode;
1131
+ depth: number;
1132
+ group: LayoutNode | null;
1133
+ }
1134
+
1135
+ /**
1136
+ * The rows a section's children make. A `disclosuregroup` among them is an
1137
+ * outline, as SwiftUI lays one out in a `List`: its label (its first child)
1138
+ * is a row, with the group's chevron at the trailing edge, and while it is
1139
+ * expanded each of its content children is a row under it, a level deeper
1140
+ * (`LIST_OUTLINE_INDENT` further in), a nested group among them opening the
1141
+ * same way. Collapsed, its content is not laid out.
1142
+ */
1143
+ function outlineRows(nodes: readonly LayoutNode[], depth: number, out: RowSource[]): RowSource[] {
1144
+ for (const row of nodes) {
1145
+ if (row.kind === 'disclosuregroup') {
1146
+ const [label, ...content] = row.children;
1147
+ if (label) out.push({ node: label, depth, group: row });
1148
+ if (row.props['expanded'] === true) outlineRows(content, depth + 1, out);
1149
+ } else {
1150
+ out.push({ node: row, depth, group: null });
1151
+ }
1152
+ }
1153
+ return out;
1154
+ }
1155
+
926
1156
  /** A list row's `Label`: where its icon is centred, and where its title starts, past the row content's leading edge (iOS). */
927
1157
  const LIST_LABEL_ICON_CENTER = 12;
928
1158
  const LIST_LABEL_TITLE_X = 40;
@@ -1189,6 +1419,12 @@ interface ListRowPlan {
1189
1419
  edit: { listRow: LayoutNode; control: Rect | null; reorder: Rect | null } | null;
1190
1420
  /** `.listRowSeparator(.hidden)`: the row's separator edges left out. */
1191
1421
  separatorHidden?: { top: boolean; bottom: boolean } | null;
1422
+ /**
1423
+ * An outline's row (`outlineRows`): how deep it sits, and the
1424
+ * `disclosuregroup` it is the label of, when it is one (the group's
1425
+ * chevron and toggle go over this row).
1426
+ */
1427
+ outline?: { depth: number; group: LayoutNode | null } | null;
1192
1428
  }
1193
1429
 
1194
1430
  /** A section's view header or footer (`sectionheader` / `sectionfooter`) as the list places it. */
@@ -1209,6 +1445,8 @@ interface ListGroupPlan {
1209
1445
  footerView: ListSupplementPlan | null;
1210
1446
  card: Rect;
1211
1447
  rows: ListRowPlan[];
1448
+ /** Each `disclosuregroup` among the rows, with the rect its rows span (its label's, then its content's while expanded). */
1449
+ outlines: { group: LayoutNode; frame: Rect }[];
1212
1450
  }
1213
1451
 
1214
1452
  interface ListPlan {
@@ -1416,7 +1654,7 @@ export class LayoutPass {
1416
1654
  // Phase 8: an `alert` is a full-screen layer too (scrim + card).
1417
1655
  this.place(root, ctx, { width: screen.width, height: screen.height }, 0, 0);
1418
1656
  } else if (root.kind === 'sheet' && this.sheetIsForm(root)) {
1419
- const rect = this.formSheetRect();
1657
+ const rect = this.formSheetRect(root, ctx);
1420
1658
  this.place(root, ctx, { width: rect.width, height: rect.height }, rect.x, rect.y);
1421
1659
  } else if (root.kind === 'sheet') {
1422
1660
  const floats = this.sheetFloats(root);
@@ -1779,7 +2017,8 @@ export class LayoutPass {
1779
2017
  const style = str(node.props['style']);
1780
2018
  return {
1781
2019
  width: finite(p.width) ? p.width : TEXTFIELD_DEFAULT_WIDTH,
1782
- height: style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
2020
+ // A Mac's field is 22 tall in every style.
2021
+ height: this.env.platform === 'macos' ? MAC_TEXTFIELD_HEIGHT : style === 'plain' ? TEXTFIELD_HEIGHT_PLAIN : TEXTFIELD_HEIGHT_ROUNDED,
1783
2022
  };
1784
2023
  }
1785
2024
  case 'shape':
@@ -1824,7 +2063,10 @@ export class LayoutPass {
1824
2063
  // GeometryReader: the proposal, 10 on unconstrained axes.
1825
2064
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
1826
2065
  case 'sheet':
1827
- if (this.sheetIsForm(node)) return { width: p.width ?? this.formSheetRect().width, height: this.formSheetRect().height };
2066
+ if (this.sheetIsForm(node)) {
2067
+ const rect = this.formSheetRect(node, ctx);
2068
+ return { width: p.width ?? rect.width, height: rect.height };
2069
+ }
1828
2070
  return { width: p.width ?? this.env.screen.width, height: this.sheetHeight(node) };
1829
2071
  // Phase 8 controls.
1830
2072
  case 'alert':
@@ -1859,12 +2101,19 @@ export class LayoutPass {
1859
2101
  }
1860
2102
  case 'menu': {
1861
2103
  // Phase 6: the label (the first child) is the only laid-out child.
2104
+ // The Mac idiom: outside a bar and a list row the menu is a pull-down
2105
+ // button around its label.
1862
2106
  const label = node.children[0];
2107
+ if (this.menuIsPullDown(ctx)) {
2108
+ const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
2109
+ const labelSize = label ? this.measure(label, this.childContext(node, ctx), inner) : ZERO;
2110
+ return { width: labelSize.width + 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON, height: Math.max(MAC_PULLDOWN_HEIGHT, labelSize.height) };
2111
+ }
1863
2112
  return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
1864
2113
  }
1865
2114
  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 };
2115
+ // Phase 6: inside a navscreen the bar places it; elsewhere a 36pt field (22 on a Mac).
2116
+ return { width: finite(p.width) ? p.width : SEARCH_FIELD_DEFAULT_WIDTH, height: this.env.platform === 'macos' ? MAC_SEARCH_FIELD_HEIGHT : SEARCH_FIELD_HEIGHT };
1868
2117
  case 'swipeactions':
1869
2118
  case 'contextmenu': {
1870
2119
  // Phase 8: the content (the first child) alone; the actions / items
@@ -2051,14 +2300,17 @@ export class LayoutPass {
2051
2300
  rowChrome['badge'] = { ...badge, x: end - chevron.width - badge.width };
2052
2301
  }
2053
2302
 
2054
- private buttonIsBordered(node: LayoutNode): boolean {
2303
+ private buttonIsBordered(node: LayoutNode, ctx: Context): boolean {
2055
2304
  const style = str(node.props['style']);
2056
- return style === 'bordered' || style === 'borderedProminent' || style === 'glass';
2305
+ if (style === 'bordered' || style === 'borderedProminent' || style === 'glass') return true;
2306
+ // The Mac idiom: a button of the automatic style is a push button too
2307
+ // (in a list row as well), except as a bar item's content.
2308
+ return this.env.platform === 'macos' && (style === null || style === 'automatic') && !ctx.navBarItem && !ctx.bottomBarItem;
2057
2309
  }
2058
2310
 
2059
2311
  private measureButton(node: LayoutNode, ctx: Context, p: Proposal): Size {
2060
2312
  const label = this.contentOf(node);
2061
- if (!this.buttonIsBordered(node)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
2313
+ if (!this.buttonIsBordered(node, ctx)) return label ? this.measure(label, this.childContext(node, ctx), p) : ZERO;
2062
2314
  const cctx = this.borderedLabelContext(node, ctx);
2063
2315
  if (ctx.bottomBarItem) {
2064
2316
  // In a bottom bar (measured in the iOS 26.2 simulator, Native
@@ -2081,7 +2333,9 @@ export class LayoutPass {
2081
2333
  }
2082
2334
 
2083
2335
  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 };
2336
+ const controlSize = str(node.props['controlSize']) ?? 'regular';
2337
+ if (this.env.platform === 'macos') return MAC_BORDERED_SIZES[controlSize] ?? (MAC_BORDERED_SIZES['regular'] as { padV: number; padH: number; minHeight: number });
2338
+ return BORDERED_SIZES[controlSize] ?? { padV: BORDERED_PAD_V, padH: BORDERED_PAD_H, minHeight: BORDERED_MIN_HEIGHT };
2085
2339
  }
2086
2340
 
2087
2341
  /** A bordered button's label context: a small or mini one sets a smaller text style. */
@@ -2091,8 +2345,20 @@ export class LayoutPass {
2091
2345
  return textStyle ? { ...cctx, font: resolveFont({ textStyle }, cctx.font, this.env) } : cctx;
2092
2346
  }
2093
2347
 
2094
- private toggleLabelProposal(p: Proposal): Proposal {
2095
- return { width: shrink(p.width, SWITCH_WIDTH + TOGGLE_GAP), height: p.height };
2348
+ /**
2349
+ * A toggle's control: iOS's switch at the trailing edge; on a Mac a
2350
+ * checkbox before the label, or the small switch in a list row (a grouped
2351
+ * form's), at the trailing edge.
2352
+ */
2353
+ private switchMetrics(_ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
2354
+ if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
2355
+ // A Mac's toggle is a checkbox before its label, in a list row too.
2356
+ return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
2357
+ }
2358
+
2359
+ private toggleLabelProposal(ctx: Context, p: Proposal): Proposal {
2360
+ const control = this.switchMetrics(ctx);
2361
+ return { width: shrink(p.width, control.width + control.gap), height: p.height };
2096
2362
  }
2097
2363
 
2098
2364
  private measureToggle(node: LayoutNode, ctx: Context, p: Proposal): Size {
@@ -2107,21 +2373,24 @@ export class LayoutPass {
2107
2373
  return { width: bottomSlot(label ?? node, size) + BOTTOM_TOGGLE_GROWTH - BOTTOM_TOGGLE_INSET, height: BOTTOM_TOGGLE_HEIGHT };
2108
2374
  }
2109
2375
  // 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) };
2376
+ const m = this.borderedMetrics(node);
2377
+ const inner: Proposal = { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
2111
2378
  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) };
2379
+ return { width: size.width + 2 * m.padH, height: Math.max(m.minHeight, size.height + 2 * m.padV) };
2113
2380
  }
2381
+ const control = this.switchMetrics(ctx);
2114
2382
  const labelSize = label
2115
- ? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(p))
2383
+ ? this.measure(label, this.childContext(node, ctx), this.toggleLabelProposal(ctx, p))
2116
2384
  : ZERO;
2117
2385
  return {
2118
- width: finite(p.width) ? p.width : labelSize.width + TOGGLE_GAP + SWITCH_WIDTH,
2119
- height: Math.max(labelSize.height, SWITCH_HEIGHT),
2386
+ width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
2387
+ height: Math.max(labelSize.height, control.height),
2120
2388
  };
2121
2389
  }
2122
2390
 
2123
2391
  private navLinkLabelProposal(ctx: Context, p: Proposal): Proposal {
2124
- return ctx.listRow ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
2392
+ // A Mac's rows have no chevron.
2393
+ return ctx.listRow && this.env.platform !== 'macos' ? { width: shrink(p.width, LIST_CHEVRON_WIDTH), height: p.height } : p;
2125
2394
  }
2126
2395
 
2127
2396
  private measureNavLink(node: LayoutNode, ctx: Context, p: Proposal): Size {
@@ -2155,14 +2424,23 @@ export class LayoutPass {
2155
2424
  return Array.isArray(detents) && str(detents[0]) === 'full';
2156
2425
  }
2157
2426
 
2158
- /** iPadOS: a sheet in a regular width (not a full-screen cover) is a centered form sheet. */
2427
+ /** 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
2428
  private sheetIsForm(node: LayoutNode): boolean {
2160
- return this.env.screen.width >= REGULAR_WIDTH && !this.sheetIsFull(node);
2429
+ return (this.env.screen.width >= REGULAR_WIDTH || this.env.platform === 'macos') && !this.sheetIsFull(node);
2161
2430
  }
2162
2431
 
2163
- /** The form sheet's card: UIKit's size, clamped to the safe area less a margin, centered in it. */
2164
- private formSheetRect(): Rect {
2432
+ /**
2433
+ * The form sheet's card: UIKit's size, clamped to the safe area less a
2434
+ * margin, centered in it. On a Mac the panel is 470 wide, as tall as its
2435
+ * bars and its content's ideal height, centered in the window.
2436
+ */
2437
+ private formSheetRect(node: LayoutNode, ctx: Context): Rect {
2165
2438
  const { screen, safeArea } = this.env;
2439
+ if (this.env.platform === 'macos') {
2440
+ const width = Math.max(0, Math.min(MAC_SHEET_WIDTH, screen.width - 2 * MAC_SHEET_MARGIN));
2441
+ const height = Math.max(0, Math.min(this.macSheetHeight(node, ctx, width), screen.height - 2 * MAC_SHEET_MARGIN));
2442
+ return { x: Math.round((screen.width - width) / 2), y: Math.round((screen.height - height) / 2), width, height };
2443
+ }
2166
2444
  const top = safeArea.top + FORM_SHEET_MARGIN;
2167
2445
  const room = { width: screen.width - 2 * FORM_SHEET_MARGIN, height: screen.height - top - safeArea.bottom - FORM_SHEET_MARGIN };
2168
2446
  const width = Math.max(0, Math.min(FORM_SHEET.width, room.width));
@@ -2170,6 +2448,34 @@ export class LayoutPass {
2170
2448
  return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
2171
2449
  }
2172
2450
 
2451
+ /** 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). */
2452
+ private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
2453
+ return this.navScreenParts(screen).toolbars.filter((toolbar) => {
2454
+ const placement = str(toolbar.props['placement']);
2455
+ if ((placement !== 'leading' && placement !== 'trailing') || str(toolbar.props['spacer']) !== null) return false;
2456
+ return toolbar.children.length > 0 && toolbar.children.every((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button');
2457
+ });
2458
+ }
2459
+
2460
+ /** A Mac sheet's root screen, when its content is a navigation stack. */
2461
+ private macSheetScreen(node: LayoutNode): LayoutNode | null {
2462
+ const child = this.contentOf(node);
2463
+ if (!child) return null;
2464
+ const stack = unwrapStyled(child);
2465
+ if (stack.kind !== 'navstack') return null;
2466
+ return stack.children[stack.children.length - 1] ?? null;
2467
+ }
2468
+
2469
+ /** 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. */
2470
+ private macSheetHeight(node: LayoutNode, ctx: Context, width: number): number {
2471
+ const screen = this.macSheetScreen(node);
2472
+ const child = this.contentOf(node);
2473
+ if (!screen) return child ? this.measure(child, this.childContext(node, ctx), { width, height: null }).height : 0;
2474
+ const content = this.navScreenParts(screen).content;
2475
+ const ideal = content ? this.measure(content, this.childContext(screen, this.childContext(node, ctx)), { width, height: null }).height : 0;
2476
+ return MAC_SHEET_TITLE_BAR + ideal + (this.macSheetActionToolbars(screen).length > 0 ? MAC_SHEET_ACTIONS_BAR : 0);
2477
+ }
2478
+
2173
2479
  /** iOS 26: a sheet whose first detent is a partial one floats inset from the screen edges. */
2174
2480
  private sheetFloats(node: LayoutNode): boolean {
2175
2481
  if (this.sheetIsForm(node)) return false;
@@ -2239,16 +2545,20 @@ export class LayoutPass {
2239
2545
  };
2240
2546
  }
2241
2547
  if (style === 'segmented') {
2242
- const font = makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2548
+ const mac = this.env.platform === 'macos';
2549
+ const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2243
2550
  let natural = 0;
2244
2551
  for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
2552
+ // A Mac shows the picker's label before the control.
2553
+ const labelText = mac ? (str(node.props['label']) ?? '') : '';
2554
+ const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
2245
2555
  return {
2246
- size: { width: finite(p.width) ? p.width : natural, height: PICKER_SEGMENTED_HEIGHT },
2556
+ size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
2247
2557
  style: 'segmented',
2248
2558
  font,
2249
2559
  options,
2250
2560
  selected,
2251
- labelWidth: 0,
2561
+ labelWidth,
2252
2562
  valueWidth: 0,
2253
2563
  labelRow: false,
2254
2564
  };
@@ -2256,12 +2566,15 @@ export class LayoutPass {
2256
2566
  const font = ctx.font;
2257
2567
  const labelWidth = this.measurer.measureWidth(str(node.props['label']) ?? '', font);
2258
2568
  const value = options[selected] ?? '';
2259
- const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH;
2569
+ const mac = this.env.platform === 'macos';
2570
+ // The Mac's pop-up button holds the value and the chevrons in a bordered button.
2571
+ const popup = mac && !ctx.listRow;
2572
+ const valueWidth = this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
2260
2573
  const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
2261
2574
  return {
2262
2575
  size: {
2263
2576
  width: finite(p.width) ? p.width : natural,
2264
- height: ctx.listRow ? PICKER_MENU_ROW_HEIGHT : PICKER_MENU_HEIGHT,
2577
+ height: ctx.listRow ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
2265
2578
  },
2266
2579
  style: 'menu',
2267
2580
  font,
@@ -2286,9 +2599,19 @@ export class LayoutPass {
2286
2599
  const inset = plan.bar ? BAR_SEGMENTED_INSET : 0;
2287
2600
  if (plan.bar) chrome['bar'] = { x, y, width: size.width, height: size.height };
2288
2601
  else delete chrome['bar'];
2289
- const segmentWidth = Math.max(0, size.width - 2 * inset) / n;
2602
+ // The Mac's label before the control (`label`, `track` the control's own frame).
2603
+ const labelWidth = Math.min(plan.labelWidth, size.width);
2604
+ if (labelWidth > 0) {
2605
+ chrome['label'] = { x, y, width: labelWidth - MAC_SEGMENTED_LABEL_GAP, height: size.height };
2606
+ chrome['track'] = { x: x + labelWidth, y, width: Math.max(0, size.width - labelWidth), height: size.height };
2607
+ } else {
2608
+ delete chrome['label'];
2609
+ delete chrome['track'];
2610
+ }
2611
+ const trackX = x + labelWidth;
2612
+ const segmentWidth = Math.max(0, size.width - labelWidth - 2 * inset) / n;
2290
2613
  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) };
2614
+ chrome[`segment${i}`] = { x: trackX + inset + i * segmentWidth, y: y + inset, width: segmentWidth, height: Math.max(0, size.height - 2 * inset) };
2292
2615
  }
2293
2616
  } else if (plan.style === 'inline') {
2294
2617
  // Rows span the list row when there is one (the hit area and the
@@ -2324,6 +2647,9 @@ export class LayoutPass {
2324
2647
  valueWidth = Math.min(valueWidth, size.width);
2325
2648
  chrome['label'] = { x, y, width: labelWidth, height: size.height };
2326
2649
  chrome['value'] = { x: x + size.width - valueWidth, y, width: valueWidth, height: size.height };
2650
+ // The page draws the Mac's pop-up button around the value (the engine padded it).
2651
+ if (this.env.platform === 'macos' && !ctx.listRow) chrome['popup'] = chrome['value'];
2652
+ else delete chrome['popup'];
2327
2653
  }
2328
2654
  this.setChrome(node, chrome);
2329
2655
  if (node.id >= 0) this.fonts.set(node.id, plan.font);
@@ -2365,6 +2691,8 @@ export class LayoutPass {
2365
2691
  if (n < 2) return [];
2366
2692
  // A disclosure group's content sits 8 under its label, as SwiftUI's does.
2367
2693
  const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
2694
+ // A Mac list row's Label (no icon column there): the title 6 after the icon.
2695
+ if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
2368
2696
  if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
2369
2697
  const gaps: number[] = [];
2370
2698
  for (let i = 0; i < n - 1; i++) {
@@ -2629,10 +2957,13 @@ export class LayoutPass {
2629
2957
  return { width: p.width ?? width, height: p.height ?? height };
2630
2958
  }
2631
2959
  const name = str(node.props['name']);
2632
- if (name === null) return square(ctx.font.leading * ctx.imageScale);
2960
+ // A symbol takes a square of the font's leading; in a Mac list row, of
2961
+ // its point size (13 in a 13-point row, measured in the Mac app).
2962
+ const symbolSide = (ctx.listCell === true ? ctx.font.size : ctx.font.leading) * ctx.imageScale;
2963
+ if (name === null) return square(symbolSide);
2633
2964
  const asset = this.env.assets?.images[name];
2634
2965
  if (!asset) return square(UNKNOWN_IMAGE_SIDE);
2635
- if (asset.kind === 'symbol') return square(ctx.font.leading * ctx.imageScale);
2966
+ if (asset.kind === 'symbol') return square(symbolSide);
2636
2967
  const width = num(asset.width) ?? 0;
2637
2968
  const height = num(asset.height) ?? 0;
2638
2969
  if (node.props['resizable'] !== true) return { width, height };
@@ -2842,28 +3173,43 @@ export class LayoutPass {
2842
3173
  // (cards, chevrons, 52pt rows) and only its sections' folding remains.
2843
3174
  const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
2844
3175
  // `.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;
3176
+ // The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
3177
+ // 11-point bold headers, a sidebar's rows inset 10 and a grouped
3178
+ // form's cards 20.
3179
+ const mac = this.env.platform === 'macos';
3180
+ const minRowHeight =
3181
+ num(node.props['minRowHeight']) ??
3182
+ (sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
3183
+ // A Mac's lists have no cards: the rows run the whole width (a
3184
+ // sidebar's inset 10).
3185
+ const inset = mac
3186
+ ? listStyle === 'sidebar'
3187
+ ? MAC_LIST_INSET_SIDEBAR
3188
+ : 0
3189
+ : listStyle === 'insetGrouped' || listStyle === 'sidebar'
3190
+ ? LIST_INSET
3191
+ : 0;
2847
3192
  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;
3193
+ const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
3194
+ const listTop = plain ? 0 : mac ? MAC_LIST_TOP : LIST_TOP;
3195
+ const listBottom = plain ? 0 : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
3196
+ const sectionGap = plain ? 0 : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
3197
+ const headerGap = mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
2851
3198
  const cardX = inset;
2852
3199
  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));
3200
+ const contentX = inset + cellPadding;
3201
+ const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
2855
3202
  // 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
- );
3203
+ const headerFont = mac
3204
+ ? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
3205
+ : makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
2861
3206
  const footerFont = makeFont(
2862
3207
  'system',
2863
- textStyleSize('footnote', this.env.dynamicTypeSize),
3208
+ textStyleSize('footnote', this.env.dynamicTypeSize, this.env.platform),
2864
3209
  400,
2865
3210
  );
2866
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: true, sidebarList: str(node.props['style']) === 'sidebar' };
3211
+ // A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
3212
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: !mac, listCell: mac, sidebarList: str(node.props['style']) === 'sidebar' };
2867
3213
  // Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
2868
3214
  // leading control (6 in from the card's edge) and its content shifts
2869
3215
  // right by 40; a movable one a trailing grip, which narrows it by 40.
@@ -2871,20 +3217,20 @@ export class LayoutPass {
2871
3217
 
2872
3218
  // Group consecutive plain rows into an implicit section. A section's
2873
3219
  // view header and footer are not rows.
2874
- type GroupSource = { section: LayoutNode | null; rows: LayoutNode[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
3220
+ type GroupSource = { section: LayoutNode | null; rows: RowSource[]; headerView: LayoutNode | null; footerView: LayoutNode | null };
2875
3221
  const groupsSource: GroupSource[] = [];
2876
3222
  for (const child of node.children) {
2877
3223
  if (child.kind === 'section') {
2878
3224
  // `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
3225
  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');
3226
+ const rows = collapsed ? [] : outlineRows(child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter'), 0, []);
2881
3227
  const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
2882
3228
  const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
2883
3229
  groupsSource.push({ section: child, rows, headerView, footerView });
2884
3230
  } else {
2885
3231
  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 });
3232
+ if (last && last.section === null) outlineRows([child], 0, last.rows);
3233
+ else groupsSource.push({ section: null, rows: outlineRows([child], 0, []), headerView: null, footerView: null });
2888
3234
  }
2889
3235
  }
2890
3236
  // View headers and footers: the content width, the header's and the
@@ -2913,27 +3259,29 @@ export class LayoutPass {
2913
3259
  const previous = groups[g - 1];
2914
3260
  if (previous) {
2915
3261
  const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
2916
- y += plain ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
3262
+ y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
2917
3263
  }
2918
3264
  const top = y;
2919
3265
  let header: ListGroupPlan['header'] = null;
2920
3266
  let footer: ListGroupPlan['footer'] = null;
2921
3267
  // A header sits in the gap above its card (from LIST_HEADER_LIFT
2922
3268
  // into it); the card then starts LIST_HEADER_GAP below the header.
2923
- const headerLift = plain ? 0 : LIST_HEADER_LIFT;
3269
+ const headerLift = plain || mac ? 0 : LIST_HEADER_LIFT;
2924
3270
  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;
3271
+ if (headerView && headerView.rect.height > 0) y += headerView.rect.height + headerGap - headerLift;
2926
3272
  const headerText = source.section ? str(source.section.props['header']) : null;
2927
3273
  const footerText = source.section ? str(source.section.props['footer']) : null;
2928
3274
  if (headerText !== null && headerText.length > 0) {
2929
3275
  const { layout, size } = makeText(headerText, headerFont);
3276
+ if (mac && previous) y += MAC_LIST_HEADER_TOP;
2930
3277
  const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
2931
3278
  header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
2932
- y = headerY + size.height + LIST_HEADER_GAP;
3279
+ y = headerY + size.height + headerGap + (mac && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
2933
3280
  }
2934
3281
  const cardTop = y;
2935
3282
  const rows: ListRowPlan[] = [];
2936
- for (const rowNode of source.rows) {
3283
+ for (const rowSource of source.rows) {
3284
+ const rowNode = rowSource.node;
2937
3285
  if (rowNode.kind === 'lazyspacer') {
2938
3286
  // Lazy rows not built yet: a row of no height, without a cell.
2939
3287
  const rect: Rect = { x: cardX, y, width: cardWidth, height: 0 };
@@ -2946,13 +3294,19 @@ export class LayoutPass {
2946
3294
  // `listRowInsets` replaces all four insets; a `badge` reserves its
2947
3295
  // width (plus a gap) at the trailing edge.
2948
3296
  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
- };
3297
+ const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
3298
+ // An outline's rows start LIST_OUTLINE_INDENT further in per level.
3299
+ const indent = rowSource.depth * LIST_OUTLINE_INDENT;
3300
+ const insets: EdgeInsets = hints.insets
3301
+ ? { ...hints.insets, leading: hints.insets.leading + indent }
3302
+ : {
3303
+ top: defaultVPad,
3304
+ leading: cellPadding + indent,
3305
+ bottom: defaultVPad,
3306
+ trailing: cellPadding,
3307
+ };
3308
+ // 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).
3309
+ const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
2956
3310
  const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
2957
3311
  const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
2958
3312
  // Phase 8 edit mode: a selectable or deletable `listrow` gains a
@@ -2964,7 +3318,7 @@ export class LayoutPass {
2964
3318
  const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
2965
3319
  const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
2966
3320
  const rowContentX = cardX + insets.leading + editShift;
2967
- const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve);
3321
+ const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve);
2968
3322
  const proposal: Proposal = { width: rowContentWidth, height: null };
2969
3323
  const contentSize = this.measure(rowNode, rowCtx, proposal);
2970
3324
  const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
@@ -2997,15 +3351,27 @@ export class LayoutPass {
2997
3351
  }
2998
3352
  : null,
2999
3353
  separatorHidden: hints.separatorHidden,
3354
+ outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
3000
3355
  });
3001
3356
  y += rowHeight;
3002
3357
  }
3358
+ // 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.
3359
+ const outlines: ListGroupPlan['outlines'] = [];
3360
+ rows.forEach((row, i) => {
3361
+ const group = row.outline?.group;
3362
+ if (!group) return;
3363
+ const depth = row.outline?.depth ?? 0;
3364
+ let end = i;
3365
+ while (end + 1 < rows.length && (rows[end + 1]?.outline?.depth ?? 0) > depth) end += 1;
3366
+ const last = (rows[end] as ListRowPlan).row;
3367
+ outlines.push({ group, frame: { x: row.row.x, y: row.row.y, width: row.row.width, height: last.y + last.height - row.row.y } });
3368
+ });
3003
3369
  const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
3004
3370
  if (footerText !== null && footerText.length > 0) {
3005
- y += LIST_FOOTER_GAP;
3371
+ y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
3006
3372
  const { layout, size } = makeText(footerText, footerFont);
3007
3373
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
3008
- y += size.height;
3374
+ y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
3009
3375
  }
3010
3376
  let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
3011
3377
  if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
@@ -3019,6 +3385,7 @@ export class LayoutPass {
3019
3385
  footerView,
3020
3386
  card,
3021
3387
  rows,
3388
+ outlines,
3022
3389
  });
3023
3390
  }
3024
3391
  const contentHeight = groupsSource.length === 0 ? listTop : y + listBottom;
@@ -3177,7 +3544,7 @@ export class LayoutPass {
3177
3544
  case 'button': {
3178
3545
  const label = this.contentOf(node);
3179
3546
  if (!label) return;
3180
- if (!this.buttonIsBordered(node)) {
3547
+ if (!this.buttonIsBordered(node, ctx)) {
3181
3548
  this.place(label, cctx, p, x, y);
3182
3549
  return;
3183
3550
  }
@@ -3195,6 +3562,8 @@ export class LayoutPass {
3195
3562
  this.setChrome(node, {
3196
3563
  ...existing,
3197
3564
  label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height },
3565
+ // The page draws the border on an automatic button the engine bordered (the Mac idiom).
3566
+ bordered: { x, y, width: size.width, height: size.height },
3198
3567
  });
3199
3568
  return;
3200
3569
  }
@@ -3216,9 +3585,10 @@ export class LayoutPass {
3216
3585
  if (isButtonToggle(node)) {
3217
3586
  // the label centered in the button (its fill, while on, the toggle's frame)
3218
3587
  if (!label) return;
3588
+ const m = this.borderedMetrics(node);
3219
3589
  const inner: Proposal = ctx.bottomBarItem
3220
3590
  ? { width: null, height: null }
3221
- : { width: shrink(p.width, 2 * BORDERED_PAD_H), height: shrink(p.height, 2 * BORDERED_PAD_V) };
3591
+ : { width: shrink(p.width, 2 * m.padH), height: shrink(p.height, 2 * m.padV) };
3222
3592
  const labelSize = this.measure(label, cctx, inner);
3223
3593
  const { dx, dy } = alignIn(size, labelSize, 'center');
3224
3594
  this.place(label, cctx, inner, x + dx, y + dy);
@@ -3226,25 +3596,31 @@ export class LayoutPass {
3226
3596
  this.setChrome(node, { ...existing, label: { x: x + dx, y: y + dy, width: labelSize.width, height: labelSize.height } });
3227
3597
  return;
3228
3598
  }
3229
- const labelProposal = this.toggleLabelProposal(p);
3599
+ const control = this.switchMetrics(ctx);
3600
+ const labelProposal = this.toggleLabelProposal(ctx, p);
3230
3601
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
3231
3602
  const labelY = y + (size.height - labelSize.height) / 2;
3232
- if (label) this.place(label, cctx, labelProposal, x, labelY);
3603
+ // A checkbox leads its label; a switch trails it.
3604
+ const labelX = control.checkbox ? x + control.width + control.gap : x;
3605
+ if (label) this.place(label, cctx, labelProposal, labelX, labelY);
3233
3606
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
3607
+ const switchRect: Rect = {
3608
+ x: control.checkbox ? x : x + size.width - control.width,
3609
+ y: y + (size.height - control.height) / 2,
3610
+ width: control.width,
3611
+ height: control.height,
3612
+ };
3234
3613
  this.setChrome(node, {
3235
3614
  ...existing,
3236
3615
  label: {
3237
- x,
3616
+ x: labelX,
3238
3617
  y: labelY,
3239
- width: Math.min(labelSize.width, Math.max(0, size.width - SWITCH_WIDTH - TOGGLE_GAP)),
3618
+ width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
3240
3619
  height: labelSize.height,
3241
3620
  },
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
- },
3621
+ switch: switchRect,
3622
+ // The page draws a checkbox at it (the Mac idiom).
3623
+ ...(control.checkbox ? { checkbox: switchRect } : {}),
3248
3624
  });
3249
3625
  return;
3250
3626
  }
@@ -3263,8 +3639,8 @@ export class LayoutPass {
3263
3639
  // Phase 7: the selected row of a `navsplit` sidebar has no chevron
3264
3640
  // (its label still reserves the chevron's width so selecting a row
3265
3641
  // 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))) {
3642
+ // of a `sidebar`-style list has one (iPadOS), nor any row on a Mac.
3643
+ if (this.env.platform !== 'macos' && !(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
3268
3644
  chrome['chevron'] = {
3269
3645
  x: x + size.width - LIST_CHEVRON_WIDTH,
3270
3646
  y,
@@ -3425,7 +3801,16 @@ export class LayoutPass {
3425
3801
  // Phase 6: the label is placed; the items are only shown in the
3426
3802
  // popover the renderer opens, so they are hidden and have no frames.
3427
3803
  const [label, ...items] = node.children;
3428
- if (label) this.place(label, cctx, p, x, y);
3804
+ if (this.menuIsPullDown(ctx)) {
3805
+ // The Mac's pull-down button: the label 10 in, centered in the 22 row, the chevron after it.
3806
+ const inner: Proposal = { width: shrink(p.width, 2 * MAC_PULLDOWN_PAD_H + MAC_PULLDOWN_CHEVRON), height: shrink(p.height, 0) };
3807
+ const labelSize = label ? this.measure(label, cctx, inner) : ZERO;
3808
+ if (label) this.place(label, cctx, inner, x + MAC_PULLDOWN_PAD_H, y + (size.height - labelSize.height) / 2);
3809
+ const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
3810
+ this.setChrome(node, { ...existing, pulldown: { x, y, width: size.width, height: size.height } });
3811
+ } else if (label) {
3812
+ this.place(label, cctx, p, x, y);
3813
+ }
3429
3814
  for (const item of items) this.hide(item, node);
3430
3815
  return;
3431
3816
  }
@@ -3532,7 +3917,9 @@ export class LayoutPass {
3532
3917
 
3533
3918
  private placeList(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
3534
3919
  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' };
3920
+ const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
3921
+ const mac = this.env.platform === 'macos';
3922
+ const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
3536
3923
  const covered = this.coveredBottom(ctx, x, y, plan.size);
3537
3924
  // Under its screen's bar (and the large title that scrolls with it), the
3538
3925
  // rows start below them.
@@ -3568,7 +3955,7 @@ export class LayoutPass {
3568
3955
  if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
3569
3956
  }
3570
3957
  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));
3958
+ 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
3959
  if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
3573
3960
  }
3574
3961
  // A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
@@ -3578,6 +3965,25 @@ export class LayoutPass {
3578
3965
  }
3579
3966
  }
3580
3967
  }
3968
+ // An outline's `disclosuregroup` is not placed through `place` (its label
3969
+ // and content are the rows): its frame is the rect its rows span, which
3970
+ // is its box too (`row`, so the page lays its rows out from it), its
3971
+ // content is hidden while collapsed, and what a pass before hid is shown.
3972
+ for (const outline of group.outlines) {
3973
+ const g = outline.group;
3974
+ const previouslyHidden = this.hiddenBy.get(g);
3975
+ if (previouslyHidden) {
3976
+ for (const id of previouslyHidden) {
3977
+ this.hidden.delete(id);
3978
+ this.touched.add(id);
3979
+ }
3980
+ this.hiddenBy.delete(g);
3981
+ }
3982
+ const frame = offset(outline.frame);
3983
+ this.setFrame(g, frame.x, frame.y, frame);
3984
+ this.setChrome(g, { row: frame });
3985
+ if (g.props['expanded'] !== true) for (const child of g.children.slice(1)) this.hide(child, g);
3986
+ }
3581
3987
  for (const row of group.rows) {
3582
3988
  // Set the row chrome before placing so a navlink can merge its own keys.
3583
3989
  const chrome: Record<string, Rect> = { row: offset(row.row) };
@@ -3616,6 +4022,14 @@ export class LayoutPass {
3616
4022
  const leading = (textX ?? rowX) - (x + row.row.x);
3617
4023
  placed['separator'] = { x: Math.max(0, leading), y: 0, width: 0, height: 0 };
3618
4024
  }
4025
+ // 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).
4026
+ const outlineGroup = row.outline?.group;
4027
+ if (outlineGroup && outlineGroup.id >= 0) {
4028
+ const rowRect = offset(row.row);
4029
+ const groupChrome: Record<string, Rect> = { ...this.chrome.get(outlineGroup.id), toggle: rowRect };
4030
+ if (!mac) groupChrome['chevron'] = { x: rowRect.x + rowRect.width - cellPadding - LIST_CHEVRON_WIDTH, y: rowRect.y, width: LIST_CHEVRON_WIDTH, height: rowRect.height };
4031
+ this.setChrome(outlineGroup, groupChrome);
4032
+ }
3619
4033
  }
3620
4034
  }
3621
4035
  }
@@ -3682,11 +4096,32 @@ export class LayoutPass {
3682
4096
  // area. `.bottomBar` hidden: the bottom toolbars draw nothing.
3683
4097
  const barHidden = node.props['navigationBarHidden'] === true;
3684
4098
  const bottomBarHidden = node.props['bottomBarHidden'] === true;
4099
+ const parts = this.navScreenParts(node);
4100
+ // The Mac idiom (MAC_* above): the title bar in place of the bar, unified
4101
+ // (52) when anything but the title is in it, plain (28) otherwise; no
4102
+ // large titles. The status bar portion is the window's title bar, which
4103
+ // the unified bar takes over; the row is the whole bar.
4104
+ const mac = this.env.platform === 'macos';
4105
+ const hasBarItems = parts.toolbars.some((t) => {
4106
+ const placement = str(t.props['placement']);
4107
+ return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
4108
+ });
4109
+ const searchInBar = mac && parts.search !== null && !barHidden;
4110
+ // A Mac tab view's toolbar row (the window's top): the tab's root screen
4111
+ // draws its bar as that row, without a title (the tab picker is there).
4112
+ const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
4113
+ // A Mac sheet's root screen: the sheet's 40 title bar, its text actions in the sheet's bottom bar.
4114
+ const sheetBar = mac && depth === 0 && cctx.macSheet !== undefined ? cctx.macSheet : undefined;
4115
+ const actionToolbars = sheetBar?.actions ? this.macSheetActionToolbars(node) : [];
4116
+ const unified = mac && tabToolbar === undefined && sheetBar === undefined && (depth > 0 || hasBarItems || searchInBar || (cctx.barLeading ?? 0) > 0 || cctx.splitColumn === true);
4117
+ const rowHeight = mac ? (tabToolbar ? tabToolbar.height : sheetBar ? MAC_SHEET_TITLE_BAR : unified ? MAC_TITLE_BAR_UNIFIED : MAC_TITLE_BAR_PLAIN) : NAV_BAR_HEIGHT;
4118
+ const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
4119
+ const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
4120
+ const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
3685
4121
  // A compact height (a phone on its side) has no large titles, whatever the
3686
4122
  // 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);
4123
+ const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
4124
+ const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3690
4125
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3691
4126
  const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3692
4127
  const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
@@ -3712,9 +4147,9 @@ export class LayoutPass {
3712
4147
  : 0;
3713
4148
  // In a tab view (a tab bar covers the bottom) the field goes under the
3714
4149
  // title, as iOS 26 places it; elsewhere it floats at the bottom.
3715
- const searchOnTop = parts.search !== null && cctx.bottomBar > 0;
4150
+ const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
3716
4151
  const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
3717
- const searchBottom = parts.search && !searchOnTop ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
4152
+ const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3718
4153
  // The on-screen keyboard (device mode) covers the screen's bottom. A
3719
4154
  // bottom search field rises above it, as iOS 26 docks it on the
3720
4155
  // keyboard, so what is typed into it can be seen; the bars stay put,
@@ -3726,20 +4161,26 @@ export class LayoutPass {
3726
4161
  const keyboard = keyboardBarShown ? KEYBOARD_BAR_HEIGHT + KEYBOARD_BAR_GAP + (keyboardAlone > 0 ? keyboardAlone : reachesBottom ? safeArea.bottom : 0) : keyboardAlone;
3727
4162
  const searchLift = searchBottom > 0 ? keyboard : 0;
3728
4163
  const contentTop = barHeight + largeHeight + searchRow;
4164
+ const actionsBar = sheetBar?.actions ? sheetBar.actions.height : 0;
3729
4165
  const content: Rect = {
3730
4166
  x,
3731
4167
  y: y + contentTop,
3732
4168
  width: size.width,
3733
- height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
4169
+ height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom) - actionsBar),
3734
4170
  };
3735
4171
  const chrome: Record<string, Rect> = { content };
3736
4172
  if (!barHidden) {
3737
4173
  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 };
4174
+ // The 44 row the bar's items sit in, under the status bar (on a Mac the
4175
+ // title bar's row, which ends at the bar's bottom).
4176
+ chrome['barRow'] = { x, y: mac ? y + barHeight - rowHeight : y + statusBar, width: size.width, height: rowHeight };
4177
+ // The page styles a unified title bar's title (leading-aligned, larger).
4178
+ if (unified) chrome['unified'] = chrome['barRow'];
4179
+ // The page styles a Mac sheet's title (leading-aligned, 13 bold).
4180
+ if (sheetBar) chrome['sheetBar'] = chrome['barRow'];
3740
4181
  }
3741
4182
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
3742
- if (parts.search) {
4183
+ if (parts.search && !searchInBar) {
3743
4184
  const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
3744
4185
  chrome['searchField'] = {
3745
4186
  x: x + marginH,
@@ -3758,12 +4199,14 @@ export class LayoutPass {
3758
4199
  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
4200
  }
3760
4201
  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);
4202
+ const { platform } = this.env;
4203
+ // 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.
4204
+ const titleFont = makeFont('system', textStyleSize(unified ? 'title3' : 'headline', dynamicTypeSize, platform), sheetBar ? 700 : 600);
4205
+ const largeTitleFont = makeFont('system', textStyleSize('largeTitle', dynamicTypeSize, platform), 700);
4206
+ const backFont = makeFont('system', textStyleSize('body', dynamicTypeSize, platform), 400);
3764
4207
 
3765
4208
  // Toolbar items at their ideal size, grouped per toolbar.
3766
- const rowY = y + statusBar;
4209
+ const rowY = (chrome['barRow'] ?? { y: y + statusBar }).y;
3767
4210
  interface Group {
3768
4211
  toolbar: LayoutNode;
3769
4212
  placement: string;
@@ -3779,15 +4222,34 @@ export class LayoutPass {
3779
4222
  ? []
3780
4223
  : parts.toolbars.filter((t) => {
3781
4224
  const placement = placementOf(t);
3782
- return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
4225
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t) && !actionToolbars.includes(t);
4226
+ });
4227
+ // A Mac sheet's actions: 22 push buttons at the trailing end of the
4228
+ // sheet's bottom bar, the trailing placement's (confirm) last, 8 apart.
4229
+ if (sheetBar?.actions) {
4230
+ const actions = sheetBar.actions;
4231
+ let cursor = actions.x + actions.width - MAC_SHEET_ACTIONS_INSET;
4232
+ const ordered = [...actionToolbars.filter((t) => placementOf(t) === 'trailing'), ...actionToolbars.filter((t) => placementOf(t) === 'leading')];
4233
+ for (const toolbar of ordered) {
4234
+ const itemCtx: Context = { ...this.childContext(toolbar, cctx), navBarItem: false };
4235
+ let first = cursor;
4236
+ let last = cursor;
4237
+ [...toolbar.children].reverse().forEach((item) => {
4238
+ const itemSize = this.measure(item, itemCtx, { width: null, height: null });
4239
+ this.place(item, itemCtx, { width: null, height: null }, cursor - itemSize.width, actions.y + (actions.height - itemSize.height) / 2);
4240
+ first = cursor - itemSize.width;
4241
+ cursor = first - MAC_SHEET_ACTIONS_GAP;
3783
4242
  });
4243
+ this.setFrame(toolbar, first, actions.y, { width: Math.max(0, last - first), height: actions.height });
4244
+ }
4245
+ }
3784
4246
  const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
3785
4247
  const groups: Group[] = barToolbars.map((toolbar) => {
3786
4248
  const itemCtx = this.childContext(toolbar, cctx);
3787
4249
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
3788
4250
  const placement = str(toolbar.props['placement']) ?? 'trailing';
3789
4251
  if (placement === 'leading' || placement === 'trailing') {
3790
- const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width));
4252
+ const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
3791
4253
  return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
3792
4254
  }
3793
4255
  const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
@@ -3811,7 +4273,7 @@ export class LayoutPass {
3811
4273
  const runWidth = (run: Run): number => {
3812
4274
  const slots = run.groups.flatMap((g) => g.slots ?? []);
3813
4275
  if (slots.length === 0) return 0;
3814
- const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * NAV_RUN_GAP;
4276
+ const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
3815
4277
  return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
3816
4278
  };
3817
4279
  for (const toolbar of parts.toolbars) {
@@ -3868,7 +4330,7 @@ export class LayoutPass {
3868
4330
  const itemCtx = this.childContext(toolbar, cctx);
3869
4331
  return toolbar.children.map((item) => {
3870
4332
  const itemSize = this.measure(item, itemCtx, { width: null, height: null });
3871
- return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
4333
+ return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width, glass, glassPad) };
3872
4334
  });
3873
4335
  });
3874
4336
  // Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
@@ -3895,26 +4357,38 @@ export class LayoutPass {
3895
4357
  }
3896
4358
  return out;
3897
4359
  };
3898
- inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - NAV_GLASS_SIZE - NAV_GROUP_SPACING);
4360
+ inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - NAV_GROUP_SPACING);
3899
4361
  }
3900
4362
  const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
3901
4363
 
3902
4364
  // Back button, iOS 26: the chevron alone in a glass circle (the previous
3903
4365
  // title no longer shows).
3904
4366
  const backLabel = '';
3905
- const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
4367
+ // A Mac window's own title bar (the top of the screen, a title bar in the
4368
+ // safe area) starts with the window buttons; the items follow them.
4369
+ const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
4370
+ const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
4371
+ const glassY = rowY + (rowHeight - glass) / 2;
3906
4372
  if (depth > 0 && !barHidden) {
3907
- chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
4373
+ chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
3908
4374
  }
3909
4375
 
3910
4376
  // Place the toolbar groups and remember how much of the row they occupy.
3911
4377
  const back = chrome['back'];
3912
4378
  let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
3913
- let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
4379
+ let trailingEdge = x + size.width - (mac ? (tabToolbar ? MAC_TAB_TOOLBAR_MARGIN_TRAILING : MAC_BAR_MARGIN_TRAILING) : NAV_ITEM_MARGIN);
4380
+ // On a Mac the search field is the trailing-most item of the title bar
4381
+ // (chrome `searchBar` tells the page to draw it as the toolbar's field).
4382
+ if (searchInBar) {
4383
+ const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
4384
+ chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_SEARCH_FIELD_HEIGHT) / 2, width, height: MAC_SEARCH_FIELD_HEIGHT };
4385
+ chrome['searchBar'] = chrome['searchField'];
4386
+ trailingEdge -= width + NAV_GROUP_SPACING;
4387
+ }
3914
4388
  // The More button is the trailing-most item.
3915
4389
  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;
4390
+ chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
4391
+ trailingEdge -= glass + NAV_GROUP_SPACING;
3918
4392
  }
3919
4393
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
3920
4394
  const itemCtx = this.childContext(group.toolbar, cctx);
@@ -3937,7 +4411,7 @@ export class LayoutPass {
3937
4411
  const placeRun = (run: Run, startX: number): void => {
3938
4412
  const margin = run.segmented ? BAR_SEGMENTED_MARGIN : 0;
3939
4413
  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 };
4414
+ chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
3941
4415
  }
3942
4416
  const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
3943
4417
  let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
@@ -3947,11 +4421,17 @@ export class LayoutPass {
3947
4421
  group.toolbar.children.forEach((item, i) => {
3948
4422
  const itemSize = group.sizes[i] as Size;
3949
4423
  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;
4424
+ this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
4425
+ // A Mac toolbar's text button is a bordered 28 button at its slot
4426
+ // (chrome `bordered`, drawn by the page at that rect); an icon is bare.
4427
+ if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
4428
+ const existing = this.chrome.get(item.id);
4429
+ this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
4430
+ }
4431
+ cursor += slot + runGap;
3952
4432
  });
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 });
4433
+ const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
4434
+ this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
3955
4435
  }
3956
4436
  };
3957
4437
  for (const run of runs.filter((r) => r.placement === 'leading')) {
@@ -3970,8 +4450,8 @@ export class LayoutPass {
3970
4450
  for (const group of groups) {
3971
4451
  if (group.placement !== 'principal') continue;
3972
4452
  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);
4453
+ this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: rowHeight });
4454
+ placeGroup(group, startX, rowY, rowHeight, NAV_ITEM_SPACING);
3975
4455
  }
3976
4456
  // The inline secondary items, right-aligned before the trailing groups,
3977
4457
  // each toolbar framed around its own; the collapsed ones are hidden (the
@@ -3983,12 +4463,12 @@ export class LayoutPass {
3983
4463
  for (const [i, entry] of inlineSecondary.entries()) {
3984
4464
  cursor += secondaryGap(inlineSecondary[i - 1], entry);
3985
4465
  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);
4466
+ this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (rowHeight - entry.size.height) / 2);
3987
4467
  const extent = extents.get(entry.toolbar);
3988
4468
  extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
3989
4469
  cursor += entry.slot;
3990
4470
  }
3991
- for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: NAV_BAR_HEIGHT });
4471
+ for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
3992
4472
  trailingEdge -= total + NAV_GROUP_SPACING;
3993
4473
  }
3994
4474
  for (const toolbar of secondaryToolbars) {
@@ -4046,7 +4526,19 @@ export class LayoutPass {
4046
4526
  const titleScrolls = large && scroller !== null;
4047
4527
  // A large title that scrolls away collapses into the inline title, which
4048
4528
  // the renderer shows once it has (`data-sb-collapsed`).
4049
- if ((!large || titleScrolls) && !principal && !barHidden) {
4529
+ if (tabToolbar) {
4530
+ // The tab picker stands where the title would be.
4531
+ } else if ((unified || sheetBar) && !principal && !barHidden) {
4532
+ // The unified title bar's title (and a Mac sheet's): leading-aligned
4533
+ // after the back button and the leading items (2 past the window
4534
+ // buttons' room when nothing is before it), cut to the room before
4535
+ // the trailing ones.
4536
+ const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
4537
+ const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
4538
+ const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
4539
+ const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
4540
+ chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4541
+ } else if ((!large || titleScrolls) && !principal && !barHidden) {
4050
4542
  const leftUsed = Math.max(
4051
4543
  back ? back.x + back.width - x : 0,
4052
4544
  leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
@@ -4067,14 +4559,14 @@ export class LayoutPass {
4067
4559
  );
4068
4560
  const centered = x + (size.width - titleWidth) / 2;
4069
4561
  const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
4070
- chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: NAV_BAR_HEIGHT };
4562
+ chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4071
4563
  }
4072
4564
  this.setChrome(node, chrome);
4073
4565
 
4074
4566
  if (node.id >= 0) {
4075
4567
  this.fonts.set(node.id, large ? largeTitleFont : titleFont);
4076
4568
  const texts: Record<string, TextLayout> = {
4077
- title: { font: titleFont, lines: [title], alignment: 'center', truncated: false },
4569
+ title: { font: titleFont, lines: [title], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
4078
4570
  };
4079
4571
  if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
4080
4572
  if (large) {
@@ -4324,6 +4816,10 @@ export class LayoutPass {
4324
4816
  */
4325
4817
  private placeTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
4326
4818
  const { screen } = this.env;
4819
+ if (this.env.platform === 'macos') {
4820
+ this.placeMacTabView(node, cctx, x, y, size);
4821
+ return;
4822
+ }
4327
4823
  const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
4328
4824
  const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
4329
4825
  const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
@@ -4363,6 +4859,64 @@ export class LayoutPass {
4363
4859
  });
4364
4860
  }
4365
4861
 
4862
+ /**
4863
+ * The Mac idiom's tab view (MAC_TAB_* above): the bar is a segmented
4864
+ * control centered in a 44 row at the top of the content, below the
4865
+ * window's title bar when the tab view is at the top of the screen; the
4866
+ * content is the rest, and the selected tab fills it (its own `navstack`
4867
+ * reserves no status bar there: the row is above it). Chrome `top` marks
4868
+ * the bar as the top one for the page.
4869
+ */
4870
+ private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
4871
+ const { safeArea } = this.env;
4872
+ // At the top of the window the row is the window's toolbar (38, the
4873
+ // title bar included); lower down, a 38 row at the top of the content.
4874
+ const atTop = y <= 0 && safeArea.top > 0;
4875
+ const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
4876
+ const tabs = node.children;
4877
+ const n = tabs.length;
4878
+ // Each segment is its title (13 regular) and 12 either side.
4879
+ const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
4880
+ const itemWidths = tabs.map((tab) => this.measurer.measureWidth(str(tab.props['title']) ?? '', font) + 2 * MAC_TAB_ITEM_PAD_H);
4881
+ const natural = itemWidths.reduce((sum, w) => sum + w, 0) + 2 * MAC_TAB_ITEM_INSET;
4882
+ const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
4883
+ const scale = natural > 0 ? width / natural : 1;
4884
+ const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height - topInset));
4885
+ 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) };
4886
+ const toolbar: Rect = { x, y: y + topInset, width: size.width, height: rowHeight };
4887
+ const contentTop = topInset + rowHeight;
4888
+ const content: Rect = { x, y: y + contentTop, width: size.width, height: Math.max(0, size.height - contentTop) };
4889
+ const chrome: Record<string, Rect> = { bar, content, top: bar };
4890
+ let itemX = bar.x + MAC_TAB_ITEM_INSET;
4891
+ for (let i = 0; i < n; i++) {
4892
+ const itemWidth = (itemWidths[i] as number) * scale;
4893
+ 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) };
4894
+ itemX += itemWidth;
4895
+ }
4896
+ this.setChrome(node, chrome);
4897
+ const selected = readSelected(node, n);
4898
+ const tabCtx: Context = { ...cctx, bottomEdge: undefined, barAbove: undefined };
4899
+ const contentSize: Size = { width: content.width, height: content.height };
4900
+ tabs.forEach((tab, i) => {
4901
+ if (i !== selected) {
4902
+ this.setFrame(tab, content.x, content.y, contentSize);
4903
+ this.hide(tab, node);
4904
+ return;
4905
+ }
4906
+ const child = this.contentOf(tab);
4907
+ // A navigation stack at the window's top fills the tab: its root
4908
+ // screen draws the toolbar row as its bar (`tabToolbar`), with the
4909
+ // picker over it; anything else starts under the row.
4910
+ if (atTop && child && unwrapStyled(child).kind === 'navstack') {
4911
+ this.setFrame(tab, x, y, size);
4912
+ this.placeTabContent(tab, this.childContext(tab, { ...tabCtx, tabToolbar: toolbar }), x, y, size, 0);
4913
+ return;
4914
+ }
4915
+ this.setFrame(tab, content.x, content.y, contentSize);
4916
+ this.placeTabContent(tab, this.childContext(tab, tabCtx), content.x, content.y, contentSize, 0);
4917
+ });
4918
+ }
4919
+
4366
4920
  /**
4367
4921
  * The selected tab's content, like a navscreen's: a `navstack` fills the
4368
4922
  * whole tab (it reserves the status bar itself and its lists run under the
@@ -4430,7 +4984,7 @@ export class LayoutPass {
4430
4984
  const leading = x <= 0 ? safeArea.leading : 0;
4431
4985
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
4432
4986
  const usable = Math.max(0, size.width - leading - trailing);
4433
- const sidebarWidth = Math.min(NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
4987
+ const sidebarWidth = Math.min(this.env.platform === 'macos' ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
4434
4988
  const visible = node.props['sidebarVisible'] !== false;
4435
4989
  // Hidden, the sidebar and its separator sit just off the leading edge.
4436
4990
  const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
@@ -4438,22 +4992,29 @@ export class LayoutPass {
4438
4992
  const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
4439
4993
  const detailWidth = Math.max(0, x + size.width - trailing - detailX);
4440
4994
  const statusBar = y <= 0 ? safeArea.top : 0;
4995
+ // On a Mac the toggle is a 28 button centered in the unified title bar's
4996
+ // row (which the columns' screens lay out: `placeNavScreen`), after the
4997
+ // window buttons when the split view is at the top of the screen.
4998
+ const mac = this.env.platform === 'macos';
4999
+ const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
5000
+ const toggleX = x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
5001
+ const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
4441
5002
  const chrome: Record<string, Rect> = {
4442
5003
  sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
4443
5004
  separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
4444
5005
  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 },
5006
+ toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
4446
5007
  };
4447
5008
  this.setChrome(node, chrome);
4448
- const toggleRoom = NAV_GLASS_SIZE + NAV_GROUP_SPACING;
5009
+ const toggleRoom = toggleSize + NAV_GROUP_SPACING;
4449
5010
  const [sidebar, detail, ...rest] = node.children;
4450
5011
  if (sidebar) {
4451
5012
  const proposal: Proposal = { width: sidebarWidth, height: size.height };
4452
- this.place(sidebar, { ...cctx, splitSidebar: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
5013
+ this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
4453
5014
  }
4454
5015
  if (detail) {
4455
5016
  const proposal: Proposal = { width: detailWidth, height: size.height };
4456
- this.place(detail, { ...cctx, splitSidebar: false, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
5017
+ this.place(detail, { ...cctx, splitSidebar: false, splitColumn: true, barLeading: visible ? 0 : toggleRoom }, proposal, detailX, y);
4457
5018
  }
4458
5019
  for (const extra of rest) this.hide(extra, node);
4459
5020
  }
@@ -4577,16 +5138,29 @@ export class LayoutPass {
4577
5138
  }
4578
5139
 
4579
5140
  private stepperLabelProposal(p: Proposal): Proposal {
4580
- return { width: shrink(p.width, STEPPER_WIDTH + STEPPER_GAP), height: p.height };
5141
+ const control = this.stepperMetrics();
5142
+ return { width: shrink(p.width, control.width + control.gap), height: p.height };
4581
5143
  }
4582
5144
 
4583
5145
  /** Like a toggle: the label leads, the 94 × 32 control trails; at least 32 high. */
5146
+ /** The Mac idiom: a menu outside a bar and a list row is a pull-down button. */
5147
+ private menuIsPullDown(ctx: Context): boolean {
5148
+ return this.env.platform === 'macos' && !ctx.listRow && !ctx.navBarItem && !ctx.bottomBarItem;
5149
+ }
5150
+
5151
+ /** The stepper's control: iOS's segmented −/+, or the Mac's 13 × 22 arrows. */
5152
+ private stepperMetrics(): { width: number; height: number; gap: number; mac: boolean } {
5153
+ if (this.env.platform === 'macos') return { width: MAC_STEPPER_WIDTH, height: MAC_STEPPER_HEIGHT, gap: MAC_STEPPER_GAP, mac: true };
5154
+ return { width: STEPPER_WIDTH, height: STEPPER_HEIGHT, gap: STEPPER_GAP, mac: false };
5155
+ }
5156
+
4584
5157
  private measureStepper(node: LayoutNode, ctx: Context, p: Proposal): Size {
4585
5158
  const label = this.contentOf(node);
5159
+ const control = this.stepperMetrics();
4586
5160
  const labelSize = label ? this.measure(label, this.childContext(node, ctx), this.stepperLabelProposal(p)) : ZERO;
4587
5161
  return {
4588
- width: finite(p.width) ? p.width : labelSize.width + STEPPER_GAP + STEPPER_WIDTH,
4589
- height: Math.max(labelSize.height, STEPPER_HEIGHT),
5162
+ width: finite(p.width) ? p.width : labelSize.width + control.gap + control.width,
5163
+ height: Math.max(labelSize.height, control.height),
4590
5164
  };
4591
5165
  }
4592
5166
 
@@ -4597,21 +5171,28 @@ export class LayoutPass {
4597
5171
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
4598
5172
  const labelY = y + (size.height - labelSize.height) / 2;
4599
5173
  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;
5174
+ const control = this.stepperMetrics();
5175
+ const controlX = x + size.width - control.width;
5176
+ const controlY = y + (size.height - control.height) / 2;
4603
5177
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
5178
+ // iOS: − and + side by side; a Mac: up (increment) over down.
5179
+ const half = control.mac ? control.height / 2 : control.width / 2;
4604
5180
  this.setChrome(node, {
4605
5181
  ...existing,
4606
5182
  label: {
4607
5183
  x,
4608
5184
  y: labelY,
4609
- width: Math.min(labelSize.width, Math.max(0, size.width - STEPPER_WIDTH - STEPPER_GAP)),
5185
+ width: Math.min(labelSize.width, Math.max(0, size.width - control.width - control.gap)),
4610
5186
  height: labelSize.height,
4611
5187
  },
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 },
5188
+ control: { x: controlX, y: controlY, width: control.width, height: control.height },
5189
+ decrement: control.mac
5190
+ ? { x: controlX, y: controlY + half, width: control.width, height: half }
5191
+ : { x: controlX, y: controlY, width: half, height: control.height },
5192
+ increment: control.mac
5193
+ ? { x: controlX, y: controlY, width: control.width, height: half }
5194
+ : { x: controlX + half, y: controlY, width: half, height: control.height },
5195
+ ...(control.mac ? { arrows: { x: controlX, y: controlY, width: control.width, height: control.height } } : {}),
4615
5196
  });
4616
5197
  }
4617
5198
 
@@ -4622,6 +5203,12 @@ export class LayoutPass {
4622
5203
  * names the slider for VoiceOver): the page hides it and names the slider
4623
5204
  * from it (`aria-labelledby`). Children are named by `labels` in order.
4624
5205
  */
5206
+ /** The slider's row, track and knob: iOS 26's pill knob, or the Mac's round one. */
5207
+ private sliderMetrics(): { height: number; track: number; thumb: number; thumbHeight: number; mac: boolean } {
5208
+ 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 };
5209
+ return { height: SLIDER_HEIGHT, track: SLIDER_TRACK_HEIGHT, thumb: SLIDER_THUMB, thumbHeight: SLIDER_THUMB_HEIGHT, mac: false };
5210
+ }
5211
+
4625
5212
  private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
4626
5213
  return this.plan(node, ctx, p, () => {
4627
5214
  const width = finite(p.width) ? p.width : SLIDER_DEFAULT_WIDTH;
@@ -4638,7 +5225,7 @@ export class LayoutPass {
4638
5225
  });
4639
5226
  const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
4640
5227
  const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
4641
- return { size: { width, height: SLIDER_HEIGHT }, label, min, max, minSize, maxSize, cctx };
5228
+ return { size: { width, height: this.sliderMetrics().height }, label, min, max, minSize, maxSize, cctx };
4642
5229
  });
4643
5230
  }
4644
5231
 
@@ -4646,35 +5233,38 @@ export class LayoutPass {
4646
5233
  const plan = this.sliderPlan(node, ctx, p);
4647
5234
  const { cctx } = plan;
4648
5235
  if (plan.label) this.hide(plan.label, node);
5236
+ const m = this.sliderMetrics();
4649
5237
  const rowY = y;
4650
5238
  let trackX = x;
4651
5239
  let trackEnd = x + size.width;
4652
5240
  if (plan.min) {
4653
- this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (SLIDER_HEIGHT - plan.minSize.height) / 2);
5241
+ this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
4654
5242
  trackX += plan.minSize.width + SLIDER_LABEL_GAP;
4655
5243
  }
4656
5244
  if (plan.max) {
4657
- this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (SLIDER_HEIGHT - plan.maxSize.height) / 2);
5245
+ this.place(plan.max, cctx, NIL_PROPOSAL, x + size.width - plan.maxSize.width, rowY + (m.height - plan.maxSize.height) / 2);
4658
5246
  trackEnd -= plan.maxSize.width + SLIDER_LABEL_GAP;
4659
5247
  }
4660
5248
  const trackWidth = Math.max(0, trackEnd - trackX);
4661
- const trackY = rowY + (SLIDER_HEIGHT - SLIDER_TRACK_HEIGHT) / 2;
5249
+ const trackY = rowY + (m.height - m.track) / 2;
4662
5250
  const fraction = sliderFraction(num(node.props['value']) ?? 0, num(node.props['min']) ?? 0, num(node.props['max']) ?? 1);
4663
5251
  // The thumb travels inside the track: its center runs from half a thumb
4664
5252
  // 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;
5253
+ const travel = Math.max(0, trackWidth - m.thumb);
5254
+ const thumbCenter = trackX + m.thumb / 2 + fraction * travel;
4667
5255
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
4668
5256
  this.setChrome(node, {
4669
5257
  ...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 },
5258
+ track: { x: trackX, y: trackY, width: trackWidth, height: m.track },
5259
+ fill: { x: trackX, y: trackY, width: Math.max(0, thumbCenter - trackX), height: m.track },
4672
5260
  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,
5261
+ x: thumbCenter - m.thumb / 2,
5262
+ y: rowY + (m.height - m.thumbHeight) / 2,
5263
+ width: m.thumb,
5264
+ height: m.thumbHeight,
4677
5265
  },
5266
+ // The page draws the Mac's round knob (styles.css).
5267
+ ...(m.mac ? { knob: { x: thumbCenter - m.thumb / 2, y: rowY + (m.height - m.thumbHeight) / 2, width: m.thumb, height: m.thumbHeight } } : {}),
4678
5268
  });
4679
5269
  }
4680
5270
 
@@ -4691,17 +5281,20 @@ export class LayoutPass {
4691
5281
  const timeZone = str(node.props['timeZone']) ?? undefined;
4692
5282
  const dateText = components === 'hourAndMinute' ? null : formatPickerDate(seconds, timeZone);
4693
5283
  const timeText = components === 'date' ? null : formatPickerTime(seconds, timeZone);
5284
+ const mac = this.env.platform === 'macos';
5285
+ const capsulePad = mac ? MAC_DATEPICKER_CAPSULE_PAD_H : DATEPICKER_CAPSULE_PAD_H;
5286
+ const capsuleGap = mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP;
4694
5287
  const capsule = (text: string | null): number =>
4695
- text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * DATEPICKER_CAPSULE_PAD_H;
5288
+ text === null ? 0 : this.measurer.measureWidth(text, ctx.font) + 2 * capsulePad;
4696
5289
  const dateWidth = capsule(dateText);
4697
5290
  const timeWidth = capsule(timeText);
4698
- const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? DATEPICKER_CAPSULE_GAP : 0);
5291
+ const capsulesWidth = dateWidth + timeWidth + (dateText !== null && timeText !== null ? capsuleGap : 0);
4699
5292
  const label = node.props['labelsHidden'] === true ? null : this.contentOf(node);
4700
5293
  const labelProposal: Proposal = { width: shrink(p.width, capsulesWidth + DATEPICKER_GAP), height: p.height };
4701
5294
  const labelSize = label ? this.measure(label, cctx, labelProposal) : ZERO;
4702
5295
  const width = finite(p.width) ? p.width : labelSize.width + (label ? DATEPICKER_GAP : 0) + capsulesWidth;
4703
5296
  return {
4704
- size: { width, height: Math.max(labelSize.height, DATEPICKER_HEIGHT) },
5297
+ size: { width, height: Math.max(labelSize.height, mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT) },
4705
5298
  label,
4706
5299
  labelProposal,
4707
5300
  labelSize,
@@ -4721,7 +5314,9 @@ export class LayoutPass {
4721
5314
  }
4722
5315
  const labelY = y + (size.height - plan.labelSize.height) / 2;
4723
5316
  if (plan.label) this.place(plan.label, plan.cctx, plan.labelProposal, x, labelY);
4724
- const capsuleY = y + (size.height - DATEPICKER_HEIGHT) / 2;
5317
+ const mac = this.env.platform === 'macos';
5318
+ const capsuleHeight = mac ? MAC_DATEPICKER_HEIGHT : DATEPICKER_HEIGHT;
5319
+ const capsuleY = y + (size.height - capsuleHeight) / 2;
4725
5320
  const existing = node.id >= 0 ? this.chrome.get(node.id) : undefined;
4726
5321
  const chrome: Record<string, Rect> = {
4727
5322
  ...existing,
@@ -4734,12 +5329,14 @@ export class LayoutPass {
4734
5329
  };
4735
5330
  let capsuleX = x + size.width - plan.capsulesWidth;
4736
5331
  if (plan.dateWidth > 0) {
4737
- chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: DATEPICKER_HEIGHT };
4738
- capsuleX += plan.dateWidth + DATEPICKER_CAPSULE_GAP;
5332
+ chrome['date'] = { x: capsuleX, y: capsuleY, width: plan.dateWidth, height: capsuleHeight };
5333
+ capsuleX += plan.dateWidth + (mac ? MAC_DATEPICKER_CAPSULE_GAP : DATEPICKER_CAPSULE_GAP);
4739
5334
  }
4740
5335
  if (plan.timeWidth > 0) {
4741
- chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: DATEPICKER_HEIGHT };
5336
+ chrome['time'] = { x: capsuleX, y: capsuleY, width: plan.timeWidth, height: capsuleHeight };
4742
5337
  }
5338
+ // The page draws the Mac's bordered fields (styles.css).
5339
+ if (mac) chrome['fields'] = { x: x + size.width - plan.capsulesWidth, y: capsuleY, width: plan.capsulesWidth, height: capsuleHeight };
4743
5340
  this.setChrome(node, chrome);
4744
5341
  }
4745
5342
 
@@ -4762,13 +5359,15 @@ export class LayoutPass {
4762
5359
  const chrome: Record<string, Rect> = {};
4763
5360
  const texts: Record<string, TextLayout> = {};
4764
5361
  const roleOf = (child: LayoutNode): string | null => str(unwrapStyled(child).props['role']);
5362
+ // The alert's metrics: iOS's card, or the Mac's panel (`data-sb-panel` for the page).
5363
+ const m = this.env.platform === 'macos' ? MAC_ALERT_METRICS : ALERT_METRICS;
4765
5364
  // `textfield` children (`.alert { TextField(...); Button(...) }`) go in
4766
5365
  // the card between the message and the buttons, in their order.
4767
5366
  const fields = node.children.filter((child) => unwrapStyled(child).kind === 'textfield');
4768
5367
  const buttons = node.children.filter((child) => !fields.includes(child));
4769
5368
  const placeFields = (top: number, left: number, width: number): void => {
4770
5369
  fields.forEach((field, i) => {
4771
- const rect: Rect = { x: left, y: top + i * (ALERT_FIELD_HEIGHT + ALERT_FIELD_SPACING), width, height: ALERT_FIELD_HEIGHT };
5370
+ const rect: Rect = { x: left, y: top + i * (m.fieldHeight + m.fieldSpacing), width, height: m.fieldHeight };
4772
5371
  chrome[`field${i}`] = rect;
4773
5372
  const proposal: Proposal = { width: rect.width, height: rect.height };
4774
5373
  const fieldSize = this.measure(field, cctx, proposal);
@@ -4776,7 +5375,7 @@ export class LayoutPass {
4776
5375
  this.setFrame(field, rect.x, rect.y, { width: rect.width, height: rect.height });
4777
5376
  });
4778
5377
  };
4779
- const fieldsHeight = fields.length > 0 ? fields.length * ALERT_FIELD_HEIGHT + (fields.length - 1) * ALERT_FIELD_SPACING : 0;
5378
+ const fieldsHeight = fields.length > 0 ? fields.length * m.fieldHeight + (fields.length - 1) * m.fieldSpacing : 0;
4780
5379
 
4781
5380
  const block = (text: string, font: ResolvedFont, width: number, alignment: 'center' | 'leading'): { layout: TextLayout; height: number } => {
4782
5381
  const wrapped = wrapText(text, font, width, null, this.measurer);
@@ -4788,62 +5387,63 @@ export class LayoutPass {
4788
5387
  const centered = (text: string, font: ResolvedFont, width: number) => block(text, font, width, 'center');
4789
5388
 
4790
5389
  if (style === 'alert') {
4791
- const cardWidth = Math.min(ALERT_WIDTH, size.width - 2 * DIALOG_MARGIN);
4792
- const textWidth = cardWidth - 2 * ALERT_PAD_H;
5390
+ const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
5391
+ const textWidth = cardWidth - 2 * m.padH;
4793
5392
  const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
4794
5393
  const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
4795
5394
  // Heights first (the card is centered), then the rects.
4796
- let h = ALERT_PAD_TOP;
5395
+ let h = m.padTop;
4797
5396
  let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
4798
5397
  let messageBlock: { layout: TextLayout; height: number; top: number } | null = null;
4799
5398
  if (title.length > 0) {
4800
- const b = block(title, titleFont, textWidth, 'leading');
5399
+ const b = block(title, titleFont, textWidth, m.textAlignment);
4801
5400
  titleBlock = { ...b, top: h };
4802
5401
  h += b.height;
4803
5402
  }
4804
5403
  if (message.length > 0) {
4805
- if (titleBlock) h += ALERT_TITLE_GAP;
4806
- const b = block(message, messageFont, textWidth, 'leading');
5404
+ if (titleBlock) h += m.titleGap;
5405
+ const b = block(message, messageFont, textWidth, m.textAlignment);
4807
5406
  messageBlock = { ...b, top: h };
4808
5407
  h += b.height;
4809
5408
  }
4810
5409
  let fieldsTop: number | null = null;
4811
5410
  if (fields.length > 0) {
4812
- if (titleBlock || messageBlock) h += ALERT_FIELDS_GAP;
5411
+ if (titleBlock || messageBlock) h += m.fieldsGap;
4813
5412
  fieldsTop = h;
4814
5413
  h += fieldsHeight;
4815
5414
  }
4816
- h += ALERT_BUTTONS_GAP;
5415
+ h += m.buttonsGap;
4817
5416
  const headerHeight = h;
4818
5417
  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;
5418
+ h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
4820
5419
  // Centered in the safe area, as iOS does.
4821
5420
  const { safeArea: inset } = this.env;
4822
5421
  const top = y + (y <= 0 ? inset.top : 0);
4823
5422
  const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
4824
5423
  const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
4825
5424
  chrome['card'] = card;
5425
+ if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
4826
5426
  if (titleBlock) {
4827
- chrome['title'] = { x: card.x + ALERT_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
5427
+ chrome['title'] = { x: card.x + m.padH, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
4828
5428
  texts['title'] = titleBlock.layout;
4829
5429
  }
4830
5430
  if (messageBlock) {
4831
- chrome['message'] = { x: card.x + ALERT_PAD_H, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
5431
+ chrome['message'] = { x: card.x + m.padH, y: card.y + messageBlock.top, width: textWidth, height: messageBlock.height };
4832
5432
  texts['message'] = messageBlock.layout;
4833
5433
  }
4834
5434
  const buttonsTop = card.y + headerHeight;
4835
- const buttonsX = card.x + ALERT_BUTTON_INSET;
4836
- const buttonsWidth = Math.max(0, cardWidth - 2 * ALERT_BUTTON_INSET);
5435
+ const buttonsX = card.x + m.buttonInset;
5436
+ const buttonsWidth = Math.max(0, cardWidth - 2 * m.buttonInset);
4837
5437
  if (fieldsTop !== null) placeFields(card.y + fieldsTop, buttonsX, buttonsWidth);
4838
5438
  if (buttons.length === 0) {
4839
- chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: ALERT_BUTTON_HEIGHT };
5439
+ chrome['ok'] = { x: buttonsX, y: buttonsTop, width: buttonsWidth, height: m.buttonHeight };
4840
5440
  } else if (buttons.length === 2) {
4841
5441
  // A `cancel` button goes first (leading), as iOS orders them.
4842
5442
  const ordered = roleOf(buttons[1] as LayoutNode) === 'cancel' ? [buttons[1], buttons[0]] : [buttons[0], buttons[1]];
4843
- const half = (buttonsWidth - ALERT_BUTTON_SPACING) / 2;
5443
+ const half = (buttonsWidth - m.rowSpacing) / 2;
4844
5444
  ordered.forEach((button, slot) => {
4845
5445
  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 };
5446
+ const rect: Rect = { x: buttonsX + slot * (half + m.rowSpacing), y: buttonsTop, width: half, height: m.buttonHeight };
4847
5447
  chrome[`button${i}`] = rect;
4848
5448
  this.placeAlertButton(button as LayoutNode, cctx, rect);
4849
5449
  });
@@ -4851,9 +5451,9 @@ export class LayoutPass {
4851
5451
  buttons.forEach((button, i) => {
4852
5452
  const rect: Rect = {
4853
5453
  x: buttonsX,
4854
- y: buttonsTop + i * (ALERT_BUTTON_HEIGHT + ALERT_BUTTON_SPACING),
5454
+ y: buttonsTop + i * (m.buttonHeight + m.buttonSpacing),
4855
5455
  width: buttonsWidth,
4856
- height: ALERT_BUTTON_HEIGHT,
5456
+ height: m.buttonHeight,
4857
5457
  };
4858
5458
  chrome[`button${i}`] = rect;
4859
5459
  this.placeAlertButton(button, cctx, rect);
@@ -4905,6 +5505,7 @@ export class LayoutPass {
4905
5505
  }
4906
5506
  const card: Rect = { x: cardX, y: cardBottom - h, width: cardWidth, height: h };
4907
5507
  chrome['card'] = card;
5508
+ if (m === MAC_ALERT_METRICS) chrome['panel'] = card;
4908
5509
  if (titleBlock) {
4909
5510
  chrome['title'] = { x: card.x + DIALOG_HEADER_PAD_H, y: card.y + titleBlock.top, width: textWidth, height: titleBlock.height };
4910
5511
  texts['title'] = titleBlock.layout;
@@ -4994,6 +5595,17 @@ export class LayoutPass {
4994
5595
  const covered = Math.max(0, y + size.height - (this.env.screen.height - this.keyboard));
4995
5596
  const kind = unwrapStyled(child).kind;
4996
5597
  if (kind === 'navstack') {
5598
+ // A Mac sheet: its stack draws the sheet's title bar and puts its text
5599
+ // actions in the bottom bar (chrome `actions`, which the page draws).
5600
+ if (this.env.platform === 'macos' && this.sheetIsForm(node)) {
5601
+ const screen = this.macSheetScreen(node);
5602
+ const actions: Rect | null = screen && this.macSheetActionToolbars(screen).length > 0
5603
+ ? { x, y: y + size.height - MAC_SHEET_ACTIONS_BAR, width: size.width, height: MAC_SHEET_ACTIONS_BAR }
5604
+ : null;
5605
+ if (actions) this.setChrome(node, { ...chrome, actions });
5606
+ this.place(child, { ...cctx, barInset: 0, macSheet: { actions } }, { width: size.width, height: size.height }, x, y);
5607
+ return;
5608
+ }
4997
5609
  this.place(child, { ...cctx, barInset: grabberRow }, { width: size.width, height: size.height }, x, y);
4998
5610
  return;
4999
5611
  }