@swiftbrowser/web 0.4.0 → 0.5.1

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.
package/README.md CHANGED
@@ -35,6 +35,7 @@ src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixella
35
35
  src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
36
36
  src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
37
37
  src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
38
+ src/page.css The app page around the frame: its toolbar, stage, error banner, device-mode chrome
38
39
  node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
39
40
  examples.ts (Examples/* as specs, build-wasm.sh runner, descriptions),
40
41
  index.ts (createDevServer / buildSite, the package entry); compiled to node/dist/
package/app/index.html CHANGED
@@ -24,6 +24,7 @@
24
24
  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
25
25
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
26
26
  <link rel="stylesheet" href="/src/styles.css" />
27
+ <link rel="stylesheet" href="/src/page.css" />
27
28
  <script>
28
29
  // Decide frame vs device mode before the first paint so a phone never
29
30
  // flashes the fake bezel. src/deviceMode.ts holds the same rule and is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -16,6 +16,7 @@
16
16
  "default": "./node/dist/node/index.js"
17
17
  },
18
18
  "./src/styles.css": "./src/styles.css",
19
+ "./src/page.css": "./src/page.css",
19
20
  "./src/*": "./src/*.ts"
20
21
  },
21
22
  "files": [
package/src/largeTitle.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { Rect } from './layout';
22
- import { setValue } from './scrollEdge';
22
+ import { edgeStrength, setProperty, setValue } from './scrollEdge';
23
23
 
24
24
  /** The scroll offset past which the title has gone under the bar. */
25
25
  const COLLAPSE_OFFSET = 44;
@@ -65,6 +65,7 @@ export function bindScrollUnderBar(scroller: HTMLElement, frame: Rect, topInset:
65
65
  delete screen.dataset['sbScrolled'];
66
66
  delete screen.dataset['sbCollapsed'];
67
67
  delete screen.dataset['sbEdgeTop'];
68
+ screen.style.removeProperty('--sb-edge-top');
68
69
  }
69
70
  }
70
71
  if (screen) releaseTitle(screen, scroller);
