@swiftbrowser/web 0.26.2 → 0.26.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { AssetCatalog } from '../assets';
8
+ import type { Platform } from '../protocol';
8
9
 
9
10
  /**
10
11
  * A size proposal from a parent to a child. `null` means "whatever you want",
@@ -97,6 +98,8 @@ export interface LayoutEnvironment {
97
98
  dynamicTypeSize: DynamicTypeSize;
98
99
  devicePixelRatio: number;
99
100
  colorScheme: 'light' | 'dark';
101
+ /** The platform whose idiom the engine lays out for (docs/notes/macos.md); iOS when absent. */
102
+ platform?: Platform;
100
103
  /**
101
104
  * Phase 5: the app's asset catalog manifest, for the intrinsic size of
102
105
  * named images. Absent (or empty) when the app has no catalog; named
@@ -11,6 +11,7 @@
11
11
  * leading (body 22): the distance between a paragraph's lines (`LEADINGS`).
12
12
  */
13
13
 
14
+ import type { Platform } from '../protocol';
14
15
  import type { DynamicTypeSize, FontFamily, LayoutEnvironment, ResolvedFont } from './types';
15
16
 
16
17
  export type TextStyle =
@@ -63,6 +64,24 @@ export const TEXT_STYLE_SIZES: Record<TextStyle, readonly number[]> = {
63
64
  caption2: [11, 11, 11, 11, 13, 15, 17, 20, 24, 29, 34, 40],
64
65
  };
65
66
 
