@swiftbrowser/web 0.18.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -413,6 +413,15 @@ const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1,
413
413
  const NAVSPLIT_SIDEBAR_WIDTH = 320;
414
414
  /** From this root width a window is regular (an iPad, a desktop browser), as `@swiftbrowser/react`'s size class. */
415
415
  const REGULAR_WIDTH = 700;
416
+ /** Whether a screen is the regular size class (an iPad, a desktop window): a `sidebar` list then draws as one. */
417
+ export const isRegularWidth = (screen: { width: number }): boolean => screen.width >= REGULAR_WIDTH;
418
+ /**
419
+ * From this root height a window is the regular vertical size class: below
420
+ * it, as an iPhone on its side (at most 440 tall, less under Safari's bars),
421
+ * it is compact, and UIKit shows no large titles. An iPad is regular either
422
+ * way (744 tall at the least).
423
+ */
424
+ const REGULAR_HEIGHT = 500;
416
425
  /** The sidebar never takes more than this share of the split view's width. */
417
426
  const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
418
427
  const NAVSPLIT_SEPARATOR_WIDTH = 1;
@@ -1139,14 +1148,12 @@ interface TextPlan {
1139
1148
  // Phase 8 controls.
1140
1149
  interface SliderPlan {
1141
1150
  size: Size;
1151
+ /** The `label` child, hidden (iOS draws no slider label). */
1142
1152
  label: LayoutNode | null;
1143
1153
  min: LayoutNode | null;
1144
1154
  max: LayoutNode | null;
1145
- labelSize: Size;
1146
1155
  minSize: Size;
1147
1156
  maxSize: Size;
1148
- /** Height of the label row above the 44 track row (0 without a label). */
1149
- labelRow: number;
1150
1157
  cctx: Context;
1151
1158
  }
1152
1159
 
@@ -1638,9 +1645,17 @@ export class LayoutPass {
1638
1645
  const imageScale = str(style['imageScale']);
1639
1646
  if (imageScale !== null) next = { ...next, imageScale: IMAGE_SCALES[imageScale] ?? 1 };
1640
1647
  if (typeof style['scrollDisabled'] === 'boolean') next = { ...next, scrollDisabled: style['scrollDisabled'] };
1648
+ // `.ignoresSafeArea` on the bottom: the content inside has no bottom
1649
+ // safe area, so a scroll view in it reaching the screen's bottom is
1650
+ // not inset by the bar there (a photo viewer's, under a bottom toolbar).
1651
+ const ignored = style['ignoresSafeArea'];
1652
+ if (ignored === 'all' || (Array.isArray(ignored) && ignored.includes('bottom'))) {
1653
+ if (next.bottomBar !== 0 || next.bottomEdge !== undefined) next = { ...next, bottomBar: 0, bottomEdge: undefined };
1654
+ }
1641
1655
  return next;
1642
1656
  }
1643
1657
  case 'vstack':
1658
+ case 'disclosuregroup':
1644
1659
  return { ...ctx, stackAxis: 'vertical', listRow: false };
1645
1660
  case 'hstack':
1646
1661
  return { ...ctx, stackAxis: 'horizontal', listRow: false };
@@ -1744,6 +1759,7 @@ export class LayoutPass {
1744
1759
  if (ctx.stackAxis === 'horizontal') return { width: 1, height: finite(p.height) ? p.height : 0 };
1745
1760
  return { width: finite(p.width) ? p.width : 0, height: 1 };
1746
1761
  case 'vstack':
1762
+ case 'disclosuregroup':
1747
1763
  return this.stackPlan(node, ctx, p, 'vertical').size;
1748
1764
  case 'hstack': {
1749
1765
  const label = ctx.labelColumn ? labelColumnOf(node) : null;
@@ -1803,6 +1819,7 @@ export class LayoutPass {
1803
1819
  return this.pickerPlan(node, ctx, p).size;
1804
1820
  case 'host':
1805
1821
  return this.measureHost(node, p);
1822
+ case 'registered':
1806
1823
  case 'geometry':
1807
1824
  // GeometryReader: the proposal, 10 on unconstrained axes.
1808
1825
  return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
@@ -2319,10 +2336,19 @@ export class LayoutPass {
2319
2336
  private stackPlan(node: LayoutNode, ctx: Context, p: Proposal, axis: Axis): StackPlan {
2320
2337
  return this.plan(node, ctx, p, () => {
2321
2338
  const cctx = this.childContext(node, ctx);
2322
- return this.layoutStack(node.children, cctx, p, axis, this.stackGaps(node, cctx, axis));
2339
+ return this.layoutStack(this.stackChildren(node), cctx, p, axis, this.stackGaps(node, cctx, axis));
2323
2340
  });
2324
2341
  }
2325
2342
 
2343
+ /**
2344
+ * The children a stack lays out: all of them, but a collapsed
2345
+ * `disclosuregroup`'s label alone (the first child; its content is in
2346
+ * the tree whatever the state, as the native renderer wants it).
2347
+ */
2348
+ private stackChildren(node: LayoutNode): readonly LayoutNode[] {
2349
+ return node.kind === 'disclosuregroup' && node.props['expanded'] !== true ? node.children.slice(0, 1) : node.children;
2350
+ }
2351
+
2326
2352
  /**
2327
2353
  * The space between a stack's children: its `spacing`, else the system's.
2328
2354
  * A horizontal stack's default is 8; in a vertical one iOS 26 spaces text
@@ -2334,14 +2360,16 @@ export class LayoutPass {
2334
2360
  * 0.6em + 1.13 above it (caption 8.33, body 11.33); other views are 8 apart.
2335
2361
  */
2336
2362
  private stackGaps(node: LayoutNode, cctx: Context, axis: Axis): number[] {
2337
- const n = node.children.length;
2363
+ const children = this.stackChildren(node);
2364
+ const n = children.length;
2338
2365
  if (n < 2) return [];
2339
- const spacing = num(node.props['spacing']);
2366
+ // A disclosure group's content sits 8 under its label, as SwiftUI's does.
2367
+ const spacing = num(node.props['spacing']) ?? (node.kind === 'disclosuregroup' ? DEFAULT_SPACING : null);
2340
2368
  if (spacing !== null || axis === 'horizontal') return new Array<number>(n - 1).fill(spacing ?? DEFAULT_SPACING);
2341
2369
  const gaps: number[] = [];
2342
2370
  for (let i = 0; i < n - 1; i++) {
2343
- const above = this.textEdge(node.children[i] as LayoutNode, cctx, 'bottom');
2344
- const below = this.textEdge(node.children[i + 1] as LayoutNode, cctx, 'top');
2371
+ const above = this.textEdge(children[i] as LayoutNode, cctx, 'bottom');
2372
+ const below = this.textEdge(children[i + 1] as LayoutNode, cctx, 'top');
2345
2373
  let gap = DEFAULT_SPACING;
2346
2374
  if (above && below) gap = below.leading - below.lineHeight + 1 / 3;
2347
2375
  else if (below) gap = 0.409 * below.size - 1.24;
@@ -2372,8 +2400,10 @@ export class LayoutPass {
2372
2400
  const content = node.children[0];
2373
2401
  return content ? this.textEdge(content, this.childContext(node, ctx), edge, depth + 1) : null;
2374
2402
  }
2375
- case 'vstack': {
2376
- const child = edge === 'top' ? node.children[0] : node.children[node.children.length - 1];
2403
+ case 'vstack':
2404
+ case 'disclosuregroup': {
2405
+ const children = this.stackChildren(node);
2406
+ const child = edge === 'top' ? children[0] : children[children.length - 1];
2377
2407
  return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
2378
2408
  }
2379
2409
  case 'hstack': {
@@ -2808,10 +2838,12 @@ export class LayoutPass {
2808
2838
  // the space between sections. `sidebar` (iPadOS) is laid out like
2809
2839
  // `insetGrouped` with 44pt rows (the page draws no cards or hairlines).
2810
2840
  const listStyle = str(node.props['style']) ?? 'insetGrouped';
2811
- const sidebarStyle = listStyle === 'sidebar';
2841
+ // In a compact width (an iPhone) a `sidebar` list looks like `insetGrouped`
2842
+ // (cards, chevrons, 52pt rows) and only its sections' folding remains.
2843
+ const sidebarStyle = listStyle === 'sidebar' && isRegularWidth(screen);
2812
2844
  // `.environment(\.defaultMinListRowHeight, _)`; 52 by default (44 in a sidebar).
2813
2845
  const minRowHeight = num(node.props['minRowHeight']) ?? (sidebarStyle ? LIST_SIDEBAR_ROW_MIN_HEIGHT : LIST_ROW_MIN_HEIGHT);
2814
- const inset = listStyle === 'insetGrouped' || sidebarStyle ? LIST_INSET : 0;
2846
+ const inset = listStyle === 'insetGrouped' || listStyle === 'sidebar' ? LIST_INSET : 0;
2815
2847
  const plain = listStyle === 'plain';
2816
2848
  const listTop = plain ? 0 : LIST_TOP;
2817
2849
  const listBottom = plain ? 0 : LIST_BOTTOM;
@@ -2843,7 +2875,9 @@ export class LayoutPass {
2843
2875
  const groupsSource: GroupSource[] = [];
2844
2876
  for (const child of node.children) {
2845
2877
  if (child.kind === 'section') {
2846
- const rows = child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
2878
+ // `Section(isExpanded:)`: collapsed, its rows are not laid out, in a sidebar-style list (iOS shows the rows in the other styles, whatever the binding).
2879
+ const collapsed = child.props['collapsible'] === true && child.props['expanded'] === false && rowCtx.sidebarList === true;
2880
+ const rows = collapsed ? [] : child.children.filter((row) => row.kind !== 'sectionheader' && row.kind !== 'sectionfooter');
2847
2881
  const headerView = child.children.find((row) => row.kind === 'sectionheader') ?? null;
2848
2882
  const footerView = child.children.find((row) => row.kind === 'sectionfooter') ?? null;
2849
2883
  groupsSource.push({ section: child, rows, headerView, footerView });
@@ -2857,11 +2891,12 @@ export class LayoutPass {
2857
2891
  // footer's font.
2858
2892
  const headerCtx: Context = { ...rowCtx, listRow: false, stackAxis: null, font: headerFont };
2859
2893
  const footerCtx: Context = { ...headerCtx, font: footerFont };
2860
- const supplement = (view: LayoutNode | null, y: number, ctx: Context): ListSupplementPlan | null => {
2894
+ const supplement = (view: LayoutNode | null, y: number, ctx: Context, full = false): ListSupplementPlan | null => {
2861
2895
  if (!view) return null;
2862
2896
  const proposal: Proposal = { width: contentWidth, height: null };
2863
2897
  const size = this.measure(view, ctx, proposal);
2864
- return { node: view, ctx, proposal, rect: { x: contentX, y, width: size.width, height: size.height } };
2898
+ // A collapsible section's header spans the content width: its chevron sits at the trailing edge.
2899
+ return { node: view, ctx, proposal, rect: { x: contentX, y, width: full ? contentWidth : size.width, height: size.height } };
2865
2900
  };
2866
2901
 
2867
2902
  const groups: ListGroupPlan[] = [];
@@ -2886,7 +2921,7 @@ export class LayoutPass {
2886
2921
  // A header sits in the gap above its card (from LIST_HEADER_LIFT
2887
2922
  // into it); the card then starts LIST_HEADER_GAP below the header.
2888
2923
  const headerLift = plain ? 0 : LIST_HEADER_LIFT;
2889
- const headerView = supplement(source.headerView, y - headerLift, headerCtx);
2924
+ const headerView = supplement(source.headerView, y - headerLift, headerCtx, source.section?.props['collapsible'] === true);
2890
2925
  if (headerView && headerView.rect.height > 0) y += headerView.rect.height + LIST_HEADER_GAP - headerLift;
2891
2926
  const headerText = source.section ? str(source.section.props['header']) : null;
2892
2927
  const footerText = source.section ? str(source.section.props['footer']) : null;
@@ -3068,8 +3103,10 @@ export class LayoutPass {
3068
3103
  case 'canvas':
3069
3104
  case 'video':
3070
3105
  case 'host':
3106
+ case 'registered':
3071
3107
  return;
3072
3108
  case 'vstack':
3109
+ case 'disclosuregroup':
3073
3110
  case 'hstack': {
3074
3111
  const label = node.kind === 'hstack' && ctx.labelColumn ? labelColumnOf(node) : null;
3075
3112
  if (label) {
@@ -3079,10 +3116,13 @@ export class LayoutPass {
3079
3116
  this.place(label.title, cctx, plan.titleProposal, x + LIST_LABEL_TITLE_X, y + (size.height - plan.title.height) / 2);
3080
3117
  return;
3081
3118
  }
3082
- const axis: Axis = node.kind === 'vstack' ? 'vertical' : 'horizontal';
3119
+ const axis: Axis = node.kind === 'hstack' ? 'horizontal' : 'vertical';
3083
3120
  const plan = this.stackPlan(node, ctx, p, axis);
3084
- const alignment = str(node.props['alignment']) ?? 'center';
3085
- this.placeStack(node.children, cctx, plan, axis, alignment, x, y, size);
3121
+ const alignment = str(node.props['alignment']) ?? (node.kind === 'disclosuregroup' ? 'leading' : 'center');
3122
+ const shown = this.stackChildren(node);
3123
+ this.placeStack(shown, cctx, plan, axis, alignment, x, y, size);
3124
+ // A collapsed `disclosuregroup`: its content is hidden, without frames.
3125
+ for (const child of node.children) if (!shown.includes(child)) this.hide(child, node);
3086
3126
  return;
3087
3127
  }
3088
3128
  case 'zstack': {
@@ -3224,7 +3264,7 @@ export class LayoutPass {
3224
3264
  // (its label still reserves the chevron's width so selecting a row
3225
3265
  // never reflows it); elsewhere `selected` keeps the chevron. No row
3226
3266
  // of a `sidebar`-style list has one (iPadOS).
3227
- if (!(node.props['selected'] === true && ctx.splitSidebar) && !ctx.sidebarList) {
3267
+ if (!(node.props['selected'] === true && ctx.splitSidebar) && !(ctx.sidebarList && isRegularWidth(this.env.screen))) {
3228
3268
  chrome['chevron'] = {
3229
3269
  x: x + size.width - LIST_CHEVRON_WIDTH,
3230
3270
  y,
@@ -3521,12 +3561,22 @@ export class LayoutPass {
3521
3561
  this.setFrame(group.section, x + group.frame.x, y + group.frame.y, group.frame);
3522
3562
  this.setChrome(group.section, chrome);
3523
3563
  for (const view of [group.headerView, group.footerView]) {
3524
- if (view) this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
3564
+ if (!view) continue;
3565
+ this.place(view.node, view.ctx, view.proposal, x + view.rect.x, y + view.rect.y);
3566
+ // A collapsible section's header spans the content width (its chevron is at the trailing edge); its content stays put.
3567
+ const placed = view.node.id >= 0 ? this.frames.get(view.node.id) : undefined;
3568
+ if (placed && placed.width < view.rect.width) this.setFrame(view.node, placed.x, placed.y, { width: view.rect.width, height: placed.height });
3525
3569
  }
3526
3570
  if (group.section.id >= 0) {
3527
3571
  this.fonts.set(group.section.id, group.header?.layout.font ?? group.footer?.layout.font ?? makeFont('system', textStyleSize('footnote', this.env.dynamicTypeSize), 400));
3528
3572
  if (Object.keys(texts).length > 0) this.chromeText.set(group.section.id, texts);
3529
3573
  }
3574
+ // A collapsed `Section(isExpanded:)` (sidebar-style list): its rows are hidden, without frames.
3575
+ if (group.rows.length === 0) {
3576
+ for (const child of group.section.children) {
3577
+ if (child.kind !== 'sectionheader' && child.kind !== 'sectionfooter') this.hide(child, group.section);
3578
+ }
3579
+ }
3530
3580
  }
3531
3581
  for (const row of group.rows) {
3532
3582
  // Set the row chrome before placing so a navlink can merge its own keys.
@@ -3613,18 +3663,43 @@ export class LayoutPass {
3613
3663
 
3614
3664
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3615
3665
  const { safeArea, screen, dynamicTypeSize } = this.env;
3666
+ // The horizontal safe area (a phone on its side: the notch on one side,
3667
+ // the corner on the other): the bar's background runs edge to edge, as
3668
+ // the screen does; its items, the titles, the search field, the bottom
3669
+ // toolbars and the content stay clear of it. A split view's columns are
3670
+ // inset already (`placeNavSplit`), so they do not reach the edges here.
3671
+ const barX = x;
3672
+ const barWidth = size.width;
3673
+ const insetLeading = x <= EPSILON ? safeArea.leading : 0;
3674
+ const insetTrailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3675
+ x += insetLeading;
3676
+ size = { width: Math.max(0, size.width - insetLeading - insetTrailing), height: size.height };
3616
3677
  const statusBar = y <= 0 ? safeArea.top : (cctx.barInset ?? 0);
3617
3678
  const displayMode = str(node.props['displayMode']) ?? 'automatic';
3618
3679
  const depth = num(node.props['depth']) ?? 0;
3619
- const large = displayMode === 'large' || (displayMode === 'automatic' && depth === 0);
3620
- const barHeight = statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3680
+ // `.toolbar(.hidden, for: .navigationBar)`: no bar, title, back button or
3681
+ // bar items; the content starts under the status bar, which stays a safe
3682
+ // area. `.bottomBar` hidden: the bottom toolbars draw nothing.
3683
+ const barHidden = node.props['navigationBarHidden'] === true;
3684
+ const bottomBarHidden = node.props['bottomBarHidden'] === true;
3685
+ // A compact height (a phone on its side) has no large titles, whatever the
3686
+ // display mode, as UIKit: the title is the bar's inline one.
3687
+ const large = !barHidden && screen.height >= REGULAR_HEIGHT && (displayMode === 'large' || (displayMode === 'automatic' && depth === 0));
3688
+ const barHeight = barHidden ? statusBar : statusBar + NAV_BAR_HEIGHT + (!large && y <= 0 ? NAV_BAR_INLINE_BOTTOM : 0);
3621
3689
  const parts = this.navScreenParts(node);
3622
3690
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3623
3691
  const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3692
+ const placementOf = (t: LayoutNode): string | null => str(t.props['placement']);
3693
+ const inHiddenBar = (t: LayoutNode): boolean => {
3694
+ const placement = placementOf(t);
3695
+ if (placement === 'keyboard') return false;
3696
+ return placement === 'bottom' ? bottomBarHidden : barHidden;
3697
+ };
3698
+ for (const toolbar of parts.toolbars.filter(inHiddenBar)) this.hide(toolbar, node);
3624
3699
  // `.largeTitle` items size the large title row; the text title is 62.
3625
3700
  const titleItemsHeight = large ? this.largeTitleItemsHeight(parts.toolbars, cctx, size.width) : null;
3626
3701
  const largeHeight = !large ? 0 : titleItemsHeight !== null ? LARGE_TITLE_ITEM_TOP + titleItemsHeight : LARGE_TITLE_HEIGHT;
3627
- const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
3702
+ const bottomToolbars = bottomBarHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'bottom');
3628
3703
  // `keyboard` toolbars: a glass capsule floating above the keyboard while a
3629
3704
  // text input of this screen is being edited (as Notes' format bar on
3630
3705
  // iOS 26; SwiftUI's input accessory view); nothing otherwise.
@@ -3657,12 +3732,12 @@ export class LayoutPass {
3657
3732
  width: size.width,
3658
3733
  height: Math.max(0, size.height - contentTop - Math.max(bottomBarHeight, searchBottom)),
3659
3734
  };
3660
- const chrome: Record<string, Rect> = {
3661
- bar: { x, y, width: size.width, height: barHeight },
3735
+ const chrome: Record<string, Rect> = { content };
3736
+ if (!barHidden) {
3737
+ chrome['bar'] = { x: barX, y, width: barWidth, height: barHeight };
3662
3738
  // The 44 row the bar's items sit in, under the status bar.
3663
- barRow: { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT },
3664
- content,
3665
- };
3739
+ chrome['barRow'] = { x, y: y + statusBar, width: size.width, height: NAV_BAR_HEIGHT };
3740
+ }
3666
3741
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: largeHeight };
3667
3742
  if (parts.search) {
3668
3743
  const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
@@ -3700,11 +3775,13 @@ export class LayoutPass {
3700
3775
  // The navigation bar's groups; spacers there only separate them (the
3701
3776
  // groups are 8 apart anyway) and draw nothing.
3702
3777
  for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3703
- const barToolbars = parts.toolbars.filter((t) => {
3704
- const placement = str(t.props['placement']);
3705
- return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
3706
- });
3707
- const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
3778
+ const barToolbars = barHidden
3779
+ ? []
3780
+ : parts.toolbars.filter((t) => {
3781
+ const placement = placementOf(t);
3782
+ return placement !== 'bottom' && placement !== 'secondary' && placement !== 'largeTitle' && placement !== 'keyboard' && !isSpacer(t);
3783
+ });
3784
+ const secondaryToolbars = barHidden ? [] : parts.toolbars.filter((t) => placementOf(t) === 'secondary' && !isSpacer(t));
3708
3785
  const groups: Group[] = barToolbars.map((toolbar) => {
3709
3786
  const itemCtx = this.childContext(toolbar, cctx);
3710
3787
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
@@ -3826,7 +3903,7 @@ export class LayoutPass {
3826
3903
  // title no longer shows).
3827
3904
  const backLabel = '';
3828
3905
  const leadingStart = x + NAV_ITEM_MARGIN + (cctx.barLeading ?? 0);
3829
- if (depth > 0) {
3906
+ if (depth > 0 && !barHidden) {
3830
3907
  chrome['back'] = { x: leadingStart, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
3831
3908
  }
3832
3909
 
@@ -3969,7 +4046,7 @@ export class LayoutPass {
3969
4046
  const titleScrolls = large && scroller !== null;
3970
4047
  // A large title that scrolls away collapses into the inline title, which
3971
4048
  // the renderer shows once it has (`data-sb-collapsed`).
3972
- if ((!large || titleScrolls) && !principal) {
4049
+ if ((!large || titleScrolls) && !principal && !barHidden) {
3973
4050
  const leftUsed = Math.max(
3974
4051
  back ? back.x + back.width - x : 0,
3975
4052
  leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
@@ -4299,10 +4376,14 @@ export class LayoutPass {
4299
4376
  // The keyboard (device mode) covers the tab bar: the content stops above
4300
4377
  // whichever is taller (a `navstack` handles the keyboard in its screens).
4301
4378
  const keyboard = y + size.height >= screen.height - EPSILON ? this.keyboard : 0;
4379
+ // Clear of the horizontal safe area (a phone on its side); a `navstack`
4380
+ // insets its own screens' items and content.
4381
+ const leading = x <= EPSILON ? safeArea.leading : 0;
4382
+ const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
4302
4383
  const area: Rect = {
4303
- x,
4384
+ x: x + leading,
4304
4385
  y: y + topInset,
4305
- width: size.width,
4386
+ width: Math.max(0, size.width - leading - trailing),
4306
4387
  height: Math.max(0, size.height - topInset - Math.max(barHeight, keyboard)),
4307
4388
  };
4308
4389
  this.setChrome(tab, { content: area });
@@ -4316,7 +4397,7 @@ export class LayoutPass {
4316
4397
  if (kind === 'list' || kind === 'scrollview') {
4317
4398
  const height = Math.max(0, size.height - topInset - keyboard);
4318
4399
  const ctx: Context = keyboard > 0 ? { ...cctx, bottomBar: Math.max(0, barHeight - keyboard), bottomEdge: y + size.height - keyboard } : cctx;
4319
- this.place(child, ctx, { width: size.width, height }, x, y + topInset);
4400
+ this.place(child, ctx, { width: area.width, height }, area.x, y + topInset);
4320
4401
  return;
4321
4402
  }
4322
4403
  const proposal: Proposal = { width: area.width, height: area.height };
@@ -4535,10 +4616,11 @@ export class LayoutPass {
4535
4616
  }
4536
4617
 
4537
4618
  /**
4538
- * A slider is the proposed width (200 when none) and 44 high, plus a label
4539
- * row above when a `label` child exists (its own height, leading). The
4540
- * `min` / `max` value labels sit at the track's ends in the 44 row, 8 from
4541
- * the track. Children are named by `labels` in order.
4619
+ * A slider is the proposed width (200 when none) and 44 high. The `min` /
4620
+ * `max` value labels sit at the track's ends, 8 from the track. Its
4621
+ * `label` child is not drawn, as iOS does not draw a slider's label (it
4622
+ * names the slider for VoiceOver): the page hides it and names the slider
4623
+ * from it (`aria-labelledby`). Children are named by `labels` in order.
4542
4624
  */
4543
4625
  private sliderPlan(node: LayoutNode, ctx: Context, p: Proposal): SliderPlan {
4544
4626
  return this.plan(node, ctx, p, () => {
@@ -4554,19 +4636,17 @@ export class LayoutPass {
4554
4636
  else if (name === 'min') min = child;
4555
4637
  else if (name === 'max') max = child;
4556
4638
  });
4557
- const labelSize = label ? this.measure(label, cctx, { width, height: null }) : ZERO;
4558
4639
  const minSize = min ? this.measure(min, cctx, NIL_PROPOSAL) : ZERO;
4559
4640
  const maxSize = max ? this.measure(max, cctx, NIL_PROPOSAL) : ZERO;
4560
- const labelRow = label ? labelSize.height : 0;
4561
- return { size: { width, height: labelRow + SLIDER_HEIGHT }, label, min, max, labelSize, minSize, maxSize, labelRow, cctx };
4641
+ return { size: { width, height: SLIDER_HEIGHT }, label, min, max, minSize, maxSize, cctx };
4562
4642
  });
4563
4643
  }
4564
4644
 
4565
4645
  private placeSlider(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number, size: Size): void {
4566
4646
  const plan = this.sliderPlan(node, ctx, p);
4567
4647
  const { cctx } = plan;
4568
- if (plan.label) this.place(plan.label, cctx, { width: size.width, height: null }, x, y);
4569
- const rowY = y + plan.labelRow;
4648
+ if (plan.label) this.hide(plan.label, node);
4649
+ const rowY = y;
4570
4650
  let trackX = x;
4571
4651
  let trackEnd = x + size.width;
4572
4652
  if (plan.min) {
package/src/layoutDom.ts CHANGED
@@ -36,6 +36,7 @@
36
36
  import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
37
37
  import { hostContentInset } from './hostInset';
38
38
  import { listRowBackgroundOf } from './layout';
39
+ import { isRegularWidth } from './layout/engine';
39
40
  import { styleToCSS } from './styles';
40
41
  import type { ShapeStyle } from './protocol';
41
42
  import { applyChartChrome } from './chartDom';
@@ -159,6 +160,8 @@ function applyFrames(ctx: LayoutDomContext, result: LayoutResult, changes: BoxCh
159
160
  if (el.hidden) el.hidden = false;
160
161
  applyChrome(result, node, el, box, frame, scrollBottomInset(ctx, result, node, frame), previous === null, changes);
161
162
  if (node.kind === 'tab') setTabHidden(el, result.hidden.has(id));
163
+ // A `sidebar` list draws as one (no cards or hairlines) in a regular width; in a compact one it looks like `insetGrouped`.
164
+ if (node.kind === 'list') setFlag(el, 'sbListLook', node.props['style'] === 'sidebar' && isRegularWidth(ctx.env.screen) ? 'sidebar' : null);
162
165
  // A child the engine did not touch kept its screen frame, but if this
163
166
  // element's box moved, the child's `left`/`top` (relative to it) did
164
167
  // not: the plus button at the bottom of a chat's composer stays put on
@@ -636,14 +639,15 @@ function applyChrome(
636
639
  setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
637
640
  setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
638
641
  // `.listRowBackground(_:)`: the row's cell paint (styles.css reads it, and
639
- // a card with such a row leaves its own background to its rows).
642
+ // a card with such a row leaves its own background to its rows; a card of
643
+ // clear rows alone, marked `clear`, clips nothing either).
640
644
  const rowBackground = chrome?.['row'] ? listRowBackgroundOf(node) : undefined;
641
645
  const rowPaint = rowBackground !== undefined ? styleToCSS(rowBackground as ShapeStyle, 'paint').value : '';
642
646
  if (el.style.getPropertyValue('--sb-row-background') !== rowPaint) {
643
647
  if (rowPaint) el.style.setProperty('--sb-row-background', rowPaint);
644
648
  else el.style.removeProperty('--sb-row-background');
645
649
  }
646
- setFlag(el, 'sbRowBackground', rowPaint ? '' : null);
650
+ setFlag(el, 'sbRowBackground', rowPaint ? (rowPaint === 'transparent' ? 'clear' : '') : null);
647
651
  // iOS 26: the separator under a list row starts at its first text (styles.css).
648
652
  const separator = chrome?.['separator'];
649
653
  const leading = separator ? `${separator.x}px` : '';
@@ -699,11 +703,22 @@ function applyChrome(
699
703
  case 'stepper':
700
704
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-stepper-control'), chrome?.['control'], box);
701
705
  return;
702
- case 'slider':
706
+ case 'slider': {
707
+ // The `label` child is hidden (iOS draws none) and names the slider, as VoiceOver reads it.
708
+ const names = Array.isArray(node.props['labels']) ? node.props['labels'] : [];
709
+ const labelNode = node.children[names.indexOf('label')];
710
+ const labelEl = labelNode && names.includes('label') ? el.querySelector<HTMLElement>(`[data-sb-id="${labelNode.id}"]`) : null;
711
+ if (labelEl) {
712
+ if (!labelEl.id) labelEl.id = `sb-slider-label-${labelNode!.id}`;
713
+ el.setAttribute('aria-labelledby', labelEl.id);
714
+ } else {
715
+ el.removeAttribute('aria-labelledby');
716
+ }
703
717
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-track'), chrome?.['track'], box);
704
718
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-fill'), chrome?.['fill'], box);
705
719
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-slider-thumb'), chrome?.['thumb'], box);
706
720
  return;
721
+ }
707
722
  case 'datepicker':
708
723
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-date'), chrome?.['date'], box);
709
724
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-datepicker-time'), chrome?.['time'], box);
@@ -745,6 +760,20 @@ function applyChrome(
745
760
  placeChrome(sw, rect, box);
746
761
  return;
747
762
  }
763
+ 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
+ const chevron = el.querySelector<HTMLElement>(':scope > .sb-disclosure-chevron');
767
+ const toggle = el.querySelector<HTMLElement>(':scope > .sb-disclosure-toggle');
768
+ if (label) {
769
+ 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
+ placeChrome(toggle, { x: frame.x, y: label.y, width: frame.width, height: label.height }, box);
771
+ } else {
772
+ placeChrome(chevron, undefined, box);
773
+ placeChrome(toggle, undefined, box);
774
+ }
775
+ return;
776
+ }
748
777
  case 'navlink': {
749
778
  // A link row's badge sits on its label (`NavigationLink { row.badge(n) }`).
750
779
  applyBadge(result, node, el, box);
@@ -839,6 +868,12 @@ function applyChrome(
839
868
  const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
840
869
  const texts = result.chromeText.get(node.id);
841
870
  const barRect = chrome?.['bar'];
871
+ // `.toolbar(.hidden, for: .navigationBar)`: the bar fades out where it
872
+ // was, its title, back button and More button with it (styles.css);
873
+ // `.statusBarHidden`: the frame's drawn status bar fades (renderer.ts).
874
+ const barHidden = node.props['navigationBarHidden'] === true;
875
+ setFlag(el, 'sbBarHidden', barHidden ? '' : null);
876
+ setFlag(el, 'sbStatusHidden', node.props['statusBarHidden'] === true ? '' : null);
842
877
  if (bar && barRect) {
843
878
  placeChrome(bar, barRect, frame);
844
879
  // The 44px bar row sits under the status bar portion of the rect
@@ -871,6 +906,11 @@ function applyChrome(
871
906
  largeTitle.hidden = true;
872
907
  }
873
908
  }
909
+ if (barHidden) return;
910
+ // The engine's choice, not the display mode's: a compact height (a
911
+ // phone on its side) has no large title row, and the bar shows the
912
+ // inline title as UIKit does (styles.css hides it under a large one).
913
+ el.dataset['sbDisplay'] = largeRect ? 'large' : 'inline';
874
914
  if (inlineTitle) {
875
915
  // Phase 6: the engine centers the inline title in the room the back
876
916
  // button and the toolbar items leave (no rect: large title or a
package/src/main.ts CHANGED
@@ -2,6 +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
6
  import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
6
7
  import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
7
8
  import { createMockApp } from './mock';
@@ -228,7 +229,8 @@ typeSizeSelect.addEventListener('change', () => {
228
229
 
229
230
  /**
230
231
  * Phase 7: the horizontal size class Swift sees (`regular` from 700 CSS px of
231
- * screen width). The frame is always compact; device mode follows the viewport.
232
+ * screen width). The iPhone frame is compact, the iPad frame regular, and
233
+ * without a frame (or in device mode) it follows the window.
232
234
  */
233
235
  function currentSizeClass(): HorizontalSizeClass {
234
236
  return horizontalSizeClassFor(screen.clientWidth);
@@ -260,6 +262,15 @@ if (deviceMode) {
260
262
  // `?debug=viewport` shows the numbers the measurement reads, over the app.
261
263
  const readout = new URLSearchParams(location.search).get('debug') === 'viewport' ? installViewportReadout() : undefined;
262
264
  watchKeyboard((height) => renderer.setKeyboard(height), { root: device, onRead: readout });
265
+ } else if (!document.documentElement.hasAttribute('data-sb-embed')) {
266
+ // A desktop preview: the toolbar's Device picker (deviceFrame.ts) draws the
267
+ // app in an iPhone, an iPad or no device; the renderer re-reads the
268
+ // screen's size when it changes, and Swift hears a new size class.
269
+ const sizeClassMayHaveChanged = (): void => {
270
+ if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
271
+ };
272
+ installDeviceFrame(renderer, { onChange: sizeClassMayHaveChanged });
273
+ watchViewport(sizeClassMayHaveChanged);
263
274
  }
264
275
 
265
276
  // ---------------------------------------------------------------------------
package/src/page.css CHANGED
@@ -141,6 +141,43 @@ body {
141
141
  border-color: transparent;
142
142
  }
143
143
 
144
+ .toolbar-close {
145
+ flex: 0 0 auto;
146
+ display: grid;
147
+ place-items: center;
148
+ width: 28px;
149
+ height: 28px;
150
+ margin-right: -4px;
151
+ padding: 0;
152
+ border: 0;
153
+ border-radius: 50%;
154
+ background: transparent;
155
+ color: var(--page-fg);
156
+ cursor: pointer;
157
+ }
158
+
159
+ .toolbar-close svg circle {
160
+ fill: currentColor;
161
+ opacity: 0.12;
162
+ }
163
+
164
+ .toolbar-close svg path {
165
+ fill: none;
166
+ stroke: currentColor;
167
+ stroke-width: 1.8;
168
+ stroke-linecap: round;
169
+ opacity: 0.7;
170
+ }
171
+
172
+ .toolbar-close:hover svg circle {
173
+ opacity: 0.2;
174
+ }
175
+
176
+ /* ?toolbar=0 (the toolbar's close button, src/deviceFrame.ts): the app alone. */
177
+ html[data-sb-toolbar="hidden"] .toolbar {
178
+ display: none;
179
+ }
180
+
144
181
  .toolbar-select {
145
182
  font: inherit;
146
183
  padding: 5px 8px;
@@ -172,6 +209,34 @@ body {
172
209
  word-break: break-word;
173
210
  }
174
211
 
212
+ /* No device (html[data-sb-frame="none"], src/deviceFrame.ts): the page is
213
+ the toolbar over the app, which fills the rest of the window and lays out
214
+ for its size as the window is resized. */
215
+ html[data-sb-frame="none"]:not([data-sb-mode="device"]) body {
216
+ display: flex;
217
+ flex-direction: column;
218
+ height: 100dvh;
219
+ min-height: 0;
220
+ overflow: hidden;
221
+ }
222
+
223
+ html[data-sb-frame="none"]:not([data-sb-mode="device"]) .stage {
224
+ flex: 1 1 auto;
225
+ min-height: 0;
226
+ align-items: stretch;
227
+ padding: 0;
228
+ gap: 0;
229
+ }
230
+
231
+ html[data-sb-frame="none"]:not([data-sb-mode="device"]) .status {
232
+ position: fixed;
233
+ left: 16px;
234
+ right: 16px;
235
+ bottom: 16px;
236
+ z-index: 10;
237
+ max-width: none;
238
+ }
239
+
175
240
  /* ---------------------------------------------------------------------------
176
241
  Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
177
242
  toolbar, no stage padding, the page behind the app in the OS appearance's