@swiftbrowser/web 0.6.0 → 0.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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": {
package/src/animation.ts CHANGED
@@ -287,8 +287,8 @@ export const DEFAULT_TRANSITION: Transition = { kind: 'opacity' };
287
287
  /** The spring a bar toolbar's groups change with (iOS 26), and how its items come and go. */
288
288
  export const TOOLBAR_ANIMATION: Animation = { type: 'spring', duration: 0.5, bounce: 0.2 };
289
289
  export const TOOLBAR_TRANSITION: Transition = { kind: 'scale', scale: 0.6 };
290
- /** The `kind` of a bottom bar glass capsule's frame change (src/layoutDom.ts): chrome, not an element. */
291
- export const BOTTOM_GLASS_KIND = 'bottomglass';
290
+ /** The `kind` of a bar glass capsule's frame change (src/layoutDom.ts): chrome, not an element. */
291
+ export const BAR_GLASS_KIND = 'barglass';
292
292
 
293
293
  const BAR_PLACEMENTS: ReadonlySet<string> = new Set(['leading', 'trailing', 'principal', 'bottom']);
294
294
 
@@ -316,18 +316,24 @@ function implicitTransitionOf(el: HTMLElement, parent: Element | null = el.paren
316
316
  /**
317
317
  * The bar toolbars whose groups this pass changed, each with its screen: one
318
318
  * that gained or lost an item (or came or went itself, on a screen already
319
- * there), or whose capsule or an item of it resized. A group that only
320
- * moved (the screen resized, a More button appeared beside it) is not one:
321
- * its items are as they were.
319
+ * there), or whose capsule or an item of it resized; and a group that only
320
+ * moved because another of its screen's changed, which slides over with
321
+ * it, as its capsule does. A group that only moved on its own (the screen
322
+ * resized, a More button appeared beside it) is not one: its items are as
323
+ * they were.
322
324
  */
323
325
  export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[]): Map<HTMLElement, Element | null> {
324
326
  const out = new Map<HTMLElement, Element | null>();
327
+ const moved = new Map<HTMLElement, Element | null>();
325
328
  const isNew = (toolbar: HTMLElement): boolean => pass.inserted.has(toolbar) || hasAncestorIn(toolbar, pass.inserted);
326
329
  for (const change of frames) {
327
330
  const toolbar = barToolbarOf(change.el);
328
331
  if (!toolbar || !change.from || !change.el.isConnected || isNew(toolbar)) continue;
329
332
  const { from, to } = change;
330
- if (change.el === toolbar && from.width === to.width && from.height === to.height) continue;
333
+ if (change.el === toolbar && from.width === to.width && from.height === to.height) {
334
+ moved.set(toolbar, toolbar.closest('.sb-navscreen'));
335
+ continue;
336
+ }
331
337
  out.set(toolbar, toolbar.closest('.sb-navscreen'));
332
338
  }
333
339
  for (const el of pass.inserted) {
@@ -343,6 +349,11 @@ export function implicitToolbars(pass: PassRecord, frames: readonly FrameChange[
343
349
  // A removed group is detached: its screen is among the ancestors it had.
344
350
  out.set(toolbar, toolbar.isConnected ? toolbar.closest('.sb-navscreen') : (rec.ancestors.find((a) => a.classList.contains('sb-navscreen')) ?? null));
345
351
  }
352
+ // (a group the bar does not draw, `secondary` items collapsed into the More button, moves nothing beside it)
353
+ const changedScreens = new Set(Array.from(out).filter(([toolbar]) => !toolbar.hidden).map(([, screen]) => screen));
354
+ for (const [toolbar, screen] of moved) {
355
+ if (!out.has(toolbar) && changedScreens.has(screen)) out.set(toolbar, screen);
356
+ }
346
357
  return out;
347
358
  }
348
359
 
@@ -495,12 +506,19 @@ export class Animator {
495
506
  if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
496
507
  }
497
508
  // iOS 26: a bar toolbar's groups change with a spring of their own (see
498
- // the top of the file); the bottom bar's glass capsules, beside their
499
- // toolbars rather than around them, follow the screen's bottom toolbars.
509
+ // the top of the file); the bar's glass capsules, beside their toolbars
510
+ // rather than around them, follow the screen's bar toolbars.
511
+ // The spring moves the groups and their items, not what is inside an
512
+ // item: a button whose label changes (a tool selected, its symbol now
513
+ // on a tinted disc) changes in place, as UIKit redraws a bar item.
500
514
  const glassScreens = new Set<Element | null>();
515
+ const groupsOnly = new Set<HTMLElement>();
501
516
  for (const [toolbar, screen] of implicitToolbars(pass, frames)) {
502
- if (!tokenRoots.has(toolbar)) tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
503
- if (toolbar.dataset['sbPlacement'] === 'bottom') glassScreens.add(screen);
517
+ if (!tokenRoots.has(toolbar)) {
518
+ tokenRoots.set(toolbar, commitAnimation ?? TOOLBAR_ANIMATION);
519
+ if (!commitAnimation) groupsOnly.add(toolbar);
520
+ }
521
+ glassScreens.add(screen);
504
522
  }
505
523
  if (!commitAnimation && tokenRoots.size === 0) {
506
524
  // Not animated: frames still tweening toward a box this pass replaced
@@ -517,8 +535,14 @@ export class Animator {
517
535
  const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
518
536
 
519
537
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
538
+ let depth = 0;
520
539
  for (const el of chain) {
521
- if (tokenRoots.has(el)) return tokenRoots.get(el) ?? { type: 'default' };
540
+ if (tokenRoots.has(el)) {
541
+ // (below an item of a group the bar's own spring moves: not animated)
542
+ if (groupsOnly.has(el) && depth > 1) return false;
543
+ return tokenRoots.get(el) ?? { type: 'default' };
544
+ }
545
+ depth++;
522
546
  }
523
547
  return commitAnimation ?? false;
524
548
  };
@@ -549,9 +573,12 @@ export class Animator {
549
573
  if (!from || !el.isConnected || pass.inserted.has(el)) continue;
550
574
  if (this.selfAnimating.has(change.kind)) continue;
551
575
  let animation = animationFor(ancestorsAndSelf(el));
552
- if (animation === false && change.kind === BOTTOM_GLASS_KIND && glassScreens.has(el.parentElement)) {
576
+ if (animation === false && change.kind === BAR_GLASS_KIND && glassScreens.has(el.parentElement)) {
553
577
  animation = TOOLBAR_ANIMATION;
554
578
  }
579
+ // An item of such a group that only resized about its own center (its
580
+ // label changed, its slot stayed) is redrawn in place, not tweened.
581
+ if (animation !== false && groupsOnly.has(el.parentElement as HTMLElement) && sameCenter(from, to)) animation = false;
555
582
  if (animation === false) {
556
583
  // Moved in place, unless it carries on with a tween of its own.
557
584
  if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
@@ -975,6 +1002,11 @@ function jumpAbove(
975
1002
  }
976
1003
 
977
1004
  /** The element's ancestors, nearest first. */
1005
+ /** Whether two boxes share their center (to within a point: each edge is snapped to a whole point). */
1006
+ function sameCenter(a: FrameRect, b: FrameRect): boolean {
1007
+ return Math.abs(a.x + a.width / 2 - (b.x + b.width / 2)) <= 1 && Math.abs(a.y + a.height / 2 - (b.y + b.height / 2)) <= 1;
1008
+ }
1009
+
978
1010
  function ancestorsOf(el: HTMLElement): HTMLElement[] {
979
1011
  const out: HTMLElement[] = [];
980
1012
  for (let a = el.parentElement; a; a = a.parentElement) out.push(a);
package/src/chartDom.ts CHANGED
@@ -135,11 +135,15 @@ function buildPlot(doc: Document, props: Partial<ChartProps>, plot: Rect, box: R
135
135
  const grid = svg(doc, 'g', { class: 'sb-chart-grid' });
136
136
  for (const u of gridLines(props.xAxis)) {
137
137
  const x = r2(Math.floor(mapX(local, u)) + 0.5);
138
- grid.append(svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'x', x1: x, y1: 0, x2: x, y2: h }));
138
+ const line = svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'x', x1: x, y1: 0, x2: x, y2: h });
139
+ if (props.xAxis?.dashed) line.dataset['sbDashed'] = '';
140
+ grid.append(line);
139
141
  }
140
142
  for (const u of gridLines(props.yAxis)) {
141
143
  const y = r2(Math.floor(mapY(local, u)) + 0.5);
142
- grid.append(svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'y', x1: 0, y1: y, x2: w, y2: y }));
144
+ const line = svg(doc, 'line', { class: 'sb-chart-gridline', 'data-sb-axis': 'y', x1: 0, y1: y, x2: w, y2: y });
145
+ if (props.yAxis?.dashed) line.dataset['sbDashed'] = '';
146
+ grid.append(line);
143
147
  }
144
148
  root.append(grid);
145
149
 
@@ -252,21 +252,38 @@ const NAV_ITEM_MARGIN = 16;
252
252
  // iOS 26: the back button is a 44pt glass circle 16 from the leading edge,
253
253
  // and the bar's leading and trailing items sit in glass capsules 44 tall,
254
254
  // each item in a slot: a symbol the 44 circle, anything else (text, a
255
- // label) its width and 12 either side, at least 44; the groups 8 apart.
255
+ // label) its width and 12 either side, at least 44. Adjacent items of one
256
+ // placement share a capsule (a run; a `ToolbarSpacer` of that placement ends
257
+ // one), their slots 12 apart and the capsule a point past the end slots; the
258
+ // capsules, the back button and the More button sit 12 apart (measured in
259
+ // the iOS 26 simulator, Examples/Fidelity `topbar*`).
256
260
  const NAV_GLASS_SIZE = 44;
257
261
  const NAV_GLASS_PAD_H = 12;
258
- const NAV_GROUP_SPACING = 8;
262
+ const NAV_GROUP_SPACING = 12;
263
+ const NAV_RUN_GAP = 12;
264
+ const NAV_RUN_PAD = 1;
265
+
266
+ /**
267
+ * The children an item draws in the bar: a menu or context menu draws its
268
+ * label (the first child) and lists the rest in its popover, so its items
269
+ * (which a lazy menu only builds when it opens) do not make it wider or a
270
+ * text item.
271
+ */
272
+ function drawnChildren(node: LayoutNode): LayoutNode[] {
273
+ return node.kind === 'menu' || node.kind === 'contextmenu' ? node.children.slice(0, 1) : node.children;
274
+ }
259
275
 
260
276
  /** Whether any text is laid out in an item (a bottom bar pads text items as text). */
261
277
  function hasText(item: LayoutNode): boolean {
262
- return item.kind === 'text' || item.children.some(hasText);
278
+ return item.kind === 'text' || drawnChildren(item).some(hasText);
263
279
  }
264
280
 
265
281
  /** A bar item that is only a symbol (an `image`, or a button, menu or link around one). */
266
282
  function isSymbolItem(item: LayoutNode): boolean {
267
283
  const inner = unwrapStyled(item);
268
284
  if (inner.kind === 'image') return true;
269
- return inner.children.length === 1 && isSymbolItem(inner.children[0] as LayoutNode);
285
+ const drawn = drawnChildren(inner);
286
+ return drawn.length === 1 && isSymbolItem(drawn[0] as LayoutNode);
270
287
  }
271
288
 
272
289
  /** A bar item's slot in its glass capsule. */
@@ -294,9 +311,11 @@ const BOTTOM_FIXED_SPACER = 8;
294
311
  const SEARCH_FIELD_HEIGHT = 36;
295
312
  const SEARCH_FIELD_MARGIN_H = 16;
296
313
  // iOS 26: a navigation stack's search field is a 48-tall glass capsule
297
- // floating 16 from the sides and 28 above the screen bottom (8 above a view
298
- // that ends higher); the content stops above it.
314
+ // floating 28 from the sides (measured in the simulator: x 28.67…364.33 on the
315
+ // Fidelity `search` screen and Food Truck's Donuts) and 28 above the screen
316
+ // bottom (8 above a view that ends higher); the content stops above it.
299
317
  const SEARCH_BOTTOM_HEIGHT = 48;
318
+ const SEARCH_BOTTOM_MARGIN_H = 28;
300
319
  const SEARCH_BOTTOM_MARGIN = 28;
301
320
  const SEARCH_BOTTOM_MARGIN_INSET = 8;
302
321
  const SEARCH_FIELD_DEFAULT_WIDTH = 200;
@@ -2094,6 +2113,17 @@ export class LayoutPass {
2094
2113
  const child = edge === 'top' ? node.children[0] : node.children[node.children.length - 1];
2095
2114
  return child && !isSpacer(child) ? this.textEdge(child, this.childContext(node, ctx), edge, depth + 1) : null;
2096
2115
  }
2116
+ case 'hstack': {
2117
+ // A row with text in it (Food Truck's flavor line: a symbol and its
2118
+ // name) is spaced as its largest text, as iOS 26 spaces it.
2119
+ const cctx = this.childContext(node, ctx);
2120
+ let font: ResolvedFont | null = null;
2121
+ for (const child of node.children) {
2122
+ const found = this.textEdge(child, cctx, edge, depth + 1);
2123
+ if (found && (!font || found.size > font.size)) font = found;
2124
+ }
2125
+ return font;
2126
+ }
2097
2127
  default:
2098
2128
  return null;
2099
2129
  }
@@ -3242,11 +3272,14 @@ export class LayoutPass {
3242
3272
  * (iOS 26); `bottom` toolbars float their items on glass in a 48 row 28
3243
3273
  * above the bottom (`placeBottomToolbars`); the content is proposed the
3244
3274
  * rest. Toolbar items are measured at their ideal size and laid out in iOS
3245
- * 26 glass capsules (44-tall slots, groups 8 apart) in the 44 row
3246
- * (`leading` after the back button, `trailing` 16 from the trailing edge,
3247
- * `principal` centered in place of the title). A `ToolbarSpacer` (an empty
3248
- * toolbar with `spacer`) only separates groups there. The inline title
3249
- * (`chrome.title`) is centered and truncated to the room the bar items leave.
3275
+ * 26 glass capsules in the 44 row: adjacent `leading` (or `trailing`)
3276
+ * toolbars share one (a run, chrome `leadingGlass0` …, `trailingGlass0` …;
3277
+ * a `ToolbarSpacer` of the placement ends one), their items in 44-tall
3278
+ * slots 12 apart, the capsules 12 apart (`leading` after the back button,
3279
+ * `trailing` ending 16 from the trailing edge, in their order in the
3280
+ * content); a `principal` group is centered in place of the title. The
3281
+ * inline title (`chrome.title`) is centered and truncated to the room the
3282
+ * bar items leave.
3250
3283
  */
3251
3284
  private placeNavScreen(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3252
3285
  const { safeArea, screen, dynamicTypeSize } = this.env;
@@ -3284,10 +3317,11 @@ export class LayoutPass {
3284
3317
  };
3285
3318
  if (large) chrome['largeTitle'] = { x, y: y + barHeight, width: size.width, height: LARGE_TITLE_HEIGHT };
3286
3319
  if (parts.search) {
3320
+ const marginH = searchOnTop ? SEARCH_FIELD_MARGIN_H : SEARCH_BOTTOM_MARGIN_H;
3287
3321
  chrome['searchField'] = {
3288
- x: x + SEARCH_FIELD_MARGIN_H,
3322
+ x: x + marginH,
3289
3323
  y: searchOnTop ? y + barHeight + largeHeight + SEARCH_TOP_ABOVE : y + size.height - searchBottom,
3290
- width: Math.max(0, size.width - 2 * SEARCH_FIELD_MARGIN_H),
3324
+ width: Math.max(0, size.width - 2 * marginH),
3291
3325
  height: searchOnTop ? SEARCH_TOP_HEIGHT : SEARCH_BOTTOM_HEIGHT,
3292
3326
  };
3293
3327
  // The page draws a field under the title as the bar's (a gray capsule)
@@ -3331,10 +3365,45 @@ export class LayoutPass {
3331
3365
  const total = sizes.reduce((sum, s) => sum + s.width, 0) + Math.max(0, sizes.length - 1) * NAV_ITEM_SPACING;
3332
3366
  return { toolbar, placement, sizes, slots: null, total };
3333
3367
  });
3334
- const groupWidth = (placement: string): number =>
3335
- groups.filter((g) => g.placement === placement).reduce((sum, g) => sum + g.total + NAV_GROUP_SPACING, 0);
3336
- const leadingWidth = groupWidth('leading');
3337
- const trailingWidth = groupWidth('trailing');
3368
+ // The leading and trailing runs: consecutive toolbars of one placement
3369
+ // share a capsule, a spacer of that placement ends one.
3370
+ interface Run {
3371
+ placement: 'leading' | 'trailing';
3372
+ groups: Group[];
3373
+ /** The capsule's width: the slots 12 apart, a point past the end slots (none for a lone item). */
3374
+ width: number;
3375
+ }
3376
+ const groupOf = new Map(groups.map((g) => [g.toolbar, g]));
3377
+ const runs: Run[] = [];
3378
+ const open: Partial<Record<'leading' | 'trailing', Run>> = {};
3379
+ const runWidth = (run: Run): number => {
3380
+ const slots = run.groups.flatMap((g) => g.slots ?? []);
3381
+ if (slots.length === 0) return 0;
3382
+ const inner = slots.reduce((sum, w) => sum + w, 0) + (slots.length - 1) * NAV_RUN_GAP;
3383
+ return slots.length > 1 ? inner + 2 * NAV_RUN_PAD : inner;
3384
+ };
3385
+ for (const toolbar of parts.toolbars) {
3386
+ const placement = str(toolbar.props['placement']);
3387
+ if (placement !== 'leading' && placement !== 'trailing') continue;
3388
+ if (isSpacer(toolbar)) {
3389
+ delete open[placement];
3390
+ continue;
3391
+ }
3392
+ const group = groupOf.get(toolbar);
3393
+ if (!group) continue;
3394
+ let run = open[placement];
3395
+ if (!run) {
3396
+ run = { placement, groups: [], width: 0 };
3397
+ open[placement] = run;
3398
+ runs.push(run);
3399
+ }
3400
+ run.groups.push(group);
3401
+ }
3402
+ for (const run of runs) run.width = runWidth(run);
3403
+ const runsWidth = (placement: string): number =>
3404
+ runs.filter((r) => r.placement === placement && r.width > 0).reduce((sum, r) => sum + r.width + NAV_GROUP_SPACING, 0);
3405
+ const leadingWidth = runsWidth('leading');
3406
+ const trailingWidth = runsWidth('trailing');
3338
3407
  const principal = groups.some((g) => g.placement === 'principal');
3339
3408
 
3340
3409
  // `.secondaryAction` items (placement `secondary`), as iOS treats them:
@@ -3356,7 +3425,7 @@ export class LayoutPass {
3356
3425
  return { toolbar, item, size: itemSize, slot: navSlot(item, itemSize.width) };
3357
3426
  });
3358
3427
  });
