@swiftbrowser/web 0.1.0 → 0.3.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.
Files changed (99) hide show
  1. package/README.md +141 -63
  2. package/app/index.html +21 -10
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +12 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +68 -3
  69. package/src/landing.ts +2 -2
  70. package/src/largeTitle.ts +79 -0
  71. package/src/layout/chart.ts +9 -3
  72. package/src/layout/engine.ts +681 -174
  73. package/src/layout/index.ts +3 -1
  74. package/src/layout/text.ts +217 -1
  75. package/src/layout/types.ts +31 -0
  76. package/src/layout/typography.ts +24 -7
  77. package/src/layout/video.ts +24 -0
  78. package/src/layoutDom.ts +66 -6
  79. package/src/main.ts +127 -4
  80. package/src/media.ts +132 -0
  81. package/src/mock.ts +4 -3
  82. package/src/notifications.ts +109 -0
  83. package/src/pathShape.ts +156 -0
  84. package/src/protocol.ts +782 -13
  85. package/src/renderer.ts +603 -44
  86. package/src/richText.ts +65 -0
  87. package/src/scanner.ts +234 -0
  88. package/src/scrollTo.ts +123 -0
  89. package/src/styles.css +521 -100
  90. package/src/styles.ts +61 -0
  91. package/src/swiftData.ts +128 -0
  92. package/src/symbols.ts +658 -28
  93. package/src/translation.ts +126 -0
  94. package/src/video.ts +299 -0
  95. package/src/vite-env.d.ts +6 -0
  96. package/src/wasm.ts +227 -32
  97. package/src/webAuth.ts +267 -0
  98. package/src/webSocket.ts +148 -0
  99. package/src/zoneinfo.ts +269 -0
package/src/renderer.ts CHANGED
@@ -51,6 +51,9 @@ import {
51
51
  type Rect,
52
52
  } from './layout';
53
53
  import { LayoutReporter } from './layoutEvents';
54
+ import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
55
+ import { bitmapStore, showBitmap } from './bitmaps';
56
+ import { clearRichText, runTemplates } from './richText';
54
57
  import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
55
58
  import { ghostClone } from './ghost';
56
59
  import { InteractionController, type InteractionHost } from './interaction';