@@ -106,6 +107,8 @@ export function syncScrollUnderBar(scroller: HTMLElement): void {
106
107
  if (!screen) return;
107
108
  const offset = scroller.scrollTop;
108
109
  setFlag(screen, 'sbScrolled', offset > 0);
110
+ // The edge effect's strength ramps up over the first `EDGE_RAMP` px.
111
+ setProperty(screen, '--sb-edge-top', edgeStrength(offset));
109
112
  const title = titles.get(screen);
110
113
  const carries = title !== undefined && title.parentElement === scroller;
111
114
  setFlag(screen, 'sbCollapsed', carries && offset > COLLAPSE_OFFSET);
@@ -200,10 +200,13 @@ const LIST_SECTION_GAP = 107 / 3;
200
200
  const LIST_SECTION_GAP_AFTER_FOOTER = 74 / 3;
201
201
  const LIST_CHEVRON_WIDTH = 20;
202
202
  // Phase 4.
203
- // iOS 26: the tab bar is a 62-tall glass capsule floating 21 from the sides
204
- // and 22 above the bottom edge (8 when the tab view ends above the screen
205
- // bottom), its items in equal slots inside a 4 inset.
203
+ // iOS 26: the tab bar is a 62-tall glass capsule floating 22 above the bottom
204
+ // edge (8 when the tab view ends above the screen bottom), its items in equal
205
+ // slots inside a 4 inset. It is as wide as its tabs (268/3 each, measured in
206
+ // the 26.2 simulator: two tabs make a 186.67 capsule), centered, and at most
207
+ // the width less 21 either side (four tabs fill that on an iPhone).
206
208
  const TAB_BAR_HEIGHT = 62;
209
+ const TAB_BAR_ITEM_WIDTH = 268 / 3;
207
210
  const TAB_BAR_MARGIN_H = 21;
208
211
  const TAB_BAR_MARGIN_BOTTOM = 22;
209
212
  const TAB_BAR_MARGIN_BOTTOM_INSET = 8;
@@ -243,7 +246,18 @@ const NAV_ITEM_MARGIN = 16;
243
246
  const NAV_GLASS_SIZE = 44;
244
247
  const NAV_GLASS_PAD_H = 12;
245
248
  const NAV_GROUP_SPACING = 8;
246
- const BOTTOM_BAR_HEIGHT = 49;
249
+ // iOS 26 (measured in the 26.2 simulator, Examples/Fidelity `bottombar*`): a
250
+ // bottom toolbar's items float on Liquid Glass in a 48 row 28 above the
251
+ // screen bottom (as the search field does) and 28 in from the sides.
252
+ // Consecutive items share a capsule: an icon takes a 50 slot, text its width
253
+ // and 18 either side, 6 between slots; a lone icon is a 48 circle.
254
+ const BOTTOM_GLASS_HEIGHT = 48;
255
+ const BOTTOM_GLASS_MARGIN_H = 28;
256
+ const BOTTOM_ICON_SLOT = 50;
257
+ const BOTTOM_TEXT_PAD_H = 18;
258
+ const BOTTOM_ITEM_GAP = 6;
259
+ /** A fixed `ToolbarSpacer` between two bottom groups. */
260
+ const BOTTOM_FIXED_SPACER = 8;
247
261
  const SEARCH_FIELD_HEIGHT = 36;
248
262
  const SEARCH_FIELD_MARGIN_H = 16;
249
263
  // iOS 26: a navigation stack's search field is a 48-tall glass capsule
@@ -256,6 +270,8 @@ const SEARCH_FIELD_DEFAULT_WIDTH = 200;
256
270
  const IMAGE_SCALES: Readonly<Record<string, number>> = { small: 0.8, medium: 1, large: 1.25 };
257
271
  // Phase 7 (docs/ops-protocol.md, "NavigationSplitView").
258
272
  const NAVSPLIT_SIDEBAR_WIDTH = 320;
273
+ /** From this root width a window is regular (an iPad, a desktop browser), as `@swiftbrowser/react`'s size class. */
274
+ const REGULAR_WIDTH = 700;
259
275
  /** The sidebar never takes more than this share of the split view's width. */
260
276
  const NAVSPLIT_SIDEBAR_MAX_FRACTION = 0.4;
261
277
  const NAVSPLIT_SEPARATOR_WIDTH = 1;
@@ -568,7 +584,7 @@ interface GridItemSpec {
568
584
  alignment: string | null;
569
585
  }
570
586
 
571
- /** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible column. */
587
+ /** `columns` of a `lazyvgrid` or `rows` of a `lazyhgrid` (untrusted JSON); an empty list is one flexible track. */
572
588
  function readGridItems(value: unknown): GridItemSpec[] {
573
589
  const items: GridItemSpec[] = [];
574
590
  if (Array.isArray(value)) {
@@ -1526,6 +1542,7 @@ export class LayoutPass {
1526
1542
  case 'grid':
1527
1543
  return this.gridPlan(node, ctx, p).size;
1528
1544
  case 'lazyvgrid':
1545
+ case 'lazyhgrid':
1529
1546
  return this.lazyGridPlan(node, ctx, p).size;
1530
1547
  case 'gauge':
1531
1548
  return this.gaugePlan(node, ctx, p).size;
@@ -2778,6 +2795,7 @@ export class LayoutPass {
2778
2795
  this.placeGrid(node, ctx, p, x, y);
2779
2796
  return;
2780
2797
  case 'lazyvgrid':
2798
+ case 'lazyhgrid':
2781
2799
  this.placeLazyGrid(node, ctx, p, x, y);
2782
2800
  return;
2783
2801
  case 'gauge':
@@ -3000,13 +3018,14 @@ export class LayoutPass {
3000
3018
  /**
3001
3019
  * The navigation bar is the status bar plus a 44 row (plus 62 for a large
3002
3020
  * title); a Phase 6 `searchfield` is a capsule floating above the bottom
3003
- * (iOS 26); a `bottom` toolbar is a 49 bar plus the bottom safe area; the
3004
- * content is proposed the rest. Toolbar
3005
- * items are measured at their ideal size and laid out in iOS 26 glass
3006
- * capsules (44-tall slots, groups 8 apart) in the 44 row (`leading` after the back button, `trailing` 16 from the
3007
- * trailing edge, `principal` centered in place of the title) or spread
3008
- * evenly across the bottom bar. The inline title (`chrome.title`) is
3009
- * centered and truncated to the room the bar items leave.
3021
+ * (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
3022
+ * above the bottom (`placeBottomToolbars`); the content is proposed the
3023
+ * rest. Toolbar items are measured at their ideal size and laid out in iOS
3024
+ * 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
3025
+ * (`leading` after the back button, `trailing` 16 from the trailing edge,
3026
+ * `principal` centered in place of the title). A `ToolbarSpacer` (an empty
3027
+ * toolbar with `spacer`) only separates groups there. The inline title
3028
+ * (`chrome.title`) is centered and truncated to the room the bar items leave.
3010
3029
  */
3011
3030
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3012
3031
  const { safeArea, screen, dynamicTypeSize } = this.env;
@@ -3018,10 +3037,12 @@ export class LayoutPass {
3018
3037
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
3019
3038
  const parts = this.navScreenParts(node);
3020
3039
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3040
+ const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3021
3041
  const bottomToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'bottom');
3022
- const bottomBarHeight =
3023
- bottomToolbars.length > 0 ? Math.min(size.height, BOTTOM_BAR_HEIGHT + (reachesBottom ? safeArea.bottom : 0)) : 0;
3024
3042
  const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
3043
+ const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
3044
+ ? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
3045
+ : 0;
3025
3046
  const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3026
3047
  const contentTop = barHeight + largeHeight;
3027
3048
  const content: Rect = {
@@ -3063,7 +3084,15 @@ export class LayoutPass {
3063
3084
  slots: number[] | null;
3064
3085
  total: number;
3065
3086
  }
3066
- const groups: Group[] = parts.toolbars.map((toolbar) => {
3087
+ // The navigation bar's groups; spacers there only separate them (the
3088
+ // groups are 8 apart anyway) and draw nothing.
3089
+ for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3090
+ const barToolbars = parts.toolbars.filter((t) => {
3091
+ const placement = str(t.props['placement']);
3092
+ return placement !== 'bottom' && placement !== 'secondary' && !isSpacer(t);
3093
+ });
3094
+ const secondaryToolbars = parts.toolbars.filter((t) => str(t.props['placement']) === 'secondary' && !isSpacer(t));
3095
+ const groups: Group[] = barToolbars.map((toolbar) => {
3067
3096
  const itemCtx = this.childContext(toolbar, cctx);
3068
3097
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
3069
3098
  const placement = str(toolbar.props['placement']) ?? 'trailing';
@@ -3080,6 +3109,53 @@ export class LayoutPass {
3080
3109
  const trailingWidth = groupWidth('trailing');
3081
3110
  const principal = groups.some((g) => g.placement === 'principal');
3082
3111
 
3112
+ // `.secondaryAction` items (placement `secondary`), as iOS treats them:
3113
+ // collapsed into the More button (chrome `more`: an ellipsis in a glass
3114
+ // circle at the trailing end, whose menu lists them). A screen with the
3115
+ // editor toolbar role in a regular width shows them inline before the
3116
+ // trailing items, as iPadOS does, clear of the title, and collapses only
3117
+ // those that do not fit.
3118
+ interface SecondaryItem {
3119
+ toolbar: LayoutNode;
3120
+ item: LayoutNode;
3121
+ size: Size;
3122
+ slot: number;
3123
+ }
3124
+ const secondaryItems: SecondaryItem[] = secondaryToolbars.flatMap((toolbar) => {
3125
+ const itemCtx = this.childContext(toolbar, cctx);
3126
+ return toolbar.children.map((item) => {
3127
+ const itemSize = this.measure(item, itemCtx, { width: null, height: null });
3128
+ return { toolbar, item, size: itemSize, slot: Math.max(NAV_GLASS_SIZE, itemSize.width + 2 * NAV_GLASS_PAD_H) };
3129
+ });
3130
+ });
3131
+ // Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
3132
+ const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
3133
+ previous && previous.toolbar !== entry.toolbar ? NAV_GROUP_SPACING : 0;
3134
+ const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
3135
+ entries.reduce((sum, entry, i) => sum + secondaryGap(entries[i - 1], entry) + entry.slot, 0);
3136
+ let inlineSecondary: SecondaryItem[] = [];
3137
+ if (secondaryItems.length > 0 && str(node.props['toolbarRole']) === 'editor' && screen.width >= REGULAR_WIDTH) {
3138
+ // The room between the title (centered, at its width up to 56% of the
3139
+ // bar) and the trailing groups; the More button takes a slot of it
3140
+ // once anything collapses.
3141
+ const titleWidth = principal ? 0 : Math.min(this.measurer.measureWidth(title, titleFont), size.width * 0.56);
3142
+ const roomStart = x + (size.width + titleWidth) / 2 + NAV_GROUP_SPACING;
3143
+ const roomEnd = x + size.width - NAV_ITEM_MARGIN - trailingWidth;
3144
+ const fitting = (room: number): SecondaryItem[] => {
3145
+ const out: SecondaryItem[] = [];
3146
+ let used = 0;
3147
+ for (const entry of secondaryItems) {
3148
+ const width = secondaryGap(out[out.length - 1], entry) + entry.slot;
3149
+ if (used + width > room) break;
3150
+ used += width;
3151
+ out.push(entry);
3152
+ }
3153
+ return out;
3154
+ };
3155
+ inlineSecondary = secondaryWidth(secondaryItems) <= roomEnd - roomStart ? secondaryItems : fitting(roomEnd - roomStart - NAV_GLASS_SIZE - NAV_GROUP_SPACING);
3156
+ }
3157
+ const collapsedSecondary = secondaryItems.filter((entry) => !inlineSecondary.includes(entry));
3158
+
3083
3159
  // Back button, iOS 26: the chevron alone in a glass circle (the previous
3084
3160
  // title no longer shows).
3085
3161
  const backLabel = '';
@@ -3092,6 +3168,11 @@ export class LayoutPass {
3092
3168
  const back = chrome['back'];
3093
3169
  let leadingCursor = back ? back.x + back.width + NAV_GROUP_SPACING : leadingStart;
3094
3170
  let trailingEdge = x + size.width - NAV_ITEM_MARGIN;
3171
+ // The More button is the trailing-most item.
3172
+ if (collapsedSecondary.length > 0) {
3173
+ chrome['more'] = { x: trailingEdge - NAV_GLASS_SIZE, y: rowY, width: NAV_GLASS_SIZE, height: NAV_GLASS_SIZE };
3174
+ trailingEdge -= NAV_GLASS_SIZE + NAV_GROUP_SPACING;
3175
+ }
3095
3176
  const placeGroup = (group: Group, startX: number, top: number, rowHeight: number, spacing: number): void => {
3096
3177
  const itemCtx = this.childContext(group.toolbar, cctx);
3097
3178
  let cursor = startX;
@@ -3118,19 +3199,6 @@ export class LayoutPass {
3118
3199
  placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
3119
3200
  break;
3120
3201
  }
3121
- case 'bottom': {
3122
- const bar = chrome['bottomBar'] as Rect;
3123
- this.setFrame(group.toolbar, bar.x, bar.y, bar);
3124
- const n = group.sizes.length;
3125
- const inner = Math.max(0, size.width - 2 * NAV_ITEM_MARGIN);
3126
- const itemsWidth = group.total - Math.max(0, n - 1) * NAV_ITEM_SPACING;
3127
- if (n === 1) placeGroup(group, x + (size.width - itemsWidth) / 2, bar.y, BOTTOM_BAR_HEIGHT, 0);
3128
- else if (n > 1) {
3129
- const gap = Math.max(NAV_ITEM_SPACING, (inner - itemsWidth) / (n - 1));
3130
- placeGroup(group, x + NAV_ITEM_MARGIN, bar.y, BOTTOM_BAR_HEIGHT, gap);
3131
- }
3132
- break;
3133
- }
3134
3202
  default: {
3135
3203
  const startX = trailingEdge - group.total;
3136
3204
  this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
@@ -3140,6 +3208,32 @@ export class LayoutPass {
3140
3208
  }
3141
3209
  }
3142
3210
  }
3211
+ // The inline secondary items, right-aligned before the trailing groups,
3212
+ // each toolbar framed around its own; the collapsed ones are hidden (the
3213
+ // renderer lists the items without a frame in the More menu).
3214
+ if (inlineSecondary.length > 0) {
3215
+ const total = secondaryWidth(inlineSecondary);
3216
+ let cursor = trailingEdge - total;
3217
+ const extents = new Map<LayoutNode, { start: number; end: number }>();
3218
+ for (const [i, entry] of inlineSecondary.entries()) {
3219
+ cursor += secondaryGap(inlineSecondary[i - 1], entry);
3220
+ const itemCtx = this.childContext(entry.toolbar, cctx);
3221
+ this.place(entry.item, itemCtx, { width: null, height: null }, cursor + (entry.slot - entry.size.width) / 2, rowY + (NAV_BAR_HEIGHT - entry.size.height) / 2);
3222
+ const extent = extents.get(entry.toolbar);
3223
+ extents.set(entry.toolbar, { start: extent?.start ?? cursor, end: cursor + entry.slot });
3224
+ cursor += entry.slot;
3225
+ }
3226
+ for (const [toolbar, extent] of extents) this.setFrame(toolbar, extent.start, rowY, { width: extent.end - extent.start, height: NAV_BAR_HEIGHT });
3227
+ trailingEdge -= total + NAV_GROUP_SPACING;
3228
+ }
3229
+ for (const toolbar of secondaryToolbars) {
3230
+ const items = toolbar.children;
3231
+ if (!inlineSecondary.some((entry) => entry.toolbar === toolbar)) this.hide(toolbar, node);
3232
+ else for (const item of items) if (collapsedSecondary.some((entry) => entry.item === item)) this.hide(item, node);
3233
+ }
3234
+ if (bottomBarHeight > 0) {
3235
+ this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
3236
+ }
3143
3237
  if (parts.search) {
3144
3238
  // The field is the whole capsule (its own measure is a 36pt field).
3145
3239
  const field = chrome['searchField'] as Rect;
@@ -3166,7 +3260,8 @@ export class LayoutPass {
3166
3260
  leadingWidth > 0 ? leadingCursor - NAV_GROUP_SPACING - x : 0,
3167
3261
  cctx.barLeading ? leadingStart - NAV_GROUP_SPACING - x : 0,
3168
3262
  );
3169
- const rightUsed = trailingWidth > 0 ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
3263
+ const trailingUsed = trailingWidth > 0 || chrome['more'] !== undefined || inlineSecondary.length > 0;
3264
+ const rightUsed = trailingUsed ? x + size.width - (trailingEdge + NAV_GROUP_SPACING) : 0;
3170
3265
  const side = Math.max(leftUsed, rightUsed) + 8;
3171
3266
  const available = Math.max(0, size.width - 2 * side);
3172
3267
  const titleWidth = Math.min(Math.ceil(this.measurer.measureWidth(title, titleFont) - 0.001), Math.floor(size.width * 0.56), Math.floor(available));
@@ -3244,9 +3339,101 @@ export class LayoutPass {
3244
3339
  this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
3245
3340
  }
3246
3341
 
3342
+ /**
3343
+ * iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
3344
+ * float on Liquid Glass in a 48 row at the top of the bottom bar's band
3345
+ * (`top`), 28 in from the sides. Consecutive items share a capsule; a
3346
+ * `ToolbarSpacer` (an empty toolbar with `spacer`) ends one. Flexible
3347
+ * spacers share the room the groups leave (with none the row is centered)
3348
+ * and fixed ones are 8 wide. An icon takes a 50 slot (a lone one is a 48
3349
+ * circle), text its width and 18 either side, 6 between slots; each item
3350
+ * is centered in its slot. The capsules are the screen's chrome
3351
+ * `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
3352
+ * items.
3353
+ */
3354
+ private placeBottomToolbars(
3355
+ toolbars: LayoutNode[],
3356
+ cctx: Context,
3357
+ chrome: Record<string, Rect>,
3358
+ x: number,
3359
+ top: number,
3360
+ width: number,
3361
+ ): void {
3362
+ interface Item {
3363
+ node: LayoutNode;
3364
+ toolbar: LayoutNode;
3365
+ size: Size;
3366
+ slot: number;
3367
+ }
3368
+ type Token = { kind: 'group'; items: Item[]; width: number } | { kind: 'flexible' } | { kind: 'fixed' };
3369
+ const tokens: Token[] = [];
3370
+ let open: Item[] = [];
3371
+ const close = (): void => {
3372
+ if (open.length === 0) return;
3373
+ const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
3374
+ const lone = open.length === 1 && icon(open[0] as Item);
3375
+ if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
3376
+ const slots = open.reduce((sum, item) => sum + item.slot, 0);
3377
+ tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
3378
+ open = [];
3379
+ };
3380
+ for (const toolbar of toolbars) {
3381
+ const spacer = str(toolbar.props['spacer']);
3382
+ if (spacer !== null) {
3383
+ close();
3384
+ tokens.push({ kind: spacer === 'fixed' ? 'fixed' : 'flexible' });
3385
+ continue;
3386
+ }
3387
+ const itemCtx = this.childContext(toolbar, cctx);
3388
+ for (const node of toolbar.children) {
3389
+ const size = this.measure(node, itemCtx, { width: null, height: null });
3390
+ const slot = size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
3391
+ open.push({ node, toolbar, size, slot });
3392
+ }
3393
+ }
3394
+ close();
3395
+ const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
3396
+ let used = 0;
3397
+ let flexible = 0;
3398
+ for (const token of tokens) {
3399
+ if (token.kind === 'group') used += token.width;
3400
+ else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
3401
+ else flexible += 1;
3402
+ }
3403
+ const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
3404
+ let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
3405
+ const spans = new Map<LayoutNode, { start: number; end: number }>();
3406
+ let glass = 0;
3407
+ for (const token of tokens) {
3408
+ if (token.kind === 'flexible') {
3409
+ cursor += share;
3410
+ continue;
3411
+ }
3412
+ if (token.kind === 'fixed') {
3413
+ cursor += BOTTOM_FIXED_SPACER;
3414
+ continue;
3415
+ }
3416
+ chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
3417
+ glass += 1;
3418
+ for (const item of token.items) {
3419
+ const itemCtx = this.childContext(item.toolbar, cctx);
3420
+ this.place(item.node, itemCtx, { width: null, height: null }, cursor + (item.slot - item.size.width) / 2, top + (BOTTOM_GLASS_HEIGHT - item.size.height) / 2);
3421
+ const span = spans.get(item.toolbar);
3422
+ spans.set(item.toolbar, { start: span ? span.start : cursor, end: cursor + item.slot });
3423
+ cursor += item.slot + BOTTOM_ITEM_GAP;
3424
+ }
3425
+ cursor -= BOTTOM_ITEM_GAP;
3426
+ }
3427
+ for (const toolbar of toolbars) {
3428
+ const span = spans.get(toolbar);
3429
+ if (span) this.setFrame(toolbar, span.start, top, { width: span.end - span.start, height: BOTTOM_GLASS_HEIGHT });
3430
+ }
3431
+ }
3432
+
3247
3433
  /**
3248
3434
  * The tab bar (iOS 26) is a glass capsule floating over the bottom of the
3249
- * content (TAB_BAR_* above), with one equal-width item per tab; what it
3435
+ * content (TAB_BAR_* above), as wide as its equal-width items (one per tab)
3436
+ * up to the width less the margins, centered; what it
3250
3437
  * covers (the capsule and the gap under it) is the content's bottom inset.
3251
3438
  * Every `tab` gets the tab view's frame; only the selected one lays its
3252
3439
  * content out, the others are reported in `hidden`.
@@ -3256,18 +3443,19 @@ export class LayoutPass {
3256
3443
  const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
3257
3444
  const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
3258
3445
  const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
3446
+ const tabs = node.children;
3447
+ const n = tabs.length;
3448
+ const width = Math.min(Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H), n * TAB_BAR_ITEM_WIDTH + 2 * TAB_BAR_ITEM_INSET);
3259
3449
  const bar: Rect = {
3260
- x: x + TAB_BAR_MARGIN_H,
3450
+ x: x + (size.width - width) / 2,
3261
3451
  y: y + size.height - barHeight,
3262
- width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
3452
+ width,
3263
3453
  height: capsuleHeight,
3264
3454
  };
3265
3455
  const chrome: Record<string, Rect> = {
3266
3456
  bar,
3267
3457
  content: { x, y, width: size.width, height: size.height - barHeight },
3268
3458
  };
3269
- const tabs = node.children;
3270
- const n = tabs.length;
3271
3459
  const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
3272
3460
  for (let i = 0; i < n; i++) {
3273
3461
  const itemWidth = inner / n;
@@ -4150,22 +4338,33 @@ export class LayoutPass {
4150
4338
  * clamped. With no width the flexible columns take the cells' largest ideal
4151
4339
  * width and an adaptive item is one column. Cells fill rows left to right.
4152
4340
  */
4341
+ /**
4342
+ * `lazyvgrid` and `lazyhgrid`. Written for the vertical grid: its columns
4343
+ * share the proposed width and its cells fill them row by row. A horizontal
4344
+ * grid is the same along the other axis (its rows share the proposed
4345
+ * height and the cells fill them column by column), so the plan's
4346
+ * `columns` are the tracks across the grid (`x`, `width`: cross axis) and
4347
+ * its `rows` the lines along it (`y`, `height`: main axis).
4348
+ */
4153
4349
  private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
4154
4350
  return this.plan(node, p, () => {
4155
- const items = readGridItems(node.props['columns']);
4156
- const rowSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
4351
+ const horizontal = node.kind === 'lazyhgrid';
4352
+ const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
4353
+ const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
4354
+ const lineSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
4157
4355
  const cctx = this.childContext(node, ctx);
4158
4356
  const cells = node.children;
4357
+ const cross = crossOf(p);
4159
4358
  let idealCell: number | null = null;
4160
4359
  const ideal = (): number => {
4161
4360
  if (idealCell === null) {
4162
4361
  idealCell = 0;
4163
- for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL).width);
4362
+ for (const cell of cells) idealCell = Math.max(idealCell, crossOf(this.measure(cell, cctx, NIL_PROPOSAL)) ?? 0);
4164
4363
  }
4165
4364
  return idealCell;
4166
4365
  };
4167
4366
  const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
4168
- if (!finite(p.width)) {
4367
+ if (!finite(cross)) {
4169
4368
  items.forEach((item, i) => {
4170
4369
  if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
4171
4370
  });
@@ -4175,7 +4374,7 @@ export class LayoutPass {
4175
4374
  let fixed = 0;
4176
4375
  for (const item of items) if (item.kind === 'fixed') fixed += item.value;
4177
4376
  const sharing = items.filter((item) => item.kind !== 'fixed').length;
4178
- const remaining = Math.max(0, p.width - fixed - gaps);
4377
+ const remaining = Math.max(0, cross - fixed - gaps);
4179
4378
  const share = sharing > 0 ? remaining / sharing : 0;
4180
4379
  let flexibleTotal = 0;
4181
4380
  let adaptiveCount = 0;
@@ -4209,9 +4408,9 @@ export class LayoutPass {
4209
4408
  });
4210
4409
  });
4211
4410
  const used = cursor;
4212
- const gridWidth = finite(p.width) ? p.width : used;
4411
+ const gridCross = finite(cross) ? cross : used;
4213
4412
  const alignment = str(node.props['alignment']) ?? 'center';
4214
- const offset = safeOffset(gridWidth, used, alignmentAnchors(alignment)[0]);
4413
+ const offset = safeOffset(gridCross, used, alignmentAnchors(alignment)[horizontal ? 1 : 0]);
4215
4414
  for (const column of columns) column.x += offset;
4216
4415
 
4217
4416
  const rows: LazyGridPlan['rows'] = [];
@@ -4223,28 +4422,31 @@ export class LayoutPass {
4223
4422
  for (let k = 0; k < perRow && start + k < cells.length; k++) {
4224
4423
  const cell = cells[start + k] as LayoutNode;
4225
4424
  const column = columns[k] as LazyColumnPlan;
4226
- const size = this.measure(cell, cctx, { width: column.width, height: null });
4227
- height = Math.max(height, size.height);
4425
+ const size = this.measure(cell, cctx, horizontal ? { width: null, height: column.width } : { width: column.width, height: null });
4426
+ height = Math.max(height, horizontal ? size.width : size.height);
4228
4427
  rowCells.push({ node: cell, column: k, size });
4229
4428
  }
4230
4429
  rows.push({ y, height, cells: rowCells });
4231
- y += height + rowSpacing;
4430
+ y += height + lineSpacing;
4232
4431
  }
4233
- const gridHeight = rows.length > 0 ? y - rowSpacing : 0;
4234
- return { size: { width: gridWidth, height: gridHeight }, columns, rows };
4432
+ const gridMain = rows.length > 0 ? y - lineSpacing : 0;
4433
+ const size = horizontal ? { width: gridMain, height: gridCross } : { width: gridCross, height: gridMain };
4434
+ return { size, columns, rows };
4235
4435
  });
4236
4436
  }
4237
4437
 
4238
4438
  private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
4239
4439
  const plan = this.lazyGridPlan(node, ctx, p);
4440
+ const horizontal = node.kind === 'lazyhgrid';
4240
4441
  const cctx = this.childContext(node, ctx);
4241
4442
  const gridAlignment = str(node.props['alignment']) ?? 'center';
4242
4443
  for (const row of plan.rows) {
4243
4444
  for (const cell of row.cells) {
4244
4445
  const column = plan.columns[cell.column] as LazyColumnPlan;
4245
- const box: Size = { width: column.width, height: row.height };
4446
+ const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
4246
4447
  const { dx, dy } = alignIn(box, cell.size, column.alignment ?? gridAlignment);
4247
- this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
4448
+ if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
4449
+ else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
4248
4450
  }
4249
4451
  }
4250
4452
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * | kind | keys |
11
11
  * |---------------------|------|
12
- * | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (Phase 6: 49 + bottom safe area, when a `bottom` toolbar is present), `content` |
12
+ * | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (iOS 26: the band from a `bottom` toolbar's 48 glass row to the screen bottom, when one is present), `bottomGlass0` … (its glass capsules, in order), `content` |
13
13
  * | `sheet` | `grabber` (36×5), `content` (below the 20px grabber row) |
14
14
  * | `toggle` | `label`, `switch` (51×31 at the trailing edge) |
15
15
  * | `button` (bordered) | `label` |
@@ -17,7 +17,7 @@
17
17
  * | list rows | `row` (the full-width row rect inside the card); `navlink` rows add `label` and `chevron` (20px at the trailing edge); rows with a Phase 6 `badge` style add `badge` (the text's rect at the trailing inset, full row height) |
18
18
  * | `scrollview` | `content` (the content rect at scroll offset 0) |
19
19
  * | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom, or a bottom toolbar or search field reaching its screen's bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset), `topInset` / `largeTitle` (the screen's bar and the large title row it runs under: its screen's content, or a vertical one in it touching the bar) |
20
- * | `tabview` | `bar` (49 + bottom safe area at the bottom), `item0` … `itemN-1` (equal-width 49px items in the bar), `content` (above the bar) |
20
+ * | `tabview` | `bar` (iOS 26: the 62 glass capsule, as wide as its tabs up to the width less 21 either side, centered), `item0` … `itemN-1` (equal-width items inside its 4 inset), `content` (above the bar and the gap under it) |
21
21
  * | `tab` | `content` (the selected tab's safe content area: below the status bar, above the bar); non-selected tabs are in `hidden` |
22
22
  * | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing) |
23
23
  * | `gauge` | `track` (the 4px bar), `fill` (the leading `value` share of the track) (Phase 6) |
@@ -25,7 +25,7 @@
25
25
  * `LayoutResult.layouts` (Phase 6) holds, per `layout` element, the proposal
26
26
  * it was placed with and its children's min / ideal / max sizes.
27
27
  * | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing); inline (Phase 6): `label` (a 44 row, only for a non-empty label), `option0` … `optionN-1` (44 rows spanning the list row when there is one) |
28
- * | `toolbar` | none; the element's frame is its item group in the bar (or the whole bottom bar) and the items have ordinary frames |
28
+ * | `toolbar` | none; the element's frame is its item group in the bar (in a bottom bar: the span of its items in the glass row; a `spacer` toolbar is hidden) and the items have ordinary frames |
29
29
  * | `navsplit` | `sidebar` (the sidebar column, including the leading safe-area inset), `separator` (the 1px hairline), `detail` (the detail column, including the trailing inset) (Phase 7) |
30
30
  * | `listrow` | Phase 8: `editControl` (the 44px leading control, full row height) while the enclosing `list` has `editing`; the row content is shifted right by 44. `swipeactions` / `contextmenu` have no keys: their children after the first are in `hidden` without frames |
31
31
  * | `alert` | Phase 8: `card` (270 wide, centered; a dialog's button card), `title` / `message` (when non-empty; `chromeText` carries the centered lines), `button<i>` (the i-th child's row), `ok` (the renderer-drawn OK, only without button children), `cancelCard` (dialog: the `cancel` button's own card) |
package/src/layoutDom.ts CHANGED
@@ -258,6 +258,26 @@ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, orig
258
258
  }
259
259
  }
260
260
 
261
+ /**
262
+ * iOS 26: the glass a screen's bottom toolbar items float on, one element
263
+ * per capsule (engine `bottomGlass0` …), under the items (styles.css).
264
+ */
265
+ function syncBottomGlass(screen: HTMLElement, chrome: Record<string, Rect> | undefined, frame: Rect): void {
266
+ const existing = Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass'));
267
+ let count = 0;
268
+ for (let rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${count}`]) {
269
+ let glass = existing[count];
270
+ if (!glass) {
271
+ glass = document.createElement('div');
272
+ glass.className = 'sb-bottom-glass';
273
+ screen.append(glass);
274
+ }
275
+ placeChrome(glass, rect, frame);
276
+ count += 1;
277
+ }
278
+ for (const extra of existing.slice(count)) extra.remove();
279
+ }
280
+
261
281
  function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
262
282
  if (!node) return;
263
283
  if (!rect) {
@@ -543,6 +563,7 @@ function applyChrome(
543
563
  bar.style.paddingTop = `${top}px`;
544
564
  bar.style.paddingBottom = `${bottom}px`;
545
565
  }
566
+ syncBottomGlass(el, chrome, frame);
546
567
  const largeRect = chrome?.['largeTitle'];
547
568
  if (largeTitle) {
548
569
  if (largeRect) {
@@ -571,6 +592,8 @@ function applyChrome(
571
592
  if (inlineTitle.style.lineHeight !== lineHeight) inlineTitle.style.lineHeight = lineHeight;
572
593
  }
573
594
  }
595
+ // The More button of the collapsed `secondary` items, at the trailing end.
596
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-more'), chrome?.['more'], frame);
574
597
  const back = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
575
598
  const backLabel = back?.querySelector<HTMLElement>('.sb-navbar-back-label');
576
599
  const backRect = chrome?.['back'];