@swiftbrowser/web 0.4.0 → 0.5.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.
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.0",
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
@@ -568,7 +582,7 @@ interface GridItemSpec {
568
582
  alignment: string | null;
569
583
  }
570
584
 
571
- /** `columns` of a `lazyvgrid` (untrusted JSON); an empty list is one flexible column. */
585
+ /** `columns` of a `lazyvgrid` or `rows` of a `lazyhgrid` (untrusted JSON); an empty list is one flexible track. */
572
586
  function readGridItems(value: unknown): GridItemSpec[] {
573
587
  const items: GridItemSpec[] = [];
574
588
  if (Array.isArray(value)) {
@@ -1526,6 +1540,7 @@ export class LayoutPass {
1526
1540
  case 'grid':
1527
1541
  return this.gridPlan(node, ctx, p).size;
1528
1542
  case 'lazyvgrid':
1543
+ case 'lazyhgrid':
1529
1544
  return this.lazyGridPlan(node, ctx, p).size;
1530
1545
  case 'gauge':
1531
1546
  return this.gaugePlan(node, ctx, p).size;
@@ -2778,6 +2793,7 @@ export class LayoutPass {
2778
2793
  this.placeGrid(node, ctx, p, x, y);
2779
2794
  return;
2780
2795
  case 'lazyvgrid':
2796
+ case 'lazyhgrid':
2781
2797
  this.placeLazyGrid(node, ctx, p, x, y);
2782
2798
  return;
2783
2799
  case 'gauge':
@@ -3000,13 +3016,14 @@ export class LayoutPass {
3000
3016
  /**
3001
3017
  * The navigation bar is the status bar plus a 44 row (plus 62 for a large
3002
3018
  * 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.
3019
+ * (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
3020
+ * above the bottom (`placeBottomToolbars`); the content is proposed the
3021
+ * rest. Toolbar items are measured at their ideal size and laid out in iOS
3022
+ * 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
3023
+ * (`leading` after the back button, `trailing` 16 from the trailing edge,
3024
+ * `principal` centered in place of the title). A `ToolbarSpacer` (an empty
3025
+ * toolbar with `spacer`) only separates groups there. The inline title
3026
+ * (`chrome.title`) is centered and truncated to the room the bar items leave.
3010
3027
  */
3011
3028
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3012
3029
  const { safeArea, screen, dynamicTypeSize } = this.env;
@@ -3018,10 +3035,12 @@ export class LayoutPass {
3018
3035
  const largeHeight = large ? LARGE_TITLE_HEIGHT : 0;
3019
3036
  const parts = this.navScreenParts(node);
3020
3037
  const reachesBottom = y + size.height >= screen.height - EPSILON;
3038
+ const isSpacer = (t: LayoutNode): boolean => str(t.props['spacer']) !== null;
3021
3039
  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
3040
  const searchMargin = reachesBottom ? SEARCH_BOTTOM_MARGIN : SEARCH_BOTTOM_MARGIN_INSET;
3041
+ const bottomBarHeight = bottomToolbars.some((t) => !isSpacer(t))
3042
+ ? Math.min(size.height, BOTTOM_GLASS_HEIGHT + searchMargin)
3043
+ : 0;
3025
3044
  const searchBottom = parts.search ? SEARCH_BOTTOM_HEIGHT + searchMargin : 0;
3026
3045
  const contentTop = barHeight + largeHeight;
3027
3046
  const content: Rect = {
@@ -3063,7 +3082,11 @@ export class LayoutPass {
3063
3082
  slots: number[] | null;
3064
3083
  total: number;
3065
3084
  }
3066
- const groups: Group[] = parts.toolbars.map((toolbar) => {
3085
+ // The navigation bar's groups; spacers there only separate them (the
3086
+ // groups are 8 apart anyway) and draw nothing.
3087
+ for (const spacer of parts.toolbars.filter(isSpacer)) this.hide(spacer, node);
3088
+ const barToolbars = parts.toolbars.filter((t) => str(t.props['placement']) !== 'bottom' && !isSpacer(t));
3089
+ const groups: Group[] = barToolbars.map((toolbar) => {
3067
3090
  const itemCtx = this.childContext(toolbar, cctx);
3068
3091
  const sizes = toolbar.children.map((item) => this.measure(item, itemCtx, { width: null, height: null }));
3069
3092
  const placement = str(toolbar.props['placement']) ?? 'trailing';
@@ -3118,19 +3141,6 @@ export class LayoutPass {
3118
3141
  placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
3119
3142
  break;
3120
3143
  }
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
3144
  default: {
3135
3145
  const startX = trailingEdge - group.total;
3136
3146
  this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
@@ -3140,6 +3150,9 @@ export class LayoutPass {
3140
3150
  }
3141
3151
  }
3142
3152
  }
3153
+ if (bottomBarHeight > 0) {
3154
+ this.placeBottomToolbars(bottomToolbars, cctx, chrome, x, y + size.height - bottomBarHeight, size.width);
3155
+ }
3143
3156
  if (parts.search) {
3144
3157
  // The field is the whole capsule (its own measure is a 36pt field).
3145
3158
  const field = chrome['searchField'] as Rect;
@@ -3244,9 +3257,101 @@ export class LayoutPass {
3244
3257
  this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
3245
3258
  }
3246
3259
 
3260
+ /**
3261
+ * iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
3262
+ * float on Liquid Glass in a 48 row at the top of the bottom bar's band
3263
+ * (`top`), 28 in from the sides. Consecutive items share a capsule; a
3264
+ * `ToolbarSpacer` (an empty toolbar with `spacer`) ends one. Flexible
3265
+ * spacers share the room the groups leave (with none the row is centered)
3266
+ * and fixed ones are 8 wide. An icon takes a 50 slot (a lone one is a 48
3267
+ * circle), text its width and 18 either side, 6 between slots; each item
3268
+ * is centered in its slot. The capsules are the screen's chrome
3269
+ * `bottomGlass0` … (drawn under the items); each toolbar's frame spans its
3270
+ * items.
3271
+ */
3272
+ private placeBottomToolbars(
3273
+ toolbars: LayoutNode[],
3274
+ cctx: Context,
3275
+ chrome: Record<string, Rect>,
3276
+ x: number,
3277
+ top: number,
3278
+ width: number,
3279
+ ): void {
3280
+ interface Item {
3281
+ node: LayoutNode;
3282
+ toolbar: LayoutNode;
3283
+ size: Size;
3284
+ slot: number;
3285
+ }
3286
+ type Token = { kind: 'group'; items: Item[]; width: number } | { kind: 'flexible' } | { kind: 'fixed' };
3287
+ const tokens: Token[] = [];
3288
+ let open: Item[] = [];
3289
+ const close = (): void => {
3290
+ if (open.length === 0) return;
3291
+ const icon = (item: Item): boolean => item.size.width < NAV_GLASS_SIZE;
3292
+ const lone = open.length === 1 && icon(open[0] as Item);
3293
+ if (lone) (open[0] as Item).slot = BOTTOM_GLASS_HEIGHT;
3294
+ const slots = open.reduce((sum, item) => sum + item.slot, 0);
3295
+ tokens.push({ kind: 'group', items: open, width: slots + (open.length - 1) * BOTTOM_ITEM_GAP });
3296
+ open = [];
3297
+ };
3298
+ for (const toolbar of toolbars) {
3299
+ const spacer = str(toolbar.props['spacer']);
3300
+ if (spacer !== null) {
3301
+ close();
3302
+ tokens.push({ kind: spacer === 'fixed' ? 'fixed' : 'flexible' });
3303
+ continue;
3304
+ }
3305
+ const itemCtx = this.childContext(toolbar, cctx);
3306
+ for (const node of toolbar.children) {
3307
+ const size = this.measure(node, itemCtx, { width: null, height: null });
3308
+ const slot = size.width < NAV_GLASS_SIZE ? BOTTOM_ICON_SLOT : size.width + 2 * BOTTOM_TEXT_PAD_H;
3309
+ open.push({ node, toolbar, size, slot });
3310
+ }
3311
+ }
3312
+ close();
3313
+ const inner = Math.max(0, width - 2 * BOTTOM_GLASS_MARGIN_H);
3314
+ let used = 0;
3315
+ let flexible = 0;
3316
+ for (const token of tokens) {
3317
+ if (token.kind === 'group') used += token.width;
3318
+ else if (token.kind === 'fixed') used += BOTTOM_FIXED_SPACER;
3319
+ else flexible += 1;
3320
+ }
3321
+ const share = flexible > 0 ? Math.max(0, inner - used) / flexible : 0;
3322
+ let cursor = flexible > 0 ? x + BOTTOM_GLASS_MARGIN_H : x + (width - used) / 2;
3323
+ const spans = new Map<LayoutNode, { start: number; end: number }>();
3324
+ let glass = 0;
3325
+ for (const token of tokens) {
3326
+ if (token.kind === 'flexible') {
3327
+ cursor += share;
3328
+ continue;
3329
+ }
3330
+ if (token.kind === 'fixed') {
3331
+ cursor += BOTTOM_FIXED_SPACER;
3332
+ continue;
3333
+ }
3334
+ chrome[`bottomGlass${glass}`] = { x: cursor, y: top, width: token.width, height: BOTTOM_GLASS_HEIGHT };
3335
+ glass += 1;
3336
+ for (const item of token.items) {
3337
+ const itemCtx = this.childContext(item.toolbar, cctx);
3338
+ this.place(item.node, itemCtx, { width: null, height: null }, cursor + (item.slot - item.size.width) / 2, top + (BOTTOM_GLASS_HEIGHT - item.size.height) / 2);
3339
+ const span = spans.get(item.toolbar);
3340
+ spans.set(item.toolbar, { start: span ? span.start : cursor, end: cursor + item.slot });
3341
+ cursor += item.slot + BOTTOM_ITEM_GAP;
3342
+ }
3343
+ cursor -= BOTTOM_ITEM_GAP;
3344
+ }
3345
+ for (const toolbar of toolbars) {
3346
+ const span = spans.get(toolbar);
3347
+ if (span) this.setFrame(toolbar, span.start, top, { width: span.end - span.start, height: BOTTOM_GLASS_HEIGHT });
3348
+ }
3349
+ }
3350
+
3247
3351
  /**
3248
3352
  * 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
3353
+ * content (TAB_BAR_* above), as wide as its equal-width items (one per tab)
3354
+ * up to the width less the margins, centered; what it
3250
3355
  * covers (the capsule and the gap under it) is the content's bottom inset.
3251
3356
  * Every `tab` gets the tab view's frame; only the selected one lays its
3252
3357
  * content out, the others are reported in `hidden`.
@@ -3256,18 +3361,19 @@ export class LayoutPass {
3256
3361
  const marginBottom = y + size.height >= screen.height - EPSILON ? TAB_BAR_MARGIN_BOTTOM : TAB_BAR_MARGIN_BOTTOM_INSET;
3257
3362
  const barHeight = Math.min(size.height, TAB_BAR_HEIGHT + marginBottom);
3258
3363
  const capsuleHeight = Math.min(TAB_BAR_HEIGHT, barHeight);
3364
+ const tabs = node.children;
3365
+ const n = tabs.length;
3366
+ 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
3367
  const bar: Rect = {
3260
- x: x + TAB_BAR_MARGIN_H,
3368
+ x: x + (size.width - width) / 2,
3261
3369
  y: y + size.height - barHeight,
3262
- width: Math.max(0, size.width - 2 * TAB_BAR_MARGIN_H),
3370
+ width,
3263
3371
  height: capsuleHeight,
3264
3372
  };
3265
3373
  const chrome: Record<string, Rect> = {
3266
3374
  bar,
3267
3375
  content: { x, y, width: size.width, height: size.height - barHeight },
3268
3376
  };
3269
- const tabs = node.children;
3270
- const n = tabs.length;
3271
3377
  const inner = Math.max(0, bar.width - 2 * TAB_BAR_ITEM_INSET);
3272
3378
  for (let i = 0; i < n; i++) {
3273
3379
  const itemWidth = inner / n;
@@ -4150,22 +4256,33 @@ export class LayoutPass {
4150
4256
  * clamped. With no width the flexible columns take the cells' largest ideal
4151
4257
  * width and an adaptive item is one column. Cells fill rows left to right.
4152
4258
  */
4259
+ /**
4260
+ * `lazyvgrid` and `lazyhgrid`. Written for the vertical grid: its columns
4261
+ * share the proposed width and its cells fill them row by row. A horizontal
4262
+ * grid is the same along the other axis (its rows share the proposed
4263
+ * height and the cells fill them column by column), so the plan's
4264
+ * `columns` are the tracks across the grid (`x`, `width`: cross axis) and
4265
+ * its `rows` the lines along it (`y`, `height`: main axis).
4266
+ */
4153
4267
  private lazyGridPlan(node: LayoutNode, ctx: Context, p: Proposal): LazyGridPlan {
4154
4268
  return this.plan(node, p, () => {
4155
- const items = readGridItems(node.props['columns']);
4156
- const rowSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
4269
+ const horizontal = node.kind === 'lazyhgrid';
4270
+ const crossOf = (size: { width: number | null; height: number | null }): number | null => (horizontal ? size.height : size.width);
4271
+ const items = readGridItems(node.props[horizontal ? 'rows' : 'columns']);
4272
+ const lineSpacing = num(node.props['spacing']) ?? DEFAULT_SPACING;
4157
4273
  const cctx = this.childContext(node, ctx);
4158
4274
  const cells = node.children;
4275
+ const cross = crossOf(p);
4159
4276
  let idealCell: number | null = null;
4160
4277
  const ideal = (): number => {
4161
4278
  if (idealCell === null) {
4162
4279
  idealCell = 0;
4163
- for (const cell of cells) idealCell = Math.max(idealCell, this.measure(cell, cctx, NIL_PROPOSAL).width);
4280
+ for (const cell of cells) idealCell = Math.max(idealCell, crossOf(this.measure(cell, cctx, NIL_PROPOSAL)) ?? 0);
4164
4281
  }
4165
4282
  return idealCell;
4166
4283
  };
4167
4284
  const widths = items.map((item): number[] => (item.kind === 'fixed' ? [item.value] : []));
4168
- if (!finite(p.width)) {
4285
+ if (!finite(cross)) {
4169
4286
  items.forEach((item, i) => {
4170
4287
  if (item.kind !== 'fixed') widths[i] = [clamp(ideal(), item.minimum, item.maximum)];
4171
4288
  });
@@ -4175,7 +4292,7 @@ export class LayoutPass {
4175
4292
  let fixed = 0;
4176
4293
  for (const item of items) if (item.kind === 'fixed') fixed += item.value;
4177
4294
  const sharing = items.filter((item) => item.kind !== 'fixed').length;
4178
- const remaining = Math.max(0, p.width - fixed - gaps);
4295
+ const remaining = Math.max(0, cross - fixed - gaps);
4179
4296
  const share = sharing > 0 ? remaining / sharing : 0;
4180
4297
  let flexibleTotal = 0;
4181
4298
  let adaptiveCount = 0;
@@ -4209,9 +4326,9 @@ export class LayoutPass {
4209
4326
  });
4210
4327
  });
4211
4328
  const used = cursor;
4212
- const gridWidth = finite(p.width) ? p.width : used;
4329
+ const gridCross = finite(cross) ? cross : used;
4213
4330
  const alignment = str(node.props['alignment']) ?? 'center';
4214
- const offset = safeOffset(gridWidth, used, alignmentAnchors(alignment)[0]);
4331
+ const offset = safeOffset(gridCross, used, alignmentAnchors(alignment)[horizontal ? 1 : 0]);
4215
4332
  for (const column of columns) column.x += offset;
4216
4333
 
4217
4334
  const rows: LazyGridPlan['rows'] = [];
@@ -4223,28 +4340,31 @@ export class LayoutPass {
4223
4340
  for (let k = 0; k < perRow && start + k < cells.length; k++) {
4224
4341
  const cell = cells[start + k] as LayoutNode;
4225
4342
  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);
4343
+ const size = this.measure(cell, cctx, horizontal ? { width: null, height: column.width } : { width: column.width, height: null });
4344
+ height = Math.max(height, horizontal ? size.width : size.height);
4228
4345
  rowCells.push({ node: cell, column: k, size });
4229
4346
  }
4230
4347
  rows.push({ y, height, cells: rowCells });
4231
- y += height + rowSpacing;
4348
+ y += height + lineSpacing;
4232
4349
  }
4233
- const gridHeight = rows.length > 0 ? y - rowSpacing : 0;
4234
- return { size: { width: gridWidth, height: gridHeight }, columns, rows };
4350
+ const gridMain = rows.length > 0 ? y - lineSpacing : 0;
4351
+ const size = horizontal ? { width: gridMain, height: gridCross } : { width: gridCross, height: gridMain };
4352
+ return { size, columns, rows };
4235
4353
  });
4236
4354
  }
4237
4355
 
4238
4356
  private placeLazyGrid(node: LayoutNode, ctx: Context, p: Proposal, x: number, y: number): void {
4239
4357
  const plan = this.lazyGridPlan(node, ctx, p);
4358
+ const horizontal = node.kind === 'lazyhgrid';
4240
4359
  const cctx = this.childContext(node, ctx);
4241
4360
  const gridAlignment = str(node.props['alignment']) ?? 'center';
4242
4361
  for (const row of plan.rows) {
4243
4362
  for (const cell of row.cells) {
4244
4363
  const column = plan.columns[cell.column] as LazyColumnPlan;
4245
- const box: Size = { width: column.width, height: row.height };
4364
+ const box: Size = horizontal ? { width: row.height, height: column.width } : { width: column.width, height: row.height };
4246
4365
  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);
4366
+ if (horizontal) this.place(cell.node, cctx, { width: null, height: column.width }, x + row.y + dx, y + column.x + dy);
4367
+ else this.place(cell.node, cctx, { width: column.width, height: null }, x + column.x + dx, y + row.y + dy);
4248
4368
  }
4249
4369
  }
4250
4370
  }
@@ -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) {