67
+ /**
68
+ * The macOS text styles (HIG "Typography" > "macOS built-in text styles"),
69
+ * one size each: the Mac has no Dynamic Type. Body is 13.
70
+ */
71
+ export const MAC_TEXT_STYLE_SIZES: Readonly<Record<TextStyle, number>> = {
72
+ largeTitle: 26,
73
+ title: 22,
74
+ title2: 17,
75
+ title3: 15,
76
+ headline: 13,
77
+ body: 13,
78
+ callout: 12,
79
+ subheadline: 11,
80
+ footnote: 10,
81
+ caption: 10,
82
+ caption2: 10,
83
+ };
84
+
66
85
  /**
67
86
  * The HIG's leading for the standard (Large) point sizes: the distance
68
87
  * between the baselines of a paragraph's lines, which SwiftUI keeps (a
@@ -122,8 +141,9 @@ export function sizeCategoryIndex(size: DynamicTypeSize): number {
122
141
  return index < 0 ? LARGE_INDEX : index;
123
142
  }
124
143
 
125
- /** Point size of a text style at a Dynamic Type size category. */
126
- export function textStyleSize(style: TextStyle, size: DynamicTypeSize): number {
144
+ /** Point size of a text style at a Dynamic Type size category (on the Mac, its one size). */
145
+ export function textStyleSize(style: TextStyle, size: DynamicTypeSize, platform: Platform = 'ios'): number {
146
+ if (platform === 'macos') return MAC_TEXT_STYLE_SIZES[style];
127
147
  const row = TEXT_STYLE_SIZES[style];
128
148
  return row[sizeCategoryIndex(size)] ?? row[LARGE_INDEX] ?? 17;
129
149
  }
@@ -166,9 +186,9 @@ export function makeFont(
166
186
  return font;
167
187
  }
168
188
 
169
- /** Body text for the current `dynamicTypeSize` (17/20.33 at Large). */
189
+ /** Body text for the current `dynamicTypeSize` (17/20.33 at Large; 13 on the Mac). */
170
190
  export function defaultFont(env: LayoutEnvironment): ResolvedFont {
171
- return makeFont('system', textStyleSize('body', env.dynamicTypeSize), 400);
191
+ return makeFont('system', textStyleSize('body', env.dynamicTypeSize, env.platform), 400);
172
192
  }
173
193
 
174
194
  /**
@@ -199,13 +219,14 @@ export function resolveFont(
199
219
  let defaultWeight = 400;
200
220
  let isNewFont = false;
201
221
  if (isTextStyle(textStyle)) {
202
- size = textStyleSize(textStyle, env.dynamicTypeSize);
222
+ size = textStyleSize(textStyle, env.dynamicTypeSize, env.platform);
203
223
  if (textStyle === 'headline') defaultWeight = 600;
204
224
  isNewFont = true;
205
225
  } else if (typeof fixedSize === 'number' && Number.isFinite(fixedSize) && fixedSize > 0) {
206
226
  size = fixedSize;
207
227
  const relativeTo = font['relativeTo'];
208
- if (isTextStyle(relativeTo)) {
228
+ // The Mac has no Dynamic Type: a fixed size stays as given.
229
+ if (isTextStyle(relativeTo) && env.platform !== 'macos') {
209
230
  const base = TEXT_STYLE_SIZES[relativeTo][LARGE_INDEX] ?? 17;
210
231
  size = fixedSize * (textStyleSize(relativeTo, env.dynamicTypeSize) / base);
211
232
  }
package/src/layoutDom.ts CHANGED
@@ -702,6 +702,8 @@ function applyChrome(
702
702
  // Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
703
703
  case 'stepper':
704
704
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
705
+ // The Mac idiom: the arrows, up over down (styles.css).
706
+ setFlag(el, 'sbArrows', chrome?.['arrows'] ? '' : null);
705
707
  return;
706
708
  case 'slider': {
707
709
  // The `label` child is hidden (iOS draws none) and names the slider, as VoiceOver reads it.
@@ -715,6 +717,8 @@ function applyChrome(
715
717
  el.removeAttribute('aria-labelledby');
716
718
  }
717
719
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-track'), chrome?.['track'], box);
720
+ // The Mac idiom: the round knob (styles.css).
721
+ setFlag(el, 'sbKnob', chrome?.['knob'] ? '' : null);
718
722
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-fill'), chrome?.['fill'], box);
719
723
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-thumb'), chrome?.['thumb'], box);
720
724
  return;
@@ -722,6 +726,8 @@ function applyChrome(
722
726
  case 'datepicker':
723
727
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-date'), chrome?.['date'], box);
724
728
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-time'), chrome?.['time'], box);
729
+ // The Mac idiom: the date and time as bordered fields (styles.css).
730
+ setFlag(el, 'sbFields', chrome?.['fields'] ? '' : null);
725
731
  return;
726
732
  case 'alert': {
727
733
  // Phase 8: the card(s), the centered title / message lines and the
@@ -742,6 +748,8 @@ function applyChrome(
742
748
  if (message && texts?.['message']) setTextLayout(message, texts['message']);
743
749
  placeChrome(part('sb-alert-ok'), chrome?.['ok'], box);
744
750
  el.toggleAttribute('data-sb-header', Boolean(chrome?.['title'] || chrome?.['message']));
751
+ // The Mac idiom: the alert as a panel (styles.css).
752
+ setFlag(el, 'sbPanel', chrome?.['panel'] ? '' : null);
745
753
  const first = chrome?.['button0'];
746
754
  const second = chrome?.['button1'];
747
755
  el.dataset['sbLayout'] = first && second && first.y === second.y && !chrome?.['button2'] ? 'row' : 'stack';
@@ -750,7 +758,34 @@ function applyChrome(
750
758
  }
751
759
  return;
752
760
  }
761
+ case 'menu':
762
+ // The Mac idiom: a pull-down button around the label (styles.css).
763
+ setFlag(el, 'sbPulldown', chrome?.['pulldown'] ? '' : null);
764
+ return;
765
+ case 'button': {
766
+ // The Mac idiom: an automatic button the engine bordered (styles.css);
767
+ // in a list row the button is drawn at its own frame on the row
768
+ // (`inset`, the frame in the row's coordinates).
769
+ // The rect the border is drawn at: the engine's (a bar item's slot) or
770
+ // the button's own frame; `inset` when it is not the element's box.
771
+ const bordered = chrome?.['bordered'];
772
+ const rect = bordered ?? frame;
773
+ const inset = bordered !== undefined && (box.x !== rect.x || box.y !== rect.y || box.width !== rect.width || box.height !== rect.height);
774
+ setFlag(el, 'sbBordered', bordered ? (inset ? 'inset' : '') : null);
775
+ const vars: [string, string][] = inset
776
+ ? [['--sb-bordered-left', `${rect.x - box.x}px`], ['--sb-bordered-top', `${rect.y - box.y}px`], ['--sb-bordered-width', `${rect.width}px`], ['--sb-bordered-height', `${rect.height}px`]]
777
+ : [['--sb-bordered-left', ''], ['--sb-bordered-top', ''], ['--sb-bordered-width', ''], ['--sb-bordered-height', '']];
778
+ for (const [name, value] of vars) {
779
+ if (el.style.getPropertyValue(name) !== value) {
780
+ if (value) el.style.setProperty(name, value);
781
+ else el.style.removeProperty(name);
782
+ }
783
+ }
784
+ return;
785
+ }
753
786
  case 'toggle': {
787
+ // The Mac idiom: a checkbox at the switch's rect (styles.css).
788
+ setFlag(el, 'sbCheckbox', chrome?.['checkbox'] ? '' : null);
754
789
  const sw = el.querySelector<HTMLElement>(':scope > .sb-switch');
755
790
  const rect = chrome?.['switch'] ?? {
756
791
  x: frame.x + frame.width - SWITCH_SIZE.width,
@@ -761,10 +796,22 @@ function applyChrome(
761
796
  return;
762
797
  }
763
798
  case 'disclosuregroup': {
764
- // The chevron at the label's trailing edge, the toggle over the label (the first child's frame).
765
- const label = node.children[0] ? result.frames.get(node.children[0].id) : undefined;
766
799
  const chevron = el.querySelector<HTMLElement>(':scope > .sb-disclosure-chevron');
767
800
  const toggle = el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle');
801
+ // In a list the group is an outline (engine.ts `outlineRows`): its label
802
+ // and content are rows (styles.css draws them as the card's), the toggle
803
+ // lies over the label's row and the chevron sits at that row's trailing
804
+ // edge, centred as a link's is.
805
+ const outline = chrome?.['toggle'];
806
+ setFlag(el, 'sbOutline', outline ? '' : null);
807
+ if (outline) {
808
+ placeChrome(toggle, outline, box);
809
+ const rect = chrome?.['chevron'];
810
+ placeChrome(chevron, rect ? { x: rect.x + (rect.width - CHEVRON_SIZE) / 2, y: rect.y + (rect.height - CHEVRON_SIZE) / 2, width: CHEVRON_SIZE, height: CHEVRON_SIZE } : undefined, box);
811
+ return;
812
+ }
813
+ // The chevron at the label's trailing edge, the toggle over the label (the first child's frame).
814
+ const label = node.children[0] ? result.frames.get(node.children[0].id) : undefined;
768
815
  if (label) {
769
816
  placeChrome(chevron, { x: frame.x + frame.width - CHEVRON_SIZE - 2, y: label.y + (label.height - CHEVRON_SIZE) / 2, width: CHEVRON_SIZE, height: CHEVRON_SIZE }, box);
770
817
  placeChrome(toggle, { x: frame.x, y: label.y, width: frame.width, height: label.height }, box);
@@ -830,6 +877,27 @@ function applyChrome(
830
877
  }
831
878
  bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
832
879
  bindScrollEdgeBottom(el, chrome?.['bottomInset'], extent);
880
+ // The Mac idiom's columns form (`data-sb-form-columns`, styles.css):
881
+ // the label column's texts (chrome `formLabel<i>`) as spans in the
882
+ // content, as many as the engine placed.
883
+ setFlag(el, 'sbFormColumns', chrome?.['columns'] ? '' : null);
884
+ if (content) {
885
+ const texts = result.chromeText.get(node.id);
886
+ const labels = content.querySelectorAll<HTMLElement>(':scope > .sb-form-label');
887
+ let i = 0;
888
+ for (; chrome?.[`formLabel${i}`]; i++) {
889
+ let label = labels[i] ?? null;
890
+ if (!label) {
891
+ label = document.createElement('span');
892
+ label.className = 'sb-form-label';
893
+ content.append(label);
894
+ }
895
+ placeChrome(label, chrome[`formLabel${i}`], frame);
896
+ const layout = texts?.[`formLabel${i}`];
897
+ if (layout) setTextLayout(label, layout);
898
+ }
899
+ for (let extra = i; extra < labels.length; extra++) labels[extra]!.remove();
900
+ }
833
901
  return;
834
902
  }
835
903
  case 'scrollview': {
@@ -884,11 +952,17 @@ function applyChrome(
884
952
  bar.style.paddingTop = `${top}px`;
885
953
  bar.style.paddingBottom = `${bottom}px`;
886
954
  }
955
+ // The Mac idiom: a unified title bar (styles.css lays its title out leading-aligned).
956
+ setFlag(el, 'sbUnified', chrome?.['unified'] ? '' : null);
957
+ // The Mac idiom: a sheet's title bar (the title leading, 13 bold).
958
+ setFlag(el, 'sbSheetBar', chrome?.['sheetBar'] ? '' : null);
887
959
  syncBarGlass(result, node, el, chrome, frame, isNew, changes);
888
960
  // The `keyboard` toolbars' bar above the keyboard while a text input of the screen is focused.
889
961
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-keyboard-bar'), chrome?.['keyboardBar'], frame);
890
962
  // iOS 26 in a tab view: the search field under the title (styles.css).
891
963
  setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
964
+ // The Mac idiom: the search field in the title bar (styles.css).
965
+ setFlag(el, 'sbSearchBar', chrome?.['searchBar'] ? '' : null);
892
966
  const largeRect = chrome?.['largeTitle'];
893
967
  if (largeTitle) {
894
968
  if (largeRect) {
@@ -923,6 +997,17 @@ function applyChrome(
923
997
  if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
924
998
  }
925
999
  }
1000
+ // The Mac idiom: the subtitle under the title in a unified title bar (chrome `subtitle`, hidden otherwise).
1001
+ const subtitleEl = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-subtitle');
1002
+ if (subtitleEl) {
1003
+ const subtitleRect = chrome?.['subtitle'];
1004
+ placeChrome(subtitleEl, subtitleRect, frame);
1005
+ if (subtitleRect) {
1006
+ if (texts?.['subtitle']) setFont(subtitleEl, texts['subtitle'].font);
1007
+ const lineHeight = `${subtitleRect.height}px`;
1008
+ if (subtitleEl.style.lineHeight !== lineHeight) subtitleEl.style.lineHeight = lineHeight;
1009
+ }
1010
+ }
926
1011
  // The More button of the collapsed `secondary` items, at the trailing end.
927
1012
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-more'), chrome?.['more'], frame);
928
1013
  const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
@@ -956,6 +1041,8 @@ function applyChrome(
956
1041
  setFlag(el, 'sbFloating', chrome?.['floating'] ? '' : null);
957
1042
  // iPadOS: in a regular width, a centered form sheet (styles.css).
958
1043
  setFlag(el, 'sbForm', chrome?.['form'] ? '' : null);
1044
+ // The Mac idiom: a bottom bar with the sheet's actions (styles.css).
1045
+ setFlag(el, 'sbActions', chrome?.['actions'] ? '' : null);
959
1046
  return;
960
1047
  }
961
1048
  case 'tabview': {
@@ -963,6 +1050,8 @@ function applyChrome(
963
1050
  const barRect = chrome?.['bar'];
964
1051
  if (!bar || !barRect) return;
965
1052
  placeChrome(bar, barRect, box);
1053
+ // The Mac idiom: the bar is a segmented control at the top of the content (styles.css).
1054
+ setFlag(el, 'sbTabsTop', chrome?.['top'] ? '' : null);
966
1055
  // Items are the 49px row at the top of the bar; the rest is the safe area.
967
1056
  const items = Array.from(bar.children) as HTMLElement[];
968
1057
  items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
@@ -1006,7 +1095,9 @@ function applyChrome(
1006
1095
  if (node.props['style'] === 'segmented') {
1007
1096
  // In a navigation bar item the control is the bar's glass capsule (engine `bar`).
1008
1097
  el.toggleAttribute('data-sb-bar', chrome?.['bar'] !== undefined);
1009
- placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), frame, box);
1098
+ // The Mac idiom: the label before the control (engine `label`, `track`).
1099
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-segment-track'), chrome?.['track'] ?? frame, box);
1100
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-label'), chrome?.['label'], box);
1010
1101
  const segments = el.querySelectorAll<HTMLElement>(':scope > .sb-segment');
1011
1102
  segments.forEach((segment, i) => placeChrome(segment, chrome?.[`segment${i}`], box));
1012
1103
  } else if (node.props['style'] === 'inline') {
@@ -1017,6 +1108,8 @@ function applyChrome(
1017
1108
  } else {
1018
1109
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-label'), chrome?.['label'], box);
1019
1110
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-picker-value'), chrome?.['value'], box);
1111
+ // The Mac idiom: the value in a pop-up button (styles.css).
1112
+ setFlag(el, 'sbPopup', chrome?.['popup'] ? '' : null);
1020
1113
  }
1021
1114
  return;
1022
1115
  }
package/src/main.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { easingFor, springEasing, timingFor } from './animation';
3
3
  import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
4
4
  import { ChromeColor } from './chromeColor';
5
- import { installDeviceFrame } from './deviceFrame';
5
+ import { frameFor, installDeviceFrame } from './deviceFrame';
6
6
  import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
7
7
  import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
8
8
  import { createMockApp } from './mock';
@@ -202,7 +202,7 @@ function currentTypeSize(): DynamicTypeSize {
202
202
  */
203
203
  function applyTypeSize(size: DynamicTypeSize, persist: boolean): void {
204
204
  device.dataset['typeSize'] = size;
205
- applyTypeSizeVariables(device, size);
205
+ applyTypeSizeVariables(device, size, renderer.platform);
206
206
  // The layout engine resolves text styles for this size and re-lays out.
207
207
  renderer.setEnvironment({ dynamicTypeSize: size });
208
208
  if (typeSizeSelect.value !== size) typeSizeSelect.value = size;
@@ -237,6 +237,8 @@ function currentSizeClass(): HorizontalSizeClass {
237
237
  }
238
238
  /** The size class last sent in an `environment` event (null before the first). */
239
239
  let sentSizeClass: HorizontalSizeClass | null = null;
240
+ /** The platform last sent in an `environment` event (null before the first). */
241
+ let sentPlatform: string | null = null;
240
242
 
241
243
  if (deviceMode) {
242
244
  // `#screen` fills the viewport (styles.css); the renderer re-reads its size
@@ -267,9 +269,12 @@ if (deviceMode) {
267
269
  // app in an iPhone, an iPad or no device; the renderer re-reads the
268
270
  // screen's size when it changes, and Swift hears a new size class.
269
271
  const sizeClassMayHaveChanged = (): void => {
270
- if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
272
+ // The Mac's text styles are its own: the device's variables follow the platform.
273
+ applyTypeSizeVariables(device, currentTypeSize(), renderer.platform);
274
+ if (sentSizeClass !== null && (currentSizeClass() !== sentSizeClass || renderer.platform !== sentPlatform)) sendEnvironment();
271
275
  };
272
- installDeviceFrame(renderer, { onChange: sizeClassMayHaveChanged });
276
+ // An app built for macOS (docs/notes/macos.md) starts in a Mac window.
277
+ installDeviceFrame(renderer, { onChange: sizeClassMayHaveChanged, preferred: frameFor(registeredApp()?.platform) });
273
278
  watchViewport(sizeClassMayHaveChanged);
274
279
  }
275
280
 
@@ -303,12 +308,16 @@ function sendEnvironment(): void {
303
308
  // Phase 8: every environment event carries the scene phase.
304
309
  const scenePhase = currentScenePhase();
305
310
  sentScenePhase = scenePhase;
311
+ // The platform the renderer draws (docs/notes/macos.md).
312
+ const platform = renderer.platform;
313
+ sentPlatform = platform;
306
314
  dispatch({
307
315
  type: 'environment',
308
316
  colorScheme: currentTheme(),
309
317
  dynamicTypeSize: currentTypeSize(),
310
318
  horizontalSizeClass,
311
319
  scenePhase,
320
+ platform,
312
321
  });
313
322
  }
314
323
 
package/src/protocol.ts CHANGED
@@ -623,6 +623,8 @@ export interface ScrollViewProps {
623
623
  export type ListStyle = 'plain' | 'insetGrouped' | 'grouped' | 'sidebar';
624
624
  export interface ListProps {
625
625
  style?: ListStyle;
626
+ /** A `Form`: on a Mac the page lays its rows out in the columns style (labels in a column before their controls). */
627
+ form?: boolean;
626
628
  }
627
629
  export interface SectionProps {
628
630
  header: string | null;
@@ -679,6 +681,8 @@ export type NavStackProps = Record<string, never>;
679
681
  export type NavDisplayMode = 'automatic' | 'large' | 'inline';
680
682
  export interface NavScreenProps {
681
683
  title: string;
684
+ /** `.navigationSubtitle`: a second line under the title in a Mac's unified title bar, after it in a plain one. */
685
+ subtitle?: string;
682
686
  displayMode: NavDisplayMode;
683
687
  /** 0 for the root screen; the renderer draws a back button when > 0. */
684
688
  depth: number;
@@ -1782,6 +1786,17 @@ export function horizontalSizeClassFor(width: number): HorizontalSizeClass {
1782
1786
  return Number.isFinite(width) && width >= REGULAR_WIDTH_MIN ? 'regular' : 'compact';
1783
1787
  }
1784
1788
 
1789
+ /**
1790
+ * The platform the app is drawn as (docs/notes/macos.md): `ios`, the
1791
+ * default, or `macos`. The renderer draws the platform's idiom and reports
1792
+ * it in the first `environment` event; it never changes in a session.
1793
+ */
1794
+ export type Platform = 'ios' | 'macos';
1795
+
1796
+ export interface EnvironmentEvent {
1797
+ platform?: Platform;
1798
+ }
1799
+
1785
1800
  // ---------------------------------------------------------------------------
1786
1801
  // Phase 7: charts (docs/ops-protocol.md, "Phase 7"). Swift turns data into
1787
1802
  // unit coordinates (0...1 of the plot rectangle, y top → bottom); the renderer
package/src/renderer.ts CHANGED
@@ -148,6 +148,7 @@ import {
148
148
  type CanvasProps,
149
149
  type Color,
150
150
  type LazySpacerProps,
151
+ type Platform,
151
152
  } from './protocol';
152
153
  import {
153
154
  combineDateSeconds,
@@ -361,6 +362,8 @@ const DEFAULT_SAFE_AREA = { top: 59, leading: 0, bottom: 34, trailing: 0 };
361
362
  export interface RendererEnvironment {
362
363
  dynamicTypeSize: DynamicTypeSize;
363
364
  colorScheme: ColorScheme;
365
+ /** The platform drawn (docs/notes/macos.md): iOS unless the page says macOS. */
366
+ platform: Platform;
364
367
  }
365
368
 
366
369
  export class Renderer {
@@ -473,7 +476,7 @@ export class Renderer {
473
476
  */
474
477
  private engine: LayoutPass | null = null;
475
478
  private engineEnvKey = '';
476
- private environment: RendererEnvironment = { dynamicTypeSize: 'large', colorScheme: 'light' };
479
+ private environment: RendererEnvironment = { dynamicTypeSize: 'large', colorScheme: 'light', platform: 'ios' };
477
480
  /** Phase 5: the app's asset catalog manifest (empty until `setAssetCatalog`). */
478
481
  private assets: AssetCatalog = EMPTY_CATALOG;
479
482
  /** The `<style>` holding the catalog colors as custom properties (see `setAssetCatalog`). */
@@ -1464,18 +1467,26 @@ export class Renderer {
1464
1467
  // -------------------------------------------------------------------------
1465
1468
 
1466
1469
  /**
1467
- * Set the device environment (Dynamic Type size, color scheme) and re-lay
1468
- * the tree out. The page calls this on boot and whenever a control changes.
1470
+ * Set the device environment (Dynamic Type size, color scheme, platform)
1471
+ * and re-lay the tree out. The page calls this on boot and whenever a
1472
+ * control changes. The platform is also on the document
1473
+ * (`html[data-sb-platform]`), which the stylesheet themes by.
1469
1474
  */
1470
1475
  setEnvironment(environment: Partial<RendererEnvironment>): void {
1471
1476
  const next = { ...this.environment, ...environment };
1472
- if (next.dynamicTypeSize === this.environment.dynamicTypeSize && next.colorScheme === this.environment.colorScheme) {
1477
+ if (next.dynamicTypeSize === this.environment.dynamicTypeSize && next.colorScheme === this.environment.colorScheme && next.platform === this.environment.platform) {
1473
1478
  return;
1474
1479
  }
1475
1480
  this.environment = next;
1481
+ this.root.ownerDocument.documentElement.dataset['sbPlatform'] = next.platform;
1476
1482
  if (this.commits > 0) this.layout();
1477
1483
  }
1478
1484
 
1485
+ /** The platform drawn (`setEnvironment`). */
1486
+ get platform(): Platform {
1487
+ return this.environment.platform;
1488
+ }
1489
+
1479
1490
  /**
1480
1491
  * Phase 5: give the renderer the app's asset catalog manifest (src/assets.ts).
1481
1492
  * Named images take their intrinsic size and `src` from it, and every
@@ -1589,6 +1600,7 @@ export class Renderer {
1589
1600
  dynamicTypeSize: this.environment.dynamicTypeSize,
1590
1601
  devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
1591
1602
  colorScheme: this.environment.colorScheme,
1603
+ platform: this.environment.platform,
1592
1604
  assets: this.assets,
1593
1605
  ...(this.contentHeight ? { contentHeight: true } : {}),
1594
1606
  ...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
@@ -3151,6 +3163,9 @@ function createShell(kind: ElementKind): HTMLElement {
3151
3163
  if (chevron) back.append(chevron);
3152
3164
  back.append(span('sb-navbar-back-label'));
3153
3165
  const inlineTitle = span('sb-navbar-title');
3166
+ // `.navigationSubtitle`: under the title in a Mac's unified title bar (chrome.subtitle).
3167
+ const subtitle = span('sb-navbar-subtitle');
3168
+ subtitle.hidden = true;
3154
3169
  // The More button of the collapsed `secondary` items (chrome.more).
3155
3170
  const more = document.createElement('button');
3156
3171
  more.type = 'button';
@@ -3160,7 +3175,7 @@ function createShell(kind: ElementKind): HTMLElement {
3160
3175
  more.hidden = true;
3161
3176
  const ellipsis = renderSymbol('ellipsis');
3162
3177
  if (ellipsis) more.append(ellipsis);
3163
- bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
3178
+ bar.append(back, inlineTitle, subtitle, div('sb-navbar-trailing'), more);
3164
3179
  const body = div('sb-navscreen-body');
3165
3180
  const slot = div('sb-slot sb-navscreen-content');
3166
3181
  // The `keyboard` toolbars' bar (chrome `keyboardBar`), under their items.
@@ -4775,6 +4790,11 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
4775
4790
  const largeTitle = largeTitleOf(el);
4776
4791
  // A large title that scrolls away collapses into the inline one (shown
4777
4792
  // with `data-sb-collapsed`, src/largeTitle.ts).
4793
+ const subtitleEl = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-subtitle');
4794
+ if (subtitleEl) {
4795
+ const subtitle = props.subtitle ?? '';
4796
+ if (subtitleEl.textContent !== subtitle) subtitleEl.textContent = subtitle;
4797
+ }
4778
4798
  if (inlineTitle) {
4779
4799
  const appearing = inlineTitle.textContent === '' && title !== '' && inlineTitle.isConnected;
4780
4800
  inlineTitle.textContent = title;
@@ -4843,8 +4863,13 @@ function applyPicker(el: HTMLElement, props: PickerProps): void {
4843
4863
  el.removeAttribute('aria-haspopup');
4844
4864
  el.removeAttribute('aria-expanded');
4845
4865
  // The gray track sits at the control's frame (which, in a list row, is
4846
- // narrower than the row box the element occupies).
4866
+ // narrower than the row box the element occupies); the label shows only
4867
+ // where the engine places it (the Mac idiom).
4847
4868
  el.append(div('sb-segment-track'));
4869
+ const segmentedLabel = span('sb-picker-label');
4870
+ segmentedLabel.textContent = label;
4871
+ segmentedLabel.hidden = true;
4872
+ el.append(segmentedLabel);
4848
4873
  options.forEach((option, index) => {
4849
4874
  const segment = document.createElement('button');
4850
4875
  segment.type = 'button';