@@ -59,6 +62,8 @@ import {
59
62
  INTERACTION_KINDS,
60
63
  ROOT_ID,
61
64
  isAssetImage,
65
+ isBitmapImage,
66
+ isUrlImage,
62
67
  type Animation,
63
68
  type ButtonProps,
64
69
  type ChartItemProps,
@@ -84,6 +89,7 @@ import {
84
89
  type ListProps,
85
90
  type ListRowProps,
86
91
  type NavLinkProps,
92
+ type TapGestureProps,
87
93
  type NavScreenProps,
88
94
  type Op,
89
95
  type PickerProps,
@@ -101,6 +107,8 @@ import {
101
107
  type TabViewProps,
102
108
  type TextFieldProps,
103
109
  type TextProps,
110
+ type TextRun,
111
+ type ShapeStyle,
104
112
  type ToggleProps,
105
113
  type ToolbarProps,
106
114
  type ViewThatFitsProps,
@@ -113,6 +121,11 @@ import {
113
121
  type StepperProps,
114
122
  type TextEditorProps,
115
123
  type RootProps,
124
+ type ScrollToCommand,
125
+ type VideoProps,
126
+ type CanvasProps,
127
+ type Color,
128
+ type LazySpacerProps,
116
129
  } from './protocol';
117
130
  import {
118
131
  combineDateSeconds,
@@ -124,9 +137,14 @@ import {
124
137
  snapSliderValue,
125
138
  timeInputValue,
126
139
  } from './layout';
127
- import { renderSymbol } from './symbols';
140
+ import { filledSymbolName, renderSymbol } from './symbols';
141
+ import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
142
+ import { VideoEvents, applyVideo, stopVideos } from './video';
143
+ import { applyCanvas, createCanvasElement } from './canvasDrawing';
144
+ import { scrollToTarget } from './scrollTo';
128
145
  import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
129
146
  import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
147
+ import { colorMultiplyFilter } from './colorMultiply';
130
148
  import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
131
149
 
132
150
  export type EventCallback = (event: RendererEvent) => void;
@@ -198,12 +216,20 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
198
216
  'slider',
199
217
  'datepicker',
200
218
  'texteditor',
219
+ // A `Section`'s view header / footer
220
+ 'sectionheader',
221
+ 'sectionfooter',
222
+ // An `AVPlayer` shown by a view (src/video.ts)
223
+ 'video',
224
+ 'lazyspacer',
225
+ // A `Canvas`'s drawing (src/canvasDrawing.ts)
226
+ 'canvas',
201
227
  ]);
202
228
 
203
229
  /** Duration of the alert / dialog fade (Phase 8); keep in sync with styles.css. */
204
230
  export const ALERT_TRANSITION_MS = 250;
205
- /** Diameter of the slider thumb (Phase 8); keep in sync with the engine and styles.css. */
206
- const SLIDER_THUMB = 27;
231
+ /** Width of the slider thumb (iOS 26: a 38×24 capsule); keep in sync with the engine and styles.css. */
232
+ const SLIDER_THUMB = 38;
207
233
 
208
234
  /** Width of the menu a `menu` picker (or a Phase 6 `menu`) opens, like an iOS context menu. */
209
235
  export const PICKER_MENU_WIDTH = 250;
@@ -253,11 +279,31 @@ export class Renderer {
253
279
  layoutResult: LayoutResult | null = null;
254
280
  /** What the current render pass did so far (handed to the Animator at `commit`). */
255
281
  private pass: PassRecord = emptyPass();
282
+ /**
283
+ * Whether the current pass can animate (see `passCanAnimate`), decided at
284
+ * its first op; null between passes. A pass that cannot skips the style
285
+ * snapshots and rectangles the Animator would only need to animate, each
286
+ * of which forces a style recalculation.
287
+ */
288
+ private passAnimates: boolean | null = null;
289
+ /** The animation root's rectangle, read once per pass that animates. */
290
+ private passRootRect: DOMRect | null = null;
291
+ /**
292
+ * The root's size as the engine last read it; null when it must be read
293
+ * again. Reading it after a pass changed the DOM forces a style and layout
294
+ * recalculation, so it is read once and then only after the root resized
295
+ * (`resizeObserver`, `viewportChanged`). Without a ResizeObserver it is
296
+ * read on every pass.
297
+ */
298
+ private rootSize: { width: number; height: number } | null = null;
299
+ private readonly resizeObserver: ResizeObserver | null = null;
256
300
  /**
257
301
  * Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
258
302
  * swipe back, the sheet drag, pull to refresh) and arbitrates between them.
259
303
  */
260
304
  readonly interaction: InteractionController;
305
+ /** `video` elements' media events, as `video` events (src/video.ts). */
306
+ private readonly videoEvents: VideoEvents;
261
307
  private readonly measurer = new CanvasTextMeasurer();
262
308
  /**
263
309
  * Phase 9: the layout pass lives across commits and is told what changed
@@ -280,6 +326,8 @@ export class Renderer {
280
326
  private readonly dirty = new Set<ElementId>();
281
327
  /** The open popover (of a `menu` picker or a `menu` element), if any. */
282
328
  private menu: OpenMenu | null = null;
329
+ /** Lazy menus: a menu the viewer opened whose items Swift is building. */
330
+ private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
283
331
  /** Size last reported for every `geometry` element (whole px). */
284
332
  private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
285
333
  /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
@@ -288,6 +336,14 @@ export class Renderer {
288
336
  private readonly layoutReporter = new LayoutReporter();
289
337
  /** Nesting depth of `applyOps` (events are flushed when it returns to 0). */
290
338
  private applying = 0;
339
+ /** `styled` elements whose props in this pass carry a new `scrollTo` command (src/scrollTo.ts). */
340
+ private pendingScrolls: ElementId[] = [];
341
+ /** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
342
+ private lazySpacers = new Set<ElementId>();
343
+ private lazyReported = new Set<ElementId>();
344
+ private lazyCheckQueued = false;
345
+ /** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
346
+ private scrollSerial = 0;
291
347
 
292
348
  private readonly onClick = (event: MouseEvent): void => {
293
349
  // `closest` picks the innermost control, so a button inside a
@@ -297,9 +353,20 @@ export class Renderer {
297
353
  '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
298
354
  '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
299
355
  // Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
300
- '.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable]',
356
+ '.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable], ' +
357
+ // A link in a rich text: innermost, so it wins over a tappable row around the text
358
+ '.sb-link',
301
359
  );
302
360
  if (!target || !this.root.contains(target)) return;
361
+ if (target.classList.contains('sb-link')) {
362
+ const text = target.closest<HTMLElement>('.sb-text');
363
+ const id = Number(text?.dataset[ID_ATTR]);
364
+ const url = target.dataset['sbLink'];
365
+ if (!text || !Number.isInteger(id) || url === undefined) return;
366
+ event.preventDefault();
367
+ this.onEvent({ type: 'link', id, value: url });
368
+ return;
369
+ }
303
370
  if (this.interaction.handleClick(target)) {
304
371
  event.preventDefault();
305
372
  return;
@@ -370,7 +437,7 @@ export class Renderer {
370
437
  if (!Number.isInteger(id)) return;
371
438
  event.preventDefault();
372
439
  if (this.menu?.anchorId === id) this.closeMenu();
373
- else if (target.classList.contains('sb-menu')) this.openMenu(target, id, this.menuRows(id));
440
+ else if (target.classList.contains('sb-menu')) this.openMenuWhenBuilt(target, id, 'menu');
374
441
  else this.openMenu(target, id, this.pickerRows(id));
375
442
  return;
376
443
  }
@@ -405,6 +472,16 @@ export class Renderer {
405
472
  const id = Number(target.dataset[ID_ATTR]);
406
473
  if (!Number.isInteger(id)) return;
407
474
  event.preventDefault();
475
+ if (target.hasAttribute('data-sb-tap-location')) {
476
+ // `onTapGesture { point in }`: the point in the element's own
477
+ // coordinates (points; the device frame may be scaled).
478
+ const rect = target.getBoundingClientRect();
479
+ const scale = target.offsetWidth > 0 ? rect.width / target.offsetWidth : 1;
480
+ const x = Math.round(((event.clientX - rect.left) / (scale || 1)) * 100) / 100;
481
+ const y = Math.round(((event.clientY - rect.top) / (scale || 1)) * 100) / 100;
482
+ this.onEvent({ type: 'tap', id, x, y });
483
+ return;
484
+ }
408
485
  this.onEvent({ type: 'tap', id });
409
486
  };
410
487
 
@@ -415,17 +492,33 @@ export class Renderer {
415
492
  */
416
493
  private syncFocus(): void {
417
494
  const active = document.activeElement;
418
- for (const input of this.root.querySelectorAll<HTMLInputElement>('.sb-textfield[data-sb-focus]')) {
495
+ for (const input of this.root.querySelectorAll<HTMLElement>('.sb-textfield[data-sb-focus], .sb-texteditor[data-sb-focus]')) {
419
496
  const wanted = input.dataset['sbFocus'] === 'true';
420
497
  if (wanted && active !== input) input.focus();
421
498
  else if (!wanted && active === input) input.blur();
422
499
  }
423
500
  }
424
501
 
502
+ /**
503
+ * `.scrollDismissesKeyboard`: scrolling a list or scroll view marked
504
+ * `data-sb-dismisses-keyboard` takes the focus from the text input being
505
+ * edited inside it (a bound field then reports the blur as usual).
506
+ */
507
+ private readonly onScrollDismissKeyboard = (event: Event): void => {
508
+ const scroller = event.target;
509
+ if (!(scroller instanceof HTMLElement) || scroller.dataset['sbDismissesKeyboard'] !== 'true') return;
510
+ const active = document.activeElement;
511
+ if ((active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) && scroller.contains(active)) active.blur();
512
+ };
513
+
425
514
  /** Phase 12: a bound field (`data-sb-focus`) reports focus changes. */
426
515
  private readonly onFocusChange = (event: FocusEvent): void => {
427
- const input = event.target as HTMLInputElement | null;
428
- if (!input || !(input instanceof HTMLInputElement) || !input.classList.contains('sb-textfield')) return;
516
+ // A bound `texteditor` (UIKit's text views) reports focus like a field.
517
+ const input = event.target as HTMLElement | null;
518
+ const isField =
519
+ (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) && input.classList.contains('sb-textfield');
520
+ const isEditor = input instanceof HTMLTextAreaElement && input.classList.contains('sb-texteditor');
521
+ if (!input || (!isField && !isEditor)) return;
429
522
  if (input.dataset['sbFocus'] === undefined || !this.root.contains(input)) return;
430
523
  const id = Number(input.dataset[ID_ATTR]);
431
524
  if (!Number.isInteger(id)) return;
@@ -445,7 +538,12 @@ export class Renderer {
445
538
  const target = event.target as HTMLElement | null;
446
539
  if (!target) return;
447
540
  // Phase 12: Enter in a text field under `.onSubmit`.
448
- if (event.key === 'Enter' && target.classList.contains('sb-textfield') && target.dataset['sbSubmit'] === 'true') {
541
+ if (
542
+ event.key === 'Enter' &&
543
+ target.classList.contains('sb-textfield') &&
544
+ target.dataset['sbSubmit'] === 'true' &&
545
+ !(event.shiftKey && target instanceof HTMLTextAreaElement)
546
+ ) {
449
547
  const id = Number(target.dataset[ID_ATTR]);
450
548
  if (Number.isInteger(id)) {
451
549
  event.preventDefault();
@@ -606,7 +704,10 @@ export class Renderer {
606
704
  root.dataset[KIND_ATTR] = 'root';
607
705
  this.elements.set(ROOT_ID, root);
608
706
  this.nodes.set(ROOT_ID, { id: ROOT_ID, kind: 'root', props: {}, children: [] });
609
- this.animator = new Animator(root, { selfAnimatingKinds: SELF_ANIMATING });
707
+ this.animator = new Animator(root, {
708
+ selfAnimatingKinds: SELF_ANIMATING,
709
+ playOwnFrames: (el, animation) => hasPathFrames(el) && playPathFrames(el, animation),
710
+ });
610
711
  // Delegated listeners instead of one per control: elements can be
611
712
  // created detached and moved around freely.
612
713
  root.addEventListener('click', this.onClick);
@@ -614,6 +715,9 @@ export class Renderer {
614
715
  root.addEventListener('input', this.onInput);
615
716
  root.addEventListener('focusin', this.onFocusChange);
616
717
  root.addEventListener('focusout', this.onFocusChange);
718
+ root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
719
+ root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
720
+ this.videoEvents = new VideoEvents(root, (event) => this.onEvent(event));
617
721
  // Phase 8: pointer gestures (the sheet drag among them) are delegated too.
618
722
  const host: InteractionHost = {
619
723
  root,
@@ -626,6 +730,16 @@ export class Renderer {
626
730
  this.interaction = new InteractionController(host);
627
731
  root.addEventListener('change', this.onChange);
628
732
  root.addEventListener('pointerdown', this.onSliderPointerDown);
733
+ if (typeof ResizeObserver !== 'undefined') {
734
+ this.resizeObserver = new ResizeObserver(() => {
735
+ const before = this.rootSize;
736
+ this.rootSize = null;
737
+ if (!before || this.commits === 0) return;
738
+ const now = this.readRootSize();
739
+ if (now.width !== before.width || now.height !== before.height) this.layout();
740
+ });
741
+ this.resizeObserver.observe(root);
742
+ }
629
743
  }
630
744
 
631
745
  /** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
@@ -642,7 +756,9 @@ export class Renderer {
642
756
  }
643
757
 
644
758
  private applyAll(ops: readonly Op[]): void {
645
- for (const op of ops) {
759
+ for (let index = 0; index < ops.length; index++) {
760
+ const op = ops[index]!;
761
+ if (this.passAnimates === null) this.passAnimates = this.passCanAnimate(ops, index);
646
762
  switch (op.op) {
647
763
  case 'create':
648
764
  this.create(op.id, op.kind, op.props, op);
@@ -657,15 +773,26 @@ export class Renderer {
657
773
  this.remove(op.id, op);
658
774
  break;
659
775
  case 'defaults':
776
+ case 'request':
777
+ case 'swiftdata':
660
778
  // Consumed by the loader (wasm.ts); nothing to render.
661
779
  break;
780
+ case 'imagedata':
781
+ // A bitmap's source, kept for the `image` elements that draw it (src/bitmaps.ts).
782
+ bitmapStore.add(op);
783
+ break;
662
784
  case 'commit': {
663
785
  const pass = this.pass;
664
786
  this.pass = emptyPass();
787
+ this.passAnimates = null;
788
+ this.passRootRect = null;
665
789
  const changes = this.layout();
666
790
  this.animator.commit(pass, op.animation, changes);
667
791
  this.commits += 1;
668
792
  this.syncFocus();
793
+ this.performScrolls(op.animation != null);
794
+ this.checkLazySpacers();
795
+ this.openPendingMenu();
669
796
  this.root.dispatchEvent(
670
797
  new CustomEvent('sb:commit', { detail: { commits: this.commits, animation: op.animation ?? null } }),
671
798
  );
@@ -679,8 +806,29 @@ export class Renderer {
679
806
  }
680
807
  }
681
808
 
809
+ /**
810
+ * Whether the pass starting at `ops[start]` can animate: its `commit` has
811
+ * an animation, or one of its updates changes an `animationToken`
812
+ * (`.animation(_:value:)`). Otherwise the Animator only retargets running
813
+ * frame tweens, which needs nothing recorded before the changes. A pass
814
+ * whose `commit` is not in this batch is assumed to animate.
815
+ */
816
+ private passCanAnimate(ops: readonly Op[], start: number): boolean {
817
+ for (let index = start; index < ops.length; index++) {
818
+ const op = ops[index]!;
819
+ if (op.op === 'commit') return op.animation != null;
820
+ if (op.op !== 'update') continue;
821
+ const token = (op.props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
822
+ if (token == null) continue;
823
+ const el = this.elements.get(op.id);
824
+ if (!el || el.dataset[ANIMATION_TOKEN] !== String(token)) return true;
825
+ }
826
+ return true;
827
+ }
828
+
682
829
  /** Detach everything under the root and forget all non-root elements. */
683
830
  reset(): void {
831
+ stopVideos(this.root);
684
832
  this.root.replaceChildren();
685
833
  this.elements.clear();
686
834
  this.elements.set(ROOT_ID, this.root);
@@ -692,6 +840,8 @@ export class Renderer {
692
840
  this.layoutResult = null;
693
841
  this.engine = null;
694
842
  this.pass = emptyPass();
843
+ this.passAnimates = null;
844
+ this.passRootRect = null;
695
845
  this.closeMenu();
696
846
  this.reportedGeometry.clear();
697
847
  this.layoutReporter.clear();
@@ -777,20 +927,20 @@ export class Renderer {
777
927
  }
778
928
 
779
929
  /**
780
- * The root's size changed (device mode: a resize or rotation). The engine
781
- * reads `#screen`'s current size on every pass, so this just re-lays out.
930
+ * The root's size changed (device mode: a resize or rotation): re-read it
931
+ * and re-lay out now rather than when the ResizeObserver reports it.
782
932
  */
783
933
  viewportChanged(): void {
934
+ this.rootSize = null;
784
935
  if (this.commits > 0) this.layout();
785
936
  }
786
937
 
787
938
  /** The environment the engine lays out for. */
788
939
  layoutEnvironment(): LayoutEnvironment {
789
- const width = this.root.clientWidth || DEFAULT_SCREEN.width;
790
- const height = this.root.clientHeight || DEFAULT_SCREEN.height;
940
+ const screen = this.resizeObserver ? (this.rootSize ??= this.readRootSize()) : this.readRootSize();
791
941
  if (!this.safeArea) this.safeArea = readSafeArea(this.root);
792
942
  return {
793
- screen: { width, height },
943
+ screen: { ...screen },
794
944
  safeArea: this.safeArea,
795
945
  dynamicTypeSize: this.environment.dynamicTypeSize,
796
946
  devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
@@ -799,6 +949,16 @@ export class Renderer {
799
949
  };
800
950
  }
801
951
 
952
+ private readRootSize(): { width: number; height: number } {
953
+ return {
954
+ width: this.root.clientWidth || DEFAULT_SCREEN.width,
955
+ height: this.root.clientHeight || DEFAULT_SCREEN.height,
956
+ };
957
+ }
958
+
959
+ /** Called after each layout pass (main.ts keeps the browser chrome's color in step). */
960
+ afterLayout: (() => void) | null = null;
961
+
802
962
  /**
803
963
  * Run the layout engine over the current tree and position every element.
804
964
  * Returns the elements whose box changed. Runs at every `commit`; call it
@@ -834,17 +994,19 @@ export class Renderer {
834
994
  this.collectGeometry(result);
835
995
  this.collectLayouts(result);
836
996
  if (this.applying === 0) this.flushGeometry();
997
+ this.afterLayout?.();
837
998
  return changes;
838
999
  }
839
1000
 
840
1001
  /**
841
- * Queue a `geometry` event for every `geometry` element whose laid-out size
1002
+ * Queue a `geometry` event for every `geometry` element (and every element
1003
+ * whose props set `reportsSize`, an anchor preference's) whose laid-out size
842
1004
  * (whole px) differs from the one last reported. Nothing is queued for an
843
1005
  * unchanged size, which is what lets the Swift re-render loop converge.
844
1006
  */
845
1007
  private collectGeometry(result: LayoutResult): void {
846
1008
  for (const [id, node] of this.nodes) {
847
- if (node.kind !== 'geometry') continue;
1009
+ if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
848
1010
  const frame = result.frames.get(id);
849
1011
  if (!frame) continue; // e.g. inside a hidden tab
850
1012
  const width = Math.round(frame.width);
@@ -947,9 +1109,47 @@ export class Renderer {
947
1109
  */
948
1110
  openContextMenu(anchor: HTMLElement, id: ElementId): void {
949
1111
  if (this.menu?.anchorId === id) return;
1112
+ if (!this.nodes.has(id) || !this.root.contains(anchor)) return;
1113
+ this.openMenuWhenBuilt(anchor, id, 'contextmenu');
1114
+ }
1115
+
1116
+ /**
1117
+ * Lazy menus: a `deferred` menu asks Swift for its items (`menu` event,
1118
+ * `open: true`) and opens once they are there, which is usually as soon as
1119
+ * the event returns (the app answers with the ops at once), else at the
1120
+ * commit that brings them.
1121
+ */
1122
+ private openMenuWhenBuilt(anchor: HTMLElement, id: ElementId, kind: 'menu' | 'contextmenu'): void {
1123
+ const node = this.nodes.get(id);
1124
+ if (!node) return;
1125
+ if (node.props['deferred'] !== true) {
1126
+ this.showMenu(anchor, id, kind);
1127
+ return;
1128
+ }
1129
+ this.closeMenu();
1130
+ this.pendingMenu = { anchor, id, kind };
1131
+ this.onEvent({ type: 'menu', id, open: true });
1132
+ this.openPendingMenu();
1133
+ }
1134
+
1135
+ private showMenu(anchor: HTMLElement, id: ElementId, kind: 'menu' | 'contextmenu'): void {
950
1136
  const node = this.nodes.get(id);
951
- if (!node || !this.root.contains(anchor)) return;
952
- this.openMenu(anchor, id, collectMenuRows(node.children.slice(1)));
1137
+ if (!node) return;
1138
+ this.openMenu(anchor, id, kind === 'menu' ? this.menuRows(id) : collectMenuRows(node.children.slice(1)));
1139
+ }
1140
+
1141
+ /** Opens the menu waiting for its items once they arrived (dropped when its element left). */
1142
+ private openPendingMenu(): void {
1143
+ const pending = this.pendingMenu;
1144
+ if (!pending) return;
1145
+ const node = this.nodes.get(pending.id);
1146
+ if (!node || !pending.anchor.isConnected) {
1147
+ this.pendingMenu = null;
1148
+ return;
1149
+ }
1150
+ if (node.props['deferred'] === true) return;
1151
+ this.pendingMenu = null;
1152
+ this.showMenu(pending.anchor, pending.id, pending.kind);
953
1153
  }
954
1154
 
955
1155
  /** Close the open popover (no-op when none is open). */
@@ -960,6 +1160,16 @@ export class Renderer {
960
1160
  document.removeEventListener('click', this.onDocumentClick, true);
961
1161
  menu.el.remove();
962
1162
  menu.anchor.setAttribute('aria-expanded', 'false');
1163
+ // Lazy menus: Swift drops the items. After the current task, so that the
1164
+ // tap of the item chosen (sent right after closing) reaches its button.
1165
+ const id = menu.anchorId;
1166
+ if (this.nodes.get(id)?.props['deferred'] === false) {
1167
+ queueMicrotask(() => {
1168
+ if (this.nodes.get(id)?.props['deferred'] === false && this.menu?.anchorId !== id) {
1169
+ this.onEvent({ type: 'menu', id, open: false });
1170
+ }
1171
+ });
1172
+ }
963
1173
  }
964
1174
 
965
1175
  /** The open popover element (for tests), or null. */
@@ -981,7 +1191,10 @@ export class Renderer {
981
1191
  this.root.removeEventListener('input', this.onInput);
982
1192
  this.root.removeEventListener('focusin', this.onFocusChange);
983
1193
  this.root.removeEventListener('focusout', this.onFocusChange);
1194
+ this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
984
1195
  this.interaction.dispose();
1196
+ this.videoEvents.dispose();
1197
+ this.resizeObserver?.disconnect();
985
1198
  this.root.removeEventListener('change', this.onChange);
986
1199
  this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
987
1200
  this.reset();
@@ -995,13 +1208,72 @@ export class Renderer {
995
1208
  if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be created', op);
996
1209
  if (this.elements.has(id)) throw new RendererError(`Element ${id} already exists`, op);
997
1210
  if (!ALL_KINDS.has(kind)) throw new RendererError(`Unknown element kind ${String(kind)}`, op);
998
- const el = createShell(kind);
1211
+ // A vertical `textfield` is a `<textarea>` (its axis is fixed at creation).
1212
+ const el = kind === 'textfield' && (props as TextFieldProps).axis === 'vertical' ? createMultilineField() : createShell(kind);
999
1213
  el.className = `sb-${kind}`;
1000
1214
  el.dataset[ID_ATTR] = String(id);
1001
1215
  el.dataset[KIND_ATTR] = kind;
1002
1216
  this.elements.set(id, el);
1003
1217
  this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
1004
1218
  applyProps(el, kind, props, this.assets);
1219
+ this.noteScroll(id, kind, props);
1220
+ if (kind === 'lazyspacer') this.lazySpacers.add(id);
1221
+ }
1222
+
1223
+ /** After a scroll, the lazy spacers are checked on the next frame (once per frame). */
1224
+ private readonly queueLazyCheck = (): void => {
1225
+ if (this.lazyCheckQueued || this.lazySpacers.size === 0) return;
1226
+ this.lazyCheckQueued = true;
1227
+ requestAnimationFrame(() => {
1228
+ this.lazyCheckQueued = false;
1229
+ this.checkLazySpacers();
1230
+ });
1231
+ };
1232
+
1233
+ /**
1234
+ * Lazy rows: a `lazyspacer` within a screen of its scroll view's visible
1235
+ * area asks Swift for the next rows (a `lazy` event), once; the next batch
1236
+ * comes with a new spacer, which is checked after that pass's layout.
1237
+ */
1238
+ private checkLazySpacers(): void {
1239
+ for (const id of this.lazySpacers) {
1240
+ const el = this.elements.get(id);
1241
+ if (!el || !el.isConnected) {
1242
+ this.lazySpacers.delete(id);
1243
+ this.lazyReported.delete(id);
1244
+ continue;
1245
+ }
1246
+ if (this.lazyReported.has(id) || !lazySpacerIsNear(el)) continue;
1247
+ this.lazyReported.add(id);
1248
+ this.onEvent({ type: 'lazy', id });
1249
+ }
1250
+ }
1251
+
1252
+ /** A `styled` whose style carries a `scrollTo` not seen before is scrolled to after this pass's layout. */
1253
+ private noteScroll(id: ElementId, kind: ElementKind, props: ElementProps): void {
1254
+ if (kind !== 'styled') return;
1255
+ const serial = (props as StyledProps)?.style?.scrollTo?.serial;
1256
+ if (typeof serial !== 'number' || serial <= this.scrollSerial) return;
1257
+ this.pendingScrolls.push(id);
1258
+ }
1259
+
1260
+ /** `ScrollViewProxy.scrollTo`: scroll to the targets this pass marked (src/scrollTo.ts). */
1261
+ private performScrolls(animated: boolean): void {
1262
+ if (this.pendingScrolls.length === 0) return;
1263
+ const ids = this.pendingScrolls;
1264
+ this.pendingScrolls = [];
1265
+ const result = this.layoutResult;
1266
+ if (!result) return;
1267
+ // Several commands in one pass: the latest wins (it scrolls last).
1268
+ const commands = ids
1269
+ .map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
1270
+ .filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
1271
+ .sort((a, b) => a.command.serial - b.command.serial);
1272
+ for (const { id, command } of commands) {
1273
+ if (command.serial <= this.scrollSerial) continue;
1274
+ scrollToTarget(this, result, id, command, animated);
1275
+ }
1276
+ for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
1005
1277
  }
1006
1278
 
1007
1279
  private update(id: ElementId, props: ElementProps, op: Op): void {
@@ -1032,7 +1304,7 @@ export class Renderer {
1032
1304
  }
1033
1305
  // Remember how the element looked before, in case the pass is animated.
1034
1306
  const previous = this.pass.updated.get(el);
1035
- const before = previous ? previous.before : this.animator.snapshot(el);
1307
+ const before = previous ? previous.before : this.passAnimates !== false ? this.animator.snapshot(el) : null;
1036
1308
  const oldToken = el.dataset[ANIMATION_TOKEN];
1037
1309
  // `update` replaces the props entirely, so drop whatever the previous
1038
1310
  // props produced before applying the new ones. The layout box is kept:
@@ -1051,6 +1323,7 @@ export class Renderer {
1051
1323
  setTransition(el, undefined);
1052
1324
  if (kind === 'text') el.textContent = '';
1053
1325
  applyProps(el, kind, props, this.assets);
1326
+ this.noteScroll(id, kind, props);
1054
1327
  const newToken = el.dataset[ANIMATION_TOKEN];
1055
1328
  const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1056
1329
  const record: UpdatedRecord = { el, before, tokenChanged };
@@ -1085,14 +1358,24 @@ export class Renderer {
1085
1358
  if (!Number.isInteger(index) || index < 0) {
1086
1359
  throw new RendererError(`Invalid child index ${index}`, op);
1087
1360
  }
1088
- const container = slotOf(parent);
1361
+ // A section's view header / footer lives beside its rows' card, in the
1362
+ // section element itself; the rows after a header are one DOM slot lower.
1363
+ const supplement = isSectionSupplement(el);
1364
+ const container = supplement && parent.classList.contains('sb-section') ? parent : slotOf(parent);
1089
1365
  // Moves: `index` is the element's position in the parent's child list
1090
1366
  // *after* the op is applied. Detaching first makes the index arithmetic
1091
1367
  // identical for fresh inserts and for moves within the same parent.
1092
1368
  const previousParent = el.parentElement;
1093
1369
  const wasAttached = el.isConnected;
1094
1370
  el.remove();
1095
- const reference = container.children[index] ?? null;
1371
+ let slotIndex = index;
1372
+ if (!supplement && index > 0 && parent.classList.contains('sb-section')) {
1373
+ const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
1374
+ if (first?.kind === 'sectionheader') slotIndex -= 1;
1375
+ }
1376
+ const reference = supplement
1377
+ ? el.classList.contains('sb-sectionheader') ? container.firstChild : null
1378
+ : (container.children[slotIndex] ?? null);
1096
1379
  container.insertBefore(el, reference);
1097
1380
  if (!wasAttached) this.pass.inserted.add(el);
1098
1381
  // Mirror the move in the layout tree.
@@ -1166,10 +1449,17 @@ export class Renderer {
1166
1449
  // `commit` whether the pass is animated.
1167
1450
  const ancestors: HTMLElement[] = [];
1168
1451
  for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
1169
- this.pass.removed.push({ el, ancestors, rect: this.animator.rectOf(el), transition: transitionOf(el) });
1452
+ let rect: DOMRect | null = null;
1453
+ if (this.passAnimates !== false) {
1454
+ this.passRootRect ??= this.animator.rootRect();
1455
+ rect = this.animator.rectOf(el, this.passRootRect);
1456
+ }
1457
+ this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el) });
1170
1458
  }
1171
1459
  this.pass.inserted.delete(el);
1172
1460
  this.pass.updated.delete(el);
1461
+ // A detached <video> would keep playing its sound.
1462
+ stopVideos(el);
1173
1463
  el.remove();
1174
1464
  // Phase 9: the layout pass drops the subtree's output; the parent re-measures.
1175
1465
  const removedNode = this.nodes.get(id);
@@ -1282,6 +1572,14 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
1282
1572
  collectMenuRows(base.children, rows);
1283
1573
  break;
1284
1574
  }
1575
+ case 'sectionheader': {
1576
+ // A view header: its text as the caption.
1577
+ const text = textOf(base);
1578
+ if (text.length > 0) rows.push({ kind: 'caption', text });
1579
+ break;
1580
+ }
1581
+ case 'sectionfooter':
1582
+ break;
1285
1583
  case 'text':
1286
1584
  rows.push({ kind: 'text', text: textOf(base) });
1287
1585
  break;
@@ -1561,7 +1859,8 @@ function createShell(kind: ElementKind): HTMLElement {
1561
1859
  track.setAttribute('aria-hidden', 'true');
1562
1860
  const fill = div('sb-gauge-fill');
1563
1861
  fill.setAttribute('aria-hidden', 'true');
1564
- el.append(slot, track, fill);
1862
+ // A circular style's ring (gaugeRing.ts), placed from the `ring` rect.
1863
+ el.append(slot, track, fill, createGaugeRing());
1565
1864
  SLOTS.set(el, slot);
1566
1865
  return el;
1567
1866
  }
@@ -1739,6 +2038,8 @@ function createShell(kind: ElementKind): HTMLElement {
1739
2038
  SLOTS.set(el, slot);
1740
2039
  return el;
1741
2040
  }
2041
+ case 'canvas':
2042
+ return createCanvasElement();
1742
2043
  case 'texteditor': {
1743
2044
  const el = document.createElement('textarea');
1744
2045
  el.autocomplete = 'off';
@@ -1750,6 +2051,19 @@ function createShell(kind: ElementKind): HTMLElement {
1750
2051
  }
1751
2052
  }
1752
2053
 
2054
+ /** A vertical `textfield`: a `<textarea>` the layout sizes to its lines. */
2055
+ function createMultilineField(): HTMLTextAreaElement {
2056
+ const el = document.createElement('textarea');
2057
+ el.autocomplete = 'off';
2058
+ el.spellcheck = false;
2059
+ el.rows = 1;
2060
+ return el;
2061
+ }
2062
+
2063
+ function isSectionSupplement(el: HTMLElement): boolean {
2064
+ return el.classList.contains('sb-sectionheader') || el.classList.contains('sb-sectionfooter');
2065
+ }
2066
+
1753
2067
  function span(className: string): HTMLSpanElement {
1754
2068
  const el = document.createElement('span');
1755
2069
  el.className = className;
@@ -1873,7 +2187,7 @@ function refreshTabBar(tabview: HTMLElement): void {
1873
2187
  item.type = 'button';
1874
2188
  item.className = 'sb-tabbar-item';
1875
2189
  item.setAttribute('role', 'tab');
1876
- item.append(span('sb-tabbar-icon'), span('sb-tabbar-title'));
2190
+ item.append(span('sb-tabbar-icon'), span('sb-tabbar-title'), span('sb-tabbar-badge'));
1877
2191
  bar.append(item);
1878
2192
  }
1879
2193
  tabs.forEach((tab, i) => {
@@ -1888,7 +2202,8 @@ function refreshTabBar(tabview: HTMLElement): void {
1888
2202
  const icon = item.querySelector<HTMLElement>(':scope > .sb-tabbar-icon');
1889
2203
  if (icon && icon.dataset['sbSymbol'] !== symbol) {
1890
2204
  icon.dataset['sbSymbol'] = symbol;
1891
- const svg = symbol ? renderSymbol(symbol) : null;
2205
+ // iOS draws tab bar symbols in their filled form.
2206
+ const svg = symbol ? renderSymbol(filledSymbolName(symbol)) : null;
1892
2207
  if (svg) icon.replaceChildren(svg);
1893
2208
  else icon.replaceChildren();
1894
2209
  icon.toggleAttribute('data-sb-missing', Boolean(symbol) && !svg);
@@ -1896,6 +2211,14 @@ function refreshTabBar(tabview: HTMLElement): void {
1896
2211
  }
1897
2212
  const label = item.querySelector<HTMLElement>(':scope > .sb-tabbar-title');
1898
2213
  if (label && label.textContent !== title) label.textContent = title;
2214
+ // `Tab(…).badge(…)` and `Tab(role: .search)` (the iOS 18 tab API).
2215
+ const badge = item.querySelector<HTMLElement>(':scope > .sb-tabbar-badge');
2216
+ const badgeText = tab.dataset['sbBadge'] ?? '';
2217
+ if (badge && badge.textContent !== badgeText) badge.textContent = badgeText;
2218
+ if (badge) badge.hidden = badgeText === '';
2219
+ const role = tab.dataset['sbRole'];
2220
+ if (role) item.dataset['sbRole'] = role;
2221
+ else delete item.dataset['sbRole'];
1899
2222
  setTabHidden(tab, !isSelected);
1900
2223
  });
1901
2224
  }
@@ -1954,7 +2277,9 @@ function animateSheetOut(container: HTMLElement, sheet: HTMLElement): void {
1954
2277
  function applySheet(el: HTMLElement, props: SheetProps): void {
1955
2278
  const detents = Array.isArray(props.detents) && props.detents.length ? props.detents : ['large'];
1956
2279
  // Phase 8: `full` is `.fullScreenCover` (no radius, grabber or drag; the presenting tree keeps its scale).
1957
- const first = detents[0] === 'medium' ? 'medium' : detents[0] === 'full' ? 'full' : 'large';
2280
+ // A custom height (`height:` / `fraction:`) leaves the presenting tree unscaled, like `medium`.
2281
+ const custom = typeof detents[0] === 'string' && /^(height|fraction):/.test(detents[0]);
2282
+ const first = detents[0] === 'medium' || custom ? 'medium' : detents[0] === 'full' ? 'full' : 'large';
1958
2283
  el.dataset['sbDetent'] = first;
1959
2284
  el.dataset['sbDetents'] = detents.join(' ');
1960
2285
  }
@@ -2077,6 +2402,9 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
2077
2402
  function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): void {
2078
2403
  const text = props.text ?? '';
2079
2404
  if (area.value !== text) area.value = text;
2405
+ // Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
2406
+ if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
2407
+ else delete area.dataset['sbFocus'];
2080
2408
  }
2081
2409
 
2082
2410
  // ---------------------------------------------------------------------------
@@ -2086,7 +2414,7 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
2086
2414
  function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2087
2415
  switch (kind) {
2088
2416
  case 'text':
2089
- applyText(el, props as TextProps);
2417
+ applyText(el, props as TextProps, assets);
2090
2418
  break;
2091
2419
  case 'vstack':
2092
2420
  applyVStack(el, props as VStackProps);
@@ -2111,11 +2439,14 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2111
2439
  case 'image':
2112
2440
  applyImage(el, props as ImageProps, assets);
2113
2441
  break;
2442
+ case 'tapgesture':
2443
+ // `location`: the tap is sent with its point (`onTapGesture { point in }`).
2444
+ el.toggleAttribute('data-sb-tap-location', (props as TapGestureProps | undefined)?.location === true);
2445
+ break;
2114
2446
  case 'divider':
2115
2447
  case 'navstack':
2116
2448
  case 'geometry':
2117
2449
  case 'menu':
2118
- case 'tapgesture':
2119
2450
  case 'navsplit':
2120
2451
  // No props.
2121
2452
  break;
@@ -2143,6 +2474,9 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2143
2474
  // Phase 8: edit mode (the engine shifts `listrow` content) and pull to refresh.
2144
2475
  el.toggleAttribute('data-sb-editing', list.editing === true);
2145
2476
  applyRefreshable(el, list.refreshable === true, list.refreshing === true);
2477
+ // `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
2478
+ if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
2479
+ if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2146
2480
  break;
2147
2481
  }
2148
2482
  case 'tabview': {
@@ -2151,9 +2485,13 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2151
2485
  break;
2152
2486
  }
2153
2487
  case 'tab': {
2154
- const { title, systemImage } = props as TabProps;
2488
+ const { title, systemImage, badge, role } = props as TabProps;
2155
2489
  el.dataset['sbTitle'] = title ?? '';
2156
2490
  el.dataset['sbSymbol'] = systemImage ?? '';
2491
+ if (badge) el.dataset['sbBadge'] = badge;
2492
+ else delete el.dataset['sbBadge'];
2493
+ if (role) el.dataset['sbRole'] = role;
2494
+ else delete el.dataset['sbRole'];
2157
2495
  el.setAttribute('aria-label', title ?? '');
2158
2496
  break;
2159
2497
  }
@@ -2166,13 +2504,14 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2166
2504
  applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
2167
2505
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
2168
2506
  else delete el.dataset['sbSnap'];
2507
+ if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2169
2508
  break;
2170
2509
  }
2171
2510
  case 'section':
2172
2511
  applySection(el, props as SectionProps);
2173
2512
  break;
2174
2513
  case 'textfield':
2175
- applyTextField(el as HTMLInputElement, props as TextFieldProps);
2514
+ applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
2176
2515
  break;
2177
2516
  case 'toggle':
2178
2517
  setSwitchState(el, Boolean((props as ToggleProps).isOn));
@@ -2275,6 +2614,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2275
2614
  case 'texteditor':
2276
2615
  applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
2277
2616
  break;
2617
+ case 'sectionheader':
2618
+ case 'sectionfooter':
2619
+ // Pure containers: the list layout positions them.
2620
+ break;
2621
+ case 'video':
2622
+ applyVideo(el, props as VideoProps);
2623
+ break;
2624
+ case 'canvas':
2625
+ applyCanvas(el, props as CanvasProps, assets);
2626
+ break;
2627
+ case 'lazyspacer':
2628
+ el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
2629
+ break;
2278
2630
  default: {
2279
2631
  const unknown: never = kind;
2280
2632
  throw new RendererError(`Unknown element kind ${String(unknown)}`);
@@ -2282,8 +2634,59 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2282
2634
  }
2283
2635
  }
2284
2636
 
2285
- function applyText(el: HTMLElement, props: TextProps): void {
2286
- el.textContent = props.text ?? '';
2637
+ function applyText(el: HTMLElement, props: TextProps, assets: AssetCatalog): void {
2638
+ if (!Array.isArray(props.runs)) {
2639
+ runTemplates.delete(el);
2640
+ delete el.dataset['sbRich'];
2641
+ clearRichText(el);
2642
+ el.textContent = props.text ?? '';
2643
+ return;
2644
+ }
2645
+ // Rich text: a template per run; the layout pass draws the lines (src/richText.ts).
2646
+ el.dataset['sbRich'] = '';
2647
+ runTemplates.set(el, props.runs.map((run) => runTemplate(run, assets)));
2648
+ }
2649
+
2650
+ /** A rich text run's span (or inline image element) with its style; the layout pass sets the font. */
2651
+ function runTemplate(run: TextRun, assets: AssetCatalog): HTMLElement {
2652
+ let el: HTMLElement;
2653
+ if (run.image) {
2654
+ el = createShell('image');
2655
+ el.className = 'sb-image sb-text-image';
2656
+ applyImage(el, run.image, assets);
2657
+ // A symbol is a glyph: centered on the line like text, not stood on the baseline.
2658
+ if (!isBitmapImage(run.image) && !isUrlImage(run.image)) el.classList.add('sb-text-symbol');
2659
+ } else {
2660
+ el = document.createElement('span');
2661
+ el.className = 'sb-text-run';
2662
+ }
2663
+ const s = el.style;
2664
+ if (run.foreground !== undefined) s.color = styleToCSS(run.foreground as ShapeStyle, 'text').solid;
2665
+ const lines: string[] = [];
2666
+ let lineColor: unknown;
2667
+ if (run.underline) {
2668
+ lines.push('underline');
2669
+ lineColor = run.underline.color;
2670
+ }
2671
+ if (run.strikethrough) {
2672
+ lines.push('line-through');
2673
+ lineColor ??= run.strikethrough.color;
2674
+ }
2675
+ if (lines.length > 0) {
2676
+ s.textDecorationLine = lines.join(' ');
2677
+ if (lineColor !== undefined) s.textDecorationColor = styleToCSS(lineColor as ShapeStyle, 'text').solid;
2678
+ }
2679
+ if (typeof run.kerning === 'number' && run.kerning !== 0) s.letterSpacing = `${run.kerning}px`;
2680
+ if (typeof run.baselineOffset === 'number' && run.baselineOffset !== 0) {
2681
+ s.position = 'relative';
2682
+ s.top = `${-run.baselineOffset}px`;
2683
+ }
2684
+ if (typeof run.link === 'string') {
2685
+ el.classList.add('sb-link');
2686
+ el.dataset['sbLink'] = run.link;
2687
+ el.setAttribute('role', 'link');
2688
+ }
2689
+ return el;
2287
2690
  }
2288
2691
 
2289
2692
  /**
@@ -2313,6 +2716,8 @@ function applyGauge(el: HTMLElement, props: GaugeProps): void {
2313
2716
  el.dataset['sbStyle'] = props.style ?? 'automatic';
2314
2717
  el.dataset['sbValue'] = String(value);
2315
2718
  el.setAttribute('aria-valuenow', String(value));
2719
+ const ring = el.querySelector<HTMLElement>(':scope > .sb-gauge-ring');
2720
+ if (ring && isCircularGauge(props.style)) updateGaugeRing(ring, props.style, value);
2316
2721
  }
2317
2722
 
2318
2723
  function applyColor(el: HTMLElement, props: ColorProps): void {
@@ -2320,7 +2725,7 @@ function applyColor(el: HTMLElement, props: ColorProps): void {
2320
2725
  }
2321
2726
 
2322
2727
  /** Radius that turns a box into each shape (the layout engine sizes it). */
2323
- const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string> = {
2728
+ const SHAPE_RADIUS: Record<Exclude<ShapeProps['shape'], 'path'>, (cornerRadius: number) => string> = {
2324
2729
  rectangle: () => '0px',
2325
2730
  roundedRectangle: (r) => `${Math.max(0, r)}px`,
2326
2731
  circle: () => '50%',
@@ -2340,7 +2745,13 @@ const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string
2340
2745
  * and `data-sb-stroke` records which one it was.
2341
2746
  */
2342
2747
  function applyShape(el: HTMLElement, props: ShapeProps): void {
2343
- const shape = props.shape in SHAPE_RADIUS ? props.shape : 'rectangle';
2748
+ if (props.shape === 'path') {
2749
+ // A custom shape: an SVG of its path (src/pathShape.ts).
2750
+ el.dataset['sbShape'] = 'path';
2751
+ applyPathShape(el, props);
2752
+ return;
2753
+ }
2754
+ const shape = props.shape in SHAPE_RADIUS ? (props.shape as keyof typeof SHAPE_RADIUS) : 'rectangle';
2344
2755
  const s = el.style;
2345
2756
  el.dataset['sbShape'] = shape;
2346
2757
  s.borderRadius = SHAPE_RADIUS[shape](Number(props.cornerRadius) || 0);
@@ -2351,9 +2762,52 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
2351
2762
  if (stroke) {
2352
2763
  const width = Number.isFinite(stroke.lineWidth) ? Math.max(0, stroke.lineWidth) : 1;
2353
2764
  const color = stroke.color ? styleToCSS(stroke.color, 'paint').solid : 'currentColor';
2354
- s.border = `${width}px solid ${color}`;
2765
+ // A dash pattern is drawn as CSS dashes (their lengths are the browser's).
2766
+ s.border = `${width}px ${stroke.dash?.length ? 'dashed' : 'solid'} ${color}`;
2355
2767
  el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
2356
2768
  }
2769
+ // A fill with a drop shadow (`.shadow(.drop(…))` on the style).
2770
+ const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
2771
+ s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
2772
+ const mask = shapeTrimMask(props.trim);
2773
+ if (mask) {
2774
+ s.setProperty('mask-image', mask);
2775
+ s.setProperty('-webkit-mask-image', mask);
2776
+ } else if (el.dataset['sbTrim']) {
2777
+ s.removeProperty('mask-image');
2778
+ s.removeProperty('-webkit-mask-image');
2779
+ }
2780
+ if (mask) el.dataset['sbTrim'] = `${props.trim!.from},${props.trim!.to}`;
2781
+ else delete el.dataset['sbTrim'];
2782
+ }
2783
+
2784
+ /** A style's drop shadow (`ShadowStyle.drop`). */
2785
+ interface ShapeShadow {
2786
+ color?: Color;
2787
+ radius?: number;
2788
+ x?: number;
2789
+ y?: number;
2790
+ }
2791
+
2792
+ function shapeShadowCSS(shadow: ShapeShadow): string {
2793
+ const n = (v: number | undefined) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
2794
+ const color = shadow.color ? colorToCSS(shadow.color) : 'rgba(0, 0, 0, 0.33)';
2795
+ return `drop-shadow(${n(shadow.x)}px ${n(shadow.y)}px ${Math.max(0, n(shadow.radius))}px ${color})`;
2796
+ }
2797
+
2798
+ /**
2799
+ * `.trim(from:to:)`: a conic gradient opaque between the fractions, starting
2800
+ * at 3 o'clock (CSS's 90deg) and turning clockwise like SwiftUI's circle path.
2801
+ * Undefined for an untrimmed (or fully kept) shape.
2802
+ */
2803
+ export function shapeTrimMask(trim: ShapeProps['trim']): string | undefined {
2804
+ if (!trim) return undefined;
2805
+ const from = Math.min(1, Math.max(0, Number(trim.from) || 0));
2806
+ const to = Math.min(1, Math.max(0, Number(trim.to) || 0));
2807
+ if (from <= 0 && to >= 1) return undefined;
2808
+ const a = Math.round(from * 36000) / 100;
2809
+ const b = Math.round(Math.max(from, to) * 36000) / 100;
2810
+ return `conic-gradient(from 90deg, transparent ${a}deg, #000 ${a}deg, #000 ${b}deg, transparent ${b}deg)`;
2357
2811
  }
2358
2812
 
2359
2813
  /** Paint an area with a resolved ShapeStyle: a color, a gradient image or a material over a blurred backdrop. */
@@ -2417,6 +2871,50 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
2417
2871
  el.setAttribute('aria-label', name);
2418
2872
  delete el.dataset['sbMissing'];
2419
2873
  };
2874
+ // `.interpolation(.none)`: scaled without smoothing (styles.css).
2875
+ if ((props as { interpolation?: string }).interpolation === 'none') el.dataset['sbInterpolation'] = 'none';
2876
+ else delete el.dataset['sbInterpolation'];
2877
+ if (isBitmapImage(props)) {
2878
+ // A `UIImage` bitmap: a canvas the recipe is drawn on once the layout
2879
+ // engine has sized the box (src/bitmaps.ts).
2880
+ delete el.dataset['sbSymbol'];
2881
+ delete el.dataset['sbAsset'];
2882
+ delete el.dataset['sbUrl'];
2883
+ if (props.resizable) el.dataset['sbResizable'] = '';
2884
+ else delete el.dataset['sbResizable'];
2885
+ let canvas = el.firstElementChild;
2886
+ if (!(canvas instanceof HTMLCanvasElement)) {
2887
+ canvas = document.createElement('canvas');
2888
+ found('', canvas);
2889
+ el.removeAttribute('aria-label');
2890
+ }
2891
+ showBitmap(canvas as HTMLCanvasElement, props.bitmap);
2892
+ return;
2893
+ }
2894
+ if (isUrlImage(props)) {
2895
+ // Phase 13: the page decoded this URL for `AsyncImage` already, so the
2896
+ // <img> shows at once; the layout engine sized the box from the props.
2897
+ delete el.dataset['sbSymbol'];
2898
+ delete el.dataset['sbAsset'];
2899
+ el.dataset['sbUrl'] = props.url;
2900
+ if (props.resizable) el.dataset['sbResizable'] = '';
2901
+ else delete el.dataset['sbResizable'];
2902
+ const existing = el.firstElementChild;
2903
+ if (existing instanceof HTMLImageElement) {
2904
+ if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
2905
+ swapImageSource(existing, props.url);
2906
+ }
2907
+ return;
2908
+ }
2909
+ const img = document.createElement('img');
2910
+ img.src = props.url;
2911
+ img.alt = '';
2912
+ img.draggable = false;
2913
+ img.decoding = 'async';
2914
+ found(props.url, img);
2915
+ return;
2916
+ }
2917
+ delete el.dataset['sbUrl'];
2420
2918
  if (isAssetImage(props)) {
2421
2919
  const name = props.name ?? '';
2422
2920
  delete el.dataset['sbSymbol'];
@@ -2499,8 +2997,10 @@ function applySection(el: HTMLElement, props: SectionProps): void {
2499
2997
  }
2500
2998
  }
2501
2999
 
2502
- function applyTextField(input: HTMLInputElement, props: TextFieldProps): void {
2503
- input.type = props.secure ? 'password' : 'text';
3000
+ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
3001
+ // A vertical field is a `<textarea>`, which has no type.
3002
+ if (input instanceof HTMLInputElement) input.type = props.secure ? 'password' : 'text';
3003
+ else input.dataset['sbAxis'] = 'vertical';
2504
3004
  input.placeholder = props.placeholder ?? '';
2505
3005
  input.dataset['sbStyle'] = props.style ?? 'plain';
2506
3006
  const text = props.text ?? '';
@@ -2552,7 +3052,9 @@ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
2552
3052
  el.dataset['sbDisplay'] = large ? 'large' : 'inline';
2553
3053
  const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
2554
3054
  const largeTitle = el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
2555
- if (inlineTitle) inlineTitle.textContent = large ? '' : title;
3055
+ // A large title that scrolls away collapses into the inline one (shown
3056
+ // with `data-sb-collapsed`, src/largeTitle.ts).
3057
+ if (inlineTitle) inlineTitle.textContent = title;
2556
3058
  if (largeTitle) {
2557
3059
  largeTitle.textContent = large ? title : '';
2558
3060
  largeTitle.hidden = !large;
@@ -2736,6 +3238,15 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2736
3238
  }
2737
3239
  }
2738
3240
 
3241
+ // View `.underline()` / `.strikethrough()` / `.italic()`: inherited by the
3242
+ // texts below (`.sb-text` reads the decoration properties; `initial` turns
3243
+ // an ancestor's off).
3244
+ if (style.underline != null) s.setProperty('--sb-text-underline', style.underline ? 'underline' : 'initial');
3245
+ if (style.strikethrough != null) {
3246
+ s.setProperty('--sb-text-strikethrough', style.strikethrough ? 'line-through' : 'initial');
3247
+ }
3248
+ if (style.italic != null) s.fontStyle = style.italic ? 'italic' : 'normal';
3249
+
2739
3250
  if (style.background) {
2740
3251
  applyPaint(el, styleToCSS(style.background, 'paint'));
2741
3252
  // A painted background is a view of its own: hittable (styles.css).
@@ -2760,6 +3271,9 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2760
3271
  if (style.cornerRadius != null) {
2761
3272
  s.borderRadius = `${style.cornerRadius}px`;
2762
3273
  s.overflow = 'hidden';
3274
+ } else if (style.backgroundCornerRadius != null) {
3275
+ // The background in its container's shape, without clipping the content.
3276
+ s.borderRadius = `${style.backgroundCornerRadius}px`;
2763
3277
  }
2764
3278
 
2765
3279
  // Phase 6 visual effects: CSS only, the layout engine never sees them.
@@ -2767,6 +3281,11 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2767
3281
  if (effects.transform) s.transform = effects.transform;
2768
3282
  if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
2769
3283
  if (effects.filter) s.filter = effects.filter;
3284
+ // `.colorMultiply`: an SVG filter, first so the other filters see its colors.
3285
+ if (style.colorMultiply) {
3286
+ const multiply = colorMultiplyFilter(style.colorMultiply, el);
3287
+ s.filter = effects.filter ? `${multiply} ${effects.filter}` : multiply;
3288
+ }
2770
3289
  // Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
2771
3290
  // nothing and takes no input; `.mask { }` clips the box to a style's alpha.
2772
3291
  if (effects.hidden) {
@@ -2794,16 +3313,27 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2794
3313
  let opacity = style.opacity;
2795
3314
  if (style.disabled) {
2796
3315
  // `.disabled(true)`: dim the subtree and block input.
3316
+ // The undimmed opacity stays available to styles.css, which draws a
3317
+ // disabled bordered button gray instead of faded.
3318
+ s.setProperty('--sb-undimmed-opacity', String(opacity ?? 1));
2797
3319
  opacity = (opacity ?? 1) * 0.4;
2798
3320
  s.pointerEvents = 'none';
2799
3321
  el.dataset['sbDisabled'] = '';
2800
3322
  el.setAttribute('aria-disabled', 'true');
2801
3323
  } else {
3324
+ s.removeProperty('--sb-undimmed-opacity');
2802
3325
  el.removeAttribute('aria-disabled');
2803
3326
  }
2804
3327
  if (opacity != null) {
2805
3328
  s.opacity = String(opacity);
2806
3329
  }
3330
+ // `.blendMode`: blends the box with what is behind it.
3331
+ if (style.blendMode) s.mixBlendMode = style.blendMode;
3332
+ else if (s.mixBlendMode) s.mixBlendMode = '';
3333
+ // `.zIndex`: in front of siblings with a lower index (boxes are positioned).
3334
+ // CSS takes integers; ×1000 keeps fractional indices (0.5) apart.
3335
+ if (style.zIndex != null && Number.isFinite(style.zIndex)) s.zIndex = String(Math.round(style.zIndex * 1000));
3336
+ else if (s.zIndex) s.zIndex = '';
2807
3337
 
2808
3338
  // Phase 3 text and layout hints (the engine reads them from the node tree).
2809
3339
  if (style.layoutPriority != null && Number.isFinite(style.layoutPriority)) {
@@ -2825,13 +3355,18 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2825
3355
  el.dataset['sbAspectRatio'] = `${ratio ?? 'auto'} ${contentMode ?? 'fit'}`;
2826
3356
  }
2827
3357
  if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
3358
+ // A `Label`'s icon: tinted in list rows (styles.css).
3359
+ if (style.labelIcon) el.dataset['sbLabelIcon'] = '';
2828
3360
  if (style.animation) ANIMATIONS.set(el, style.animation);
2829
3361
  if (style.transition) setTransition(el, style.transition);
2830
3362
 
2831
3363
  // Phase 6 (docs/phase6/layout-containers.md).
2832
3364
  if (style.tint) {
2833
- // `.tint`: gauges, switches and prominent buttons below read `--sb-tint`.
2834
- s.setProperty('--sb-tint', colorToCSS(style.tint));
3365
+ // `.tint`: gauges, switches and prominent buttons below read `--sb-tint`;
3366
+ // a bordered button fills with the tint, faintly (`--sb-tint-fill`).
3367
+ const tint = colorToCSS(style.tint);
3368
+ s.setProperty('--sb-tint', tint);
3369
+ s.setProperty('--sb-tint-fill', `color-mix(in srgb, ${tint} var(--sb-tint-fill-amount), transparent)`);
2835
3370
  }
2836
3371
  if (style.position) {
2837
3372
  const { x, y } = style.position;
@@ -2858,6 +3393,7 @@ export { colorToCSS, styleToCSS } from './styles';
2858
3393
  export function assetColorsCSS(catalog: AssetCatalog): string {
2859
3394
  const names = Object.keys(catalog.colors).sort();
2860
3395
  if (names.length === 0) return '';
3396
+ const accent = catalog.colors[ACCENT_COLOR_NAME];
2861
3397
  const declarations = (scheme: 'light' | 'dark'): string =>
2862
3398
  names
2863
3399
  .map((name) => {
@@ -2866,13 +3402,19 @@ export function assetColorsCSS(catalog: AssetCatalog): string {
2866
3402
  const value = scheme === 'dark' ? (color.dark ?? color.light) : color.light;
2867
3403
  return ` ${assetColorVar(name)}: ${sanitizeColor(value)};\n`;
2868
3404
  })
2869
- .join('');
3405
+ .join('') +
3406
+ // The app's accent color, as Xcode's global accent setting (`AccentColor`
3407
+ // in its templates) makes it on iOS: buttons, links, toggles, selection.
3408
+ (accent ? ` --sb-color-accent: var(${assetColorVar(ACCENT_COLOR_NAME)});\n` : '');
2870
3409
  return (
2871
3410
  `.device[data-theme="light"] {\n${declarations('light')}}\n` +
2872
3411
  `.device[data-theme="dark"] {\n${declarations('dark')}}\n`
2873
3412
  );
2874
3413
  }
2875
3414
 
3415
+ /** The catalog color that is the app's accent (Xcode's `ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME` default). */
3416
+ const ACCENT_COLOR_NAME = 'AccentColor';
3417
+
2876
3418
  /** A manifest color value is injected into a stylesheet: keep only CSS color syntax characters. */
2877
3419
  function sanitizeColor(value: string): string {
2878
3420
  return /^[A-Za-z0-9#(),.%\s/-]+$/.test(value) ? value : 'magenta';
@@ -2880,3 +3422,20 @@ function sanitizeColor(value: string): string {
2880
3422
 
2881
3423
  // Re-exported so callers can build typed fixtures without importing protocol.ts.
2882
3424
  export type { Op, ElementKind, ElementPropsByKind, RendererEvent };
3425
+
3426
+ /**
3427
+ * Whether a `lazyspacer` is within one screen of the visible area of the
3428
+ * scroll view (or list) it is in, along its axis; the page's viewport when it
3429
+ * is in none. Spacers in hidden tabs, menus or candidates are not.
3430
+ */
3431
+ function lazySpacerIsNear(el: HTMLElement): boolean {
3432
+ if (el.closest('[data-sb-tab-hidden], [hidden]')) return false;
3433
+ const rect = el.getBoundingClientRect();
3434
+ const scroller = el.closest<HTMLElement>('.sb-scrollview, .sb-list');
3435
+ const view = scroller ? scroller.getBoundingClientRect() : new DOMRect(0, 0, window.innerWidth, window.innerHeight);
3436
+ if (view.width <= 0 || view.height <= 0) return false;
3437
+ if (el.dataset['axis'] === 'horizontal') {
3438
+ return rect.left < view.right + view.width && rect.right > view.left - view.width;
3439
+ }
3440
+ return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
3441
+ }