3359
- // Items of one toolbar share its glass capsule; separate toolbars sit 8 apart, as the trailing groups do.
3428
+ // Items of one toolbar share its glass capsule; separate toolbars sit 12 apart, as the trailing runs do.
3360
3429
  const secondaryGap = (previous: SecondaryItem | undefined, entry: SecondaryItem): number =>
3361
3430
  previous && previous.toolbar !== entry.toolbar ? NAV_GROUP_SPACING : 0;
3362
3431
  const secondaryWidth = (entries: readonly SecondaryItem[]): number =>
@@ -3413,28 +3482,46 @@ export class LayoutPass {
3413
3482
  cursor += (slot ?? itemSize.width) + spacing;
3414
3483
  });
3415
3484
  };
3416
- for (const group of groups) {
3417
- switch (group.placement) {
3418
- case 'leading': {
3419
- this.setFrame(group.toolbar, leadingCursor, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
3420
- placeGroup(group, leadingCursor, rowY, NAV_BAR_HEIGHT, 0);
3421
- leadingCursor += group.total + NAV_GROUP_SPACING;
3422
- break;
3423
- }
3424
- case 'principal': {
3425
- const startX = x + (size.width - group.total) / 2;
3426
- this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
3427
- placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
3428
- break;
3429
- }
3430
- default: {
3431
- const startX = trailingEdge - group.total;
3432
- this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
3433
- placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, 0);
3434
- trailingEdge = startX - NAV_GROUP_SPACING;
3435
- break;
3436
- }
3485
+ // A run's capsule (chrome `leadingGlass0` …, `trailingGlass0` …) and its
3486
+ // items, in slots 12 apart from `startX`; each toolbar's frame spans its
3487
+ // own items' slots. Runs of a placement follow their order in the
3488
+ // content, leading-to-trailing.
3489
+ const placeRun = (run: Run, startX: number, index: number): void => {
3490
+ chrome[`${run.placement}Glass${index}`] = { x: startX, y: rowY, width: run.width, height: NAV_BAR_HEIGHT };
3491
+ const count = run.groups.reduce((sum, g) => sum + (g.slots?.length ?? 0), 0);
3492
+ let cursor = startX + (count > 1 ? NAV_RUN_PAD : 0);
3493
+ for (const group of run.groups) {
3494
+ const itemCtx = this.childContext(group.toolbar, cctx);
3495
+ const first = cursor;
3496
+ group.toolbar.children.forEach((item, i) => {
3497
+ const itemSize = group.sizes[i] as Size;
3498
+ const slot = group.slots?.[i] ?? itemSize.width;
3499
+ this.place(item, itemCtx, { width: null, height: null }, cursor + (slot - itemSize.width) / 2, rowY + (NAV_BAR_HEIGHT - itemSize.height) / 2);
3500
+ cursor += slot + NAV_RUN_GAP;
3501
+ });
3502
+ const last = group.toolbar.children.length > 0 ? cursor - NAV_RUN_GAP : first;
3503
+ this.setFrame(group.toolbar, first, rowY, { width: last - first, height: NAV_BAR_HEIGHT });
3437
3504
  }
3505
+ };
3506
+ let leadingIndex = 0;
3507
+ for (const run of runs.filter((r) => r.placement === 'leading')) {
3508
+ if (run.width === 0) continue;
3509
+ placeRun(run, leadingCursor, leadingIndex++);
3510
+ leadingCursor += run.width + NAV_GROUP_SPACING;
3511
+ }
3512
+ const trailingRuns = runs.filter((r) => r.placement === 'trailing' && r.width > 0);
3513
+ let trailingCursor = trailingEdge - (trailingWidth > 0 ? trailingWidth - NAV_GROUP_SPACING : 0);
3514
+ const trailingStart = trailingCursor;
3515
+ trailingRuns.forEach((run, index) => {
3516
+ placeRun(run, trailingCursor, index);
3517
+ trailingCursor += run.width + NAV_GROUP_SPACING;
3518
+ });
3519
+ if (trailingRuns.length > 0) trailingEdge = trailingStart - NAV_GROUP_SPACING;
3520
+ for (const group of groups) {
3521
+ if (group.placement !== 'principal') continue;
3522
+ const startX = x + (size.width - group.total) / 2;
3523
+ this.setFrame(group.toolbar, startX, rowY, { width: group.total, height: NAV_BAR_HEIGHT });
3524
+ placeGroup(group, startX, rowY, NAV_BAR_HEIGHT, NAV_ITEM_SPACING);
3438
3525
  }
3439
3526
  // The inline secondary items, right-aligned before the trailing groups,
3440
3527
  // each toolbar framed around its own; the collapsed ones are hidden (the
@@ -3475,11 +3562,9 @@ export class LayoutPass {
3475
3562
  // takes up to a pixel off a fractional width at a fractional origin, and
3476
3563
  // the title truncates with an ellipsis ("Animati…") when its box is any
3477
3564
  // narrower than its text.
3478
- const scroller = parts.content ? unwrapStyled(parts.content) : null;
3479
- const titleScrolls =
3480
- large &&
3481
- scroller !== null &&
3482
- (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
3565
+ const direct = parts.content ? unwrapStyled(parts.content) : null;
3566
+ const scroller = parts.content ? this.screenScroller(parts.content) : null;
3567
+ const titleScrolls = large && scroller !== null;
3483
3568
  // A large title that scrolls away collapses into the inline title, which
3484
3569
  // the renderer shows once it has (`data-sb-collapsed`).
3485
3570
  if ((!large || titleScrolls) && !principal) {
@@ -3543,9 +3628,7 @@ export class LayoutPass {
3543
3628
  barAbove: undefined,
3544
3629
  ...(bottomCover > 0 ? { bottomBar: bottomCover, bottomEdge: y + size.height } : {}),
3545
3630
  };
3546
- const scrollsUnderBar =
3547
- scroller !== null && (scroller.kind === 'list' || (scroller.kind === 'scrollview' && this.scrollAxes(scroller).vertical));
3548
- if (scroller && scrollsUnderBar) {
3631
+ if (scroller) {
3549
3632
  // iOS 26: the scroll view starts at the top of the screen and its
3550
3633
  // content scrolls under the bar (and the large title moves with it);
3551
3634
  // the content starts below them. It runs to the bottom of the screen,
@@ -3559,14 +3642,14 @@ export class LayoutPass {
3559
3642
  this.place(child, contentCtx, { width: content.width, height: content.height + top + bottomCover }, content.x, y);
3560
3643
  return;
3561
3644
  }
3562
- if (scroller) this.scrollInsets.delete(scroller);
3645
+ if (direct) this.scrollInsets.delete(direct);
3563
3646
  // A scroll view deeper in the content that touches the bar runs under it
3564
3647
  // (`scrollEdgeGrowth`); not under a large title, which stays put then.
3565
3648
  // One that reaches the bottom of the content runs down under all the
3566
3649
  // content clears there.
3567
3650
  if (!large) contentCtx.barAbove = { top: content.y, height: content.y - y, x, width: size.width };
3568
3651
  if (bottomCover > 0) contentCtx.bottomBar = bottomCover + bottomInset;
3569
- if (scroller && scroller.kind === 'scrollview') {
3652
+ if (direct && direct.kind === 'scrollview') {
3570
3653
  this.place(child, contentCtx, { width: content.width, height: content.height }, content.x, content.y);
3571
3654
  return;
3572
3655
  }
@@ -3577,6 +3660,30 @@ export class LayoutPass {
3577
3660
  this.place(child, contentCtx, proposal, content.x + dx, content.y + dy);
3578
3661
  }
3579
3662
 
3663
+ /**
3664
+ * The list or vertical scroll view that is a screen's content: through
3665
+ * modifiers and wrappers that put their child at their own origin and size
3666
+ * (a `GeometryReader`'s or a one-child `ZStack`'s child, a decoration's
3667
+ * content), as UIKit finds a screen's
3668
+ * scroll view. Food Truck's Truck page is a `GeometryReader` around its
3669
+ * `ScrollView`, its Donuts page a `ZStack` with a background around one:
3670
+ * their large titles scroll and collapse as on iOS. Null when there is none.
3671
+ */
3672
+ private screenScroller(node: LayoutNode): LayoutNode | null {
3673
+ let current: LayoutNode | undefined = node;
3674
+ for (let depth = 0; current && depth < 32; depth++) {
3675
+ if (current.kind === 'list' || (current.kind === 'scrollview' && this.scrollAxes(current).vertical)) return current;
3676
+ if ((current.kind === 'styled' || current.kind === 'geometry' || current.kind === 'zstack') && current.children.length === 1) {
3677
+ current = current.children[0];
3678
+ } else if (current.kind === 'decorated') {
3679
+ current = current.children[0];
3680
+ } else {
3681
+ return null;
3682
+ }
3683
+ }
3684
+ return null;
3685
+ }
3686
+
3580
3687
  /**
3581
3688
  * iOS 26's bottom toolbar: the items of the `bottom` toolbars, in order,
3582
3689
  * float on Liquid Glass in a 48 row at the top of the bottom bar's band
@@ -3843,7 +3950,10 @@ export class LayoutPass {
3843
3950
  /**
3844
3951
  * The inspector column: its content below the status bar (at the top of
3845
3952
  * the screen) and clear of the trailing inset, top-aligned and centered
3846
- * horizontally (a list or form fills it).
3953
+ * horizontally (a list or form fills it). A navigation stack fills the
3954
+ * column from its top instead, as the split view's columns do, so its bar
3955
+ * takes the status bar's room and is as tall as theirs (with the 10 under
3956
+ * an inline title) and its content starts level with theirs.
3847
3957
  */
3848
3958
  private placeInspectorPanel(node: LayoutNode, cctx: Context, x: number, y: number, size: Size): void {
3849
3959
  const { screen, safeArea } = this.env;
@@ -3851,6 +3961,10 @@ export class LayoutPass {
3851
3961
  const trailing = x + size.width >= screen.width - EPSILON ? safeArea.trailing : 0;
3852
3962
  const area: Size = { width: Math.max(0, size.width - trailing), height: Math.max(0, size.height - top) };
3853
3963
  for (const child of node.children) {
3964
+ if (unwrapStyled(child).kind === 'navstack') {
3965
+ this.place(child, cctx, { width: area.width, height: size.height }, x, y);
3966
+ continue;
3967
+ }
3854
3968
  const childSize = this.measure(child, cctx, area);
3855
3969
  const { dx } = alignIn(area, childSize, 'center');
3856
3970
  this.place(child, cctx, area, x + dx, y + top);
@@ -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` (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` |
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), `leadingGlass0` …, `trailingGlass0` … (iOS 26: the bar's glass capsules, one per run of adjacent `leading` / `trailing` toolbars, in order), `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` |
package/src/layoutDom.ts CHANGED
@@ -42,7 +42,7 @@ import { bitmapLaidOut } from './bitmaps';
42
42
  import { drawRichText } from './richText';
43
43
  import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
44
44
  import { bindScrollEdgeBottom } from './scrollEdge';
45
- import { BOTTOM_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
45
+ import { BAR_GLASS_KIND, TOOLBAR_ANIMATION, TOOLBAR_TRANSITION, baseTiming, timingFor, transitionStart } from './animation';
46
46
 
47
47
  export interface LayoutDomContext {
48
48
  root: HTMLElement;
@@ -263,17 +263,21 @@ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, orig
263
263
  }
264
264
 
265
265
  /**
266
- * iOS 26: the glass a screen's bottom toolbar items float on, one element
267
- * per capsule (engine `bottomGlass0` …), under the items (styles.css). A
268
- * capsule is known by the bottom toolbars drawn on it (`data-sb-toolbars`:
269
- * the ids of those whose frames lie in it), not by its index, so a group
270
- * that goes takes its own capsule with it and the groups after it keep
271
- * theirs. A capsule that moves or resizes is reported in `changes` (kind
272
- * `bottomglass`, no id) so the Animator tweens it with the toolbar's items;
273
- * one that appears on a screen already there, or goes, scales and fades as
274
- * the items do (`TOOLBAR_TRANSITION`), in the toolbar's own spring.
266
+ * iOS 26: the glass a screen's bar items float on, one element per capsule
267
+ * (`.sb-bar-glass`, styles.css): the navigation bar's runs of leading and
268
+ * trailing items (engine `leadingGlass0` …, `trailingGlass0` …) and the
269
+ * bottom bar's (`bottomGlass0` …), under the items. A capsule is known by
270
+ * the toolbars drawn on it (`data-sb-toolbars`: the ids of those of its
271
+ * placement whose frames lie in it), not by its index, so a group that goes
272
+ * takes its own capsule with it and the groups after it keep theirs, and an
273
+ * item swapped for another (its toolbar's element kept, Toolbar.swift)
274
+ * changes on a capsule that stays. A capsule that moves or resizes is
275
+ * reported in `changes` (kind `barglass`, no id) so the Animator tweens it
276
+ * with the toolbar's items; one that appears on a screen already there, or
277
+ * goes, scales and fades as the items do (`TOOLBAR_TRANSITION`), in the
278
+ * toolbar's own spring.
275
279
  */
276
- function syncBottomGlass(
280
+ function syncBarGlass(
277
281
  result: LayoutResult,
278
282
  node: LayoutNode,
279
283
  screen: HTMLElement,
@@ -282,28 +286,31 @@ function syncBottomGlass(
282
286
  screenIsNew: boolean,
283
287
  changes: BoxChange[],
284
288
  ): void {
285
- const unmatched = new Set(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass:not([data-sb-exiting])'));
286
- const toolbars = node.children.filter((child) => child.kind === 'toolbar' && child.props['placement'] === 'bottom');
287
- for (let count = 0, rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${++count}`]) {
288
- const capsule = rect;
289
- const ids = toolbars.filter((toolbar) => centeredIn(result.frames.get(toolbar.id), capsule)).map((toolbar) => String(toolbar.id));
290
- const relative = { x: rect.x - frame.x, y: rect.y - frame.y, width: rect.width, height: rect.height };
291
- let glass = Array.from(unmatched).find((candidate) => (candidate.dataset['sbToolbars'] ?? '').split(' ').some((id) => ids.includes(id)));
292
- if (glass) {
293
- unmatched.delete(glass);
294
- const from = glassBox(glass);
295
- if (!sameRect(from, relative)) changes.push({ id: -1, el: glass, kind: BOTTOM_GLASS_KIND, from, to: relative });
296
- } else {
297
- glass = document.createElement('div');
298
- glass.className = 'sb-bottom-glass';
299
- screen.append(glass);
300
- if (!screenIsNew) transitionGlass(glass, false);
301
- changes.push({ id: -1, el: glass, kind: BOTTOM_GLASS_KIND, from: null, to: relative });
289
+ for (const placement of ['leading', 'trailing', 'bottom'] as const) {
290
+ const unmatched = new Set(screen.querySelectorAll<HTMLElement>(`:scope > .sb-bar-glass[data-sb-placement="${placement}"]:not([data-sb-exiting])`));
291
+ const toolbars = node.children.filter((child) => child.kind === 'toolbar' && child.props['placement'] === placement);
292
+ for (let count = 0, rect = chrome?.[`${placement}Glass0`]; rect; rect = chrome?.[`${placement}Glass${++count}`]) {
293
+ const capsule = rect;
294
+ const ids = toolbars.filter((toolbar) => centeredIn(result.frames.get(toolbar.id), capsule)).map((toolbar) => String(toolbar.id));
295
+ const relative = { x: rect.x - frame.x, y: rect.y - frame.y, width: rect.width, height: rect.height };
296
+ let glass = Array.from(unmatched).find((candidate) => (candidate.dataset['sbToolbars'] ?? '').split(' ').some((id) => ids.includes(id)));
297
+ if (glass) {
298
+ unmatched.delete(glass);
299
+ const from = glassBox(glass);
300
+ if (!sameRect(from, relative)) changes.push({ id: -1, el: glass, kind: BAR_GLASS_KIND, from, to: relative });
301
+ } else {
302
+ glass = document.createElement('div');
303
+ glass.className = 'sb-bar-glass';
304
+ glass.dataset['sbPlacement'] = placement;
305
+ screen.append(glass);
306
+ if (!screenIsNew) transitionGlass(glass, false);
307
+ changes.push({ id: -1, el: glass, kind: BAR_GLASS_KIND, from: null, to: relative });
308
+ }
309
+ glass.dataset['sbToolbars'] = ids.join(' ');
310
+ placeChrome(glass, rect, frame);
302
311
  }
303
- glass.dataset['sbToolbars'] = ids.join(' ');
304
- placeChrome(glass, rect, frame);
312
+ for (const extra of unmatched) transitionGlass(extra, true);
305
313
  }
306
- for (const extra of unmatched) transitionGlass(extra, true);
307
314
  }
308
315
 
309
316
  /** Whether `rect`'s center lies in `area` (a toolbar's frame spans its items, all on one capsule). */
@@ -320,7 +327,7 @@ function glassBox(glass: HTMLElement): Rect {
320
327
  return { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, width: parseFloat(s.width) || 0, height: parseFloat(s.height) || 0 };
321
328
  }
322
329
 
323
- /** Scales and fades a bottom bar capsule in, or out (then removes it), as its toolbar's items do. */
330
+ /** Scales and fades a bar capsule in, or out (then removes it), as its toolbar's items do. */
324
331
  function transitionGlass(glass: HTMLElement, exit: boolean): void {
325
332
  const timing = baseTiming(timingFor(TOOLBAR_ANIMATION));
326
333
  const gone = transitionStart(TOOLBAR_TRANSITION, exit);
@@ -705,7 +712,7 @@ function applyChrome(
705
712
  bar.style.paddingTop = `${top}px`;
706
713
  bar.style.paddingBottom = `${bottom}px`;
707
714
  }
708
- syncBottomGlass(result, node, el, chrome, frame, isNew, changes);
715
+ syncBarGlass(result, node, el, chrome, frame, isNew, changes);
709
716
  // iOS 26 in a tab view: the search field under the title (styles.css).
710
717
  setFlag(el, 'sbSearchTop', chrome?.['searchTop'] ? '' : null);
711
718
  const largeRect = chrome?.['largeTitle'];