@swiftbrowser/web 0.26.4 → 0.26.5

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.
@@ -106,6 +106,8 @@ export interface Context {
106
106
  * its icon, as the Mac app draws them.
107
107
  */
108
108
  listCell?: boolean;
109
+ /** A row of a Mac's grouped form (macOS 26): a toggle is a switch at the trailing end, a slider's and a text field's label lead their control, a link has its chevron. */
110
+ groupedRow?: boolean;
109
111
  /**
110
112
  * The Mac idiom: a tab view's toolbar row at the top of the window. The
111
113
  * screens of a tab's navigation stack draw their bar as that row: no
@@ -320,35 +322,45 @@ const NAV_GROUP_SPACING = 12;
320
322
  const NAV_RUN_GAP = 12;
321
323
  const NAV_RUN_PAD = 1;
322
324
  // The Mac idiom (docs/notes/macos.md; `LayoutEnvironment.platform` is
323
- // `macos`): the window's title bar stands in for the navigation bar. A 52
324
- // unified title bar holds the back button, the bar items and the title,
325
- // leading-aligned, as SwiftUI on macOS 14 lays a NavigationStack's toolbar
326
- // out; a window with nothing in its bar but the title has the plain 28 one,
327
- // the title centered. There are no large titles and no glass: the items
328
- // are 28 tall, in slots 8 either side, 8 apart. On the screen's own window
325
+ // `macos`), measured on macOS 26: the window's title bar stands in for the
326
+ // navigation bar, with no background or rule of its own (the content's
327
+ // color runs up under it). A 52 toolbar holds the back button, the bar items
328
+ // and the title, leading-aligned; a window with nothing in its bar but the
329
+ // title has the compact 32 one, the title leading-aligned after the window
330
+ // buttons. There are no large titles. The items are Liquid Glass, as on iOS
331
+ // 26 but smaller: 36 tall, centered in the bar; an icon a 36 slot,
332
+ // adjacent items in one capsule (the back button in the leading one), a
333
+ // text item a capsule of its own at its width and 10 either side;
334
+ // capsules 8 apart and 8 from the window's edge. On the screen's own window
329
335
  // (the top of the screen, when the safe area carries a title bar) the three
330
- // window buttons take the first 80 of the bar. Every number here is a
331
- // first pass, to be measured against the real app (the Mac fidelity
332
- // harness, stage 3).
336
+ // window buttons take the first 96 of the toolbar; the title is 12 after
337
+ // whatever is before it.
333
338
  const MAC_TITLE_BAR_UNIFIED = 52;
334
- const MAC_TITLE_BAR_PLAIN = 28;
335
- const MAC_BAR_ITEM_SIZE = 28;
336
- const MAC_BAR_ITEM_PAD_H = 6;
337
- const MAC_BAR_RUN_GAP = 10;
338
- /** The window buttons' room: the first item's slot starts 90 in; a title with no item before it 2 further. */
339
- const MAC_WINDOW_BUTTONS_ROOM = 90;
340
- const MAC_TITLE_INSET = 2;
341
- /** A Mac bar's trailing items end 13 before the window's edge; a tab view's toolbar's 7. */
342
- const MAC_BAR_MARGIN_TRAILING = 13;
343
- const MAC_TAB_TOOLBAR_MARGIN_TRAILING = 7;
344
- // A Mac tab view's bar is a segmented control at the top of the content,
345
- // centered in a 44 row: 24 tall, each tab a 96 segment inside a 1 inset, at
346
- // most the width less 20 either side.
347
- const MAC_TAB_ROW_HEIGHT = 38;
348
- const MAC_TAB_BAR_HEIGHT = 22;
349
- /** A tab picker's segment: its title (13 regular) and 12 either side. */
350
- const MAC_TAB_ITEM_PAD_H = 12;
351
- const MAC_TAB_ITEM_INSET = 1;
339
+ const MAC_TITLE_BAR_PLAIN = 32;
340
+ const MAC_BAR_ITEM_SIZE = 36;
341
+ const MAC_BAR_ITEM_PAD_H = 10;
342
+ const MAC_BAR_RUN_GAP = 0;
343
+ /** A capsule of several slots: 3 past its end slots. */
344
+ const MAC_BAR_RUN_PAD = 3;
345
+ /** Between capsules, and between the last one and the window's edge. */
346
+ const MAC_BAR_GROUP_GAP = 8;
347
+ /** The window buttons' room: the first item's slot starts 96 in; a title is 12 after the last thing before it. */
348
+ const MAC_WINDOW_BUTTONS_ROOM = 96;
349
+ const MAC_TITLE_GAP = 12;
350
+ /** A compact title bar's title: 84 in, after the window buttons (9 in, 14 across, 9 apart) and 15. */
351
+ const MAC_COMPACT_TITLE_X = 84;
352
+ /** A Mac bar's trailing items end 8 before the window's edge. */
353
+ const MAC_BAR_MARGIN_TRAILING = 8;
354
+ // A Mac tab view's bar (macOS 26) is a glass capsule centered in the
355
+ // window's 52 toolbar: 36 tall, 8 from the top, its tabs equal segments (the
356
+ // widest title and 11 either side) inside a 4 inset, the selected one a 24
357
+ // gray pill (6 from the capsule's top and bottom).
358
+ const MAC_TAB_ROW_HEIGHT = 52;
359
+ const MAC_TAB_BAR_HEIGHT = 36;
360
+ /** A tab picker's segment: the widest title (13 regular) and 11 either side. */
361
+ const MAC_TAB_ITEM_PAD_H = 11;
362
+ const MAC_TAB_ITEM_INSET = 4;
363
+ const MAC_TAB_ITEM_HEIGHT = 24;
352
364
  const MAC_TAB_BAR_MARGIN_H = 20;
353
365
  // Mac controls (macOS 14, a first pass): a bordered button is 22 tall with 10
354
366
  // either side of its label, and every `automatic` button outside a list row
@@ -356,23 +368,28 @@ const MAC_TAB_BAR_MARGIN_H = 20;
356
368
  // toggle in a list row (a grouped form's) is the small switch, 26 × 15; a
357
369
  // pop-up button is 22 tall (24 in a row), its value in a bordered button
358
370
  // with 8 either side.
371
+ // macOS 26: a push button is a capsule, 24 tall with 13 either side of its
372
+ // label (a large one 28, an extra large one 36), on a translucent gray (styles.css).
359
373
  const MAC_BORDERED_SIZES: Record<string, { padV: number; padH: number; minHeight: number; textStyle?: string }> = {
360
- regular: { padV: 3, padH: 10, minHeight: 22 },
361
- mini: { padV: 1, padH: 6, minHeight: 16, textStyle: 'caption' },
362
- small: { padV: 2, padH: 8, minHeight: 19, textStyle: 'subheadline' },
363
- large: { padV: 5, padH: 14, minHeight: 28 },
364
- extraLarge: { padV: 7, padH: 18, minHeight: 34 },
374
+ regular: { padV: 4, padH: 13, minHeight: 24 },
375
+ mini: { padV: 1, padH: 7, minHeight: 16, textStyle: 'caption' },
376
+ small: { padV: 2, padH: 10, minHeight: 20, textStyle: 'subheadline' },
377
+ large: { padV: 6, padH: 16, minHeight: 28 },
378
+ extraLarge: { padV: 10, padH: 20, minHeight: 36 },
365
379
  };
366
380
  const MAC_CHECKBOX_SIZE = 14;
367
381
  const MAC_CHECKBOX_GAP = 6;
368
- const MAC_PICKER_MENU_HEIGHT = 22;
382
+ const MAC_PICKER_MENU_HEIGHT = 24;
369
383
  // A Mac's segmented control is 22 tall with 13-point regular text, its
370
384
  // label (which iOS drops) 10 before it.
371
- const MAC_SEGMENTED_HEIGHT = 22;
385
+ const MAC_SEGMENTED_HEIGHT = 24;
386
+ /** A segment's room beside its label (macOS 26: the widest label's, every segment). */
387
+ const MAC_SEGMENT_PAD = 18;
372
388
  const MAC_SEGMENTED_FONT_SIZE = 13;
373
389
  const MAC_SEGMENTED_LABEL_GAP = 10;
374
390
  const MAC_PICKER_MENU_ROW_HEIGHT = 24;
375
- const MAC_PICKER_POPUP_PAD = 8;
391
+ /** A pop-up's room beyond its widest option: 13 before it, the 8 chevrons 13 after it and 9 from the end. */
392
+ const MAC_PICKER_POPUP_EXTRA = 43;
376
393
  // Mac lists (measured in the Mac app, docs/notes/macos.md): no cards, the
377
394
  // rows full width with 16 beside their content, at least 24 tall (28 in a
378
395
  // sidebar) with 4 (6) above and below their content, a bordered button's 3;
@@ -382,15 +399,26 @@ const MAC_PICKER_POPUP_PAD = 8;
382
399
  // footer (11) sits 8 under the last row, with a rule 5 under it. A
383
400
  // sidebar's rows inset 10; no chevrons. The sidebar column is 220 wide.
384
401
  const MAC_LIST_ROW_MIN_HEIGHT = 24;
385
- const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 28;
402
+ const MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT = 32;
386
403
  const MAC_LIST_ROW_VPAD = 4;
387
404
  const MAC_LIST_SIDEBAR_ROW_VPAD = 6;
388
405
  const MAC_LIST_BUTTON_ROW_VPAD = 3;
389
406
  const MAC_LIST_CELL_PADDING = 16;
407
+ /** A sidebar's rows (macOS 26): their content 12 in from the selection's edges, a Label's title 10 after its icon, one line. */
408
+ const MAC_LIST_SIDEBAR_CELL_PADDING = 12;
409
+ /** A sidebar's sections: 13 apart, a header 2 under the top (4 under rows), 4 in from the selection's edge and 4 over its rows, no rule. */
410
+ const MAC_LIST_SIDEBAR_SECTION_GAP = 13;
411
+ const MAC_LIST_SIDEBAR_HEADER_TOP = 2;
412
+ const MAC_LIST_SIDEBAR_HEADER_GAP = 4;
413
+ const MAC_LIST_SIDEBAR_HEADER_INSET = 4;
414
+ const MAC_LIST_SIDEBAR_BADGE_FONT_SIZE = 11;
415
+ const MAC_LIST_SIDEBAR_LABEL_GAP = 10;
390
416
  /** A Mac list row's Label: the icon (a symbol at the font's point size) 6 before the title. */
391
417
  const MAC_LIST_LABEL_GAP = 6;
392
418
  const MAC_LIST_INSET_SIDEBAR = 10;
393
- const MAC_LIST_TOP = 6;
419
+ /** A Mac list's first row starts 10 under the top (the Showcase on macOS 26); a header at the top sits 4 higher (Fidelity's). */
420
+ const MAC_LIST_TOP = 10;
421
+ const MAC_LIST_FIRST_HEADER_LIFT = 4;
394
422
  const MAC_LIST_BOTTOM = 8;
395
423
  const MAC_LIST_SECTION_GAP = 22;
396
424
  const MAC_LIST_HEADER_TOP = 4;
@@ -411,24 +439,64 @@ const MAC_FORM_HEADER_GAP = 8;
411
439
  const MAC_FORM_HEADER_TOP = 4;
412
440
  const MAC_FORM_ROW_GAP = 7;
413
441
  const MAC_FORM_FOOTER_GAP = 2;
442
+ // A Mac's grouped form (`.formStyle(.grouped)`, macOS 26): rounded cards
443
+ // (11) at most 704 wide, centered, at least 20 from the sides; 36 rows (a
444
+ // button's 44) with their content 10 in, a 1 separator between rows 10 in
445
+ // from either side; a header (13 semibold) 10 over its card, 20 below the
446
+ // top; sections 30 apart; a footer 10 under its card.
447
+ const MAC_GROUPED_MAX_WIDTH = 704;
448
+ const MAC_GROUPED_MARGIN = 20;
449
+ const MAC_GROUPED_CELL_PADDING = 10;
450
+ const MAC_GROUPED_ROW_HEIGHT = 36;
451
+ const MAC_GROUPED_ROW_VPAD = 7;
452
+ const MAC_GROUPED_BUTTON_ROW_VPAD = 11;
453
+ const MAC_GROUPED_SEPARATOR = 1;
454
+ const MAC_GROUPED_TOP = 20;
455
+ const MAC_GROUPED_HEADER_GAP = 10;
456
+ const MAC_GROUPED_SECTION_GAP = 30;
457
+ const MAC_GROUPED_FOOTER_GAP = 10;
458
+ /** In a grouped row: the switch, 36 × 18; a slider's track the trailing 55% of the row; a link's chevron 6 wide, 2 in from the content's edge. */
459
+ const MAC_GROUPED_SWITCH_WIDTH = 36;
460
+ const MAC_GROUPED_SWITCH_HEIGHT = 18;
461
+ const MAC_GROUPED_SLIDER_FRACTION = 0.55;
462
+ const MAC_GROUPED_CHEVRON_WIDTH = 8;
463
+ const MAC_GROUPED_LABEL_GAP = 10;
414
464
  /** The row kinds whose label goes in a Mac form's label column (a stack with one is shifted to it). */
415
465
  const MAC_FORM_LABELED_KINDS = ['textfield', 'picker', 'stepper', 'datepicker', 'slider'];
416
466
  /** Under a Mac footer: 5 to its rule, the rule. */
417
467
  const MAC_LIST_FOOTER_BOTTOM = 6;
418
- const MAC_NAVSPLIT_SIDEBAR_WIDTH = 220;
419
- // A Mac's search field is in the toolbar, at its trailing end: 22 tall, 180
420
- // wide (less when the bar is narrow), 16 from the edge. A sheet is a
468
+ // A Mac's split view (macOS 26): the sidebar is a floating panel, 140 wide,
469
+ // 8 from the window's leading, top and bottom edges, rounded 22, on the
470
+ // toolbar's glass fill with its own top 44 the window's color (the window
471
+ // buttons and, 6 from its trailing edge, the sidebar toggle, a bare 36
472
+ // slot); the detail column starts at its trailing edge. Its title is not
473
+ // shown. A list in it is a sidebar: 32 rows inset 10 in the panel, the
474
+ // selected one a gray rounded fill; a search field over it, 28 tall, 9 under
475
+ // the bar and 10 above the rows. A screen not at the window's leading edge
476
+ // starts its toolbar 10 in.
477
+ const MAC_NAVSPLIT_SIDEBAR_WIDTH = 140;
478
+ const MAC_NAVSPLIT_SIDEBAR_INSET = 8;
479
+ const MAC_NAVSPLIT_TOGGLE_INSET = 6;
480
+ const MAC_SIDEBAR_SEARCH_ABOVE = 9;
481
+ const MAC_SIDEBAR_SEARCH_HEIGHT = 28;
482
+ const MAC_SIDEBAR_SEARCH_BELOW = 10;
483
+ const MAC_SIDEBAR_SEARCH_MARGIN_H = 10;
484
+ const MAC_BAR_LEADING_MARGIN = 10;
485
+ // A Mac's search field is in the toolbar, at its trailing end (macOS 26): a
486
+ // 36 glass capsule like the bar's items, 325 wide (less when the bar is
487
+ // narrow), 8 from the edge; elsewhere a field is 22 tall. A sheet is a
421
488
  // window-modal panel that drops from the title bar, 480 × 400 at most,
422
489
  // centered, with no grabber.
423
- const MAC_SEARCH_FIELD_WIDTH = 180;
490
+ const MAC_SEARCH_FIELD_WIDTH = 325;
424
491
  const MAC_SEARCH_FIELD_HEIGHT = 22;
425
- // A Mac's sheet (measured in the Mac app): a 470 panel centered in the
426
- // window, its 40 title bar (the title 13 bold at 16) over the content at its
427
- // ideal height, and a 60 bar at the bottom with the text actions (cancel,
428
- // confirm) as 22 push buttons at its trailing end, 16 in and 8 apart; at
429
- // least 40 clear of the window's edges.
492
+ // A Mac's sheet (measured in the Mac app, macOS 26): a 470 panel centered in
493
+ // the window, its 48 title bar (the title 13 bold, 48 in) over the content at
494
+ // its ideal height, and a 60 bar at the bottom with the text actions (cancel,
495
+ // confirm) as push buttons at its trailing end, 16 in and 8 apart; at least
496
+ // 40 clear of the window's edges.
430
497
  const MAC_SHEET_WIDTH = 470;
431
- const MAC_SHEET_TITLE_BAR = 40;
498
+ const MAC_SHEET_TITLE_BAR = 48;
499
+ const MAC_SHEET_TITLE_X = 48;
432
500
  const MAC_SHEET_ACTIONS_BAR = 60;
433
501
  const MAC_SHEET_ACTIONS_INSET = 16;
434
502
  const MAC_SHEET_ACTIONS_GAP = 8;
@@ -675,25 +743,25 @@ const ALERT_METRICS: AlertMetrics = {
675
743
  fieldsGap: ALERT_FIELDS_GAP,
676
744
  textAlignment: 'leading',
677
745
  };
678
- // Measured in the Mac app: the app icon (54, which the page draws as a
679
- // placeholder) 25 under the top and 25 over the title, the message 9 under
680
- // it, 28-tall buttons 17 under the message and 16 over the bottom, two
681
- // side by side 8 apart.
746
+ // Measured in the Mac app (macOS 26): no app icon; the title (13 bold) 20
747
+ // under the top and 22 in, the message (13 regular) 10 under it, both
748
+ // leading-aligned; 28-tall capsule buttons 16 under the message, 16 in and
749
+ // 16 over the bottom, two side by side 8 apart (stacked 4 apart).
682
750
  const MAC_ALERT_METRICS: AlertMetrics = {
683
751
  width: 260,
684
- padTop: 104,
685
- padH: 16,
752
+ padTop: 20,
753
+ padH: 22,
686
754
  buttonInset: 16,
687
- buttonsGap: 17,
755
+ buttonsGap: 16,
688
756
  padBottom: 16,
689
- buttonSpacing: 8,
757
+ buttonSpacing: 4,
690
758
  rowSpacing: 8,
691
- titleGap: 9,
759
+ titleGap: 10,
692
760
  buttonHeight: 28,
693
761
  fieldHeight: 22,
694
762
  fieldSpacing: 8,
695
763
  fieldsGap: 12,
696
- textAlignment: 'center',
764
+ textAlignment: 'leading',
697
765
  };
698
766
  /** Gap between an action sheet's title and its message. */
699
767
  const DIALOG_TITLE_GAP = 4;
@@ -1941,7 +2009,7 @@ export class LayoutPass {
1941
2009
  return { ...ctx, barAbove: undefined, stackAxis: null, listRow: false };
1942
2010
  }
1943
2011
  case 'toolbar': {
1944
- const placement = str(node.props['placement']);
2012
+ const placement = this.barPlacement(node);
1945
2013
  const navBarItem = placement === 'leading' || placement === 'trailing';
1946
2014
  const bottomBarItem = placement === 'bottom';
1947
2015
  if (ctx.stackAxis === null && !ctx.listRow && ctx.navBarItem === navBarItem && ctx.bottomBarItem === bottomBarItem) return ctx;
@@ -2385,8 +2453,10 @@ export class LayoutPass {
2385
2453
  * checkbox before the label, or the small switch in a list row (a grouped
2386
2454
  * form's), at the trailing edge.
2387
2455
  */
2388
- private switchMetrics(_ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
2456
+ private switchMetrics(ctx: Context): { width: number; height: number; gap: number; checkbox: boolean } {
2389
2457
  if (this.env.platform !== 'macos') return { width: SWITCH_WIDTH, height: SWITCH_HEIGHT, gap: TOGGLE_GAP, checkbox: false };
2458
+ // A grouped form's toggle is a switch at the row's trailing end (macOS 26).
2459
+ if (ctx.groupedRow) return { width: MAC_GROUPED_SWITCH_WIDTH, height: MAC_GROUPED_SWITCH_HEIGHT, gap: MAC_GROUPED_LABEL_GAP, checkbox: false };
2390
2460
  // A Mac's toggle is a checkbox before its label, in a list row too.
2391
2461
  return { width: MAC_CHECKBOX_SIZE, height: MAC_CHECKBOX_SIZE, gap: MAC_CHECKBOX_GAP, checkbox: true };
2392
2462
  }
@@ -2483,6 +2553,17 @@ export class LayoutPass {
2483
2553
  return { x: Math.round((screen.width - width) / 2), y: Math.round(top + (room.height - height) / 2), width, height };
2484
2554
  }
2485
2555
 
2556
+ /**
2557
+ * Where a `toolbar` is drawn: its placement, except that a Mac has no
2558
+ * bottom bar (SwiftUI's `.bottomBar` is iOS's): its items join the
2559
+ * toolbar's trailing ones, in order, as the native renderer places them
2560
+ * (`.automatic` there).
2561
+ */
2562
+ private barPlacement(toolbar: LayoutNode): string | null {
2563
+ const placement = str(toolbar.props['placement']);
2564
+ return placement === 'bottom' && this.env.platform === 'macos' ? 'trailing' : placement;
2565
+ }
2566
+
2486
2567
  /** The toolbars of a Mac sheet's root screen that go in its bottom bar: those whose items are all text buttons (a cancel or confirm action). */
2487
2568
  private macSheetActionToolbars(screen: LayoutNode): LayoutNode[] {
2488
2569
  return this.navScreenParts(screen).toolbars.filter((toolbar) => {
@@ -2583,12 +2664,23 @@ export class LayoutPass {
2583
2664
  const mac = this.env.platform === 'macos';
2584
2665
  const font = mac ? makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400) : makeFont('system', PICKER_SEGMENT_FONT_SIZE, 600);
2585
2666
  let natural = 0;
2586
- for (const option of options) natural += this.measurer.measureWidth(option, font) + PICKER_SEGMENT_PAD;
2667
+ let widest = 0;
2668
+ for (const option of options) {
2669
+ const optionWidth = this.measurer.measureWidth(option, font);
2670
+ natural += optionWidth + PICKER_SEGMENT_PAD;
2671
+ widest = Math.max(widest, optionWidth);
2672
+ }
2673
+ // A Mac's segments are equal, the widest label and 18 (macOS 26).
2674
+ if (mac) natural = options.length * Math.ceil(widest + MAC_SEGMENT_PAD);
2587
2675
  // A Mac shows the picker's label before the control (in a form's row it is in the label column).
2588
2676
  const labelText = mac && !ctx.formColumn ? (str(node.props['label']) ?? '') : '';
2589
2677
  const labelWidth = labelText.length > 0 && !ctx.navBarItem ? this.measurer.measureWidth(labelText, ctx.font) + MAC_SEGMENTED_LABEL_GAP : 0;
2678
+ // On a Mac it is as wide as its segments; elsewhere it fills the width offered.
2679
+ const width = mac
2680
+ ? Math.min(natural + labelWidth, finite(p.width) ? p.width : Infinity)
2681
+ : finite(p.width) ? p.width : natural + labelWidth;
2590
2682
  return {
2591
- size: { width: finite(p.width) ? p.width : natural + labelWidth, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
2683
+ size: { width, height: mac ? MAC_SEGMENTED_HEIGHT : PICKER_SEGMENTED_HEIGHT },
2592
2684
  style: 'segmented',
2593
2685
  font,
2594
2686
  options,
@@ -2605,11 +2697,14 @@ export class LayoutPass {
2605
2697
  const mac = this.env.platform === 'macos';
2606
2698
  // The Mac's pop-up button holds the value and the chevrons in a bordered button.
2607
2699
  const popup = mac && (!ctx.listRow || ctx.formColumn === true);
2608
- const valueWidth = ctx.formColumn && finite(p.width) ? p.width : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH + (popup ? 2 * MAC_PICKER_POPUP_PAD : 0);
2700
+ // macOS 26: a pop-up is as wide as its widest option, 13 before the label and the chevrons 9 from its end.
2701
+ const popupWidth = (): number => Math.ceil(Math.max(...options.map((option) => this.measurer.measureWidth(option, font)), 0) + MAC_PICKER_POPUP_EXTRA);
2702
+ const valueWidth = popup ? popupWidth() : this.measurer.measureWidth(value, font) + PICKER_MENU_CHEVRON_WIDTH;
2609
2703
  const natural = labelWidth + PICKER_MENU_GAP + valueWidth;
2610
2704
  return {
2611
2705
  size: {
2612
- width: finite(p.width) ? p.width : natural,
2706
+ // In a Mac columns form's row: the pop-up alone, at its width.
2707
+ width: ctx.formColumn ? Math.min(valueWidth, finite(p.width) ? p.width : Infinity) : finite(p.width) ? p.width : natural,
2613
2708
  height: ctx.listRow && !ctx.formColumn ? (mac ? MAC_PICKER_MENU_ROW_HEIGHT : PICKER_MENU_ROW_HEIGHT) : mac ? MAC_PICKER_MENU_HEIGHT : PICKER_MENU_HEIGHT,
2614
2709
  },
2615
2710
  style: 'menu',
@@ -2729,7 +2824,7 @@ export class LayoutPass {
2729
2824
  // A disclosure group's content sits 8 under its label, as SwiftUI's does.
2730
2825
  const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
2731
2826
  // A Mac list row's Label (no icon column there): the title 6 after the icon.
2732
- if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [MAC_LIST_LABEL_GAP];
2827
+ if (spacing === null && axis === 'horizontal' && cctx.listCell === true && labelColumnOf(node)) return [cctx.sidebarList ? MAC_LIST_SIDEBAR_LABEL_GAP : MAC_LIST_LABEL_GAP];
2733
2828
  if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
2734
2829
  const gaps: number[] = [];
2735
2830
  for (let i = 0; i < n - 1; i++) {
@@ -3196,9 +3291,15 @@ export class LayoutPass {
3196
3291
  // List
3197
3292
  // -------------------------------------------------------------------------
3198
3293
 
3294
+ /** A list's style: its own, but on a Mac an automatic list in a split view's sidebar is a sidebar, as SwiftUI's is. */
3295
+ private listStyleOf(node: LayoutNode, ctx: Context): string {
3296
+ const style = str(node.props['style']) ?? 'insetGrouped';
3297
+ return this.env.platform === 'macos' && ctx.splitSidebar && style === 'insetGrouped' ? 'sidebar' : style;
3298
+ }
3299
+
3199
3300
  private listPlan(node: LayoutNode, ctx: Context, p: Proposal): ListPlan {
3200
3301
  return this.plan(node, ctx, p, () => {
3201
- if (this.env.platform === 'macos' && node.props['form'] === true) return this.macFormPlan(node, ctx, p);
3302
+ if (this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] !== 'grouped') return this.macFormPlan(node, ctx, p);
3202
3303
  const { screen } = this.env;
3203
3304
  const width = finite(p.width) ? p.width : p.width === Infinity ? Infinity : screen.width;
3204
3305
  const layoutWidth = Number.isFinite(width) ? width : screen.width;
@@ -3206,39 +3307,51 @@ export class LayoutPass {
3206
3307
  // sections are full width. `plain` also drops the top/bottom gaps and
3207
3308
  // the space between sections. `sidebar` (iPadOS) is laid out like
3208
3309
  // `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
3209
- const listStyle = str(node.props['style']) ?? 'insetGrouped';
3310
+ const listStyle = this.listStyleOf(node, ctx);
3210
3311
  // In a compact width (an iPhone) a `sidebar` list looks like `insetGrouped`
3211
3312
  // (cards, chevrons, 52pt rows) and only its sections' folding remains.
3212
3313
  const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
3213
- // `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
3314
+ // `.environment(\.defaultMinListRowHeight, _)` (the list's `minRowHeight`); 52 by default (44 in a sidebar, 24 on a Mac).
3214
3315
  // The Mac idiom (MAC_LIST_* above): smaller rows, margins and gaps,
3215
3316
  // 11-point bold headers, a sidebar's rows inset 10 and a grouped
3216
3317
  // form's cards 20.
3217
3318
  const mac = this.env.platform === 'macos';
3319
+ const macGrouped = mac && node.props['form'] === true && node.props['formStyle'] === 'grouped';
3218
3320
  const minRowHeight =
3219
3321
  num(node.props['minRowHeight']) ??
3322
+ (macGrouped ? MAC_GROUPED_ROW_HEIGHT : null) ??
3220
3323
  (sidebarStyle ? (mac ? MAC_LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_SIDEBAR_ROW_MIN_HEIGHT) : mac ? MAC_LIST_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
3221
3324
  // A Mac's lists have no cards: the rows run the whole width (a
3222
3325
  // sidebar's inset 10).
3223
3326
  const inset = mac
3224
- ? listStyle === 'sidebar'
3225
- ? MAC_LIST_INSET_SIDEBAR
3226
- : 0
3327
+ ? macGrouped
3328
+ ? Math.max(MAC_GROUPED_MARGIN, (layoutWidth - MAC_GROUPED_MAX_WIDTH) / 2)
3329
+ : listStyle === 'sidebar'
3330
+ ? MAC_LIST_INSET_SIDEBAR
3331
+ // SwiftUI's inset list style: the list's own 16, which
3332
+ // `listRowInsets` does not touch (its rows have no padding of
3333
+ // their own); a row's separator runs to the trailing edge of it.
3334
+ : MAC_LIST_CELL_PADDING
3227
3335
  : listStyle === 'insetGrouped' || listStyle === 'sidebar'
3228
3336
  ? LIST_INSET
3229
3337
  : 0;
3230
3338
  const plain = listStyle === 'plain';
3231
- const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
3232
- const listTop = plain ? 0 : mac ? MAC_LIST_TOP : LIST_TOP;
3233
- const listBottom = plain ? 0 : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
3234
- const sectionGap = plain ? 0 : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
3235
- const headerGap = mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
3339
+ const cellPadding = mac
3340
+ ? macGrouped ? MAC_GROUPED_CELL_PADDING : listStyle === 'sidebar' ? MAC_LIST_SIDEBAR_CELL_PADDING : 0
3341
+ : LIST_CELL_PADDING;
3342
+ const macSidebar = mac && listStyle === 'sidebar';
3343
+ const listTop = plain || macSidebar ? 0 : macGrouped ? MAC_GROUPED_TOP : mac ? MAC_LIST_TOP : LIST_TOP;
3344
+ const listBottom = plain ? 0 : macGrouped ? MAC_GROUPED_TOP : mac ? MAC_LIST_BOTTOM : LIST_BOTTOM;
3345
+ const sectionGap = plain ? 0 : macGrouped ? MAC_GROUPED_SECTION_GAP : macSidebar ? MAC_LIST_SIDEBAR_SECTION_GAP : mac ? MAC_LIST_SECTION_GAP : LIST_SECTION_GAP;
3346
+ const headerGap = macGrouped ? MAC_GROUPED_HEADER_GAP : macSidebar ? MAC_LIST_SIDEBAR_HEADER_GAP : mac ? MAC_LIST_HEADER_GAP : LIST_HEADER_GAP;
3236
3347
  const cardX = inset;
3237
3348
  const cardWidth = Math.max(0, layoutWidth - 2 * inset);
3238
3349
  const contentX = inset + cellPadding;
3239
3350
  const contentWidth = Math.max(0, layoutWidth - 2 * (inset + cellPadding));
3240
3351
  // iOS 26: headers in the headline size (semibold), footers in footnote.
3241
- const headerFont = mac
3352
+ const headerFont = macGrouped
3353
+ ? makeFont('system', textStyleSize('body', this.env.dynamicTypeSize, 'macos'), 600)
3354
+ : mac
3242
3355
  ? makeFont('system', textStyleSize('subheadline', this.env.dynamicTypeSize, 'macos'), 700)
3243
3356
  : makeFont('system', textStyleSize('headline', this.env.dynamicTypeSize, this.env.platform), 600);
3244
3357
  const footerFont = makeFont(
@@ -3247,7 +3360,15 @@ export class LayoutPass {
3247
3360
  400,
3248
3361
  );
3249
3362
  // A Mac's rows have no icon column: a Label is its icon and title, 6 apart.
3250
- const rowCtx: Context = { ...this.childContext(node, ctx), listRow: true, labelColumn: !mac, listCell: mac, sidebarList: str(node.props['style']) === 'sidebar' };
3363
+ const rowCtx: Context = {
3364
+ ...this.childContext(node, ctx),
3365
+ listRow: true,
3366
+ labelColumn: !mac,
3367
+ listCell: mac,
3368
+ sidebarList: listStyle === 'sidebar',
3369
+ ...(mac && listStyle === 'sidebar' ? { lineLimit: 1 } : {}),
3370
+ ...(macGrouped ? { groupedRow: true } : {}),
3371
+ };
3251
3372
  // Phase 8: in edit mode a selectable or deletable `listrow` gains a 44
3252
3373
  // leading control (6 in from the card's edge) and its content shifts
3253
3374
  // right by 40; a movable one a trailing grip, which narrows it by 40.
@@ -3284,6 +3405,8 @@ export class LayoutPass {
3284
3405
  };
3285
3406
 
3286
3407
  const groups: ListGroupPlan[] = [];
3408
+ // A Mac grouped form's text fields: their titles lead their rows (the page draws them as the columns form's labels).
3409
+ const fieldLabels: FormLabelPlan[] = [];
3287
3410
  let y = listTop;
3288
3411
  const makeText = (text: string, font: ResolvedFont): { layout: TextLayout; size: Size } => {
3289
3412
  const wrapped = wrapText(text, font, contentWidth, null, this.measurer);
@@ -3299,6 +3422,8 @@ export class LayoutPass {
3299
3422
  const footed = previous.footer !== null || (previous.footerView !== null && previous.footerView.rect.height > 0);
3300
3423
  y += plain || mac ? sectionGap : footed ? LIST_SECTION_GAP_AFTER_FOOTER : sectionGap;
3301
3424
  }
3425
+ // A Mac list's first header sits 4 above where its first row would.
3426
+ if (mac && !macGrouped && !macSidebar && !previous && (source.headerView !== null || (source.section !== null && (str(source.section.props['header']) ?? '').length > 0))) y -= MAC_LIST_FIRST_HEADER_LIFT;
3302
3427
  const top = y;
3303
3428
  let header: ListGroupPlan['header'] = null;
3304
3429
  let footer: ListGroupPlan['footer'] = null;
@@ -3311,10 +3436,12 @@ export class LayoutPass {
3311
3436
  const footerText = source.section ? str(source.section.props['footer']) : null;
3312
3437
  if (headerText !== null && headerText.length > 0) {
3313
3438
  const { layout, size } = makeText(headerText, headerFont);
3314
- if (mac && previous) y += MAC_LIST_HEADER_TOP;
3439
+ if (mac && !macGrouped && previous) y += MAC_LIST_HEADER_TOP;
3440
+ else if (macSidebar) y += MAC_LIST_SIDEBAR_HEADER_TOP;
3315
3441
  const headerY = headerView && headerView.rect.height > 0 ? y : y - headerLift;
3316
- header = { rect: { x: contentX, y: headerY, width: contentWidth, height: size.height }, layout };
3317
- y = headerY + size.height + headerGap + (mac && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
3442
+ const headerX = macSidebar ? inset + MAC_LIST_SIDEBAR_HEADER_INSET : contentX;
3443
+ header = { rect: { x: headerX, y: headerY, width: contentWidth, height: size.height }, layout };
3444
+ y = headerY + size.height + headerGap + (mac && !macSidebar && !macGrouped && !previous ? MAC_LIST_FIRST_ROWS_GAP : 0);
3318
3445
  }
3319
3446
  const cardTop = y;
3320
3447
  const rows: ListRowPlan[] = [];
@@ -3332,7 +3459,9 @@ export class LayoutPass {
3332
3459
  // `listRowInsets` replaces all four insets; a `badge` reserves its
3333
3460
  // width (plus a gap) at the trailing edge.
3334
3461
  const hints = rowHintsOf(rowNode);
3335
- const defaultVPad = listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
3462
+ const defaultVPad = macGrouped
3463
+ ? unwrapStyled(rowNode).kind === 'button' ? MAC_GROUPED_BUTTON_ROW_VPAD : MAC_GROUPED_ROW_VPAD
3464
+ : listRowVPad(unwrapStyled(rowNode), sidebarStyle, mac);
3336
3465
  // An outline's rows start LIST_OUTLINE_INDENT further in per level.
3337
3466
  const indent = rowSource.depth * LIST_OUTLINE_INDENT;
3338
3467
  const insets: EdgeInsets = hints.insets
@@ -3345,7 +3474,9 @@ export class LayoutPass {
3345
3474
  };
3346
3475
  // A group's label row keeps the chevron's width at its trailing edge, as a link's does (a Mac's rows have no chevron).
3347
3476
  const chevronReserve = rowSource.group && !mac ? LIST_CHEVRON_WIDTH : 0;
3348
- const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, rowCtx.font) : 0;
3477
+ // A Mac sidebar's badge is the small (11) text (macOS 26).
3478
+ const badgeFont = macSidebar ? makeFont('system', MAC_LIST_SIDEBAR_BADGE_FONT_SIZE, 400) : rowCtx.font;
3479
+ const badgeWidth = hints.badge !== null ? this.measurer.measureWidth(hints.badge, badgeFont) : 0;
3349
3480
  const badgeReserve = hints.badge !== null ? badgeWidth + BADGE_GAP : 0;
3350
3481
  // Phase 8 edit mode: a selectable or deletable `listrow` gains a
3351
3482
  // leading control and its content shifts right; a movable one a
@@ -3355,8 +3486,12 @@ export class LayoutPass {
3355
3486
  const hasGrip = listRow !== null && listRow.props['movable'] === true;
3356
3487
  const editShift = hasControl ? LIST_EDIT_CONTROL_SHIFT : 0;
3357
3488
  const gripReserve = hasGrip ? LIST_EDIT_CONTROL_SHIFT : 0;
3358
- const rowContentX = cardX + insets.leading + editShift;
3359
- const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve);
3489
+ const fieldNode = macGrouped ? unwrapStyled(rowNode) : null;
3490
+ const fieldLabel = fieldNode && fieldNode.kind === 'textfield' ? str(fieldNode.props['placeholder']) || null : null;
3491
+ const fieldLabelWidth = fieldLabel ? this.measurer.measureWidth(fieldLabel, rowCtx.font) : 0;
3492
+ const fieldShift = fieldLabel ? Math.ceil(fieldLabelWidth) + MAC_GROUPED_LABEL_GAP : 0;
3493
+ const rowContentX = cardX + insets.leading + editShift + fieldShift;
3494
+ const rowContentWidth = Math.max(0, cardWidth - insets.leading - insets.trailing - badgeReserve - editShift - gripReserve - chevronReserve - fieldShift);
3360
3495
  const proposal: Proposal = { width: rowContentWidth, height: null };
3361
3496
  const contentSize = this.measure(rowNode, rowCtx, proposal);
3362
3497
  const rowHeight = Math.max(minRowHeight, contentSize.height + insets.top + insets.bottom);
@@ -3376,7 +3511,7 @@ export class LayoutPass {
3376
3511
  hints.badge !== null
3377
3512
  ? {
3378
3513
  rect: { x: cardX + cardWidth - insets.trailing - badgeWidth, y, width: badgeWidth, height: rowHeight },
3379
- layout: { font: rowCtx.font, lines: [hints.badge], alignment: 'trailing', truncated: false },
3514
+ layout: { font: badgeFont, lines: [hints.badge], alignment: 'trailing', truncated: false },
3380
3515
  }
3381
3516
  : null,
3382
3517
  edit: listRow
@@ -3391,7 +3526,17 @@ export class LayoutPass {
3391
3526
  separatorHidden: hints.separatorHidden,
3392
3527
  outline: rowSource.group || rowSource.depth > 0 ? { depth: rowSource.depth, group: rowSource.group } : null,
3393
3528
  });
3529
+ if (fieldLabel) {
3530
+ fieldLabels.push({
3531
+ rect: { x: cardX + insets.leading, y: y + (rowHeight - rowCtx.font.lineHeight) / 2, width: Math.ceil(fieldLabelWidth), height: rowCtx.font.lineHeight },
3532
+ layout: { font: rowCtx.font, lines: [fieldLabel], alignment: 'leading', truncated: false },
3533
+ node: null,
3534
+ ctx: rowCtx,
3535
+ });
3536
+ }
3394
3537
  y += rowHeight;
3538
+ // A Mac grouped form's separators are a point between its rows.
3539
+ if (macGrouped && rowSource !== source.rows[source.rows.length - 1]) y += MAC_GROUPED_SEPARATOR;
3395
3540
  }
3396
3541
  // Each group's rect: its label's row, then the rows under it that sit deeper, up to the next at its level or above.
3397
3542
  const outlines: ListGroupPlan['outlines'] = [];
@@ -3406,10 +3551,10 @@ export class LayoutPass {
3406
3551
  });
3407
3552
  const card: Rect = { x: cardX, y: cardTop, width: cardWidth, height: y - cardTop };
3408
3553
  if (footerText !== null && footerText.length > 0) {
3409
- y += mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
3554
+ y += macGrouped ? MAC_GROUPED_FOOTER_GAP : mac ? MAC_LIST_FOOTER_GAP : LIST_FOOTER_GAP;
3410
3555
  const { layout, size } = makeText(footerText, footerFont);
3411
3556
  footer = { rect: { x: contentX, y, width: contentWidth, height: size.height }, layout };
3412
- y += size.height + (mac ? MAC_LIST_FOOTER_BOTTOM : 0);
3557
+ y += size.height + (mac && !macGrouped ? MAC_LIST_FOOTER_BOTTOM : 0);
3413
3558
  }
3414
3559
  let footerView = supplement(source.footerView, y + LIST_FOOTER_GAP, footerCtx);
3415
3560
  if (footerView && footerView.rect.height > 0) y += LIST_FOOTER_GAP + footerView.rect.height;
@@ -3431,7 +3576,7 @@ export class LayoutPass {
3431
3576
  width,
3432
3577
  height: p.height === null ? contentHeight : p.height,
3433
3578
  };
3434
- return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups };
3579
+ return { size, contentSize: { width: layoutWidth, height: contentHeight }, groups, ...(fieldLabels.length > 0 ? { labels: fieldLabels } : {}) };
3435
3580
  });
3436
3581
  }
3437
3582
 
@@ -3685,6 +3830,9 @@ export class LayoutPass {
3685
3830
  width: LIST_CHEVRON_WIDTH,
3686
3831
  height: size.height,
3687
3832
  };
3833
+ } else if (ctx.groupedRow) {
3834
+ // A grouped form's link has the small chevron at its trailing end (macOS 26).
3835
+ chrome['chevron'] = { x: x + size.width - MAC_GROUPED_CHEVRON_WIDTH, y, width: MAC_GROUPED_CHEVRON_WIDTH, height: size.height };
3688
3836
  }
3689
3837
  this.setChrome(node, chrome);
3690
3838
  }
@@ -4144,9 +4292,17 @@ export class LayoutPass {
4144
4292
  const columns = plan.columns === true;
4145
4293
  const rowCtx: Context = columns
4146
4294
  ? { ...this.childContext(node, ctx), listRow: true, labelColumn: false, listCell: true, formColumn: true, sidebarList: false }
4147
- : { ...this.childContext(node, ctx), listRow: true, labelColumn: this.env.platform !== 'macos', listCell: this.env.platform === 'macos', sidebarList: str(node.props['style']) === 'sidebar' };
4295
+ : {
4296
+ ...this.childContext(node, ctx),
4297
+ listRow: true,
4298
+ labelColumn: this.env.platform !== 'macos',
4299
+ listCell: this.env.platform === 'macos',
4300
+ sidebarList: this.listStyleOf(node, ctx) === 'sidebar',
4301
+ ...(this.env.platform === 'macos' && this.listStyleOf(node, ctx) === 'sidebar' ? { lineLimit: 1 } : {}),
4302
+ ...(this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] === 'grouped' ? { groupedRow: true } : {}),
4303
+ };
4148
4304
  const mac = this.env.platform === 'macos';
4149
- const cellPadding = mac ? MAC_LIST_CELL_PADDING : LIST_CELL_PADDING;
4305
+ const cellPadding = mac ? (this.listStyleOf(node, ctx) === 'sidebar' ? MAC_LIST_SIDEBAR_CELL_PADDING : MAC_LIST_CELL_PADDING) : LIST_CELL_PADDING;
4150
4306
  const covered = this.coveredBottom(ctx, x, y, plan.size);
4151
4307
  // Under its screen's bar (and the large title that scrolls with it), the
4152
4308
  // rows start below them.
@@ -4154,6 +4310,10 @@ export class LayoutPass {
4154
4310
  const listChrome: Record<string, Rect> = {};
4155
4311
  const listTexts: Record<string, TextLayout> = {};
4156
4312
  if (covered) listChrome['bottomInset'] = covered;
4313
+ // The page draws a sidebar's look (`data-sb-list-look`) for a list the engine laid out as one.
4314
+ if (this.listStyleOf(node, ctx) === 'sidebar') listChrome['sidebarLook'] = { x, y, width: plan.size.width, height: plan.size.height };
4315
+ // A Mac's grouped form: the page draws its cards.
4316
+ if (this.env.platform === 'macos' && node.props['form'] === true && node.props['formStyle'] === 'grouped') listChrome['grouped'] = { x, y, width: plan.size.width, height: plan.size.height };
4157
4317
  if (scrollInset) listChrome['topInset'] = { x, y, width: plan.size.width, height: scrollInset.bar };
4158
4318
  if (scrollInset?.largeTitle) listChrome['largeTitle'] = scrollInset.largeTitle;
4159
4319
  const inset = scrollInset ? scrollInset.bar + (scrollInset.largeTitle?.height ?? 0) + (scrollInset.search ?? 0) : 0;
@@ -4162,8 +4322,8 @@ export class LayoutPass {
4162
4322
  const offset = (r: Rect): Rect => ({ x: x + r.x, y: y + r.y, width: r.width, height: r.height });
4163
4323
  // The Mac idiom's columns form: the label column (chrome `formLabel<i>`
4164
4324
  // with its text, or a view placed there) and the flag for the page.
4165
- if (columns) {
4166
- listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
4325
+ if (columns) listChrome['columns'] = { x, y, width: plan.size.width, height: plan.size.height };
4326
+ {
4167
4327
  (plan.labels ?? []).forEach((label, i) => {
4168
4328
  if (label.layout) {
4169
4329
  listChrome[`formLabel${i}`] = offset(label.rect);
@@ -4365,10 +4525,12 @@ export class LayoutPass {
4365
4525
  // the unified bar takes over; the row is the whole bar.
4366
4526
  const mac = this.env.platform === 'macos';
4367
4527
  const hasBarItems = parts.toolbars.some((t) => {
4368
- const placement = str(t.props['placement']);
4528
+ const placement = this.barPlacement(t);
4369
4529
  return placement !== 'bottom' && placement !== 'keyboard' && placement !== 'largeTitle' && str(t.props['spacer']) === null;
4370
4530
  });
4371
- const searchInBar = mac && parts.search !== null && !barHidden;
4531
+ // On a Mac the field is the toolbar's, or over a sidebar's list.
4532
+ const searchInSidebar = mac && parts.search !== null && !barHidden && cctx.splitSidebar;
4533
+ const searchInBar = mac && parts.search !== null && !barHidden && !searchInSidebar;
4372
4534
  // A Mac tab view's toolbar row (the window's top): the tab's root screen
4373
4535
  // draws its bar as that row, without a title (the tab picker is there).
4374
4536
  const tabToolbar = mac && cctx.tabToolbar !== undefined && y <= cctx.tabToolbar.y + EPSILON ? cctx.tabToolbar : undefined;
@@ -4380,13 +4542,14 @@ export class LayoutPass {
4380
4542
  const glass = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
4381
4543
  const glassPad = mac ? MAC_BAR_ITEM_PAD_H : NAV_GLASS_PAD_H;
4382
4544
  const runGap = mac ? MAC_BAR_RUN_GAP : NAV_RUN_GAP;
4545
+ const groupGap = mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING;
4383
4546
  // A compact height (a phone on its side) has no large titles, whatever the
4384
4547
  // display mode, as UIKit: the title is the bar's inline one.
4385
4548
  const large = !mac && !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
4386
4549
  const barHeight = barHidden ? statusBar : mac ? Math.max(statusBar, rowHeight) : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
4387
4550
  const reachesBottom = y + size.height >= screen.height - EPSILON;
4388
4551
  const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
4389
- const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
4552
+ const placementOf = (t: LayoutNode): string | null => this.barPlacement(t);
4390
4553
  const inHiddenBar = (t: LayoutNode): boolean => {
4391
4554
  const placement = placementOf(t);
4392
4555
  if (placement === 'keyboard') return false;
@@ -4409,8 +4572,8 @@ export class LayoutPass {
4409
4572
  : 0;
4410
4573
  // In a tab view (a tab bar covers the bottom) the field goes under the
4411
4574
  // title, as iOS 26 places it; elsewhere it floats at the bottom.
4412
- const searchOnTop = !mac && parts.search !== null && cctx.bottomBar > 0;
4413
- const searchRow = searchOnTop ? SEARCH_TOP_ROW : 0;
4575
+ const searchOnTop = (!mac && parts.search !== null && cctx.bottomBar > 0) || searchInSidebar;
4576
+ const searchRow = searchInSidebar ? MAC_SIDEBAR_SEARCH_ABOVE + MAC_SIDEBAR_SEARCH_HEIGHT + MAC_SIDEBAR_SEARCH_BELOW : searchOnTop ? SEARCH_TOP_ROW : 0;
4414
4577
  const searchBottom = parts.search && !searchOnTop && !searchInBar ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
4415
4578
  // The on-screen keyboard (device mode) covers the screen's bottom. A
4416
4579
  // bottom search field rises above it, as iOS 26 docks it on the
@@ -4443,12 +4606,12 @@ export class LayoutPass {
4443
4606
  }
4444
4607
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
4445
4608
  if (parts.search && !searchInBar) {
4446
- const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
4609
+ const marginH = searchInSidebar ? MAC_SIDEBAR_SEARCH_MARGIN_H : searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
4447
4610
  chrome['searchField'] = {
4448
4611
  x: x + marginH,
4449
- y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchLift - searchBottom,
4612
+ y: searchOnTop ? y + barHeight + largeHeight + (searchInSidebar ? MAC_SIDEBAR_SEARCH_ABOVE : SEARCH_TOP_ABOVE) : y + size.height - searchLift - searchBottom,
4450
4613
  width: Math.max(0, size.width - 2 * marginH),
4451
- height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
4614
+ height: searchInSidebar ? MAC_SIDEBAR_SEARCH_HEIGHT : searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
4452
4615
  };
4453
4616
  // The page draws a field under the title as the bar's (a gray capsule)
4454
4617
  // and moves it with the content when that scrolls under the bar.
@@ -4514,7 +4677,7 @@ export class LayoutPass {
4514
4677
  const groups: Group[] = barToolbars.map((toolbar) => {
4515
4678
  const itemCtx = this.childContext(toolbar, cctx);
4516
4679
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
4517
- const placement = str(toolbar.props['placement']) ?? 'trailing';
4680
+ const placement = placementOf(toolbar) ?? 'trailing';
4518
4681
  if (placement === 'leading' || placement === 'trailing') {
4519
4682
  const slots = sizes.map((s, i) => navSlot(toolbar.children[i] as LayoutNode, s.width, glass, glassPad));
4520
4683
  return { toolbar, placement, sizes, slots, total: slots.reduce((sum, w) => sum + w, 0) };
@@ -4541,10 +4704,10 @@ export class LayoutPass {
4541
4704
  const slots = run.groups.flatMap((g) => g.slots ?? []);
4542
4705
  if (slots.length === 0) return 0;
4543
4706
  const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * runGap;
4544
- return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
4707
+ return slots.length > 1 ? inner + 2 * (mac ? MAC_BAR_RUN_PAD : NAV_RUN_PAD) : inner;
4545
4708
  };
4546
4709
  for (const toolbar of parts.toolbars) {
4547
- const placement = str(toolbar.props['placement']);
4710
+ const placement = placementOf(toolbar);
4548
4711
  if (placement !== 'leading' && placement !== 'trailing') continue;
4549
4712
  if (isSpacer(toolbar)) {
4550
4713
  delete open[placement];
@@ -4559,6 +4722,12 @@ export class LayoutPass {
4559
4722
  runs.push({ placement, groups: [group], width: 0, segmented: true });
4560
4723
  continue;
4561
4724
  }
4725
+ // A Mac's text item is a capsule of its own (macOS 26), neither joining nor joined.
4726
+ if (mac && toolbar.children.some((item) => !isSymbolItem(item) && unwrapStyled(item).kind === 'button')) {
4727
+ delete open[placement];
4728
+ runs.push({ placement, groups: [group], width: 0 });
4729
+ continue;
4730
+ }
4562
4731
  // An item that hides the bar's shared glass stands alone, at its own width.
4563
4732
  if (str(toolbar.props['sharedBackground']) === 'hidden') {
4564
4733
  delete open[placement];
@@ -4576,7 +4745,7 @@ export class LayoutPass {
4576
4745
  }
4577
4746
  for (const run of runs) run.width = runWidth(run);
4578
4747
  const runsWidth = (placement: string): number =>
4579
- runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width + NAV_GROUP_SPACING, 0);
4748
+ runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width + groupGap, 0);
4580
4749
  const leadingWidth = runsWidth('leading');
4581
4750
  const trailingWidth = runsWidth('trailing');
4582
4751
  const principal = groups.some((g) => g.placement === 'principal');
@@ -4602,7 +4771,7 @@ export class LayoutPass {
4602
4771
  });
4603
4772
  // Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
4604
4773
  const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
4605
- previous && previous.toolbar !== entry.toolbar ? NAV_GROUP_SPACING : 0;
4774
+ previous && previous.toolbar !== entry.toolbar ? groupGap : 0;
4606
4775
  const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
4607
4776
  entries.reduce((sum, entry, i) => sum + secondaryGap(entries[i - 1], entry) + entry.slot, 0);
4608
4777
  let inlineSecondary: SecondaryItem[] = [];
@@ -4611,7 +4780,7 @@ export class LayoutPass {
4611
4780
  // bar) and the trailing groups; the More button takes a slot of it
4612
4781
  // once anything collapses.
4613
4782
  const titleWidth = principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
4614
- const roomStart = x + (size.width + titleWidth) / 2 + NAV_GROUP_SPACING;
4783
+ const roomStart = x + (size.width + titleWidth) / 2 + groupGap;
4615
4784
  const roomEnd = x + size.width - NAV_ITEM_MARGIN - trailingWidth;
4616
4785
  const fitting = (room: number): SecondaryItem[] => {
4617
4786
  const out: SecondaryItem[] = [];
@@ -4624,7 +4793,7 @@ export class LayoutPass {
4624
4793
  }
4625
4794
  return out;
4626
4795
  };
4627
- inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - NAV_GROUP_SPACING);
4796
+ inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - glass - groupGap);
4628
4797
  }
4629
4798
  const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
4630
4799
 
@@ -4634,7 +4803,7 @@ export class LayoutPass {
4634
4803
  // A Mac window's own title bar (the top of the screen, a title bar in the
4635
4804
  // safe area) starts with the window buttons; the items follow them.
4636
4805
  const windowButtons = mac && y <= 0 && barX <= EPSILON && safeArea.top > 0;
4637
- const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
4806
+ const leadingStart = x + (windowButtons ? MAC_WINDOW_BUTTONS_ROOM : mac ? MAC_BAR_LEADING_MARGIN : NAV_ITEM_MARGIN) + (cctx.barLeading ?? 0);
4638
4807
  const glassY = rowY + (rowHeight - glass) / 2;
4639
4808
  if (depth > 0 && !barHidden) {
4640
4809
  chrome['back'] = { x: leadingStart, y: glassY, width: glass, height: glass };
@@ -4642,20 +4811,21 @@ export class LayoutPass {
4642
4811
 
4643
4812
  // Place the toolbar groups and remember how much of the row they occupy.
4644
4813
  const back = chrome['back'];
4645
- let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
4646
- let trailingEdge = x + size.width - (mac ? (tabToolbar ? MAC_TAB_TOOLBAR_MARGIN_TRAILING : MAC_BAR_MARGIN_TRAILING) : NAV_ITEM_MARGIN);
4814
+ // On a Mac the back button is the first slot of the leading capsule (macOS 26).
4815
+ let leadingCursor = back ? back.x + back.width + (mac ? 0 : groupGap) : leadingStart;
4816
+ let trailingEdge = x + size.width - (mac ? MAC_BAR_MARGIN_TRAILING : NAV_ITEM_MARGIN);
4647
4817
  // On a Mac the search field is the trailing-most item of the title bar
4648
4818
  // (chrome `searchBar` tells the page to draw it as the toolbar's field).
4649
4819
  if (searchInBar) {
4650
4820
  const width = Math.max(0, Math.min(MAC_SEARCH_FIELD_WIDTH, trailingEdge - leadingStart));
4651
- chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_SEARCH_FIELD_HEIGHT) / 2, width, height: MAC_SEARCH_FIELD_HEIGHT };
4821
+ chrome['searchField'] = { x: trailingEdge - width, y: rowY + (rowHeight - MAC_BAR_ITEM_SIZE) / 2, width, height: MAC_BAR_ITEM_SIZE };
4652
4822
  chrome['searchBar'] = chrome['searchField'];
4653
- trailingEdge -= width + NAV_GROUP_SPACING;
4823
+ trailingEdge -= width + groupGap;
4654
4824
  }
4655
4825
  // The More button is the trailing-most item.
4656
4826
  if (collapsedSecondary.length > 0) {
4657
4827
  chrome['more'] = { x: trailingEdge - glass, y: glassY, width: glass, height: glass };
4658
- trailingEdge -= glass + NAV_GROUP_SPACING;
4828
+ trailingEdge -= glass + groupGap;
4659
4829
  }
4660
4830
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
4661
4831
  const itemCtx = this.childContext(group.toolbar, cctx);
@@ -4681,7 +4851,7 @@ export class LayoutPass {
4681
4851
  chrome[`${run.placement}Glass${glassCount[run.placement]++}`] = { x: startX + margin, y: glassY, width: Math.max(0, run.width - 2 * margin), height: glass };
4682
4852
  }
4683
4853
  const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
4684
- let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
4854
+ let cursor = startX + (count > 1 ? (mac ? MAC_BAR_RUN_PAD : NAV_RUN_PAD) : 0);
4685
4855
  for (const group of run.groups) {
4686
4856
  const itemCtx = this.childContext(group.toolbar, cctx);
4687
4857
  const first = cursor;
@@ -4689,31 +4859,34 @@ export class LayoutPass {
4689
4859
  const itemSize = group.sizes[i] as Size;
4690
4860
  const slot = group.slots?.[i] ?? itemSize.width;
4691
4861
  this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (rowHeight - itemSize.height) / 2);
4692
- // A Mac toolbar's text button is a bordered 28 button at its slot
4693
- // (chrome `bordered`, drawn by the page at that rect); an icon is bare.
4694
- if (mac && !run.bare && !isSymbolItem(item) && unwrapStyled(item).kind === 'button' && item.id >= 0) {
4695
- const existing = this.chrome.get(item.id);
4696
- this.setChrome(item, { ...existing, bordered: { x: cursor, y: glassY, width: slot, height: glass } });
4697
- }
4698
4862
  cursor += slot + runGap;
4699
4863
  });
4700
4864
  const last = group.toolbar.children.length > 0 ? cursor - runGap : first;
4701
4865
  this.setFrame(group.toolbar, first, rowY, { width: last - first, height: rowHeight });
4702
4866
  }
4703
4867
  };
4704
- for (const run of runs.filter((r) => r.placement === 'leading')) {
4705
- if (run.width === 0) continue;
4868
+ const leadingRuns = runs.filter((r) => r.placement === 'leading' && r.width > 0);
4869
+ for (const run of leadingRuns) {
4706
4870
  placeRun(run, leadingCursor);
4707
- leadingCursor += run.width + NAV_GROUP_SPACING;
4871
+ leadingCursor += run.width + groupGap;
4872
+ }
4873
+ // A Mac's back button: its capsule is the first leading one's, widened to
4874
+ // take it in, or one of its own.
4875
+ if (mac && back) {
4876
+ const first = leadingRuns[0];
4877
+ const merged = first && !first.bare && !first.segmented ? chrome['leadingGlass0'] : undefined;
4878
+ if (merged) chrome['leadingGlass0'] = { ...merged, x: back.x, width: merged.x + merged.width - back.x };
4879
+ else chrome[`leadingGlass${glassCount.leading++}`] = { ...back };
4880
+ if (leadingRuns.length === 0) leadingCursor = back.x + back.width + groupGap;
4708
4881
  }
4709
4882
  const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
4710
- let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
4883
+ let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - groupGap : 0);
4711
4884
  const trailingStart = trailingCursor;
4712
4885
  for (const run of trailingRuns) {
4713
4886
  placeRun(run, trailingCursor);
4714
- trailingCursor += run.width + NAV_GROUP_SPACING;
4887
+ trailingCursor += run.width + groupGap;
4715
4888
  }
4716
- if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
4889
+ if (trailingRuns.length > 0) trailingEdge = trailingStart - groupGap;
4717
4890
  for (const group of groups) {
4718
4891
  if (group.placement !== 'principal') continue;
4719
4892
  const startX = x + (size.width - group.total) / 2;
@@ -4736,7 +4909,7 @@ export class LayoutPass {
4736
4909
  cursor += entry.slot;
4737
4910
  }
4738
4911
  for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: rowHeight });
4739
- trailingEdge -= total + NAV_GROUP_SPACING;
4912
+ trailingEdge -= total + groupGap;
4740
4913
  }
4741
4914
  for (const toolbar of secondaryToolbars) {
4742
4915
  const items = toolbar.children;
@@ -4795,14 +4968,19 @@ export class LayoutPass {
4795
4968
  // the renderer shows once it has (`data-sb-collapsed`).
4796
4969
  if (tabToolbar) {
4797
4970
  // The tab picker stands where the title would be.
4971
+ } else if (mac && cctx.splitSidebar) {
4972
+ // A Mac's sidebar shows no title.
4798
4973
  } else if ((unified || sheetBar) && !principal && !barHidden) {
4799
4974
  // The unified title bar's title (and a Mac sheet's): leading-aligned
4800
4975
  // after the back button and the leading items (2 past the window
4801
4976
  // buttons' room when nothing is before it), cut to the room before
4802
4977
  // the trailing ones.
4803
4978
  const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0 || searchInBar;
4804
- const titleX = Math.max(leadingStart + (unified && windowButtons && !back && leadingWidth === 0 && (cctx.barLeading ?? 0) === 0 ? MAC_TITLE_INSET : 0), leadingCursor);
4805
- const roomEnd = trailingUsed ? trailingEdge + NAV_GROUP_SPACING - 8 : x + size.width - NAV_ITEM_MARGIN;
4979
+ // A Mac toolbar's title is 12 after the window buttons' room or after the leading capsules.
4980
+ const titleX = unified
4981
+ ? (back || leadingWidth > 0 ? leadingCursor - groupGap : leadingStart) + MAC_TITLE_GAP
4982
+ : Math.max(x + MAC_SHEET_TITLE_X, leadingCursor);
4983
+ const roomEnd = trailingUsed ? trailingEdge + groupGap - 8 : x + size.width - NAV_ITEM_MARGIN;
4806
4984
  const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(Math.max(0, roomEnd - titleX)));
4807
4985
  if (subtitle.length > 0) {
4808
4986
  // The title and the subtitle stacked, centered in the row.
@@ -4817,11 +4995,11 @@ export class LayoutPass {
4817
4995
  } else if ((!large || titleScrolls) && !principal && !barHidden) {
4818
4996
  const leftUsed = Math.max(
4819
4997
  back ? back.x + back.width - x : 0,
4820
- leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
4821
- cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
4998
+ leadingWidth > 0 ? leadingCursor - groupGap - x : 0,
4999
+ cctx.barLeading ? leadingStart - groupGap - x : 0,
4822
5000
  );
4823
5001
  const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
4824
- const rightUsed = trailingUsed ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
5002
+ const rightUsed = trailingUsed ? x + size.width - (trailingEdge + groupGap) : 0;
4825
5003
  // As UIKit places it: centered in the bar when it fits there; else slid
4826
5004
  // off-center into the room the items leave (between them, 8 clear of
4827
5005
  // each side), and truncated only when it does not fit even there.
@@ -4834,7 +5012,9 @@ export class LayoutPass {
4834
5012
  Math.floor(Math.max(0, roomEnd - roomStart)),
4835
5013
  );
4836
5014
  const centered = x + (size.width - titleWidth) / 2;
4837
- const titleX = Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
5015
+ // A Mac's compact title bar: the title leading-aligned after the window buttons (macOS 26).
5016
+ const leading = mac ? x + (windowButtons ? MAC_COMPACT_TITLE_X : NAV_ITEM_MARGIN) : null;
5017
+ const titleX = leading !== null ? Math.max(roomStart, Math.min(leading, roomEnd - titleWidth)) : Math.max(roomStart, Math.min(centered, roomEnd - titleWidth));
4838
5018
  chrome['title'] = { x: titleX, y: rowY, width: titleWidth, height: rowHeight };
4839
5019
  }
4840
5020
  this.setChrome(node, chrome);
@@ -4842,7 +5022,7 @@ export class LayoutPass {
4842
5022
  if (node.id >= 0) {
4843
5023
  this.fonts.set(node.id, large ? largeTitleFont : titleFont);
4844
5024
  const texts: Record<string, TextLayout> = {
4845
- title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar ? 'leading' : 'center', truncated: false },
5025
+ title: { font: titleFont, lines: [shownTitle], alignment: unified || sheetBar || mac ? 'leading' : 'center', truncated: false },
4846
5026
  };
4847
5027
  if (chrome['subtitle']) texts['subtitle'] = { font: subtitleFont, lines: [subtitle], alignment: 'leading', truncated: false };
4848
5028
  if (depth > 0) texts['back'] = { font: backFont, lines: [backLabel], alignment: 'leading', truncated: false };
@@ -5137,38 +5317,34 @@ export class LayoutPass {
5137
5317
  }
5138
5318
 
5139
5319
  /**
5140
- * The Mac idiom's tab view (MAC_TAB_* above): the bar is a segmented
5141
- * control centered in a 44 row at the top of the content, below the
5142
- * window's title bar when the tab view is at the top of the screen; the
5143
- * content is the rest, and the selected tab fills it (its own `navstack`
5144
- * reserves no status bar there: the row is above it). Chrome `top` marks
5145
- * the bar as the top one for the page.
5320
+ * The Mac idiom's tab view (MAC_TAB_* above): the bar is a glass capsule
5321
+ * of equal segments centered in a 52 row at the top: the window's toolbar
5322
+ * when the tab view is at the top of the screen, else a row at the top of
5323
+ * the content. The content is the rest, and the selected tab fills it (its
5324
+ * own `navstack` reserves no status bar there: the row is above it).
5325
+ * Chrome `top` marks the bar as the top one for the page.
5146
5326
  */
5147
5327
  private placeMacTabView(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
5148
5328
  const { safeArea } = this.env;
5149
- // At the top of the window the row is the window's toolbar (38, the
5150
- // title bar included); lower down, a 38 row at the top of the content.
5151
5329
  const atTop = y <= 0 && safeArea.top > 0;
5152
- const topInset = atTop ? 0 : y <= 0 ? safeArea.top : 0;
5153
5330
  const tabs = node.children;
5154
5331
  const n = tabs.length;
5155
- // Each segment is its title (13 regular) and 12 either side.
5332
+ // Each segment is the widest title (13 regular) and 11 either side.
5156
5333
  const font = makeFont('system', MAC_SEGMENTED_FONT_SIZE, 400);
5157
- const itemWidths = tabs.map((tab) => this.measurer.measureWidth(str(tab.props['title']) ?? '', font) + 2 * MAC_TAB_ITEM_PAD_H);
5158
- const natural = itemWidths.reduce((sum, w) => sum + w, 0) + 2 * MAC_TAB_ITEM_INSET;
5334
+ const widest = tabs.reduce((w, tab) => Math.max(w, this.measurer.measureWidth(str(tab.props['title']) ?? '', font)), 0);
5335
+ const itemWidth = widest + 2 * MAC_TAB_ITEM_PAD_H;
5336
+ const natural = n * itemWidth + 2 * MAC_TAB_ITEM_INSET;
5159
5337
  const width = Math.min(Math.max(0, size.width - 2 * MAC_TAB_BAR_MARGIN_H), natural);
5160
- const scale = natural > 0 ? width / natural : 1;
5161
- const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height - topInset));
5162
- const bar: Rect = { x: x + (size.width - width) / 2, y: y + topInset + (rowHeight - MAC_TAB_BAR_HEIGHT) / 2, width, height: Math.min(MAC_TAB_BAR_HEIGHT, rowHeight) };
5163
- const toolbar: Rect = { x, y: y + topInset, width: size.width, height: rowHeight };
5164
- const contentTop = topInset + rowHeight;
5165
- const content: Rect = { x, y: y + contentTop, width: size.width, height: Math.max(0, size.height - contentTop) };
5338
+ const segment = n > 0 ? (width - 2 * MAC_TAB_ITEM_INSET) / n : 0;
5339
+ const rowHeight = Math.min(MAC_TAB_ROW_HEIGHT, Math.max(0, size.height));
5340
+ const barHeight = Math.min(MAC_TAB_BAR_HEIGHT, rowHeight);
5341
+ const bar: Rect = snapRect({ x: x + (size.width - width) / 2, y: y + (rowHeight - barHeight) / 2, width, height: barHeight });
5342
+ const toolbar: Rect = { x, y, width: size.width, height: rowHeight };
5343
+ const content: Rect = { x, y: y + rowHeight, width: size.width, height: Math.max(0, size.height - rowHeight) };
5166
5344
  const chrome: Record<string, Rect> = { bar, content, top: bar };
5167
- let itemX = bar.x + MAC_TAB_ITEM_INSET;
5345
+ const itemHeight = Math.min(MAC_TAB_ITEM_HEIGHT, barHeight);
5168
5346
  for (let i = 0; i < n; i++) {
5169
- const itemWidth = (itemWidths[i] as number) * scale;
5170
- chrome[`item${i}`] = { x: itemX, y: bar.y + MAC_TAB_ITEM_INSET, width: itemWidth, height: Math.max(0, bar.height - 2 * MAC_TAB_ITEM_INSET) };
5171
- itemX += itemWidth;
5347
+ chrome[`item${i}`] = snapRect({ x: bar.x + MAC_TAB_ITEM_INSET + i * segment, y: bar.y + (barHeight - itemHeight) / 2, width: segment, height: itemHeight });
5172
5348
  }
5173
5349
  this.setChrome(node, chrome);
5174
5350
  const selected = readSelected(node, n);
@@ -5261,33 +5437,41 @@ export class LayoutPass {
5261
5437
  const leading = x <= 0 ? safeArea.leading : 0;
5262
5438
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
5263
5439
  const usable = Math.max(0, size.width - leading - trailing);
5264
- const sidebarWidth = Math.min(this.env.platform === 'macos' ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
5440
+ const mac = this.env.platform === 'macos';
5441
+ const sidebarWidth = Math.min(mac ? MAC_NAVSPLIT_SIDEBAR_WIDTH : NAVSPLIT_SIDEBAR_WIDTH, usable * NAVSPLIT_SIDEBAR_MAX_FRACTION);
5265
5442
  const visible = node.props['sidebarVisible'] !== false;
5443
+ // A Mac's sidebar is a panel inset from the window's edges, with no separator.
5444
+ const panelInset = mac ? MAC_NAVSPLIT_SIDEBAR_INSET : 0;
5445
+ const separatorWidth = mac ? 0 : NAVSPLIT_SEPARATOR_WIDTH;
5266
5446
  // Hidden, the sidebar and its separator sit just off the leading edge.
5267
- const sidebarX = visible ? x + leading : x - sidebarWidth - NAVSPLIT_SEPARATOR_WIDTH;
5447
+ const sidebarX = visible ? x + leading + panelInset : x - sidebarWidth - separatorWidth;
5268
5448
  const separatorX = sidebarX + sidebarWidth;
5269
- const detailX = visible ? separatorX + NAVSPLIT_SEPARATOR_WIDTH : x + leading;
5449
+ const detailX = visible ? separatorX + separatorWidth : x + leading;
5270
5450
  const detailWidth = Math.max(0, x + size.width - trailing - detailX);
5271
5451
  const statusBar = y <= 0 ? safeArea.top : 0;
5272
- // On a Mac the toggle is a 28 button centered in the unified title bar's
5273
- // row (which the columns' screens lay out: `placeNavScreen`), after the
5274
- // window buttons when the split view is at the top of the screen.
5275
- const mac = this.env.platform === 'macos';
5452
+ // On a Mac the toggle is a bare 36 slot centered in the toolbar's row: at
5453
+ // the sidebar panel's trailing end, or after the window buttons while
5454
+ // the sidebar is hidden.
5276
5455
  const toggleSize = mac ? MAC_BAR_ITEM_SIZE : NAV_GLASS_SIZE;
5277
- const toggleX = x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
5456
+ const toggleX = mac && visible
5457
+ ? sidebarX + sidebarWidth - MAC_NAVSPLIT_TOGGLE_INSET - toggleSize
5458
+ : x + leading + (mac && y <= 0 && x <= EPSILON && safeArea.top > 0 ? MAC_WINDOW_BUTTONS_ROOM : NAV_ITEM_MARGIN);
5278
5459
  const toggleY = mac ? y + Math.max(statusBar, MAC_TITLE_BAR_UNIFIED) - (MAC_TITLE_BAR_UNIFIED + toggleSize) / 2 : y + statusBar;
5279
5460
  const chrome: Record<string, Rect> = {
5280
5461
  sidebar: { x: visible ? x : sidebarX, y, width: visible ? leading + sidebarWidth : sidebarWidth, height: size.height },
5281
- separator: { x: separatorX, y, width: NAVSPLIT_SEPARATOR_WIDTH, height: size.height },
5462
+ separator: { x: separatorX, y, width: separatorWidth, height: size.height },
5282
5463
  detail: { x: visible ? detailX : x, y, width: Math.max(0, x + size.width - (visible ? detailX : x)), height: size.height },
5283
5464
  toggle: { x: toggleX, y: toggleY, width: toggleSize, height: toggleSize },
5284
5465
  };
5466
+ // The Mac's sidebar panel (the page draws it), inset from the window's edges.
5467
+ if (mac) chrome['panel'] = { x: sidebarX, y: y + panelInset, width: sidebarWidth, height: Math.max(0, size.height - 2 * panelInset) };
5285
5468
  this.setChrome(node, chrome);
5286
- const toggleRoom = toggleSize + NAV_GROUP_SPACING;
5469
+ const toggleRoom = toggleSize + (mac ? MAC_BAR_GROUP_GAP : NAV_GROUP_SPACING);
5287
5470
  const [sidebar, detail, ...rest] = node.children;
5288
5471
  if (sidebar) {
5289
5472
  const proposal: Proposal = { width: sidebarWidth, height: size.height };
5290
- this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible ? toggleRoom : 0 }, proposal, sidebarX, y);
5473
+ // On a Mac the toggle is at the sidebar's trailing end: its screens' items start where they would.
5474
+ this.place(sidebar, { ...cctx, splitSidebar: true, splitColumn: true, barLeading: visible && !mac ? toggleRoom : 0 }, proposal, sidebarX, y);
5291
5475
  }
5292
5476
  if (detail) {
5293
5477
  const proposal: Proposal = { width: detailWidth, height: size.height };
@@ -5512,12 +5696,20 @@ export class LayoutPass {
5512
5696
  private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
5513
5697
  const plan = this.sliderPlan(node, ctx, p);
5514
5698
  const { cctx } = plan;
5515
- // The label is not drawn (a Mac form places it in its label column).
5516
- if (plan.label && !ctx.formColumn) this.hide(plan.label, node);
5517
5699
  const m = this.sliderMetrics();
5518
5700
  const rowY = y;
5519
5701
  let trackX = x;
5520
5702
  let trackEnd = x + size.width;
5703
+ // In a Mac grouped form the label leads the row and the slider takes its
5704
+ // trailing 55%; elsewhere the label is not drawn (a Mac columns form
5705
+ // places it in its label column).
5706
+ if (plan.label && ctx.groupedRow) {
5707
+ const labelSize = this.measure(plan.label, cctx, NIL_PROPOSAL);
5708
+ this.place(plan.label, cctx, NIL_PROPOSAL, x, rowY + (m.height - labelSize.height) / 2);
5709
+ trackX = x + size.width * (1 - MAC_GROUPED_SLIDER_FRACTION);
5710
+ } else if (plan.label && !ctx.formColumn) {
5711
+ this.hide(plan.label, node);
5712
+ }
5521
5713
  if (plan.min) {
5522
5714
  this.place(plan.min, cctx, NIL_PROPOSAL, x, rowY + (m.height - plan.minSize.height) / 2);
5523
5715
  trackX += plan.minSize.width + SLIDER_LABEL_GAP;
@@ -5672,7 +5864,8 @@ export class LayoutPass {
5672
5864
  const cardWidth = Math.min(m.width, size.width - 2 * DIALOG_MARGIN);
5673
5865
  const textWidth = cardWidth - 2 * m.padH;
5674
5866
  const titleFont = resolveFont({ textStyle: 'headline' }, ctx.font, this.env);
5675
- const messageFont = resolveFont({ textStyle: 'subheadline' }, ctx.font, this.env);
5867
+ // The Mac's message is the body text (13), as large as the title.
5868
+ const messageFont = resolveFont({ textStyle: m === MAC_ALERT_METRICS ? 'body' : 'subheadline' }, ctx.font, this.env);
5676
5869
  // Heights first (the card is centered), then the rects.
5677
5870
  let h = m.padTop;
5678
5871
  let titleBlock: { layout: TextLayout; height: number; top: number } | null = null;
@@ -5698,9 +5891,9 @@ export class LayoutPass {
5698
5891
  const headerHeight = h;
5699
5892
  const rows = buttons.length === 0 ? 1 : buttons.length === 2 ? 1 : buttons.length;
5700
5893
  h += rows * m.buttonHeight + (rows - 1) * m.buttonSpacing + m.padBottom;
5701
- // Centered in the safe area, as iOS does.
5894
+ // Centered in the safe area, as iOS does; a Mac's panel in the window.
5702
5895
  const { safeArea: inset } = this.env;
5703
- const top = y + (y <= 0 ? inset.top : 0);
5896
+ const top = y + (y <= 0 && m !== MAC_ALERT_METRICS ? inset.top : 0);
5704
5897
  const bottom = y + size.height - (y + size.height >= this.env.screen.height - EPSILON ? inset.bottom : 0);
5705
5898
  const card: Rect = { x: x + (size.width - cardWidth) / 2, y: top + (bottom - top - h) / 2, width: cardWidth, height: h };
5706
5899
  chrome['card'] = card;