@swiftbrowser/web 0.1.0 → 0.2.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 (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  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 +11 -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 +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. 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,
@@ -125,8 +138,13 @@ import {
125
138
  timeInputValue,
126
139
  } from './layout';
127
140
  import { 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,6 +216,14 @@ 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. */
@@ -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) => {
@@ -1896,6 +2210,14 @@ function refreshTabBar(tabview: HTMLElement): void {
1896
2210
  }
1897
2211
  const label = item.querySelector<HTMLElement>(':scope > .sb-tabbar-title');
1898
2212
  if (label && label.textContent !== title) label.textContent = title;
2213
+ // `Tab(…).badge(…)` and `Tab(role: .search)` (the iOS 18 tab API).
2214
+ const badge = item.querySelector<HTMLElement>(':scope > .sb-tabbar-badge');
2215
+ const badgeText = tab.dataset['sbBadge'] ?? '';
2216
+ if (badge && badge.textContent !== badgeText) badge.textContent = badgeText;
2217
+ if (badge) badge.hidden = badgeText === '';
2218
+ const role = tab.dataset['sbRole'];
2219
+ if (role) item.dataset['sbRole'] = role;
2220
+ else delete item.dataset['sbRole'];
1899
2221
  setTabHidden(tab, !isSelected);
1900
2222
  });
1901
2223
  }
@@ -1954,7 +2276,9 @@ function animateSheetOut(container: HTMLElement, sheet: HTMLElement): void {
1954
2276
  function applySheet(el: HTMLElement, props: SheetProps): void {
1955
2277
  const detents = Array.isArray(props.detents) && props.detents.length ? props.detents : ['large'];
1956
2278
  // 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';
2279
+ // A custom height (`height:` / `fraction:`) leaves the presenting tree unscaled, like `medium`.
2280
+ const custom = typeof detents[0] === 'string' && /^(height|fraction):/.test(detents[0]);
2281
+ const first = detents[0] === 'medium' || custom ? 'medium' : detents[0] === 'full' ? 'full' : 'large';
1958
2282
  el.dataset['sbDetent'] = first;
1959
2283
  el.dataset['sbDetents'] = detents.join(' ');
1960
2284
  }
@@ -2077,6 +2401,9 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
2077
2401
  function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): void {
2078
2402
  const text = props.text ?? '';
2079
2403
  if (area.value !== text) area.value = text;
2404
+ // Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
2405
+ if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
2406
+ else delete area.dataset['sbFocus'];
2080
2407
  }
2081
2408
 
2082
2409
  // ---------------------------------------------------------------------------
@@ -2086,7 +2413,7 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
2086
2413
  function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2087
2414
  switch (kind) {
2088
2415
  case 'text':
2089
- applyText(el, props as TextProps);
2416
+ applyText(el, props as TextProps, assets);
2090
2417
  break;
2091
2418
  case 'vstack':
2092
2419
  applyVStack(el, props as VStackProps);
@@ -2111,11 +2438,14 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2111
2438
  case 'image':
2112
2439
  applyImage(el, props as ImageProps, assets);
2113
2440
  break;
2441
+ case 'tapgesture':
2442
+ // `location`: the tap is sent with its point (`onTapGesture { point in }`).
2443
+ el.toggleAttribute('data-sb-tap-location', (props as TapGestureProps | undefined)?.location === true);
2444
+ break;
2114
2445
  case 'divider':
2115
2446
  case 'navstack':
2116
2447
  case 'geometry':
2117
2448
  case 'menu':
2118
- case 'tapgesture':
2119
2449
  case 'navsplit':
2120
2450
  // No props.
2121
2451
  break;
@@ -2143,6 +2473,9 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2143
2473
  // Phase 8: edit mode (the engine shifts `listrow` content) and pull to refresh.
2144
2474
  el.toggleAttribute('data-sb-editing', list.editing === true);
2145
2475
  applyRefreshable(el, list.refreshable === true, list.refreshing === true);
2476
+ // `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
2477
+ if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
2478
+ if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2146
2479
  break;
2147
2480
  }
2148
2481
  case 'tabview': {
@@ -2151,9 +2484,13 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2151
2484
  break;
2152
2485
  }
2153
2486
  case 'tab': {
2154
- const { title, systemImage } = props as TabProps;
2487
+ const { title, systemImage, badge, role } = props as TabProps;
2155
2488
  el.dataset['sbTitle'] = title ?? '';
2156
2489
  el.dataset['sbSymbol'] = systemImage ?? '';
2490
+ if (badge) el.dataset['sbBadge'] = badge;
2491
+ else delete el.dataset['sbBadge'];
2492
+ if (role) el.dataset['sbRole'] = role;
2493
+ else delete el.dataset['sbRole'];
2157
2494
  el.setAttribute('aria-label', title ?? '');
2158
2495
  break;
2159
2496
  }
@@ -2166,13 +2503,14 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2166
2503
  applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
2167
2504
  if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
2168
2505
  else delete el.dataset['sbSnap'];
2506
+ if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
2169
2507
  break;
2170
2508
  }
2171
2509
  case 'section':
2172
2510
  applySection(el, props as SectionProps);
2173
2511
  break;
2174
2512
  case 'textfield':
2175
- applyTextField(el as HTMLInputElement, props as TextFieldProps);
2513
+ applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
2176
2514
  break;
2177
2515
  case 'toggle':
2178
2516
  setSwitchState(el, Boolean((props as ToggleProps).isOn));
@@ -2275,6 +2613,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2275
2613
  case 'texteditor':
2276
2614
  applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
2277
2615
  break;
2616
+ case 'sectionheader':
2617
+ case 'sectionfooter':
2618
+ // Pure containers: the list layout positions them.
2619
+ break;
2620
+ case 'video':
2621
+ applyVideo(el, props as VideoProps);
2622
+ break;
2623
+ case 'canvas':
2624
+ applyCanvas(el, props as CanvasProps, assets);
2625
+ break;
2626
+ case 'lazyspacer':
2627
+ el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
2628
+ break;
2278
2629
  default: {
2279
2630
  const unknown: never = kind;
2280
2631
  throw new RendererError(`Unknown element kind ${String(unknown)}`);
@@ -2282,8 +2633,59 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
2282
2633
  }
2283
2634
  }
2284
2635
 
2285
- function applyText(el: HTMLElement, props: TextProps): void {
2286
- el.textContent = props.text ?? '';
2636
+ function applyText(el: HTMLElement, props: TextProps, assets: AssetCatalog): void {
2637
+ if (!Array.isArray(props.runs)) {
2638
+ runTemplates.delete(el);
2639
+ delete el.dataset['sbRich'];
2640
+ clearRichText(el);
2641
+ el.textContent = props.text ?? '';
2642
+ return;
2643
+ }
2644
+ // Rich text: a template per run; the layout pass draws the lines (src/richText.ts).
2645
+ el.dataset['sbRich'] = '';
2646
+ runTemplates.set(el, props.runs.map((run) => runTemplate(run, assets)));
2647
+ }
2648
+
2649
+ /** A rich text run's span (or inline image element) with its style; the layout pass sets the font. */
2650
+ function runTemplate(run: TextRun, assets: AssetCatalog): HTMLElement {
2651
+ let el: HTMLElement;
2652
+ if (run.image) {
2653
+ el = createShell('image');
2654
+ el.className = 'sb-image sb-text-image';
2655
+ applyImage(el, run.image, assets);
2656
+ // A symbol is a glyph: centered on the line like text, not stood on the baseline.
2657
+ if (!isBitmapImage(run.image) && !isUrlImage(run.image)) el.classList.add('sb-text-symbol');
2658
+ } else {
2659
+ el = document.createElement('span');
2660
+ el.className = 'sb-text-run';
2661
+ }
2662
+ const s = el.style;
2663
+ if (run.foreground !== undefined) s.color = styleToCSS(run.foreground as ShapeStyle, 'text').solid;
2664
+ const lines: string[] = [];
2665
+ let lineColor: unknown;
2666
+ if (run.underline) {
2667
+ lines.push('underline');
2668
+ lineColor = run.underline.color;
2669
+ }
2670
+ if (run.strikethrough) {
2671
+ lines.push('line-through');
2672
+ lineColor ??= run.strikethrough.color;
2673
+ }
2674
+ if (lines.length > 0) {
2675
+ s.textDecorationLine = lines.join(' ');
2676
+ if (lineColor !== undefined) s.textDecorationColor = styleToCSS(lineColor as ShapeStyle, 'text').solid;
2677
+ }
2678
+ if (typeof run.kerning === 'number' && run.kerning !== 0) s.letterSpacing = `${run.kerning}px`;
2679
+ if (typeof run.baselineOffset === 'number' && run.baselineOffset !== 0) {
2680
+ s.position = 'relative';
2681
+ s.top = `${-run.baselineOffset}px`;
2682
+ }
2683
+ if (typeof run.link === 'string') {
2684
+ el.classList.add('sb-link');
2685
+ el.dataset['sbLink'] = run.link;
2686
+ el.setAttribute('role', 'link');
2687
+ }
2688
+ return el;
2287
2689
  }
2288
2690
 
2289
2691
  /**
@@ -2313,6 +2715,8 @@ function applyGauge(el: HTMLElement, props: GaugeProps): void {
2313
2715
  el.dataset['sbStyle'] = props.style ?? 'automatic';
2314
2716
  el.dataset['sbValue'] = String(value);
2315
2717
  el.setAttribute('aria-valuenow', String(value));
2718
+ const ring = el.querySelector<HTMLElement>(':scope > .sb-gauge-ring');
2719
+ if (ring && isCircularGauge(props.style)) updateGaugeRing(ring, props.style, value);
2316
2720
  }
2317
2721
 
2318
2722
  function applyColor(el: HTMLElement, props: ColorProps): void {
@@ -2320,7 +2724,7 @@ function applyColor(el: HTMLElement, props: ColorProps): void {
2320
2724
  }
2321
2725
 
2322
2726
  /** Radius that turns a box into each shape (the layout engine sizes it). */
2323
- const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string> = {
2727
+ const SHAPE_RADIUS: Record<Exclude<ShapeProps['shape'], 'path'>, (cornerRadius: number) => string> = {
2324
2728
  rectangle: () => '0px',
2325
2729
  roundedRectangle: (r) => `${Math.max(0, r)}px`,
2326
2730
  circle: () => '50%',
@@ -2340,7 +2744,13 @@ const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string
2340
2744
  * and `data-sb-stroke` records which one it was.
2341
2745
  */
2342
2746
  function applyShape(el: HTMLElement, props: ShapeProps): void {
2343
- const shape = props.shape in SHAPE_RADIUS ? props.shape : 'rectangle';
2747
+ if (props.shape === 'path') {
2748
+ // A custom shape: an SVG of its path (src/pathShape.ts).
2749
+ el.dataset['sbShape'] = 'path';
2750
+ applyPathShape(el, props);
2751
+ return;
2752
+ }
2753
+ const shape = props.shape in SHAPE_RADIUS ? (props.shape as keyof typeof SHAPE_RADIUS) : 'rectangle';
2344
2754
  const s = el.style;
2345
2755
  el.dataset['sbShape'] = shape;
2346
2756
  s.borderRadius = SHAPE_RADIUS[shape](Number(props.cornerRadius) || 0);
@@ -2354,6 +2764,48 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
2354
2764
  s.border = `${width}px solid ${color}`;
2355
2765
  el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
2356
2766
  }
2767
+ // A fill with a drop shadow (`.shadow(.drop(…))` on the style).
2768
+ const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
2769
+ s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
2770
+ const mask = shapeTrimMask(props.trim);
2771
+ if (mask) {
2772
+ s.setProperty('mask-image', mask);
2773
+ s.setProperty('-webkit-mask-image', mask);
2774
+ } else if (el.dataset['sbTrim']) {
2775
+ s.removeProperty('mask-image');
2776
+ s.removeProperty('-webkit-mask-image');
2777
+ }
2778
+ if (mask) el.dataset['sbTrim'] = `${props.trim!.from},${props.trim!.to}`;
2779
+ else delete el.dataset['sbTrim'];
2780
+ }
2781
+
2782
+ /** A style's drop shadow (`ShadowStyle.drop`). */
2783
+ interface ShapeShadow {
2784
+ color?: Color;
2785
+ radius?: number;
2786
+ x?: number;
2787
+ y?: number;
2788
+ }
2789
+
2790
+ function shapeShadowCSS(shadow: ShapeShadow): string {
2791
+ const n = (v: number | undefined) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
2792
+ const color = shadow.color ? colorToCSS(shadow.color) : 'rgba(0, 0, 0, 0.33)';
2793
+ return `drop-shadow(${n(shadow.x)}px ${n(shadow.y)}px ${Math.max(0, n(shadow.radius))}px ${color})`;
2794
+ }
2795
+
2796
+ /**
2797
+ * `.trim(from:to:)`: a conic gradient opaque between the fractions, starting
2798
+ * at 3 o'clock (CSS's 90deg) and turning clockwise like SwiftUI's circle path.
2799
+ * Undefined for an untrimmed (or fully kept) shape.
2800
+ */
2801
+ export function shapeTrimMask(trim: ShapeProps['trim']): string | undefined {
2802
+ if (!trim) return undefined;
2803
+ const from = Math.min(1, Math.max(0, Number(trim.from) || 0));
2804
+ const to = Math.min(1, Math.max(0, Number(trim.to) || 0));
2805
+ if (from <= 0 && to >= 1) return undefined;
2806
+ const a = Math.round(from * 36000) / 100;
2807
+ const b = Math.round(Math.max(from, to) * 36000) / 100;
2808
+ return `conic-gradient(from 90deg, transparent ${a}deg, #000 ${a}deg, #000 ${b}deg, transparent ${b}deg)`;
2357
2809
  }
2358
2810
 
2359
2811
  /** Paint an area with a resolved ShapeStyle: a color, a gradient image or a material over a blurred backdrop. */
@@ -2417,6 +2869,50 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
2417
2869
  el.setAttribute('aria-label', name);
2418
2870
  delete el.dataset['sbMissing'];
2419
2871
  };
2872
+ // `.interpolation(.none)`: scaled without smoothing (styles.css).
2873
+ if ((props as { interpolation?: string }).interpolation === 'none') el.dataset['sbInterpolation'] = 'none';
2874
+ else delete el.dataset['sbInterpolation'];
2875
+ if (isBitmapImage(props)) {
2876
+ // A `UIImage` bitmap: a canvas the recipe is drawn on once the layout
2877
+ // engine has sized the box (src/bitmaps.ts).
2878
+ delete el.dataset['sbSymbol'];
2879
+ delete el.dataset['sbAsset'];
2880
+ delete el.dataset['sbUrl'];
2881
+ if (props.resizable) el.dataset['sbResizable'] = '';
2882
+ else delete el.dataset['sbResizable'];
2883
+ let canvas = el.firstElementChild;
2884
+ if (!(canvas instanceof HTMLCanvasElement)) {
2885
+ canvas = document.createElement('canvas');
2886
+ found('', canvas);
2887
+ el.removeAttribute('aria-label');
2888
+ }
2889
+ showBitmap(canvas as HTMLCanvasElement, props.bitmap);
2890
+ return;
2891
+ }
2892
+ if (isUrlImage(props)) {
2893
+ // Phase 13: the page decoded this URL for `AsyncImage` already, so the
2894
+ // <img> shows at once; the layout engine sized the box from the props.
2895
+ delete el.dataset['sbSymbol'];
2896
+ delete el.dataset['sbAsset'];
2897
+ el.dataset['sbUrl'] = props.url;
2898
+ if (props.resizable) el.dataset['sbResizable'] = '';
2899
+ else delete el.dataset['sbResizable'];
2900
+ const existing = el.firstElementChild;
2901
+ if (existing instanceof HTMLImageElement) {
2902
+ if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
2903
+ swapImageSource(existing, props.url);
2904
+ }
2905
+ return;
2906
+ }
2907
+ const img = document.createElement('img');
2908
+ img.src = props.url;
2909
+ img.alt = '';
2910
+ img.draggable = false;
2911
+ img.decoding = 'async';
2912
+ found(props.url, img);
2913
+ return;
2914
+ }
2915
+ delete el.dataset['sbUrl'];
2420
2916
  if (isAssetImage(props)) {
2421
2917
  const name = props.name ?? '';
2422
2918
  delete el.dataset['sbSymbol'];
@@ -2499,8 +2995,10 @@ function applySection(el: HTMLElement, props: SectionProps): void {
2499
2995
  }
2500
2996
  }
2501
2997
 
2502
- function applyTextField(input: HTMLInputElement, props: TextFieldProps): void {
2503
- input.type = props.secure ? 'password' : 'text';
2998
+ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
2999
+ // A vertical field is a `<textarea>`, which has no type.
3000
+ if (input instanceof HTMLInputElement) input.type = props.secure ? 'password' : 'text';
3001
+ else input.dataset['sbAxis'] = 'vertical';
2504
3002
  input.placeholder = props.placeholder ?? '';
2505
3003
  input.dataset['sbStyle'] = props.style ?? 'plain';
2506
3004
  const text = props.text ?? '';
@@ -2736,6 +3234,15 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2736
3234
  }
2737
3235
  }
2738
3236
 
3237
+ // View `.underline()` / `.strikethrough()` / `.italic()`: inherited by the
3238
+ // texts below (`.sb-text` reads the decoration properties; `initial` turns
3239
+ // an ancestor's off).
3240
+ if (style.underline != null) s.setProperty('--sb-text-underline', style.underline ? 'underline' : 'initial');
3241
+ if (style.strikethrough != null) {
3242
+ s.setProperty('--sb-text-strikethrough', style.strikethrough ? 'line-through' : 'initial');
3243
+ }
3244
+ if (style.italic != null) s.fontStyle = style.italic ? 'italic' : 'normal';
3245
+
2739
3246
  if (style.background) {
2740
3247
  applyPaint(el, styleToCSS(style.background, 'paint'));
2741
3248
  // A painted background is a view of its own: hittable (styles.css).
@@ -2760,6 +3267,9 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2760
3267
  if (style.cornerRadius != null) {
2761
3268
  s.borderRadius = `${style.cornerRadius}px`;
2762
3269
  s.overflow = 'hidden';
3270
+ } else if (style.backgroundCornerRadius != null) {
3271
+ // The background in its container's shape, without clipping the content.
3272
+ s.borderRadius = `${style.backgroundCornerRadius}px`;
2763
3273
  }
2764
3274
 
2765
3275
  // Phase 6 visual effects: CSS only, the layout engine never sees them.
@@ -2767,6 +3277,11 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2767
3277
  if (effects.transform) s.transform = effects.transform;
2768
3278
  if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
2769
3279
  if (effects.filter) s.filter = effects.filter;
3280
+ // `.colorMultiply`: an SVG filter, first so the other filters see its colors.
3281
+ if (style.colorMultiply) {
3282
+ const multiply = colorMultiplyFilter(style.colorMultiply, el);
3283
+ s.filter = effects.filter ? `${multiply} ${effects.filter}` : multiply;
3284
+ }
2770
3285
  // Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
2771
3286
  // nothing and takes no input; `.mask { }` clips the box to a style's alpha.
2772
3287
  if (effects.hidden) {
@@ -2804,6 +3319,13 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2804
3319
  if (opacity != null) {
2805
3320
  s.opacity = String(opacity);
2806
3321
  }
3322
+ // `.blendMode`: blends the box with what is behind it.
3323
+ if (style.blendMode) s.mixBlendMode = style.blendMode;
3324
+ else if (s.mixBlendMode) s.mixBlendMode = '';
3325
+ // `.zIndex`: in front of siblings with a lower index (boxes are positioned).
3326
+ // CSS takes integers; ×1000 keeps fractional indices (0.5) apart.
3327
+ if (style.zIndex != null && Number.isFinite(style.zIndex)) s.zIndex = String(Math.round(style.zIndex * 1000));
3328
+ else if (s.zIndex) s.zIndex = '';
2807
3329
 
2808
3330
  // Phase 3 text and layout hints (the engine reads them from the node tree).
2809
3331
  if (style.layoutPriority != null && Number.isFinite(style.layoutPriority)) {
@@ -2825,6 +3347,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
2825
3347
  el.dataset['sbAspectRatio'] = `${ratio ?? 'auto'} ${contentMode ?? 'fit'}`;
2826
3348
  }
2827
3349
  if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
3350
+ // A `Label`'s icon: tinted in list rows (styles.css).
3351
+ if (style.labelIcon) el.dataset['sbLabelIcon'] = '';
2828
3352
  if (style.animation) ANIMATIONS.set(el, style.animation);
2829
3353
  if (style.transition) setTransition(el, style.transition);
2830
3354
 
@@ -2858,6 +3382,7 @@ export { colorToCSS, styleToCSS } from './styles';
2858
3382
  export function assetColorsCSS(catalog: AssetCatalog): string {
2859
3383
  const names = Object.keys(catalog.colors).sort();
2860
3384
  if (names.length === 0) return '';
3385
+ const accent = catalog.colors[ACCENT_COLOR_NAME];
2861
3386
  const declarations = (scheme: 'light' | 'dark'): string =>
2862
3387
  names
2863
3388
  .map((name) => {
@@ -2866,13 +3391,19 @@ export function assetColorsCSS(catalog: AssetCatalog): string {
2866
3391
  const value = scheme === 'dark' ? (color.dark ?? color.light) : color.light;
2867
3392
  return ` ${assetColorVar(name)}: ${sanitizeColor(value)};\n`;
2868
3393
  })
2869
- .join('');
3394
+ .join('') +
3395
+ // The app's accent color, as Xcode's global accent setting (`AccentColor`
3396
+ // in its templates) makes it on iOS: buttons, links, toggles, selection.
3397
+ (accent ? ` --sb-color-accent: var(${assetColorVar(ACCENT_COLOR_NAME)});\n` : '');
2870
3398
  return (
2871
3399
  `.device[data-theme="light"] {\n${declarations('light')}}\n` +
2872
3400
  `.device[data-theme="dark"] {\n${declarations('dark')}}\n`
2873
3401
  );
2874
3402
  }
2875
3403
 
3404
+ /** The catalog color that is the app's accent (Xcode's `ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME` default). */
3405
+ const ACCENT_COLOR_NAME = 'AccentColor';
3406
+
2876
3407
  /** A manifest color value is injected into a stylesheet: keep only CSS color syntax characters. */
2877
3408
  function sanitizeColor(value: string): string {
2878
3409
  return /^[A-Za-z0-9#(),.%\s/-]+$/.test(value) ? value : 'magenta';
@@ -2880,3 +3411,20 @@ function sanitizeColor(value: string): string {
2880
3411
 
2881
3412
  // Re-exported so callers can build typed fixtures without importing protocol.ts.
2882
3413
  export type { Op, ElementKind, ElementPropsByKind, RendererEvent };
3414
+
3415
+ /**
3416
+ * Whether a `lazyspacer` is within one screen of the visible area of the
3417
+ * scroll view (or list) it is in, along its axis; the page's viewport when it
3418
+ * is in none. Spacers in hidden tabs, menus or candidates are not.
3419
+ */
3420
+ function lazySpacerIsNear(el: HTMLElement): boolean {
3421
+ if (el.closest('[data-sb-tab-hidden], [hidden]')) return false;
3422
+ const rect = el.getBoundingClientRect();
3423
+ const scroller = el.closest<HTMLElement>('.sb-scrollview, .sb-list');
3424
+ const view = scroller ? scroller.getBoundingClientRect() : new DOMRect(0, 0, window.innerWidth, window.innerHeight);
3425
+ if (view.width <= 0 || view.height <= 0) return false;
3426
+ if (el.dataset['axis'] === 'horizontal') {
3427
+ return rect.left < view.right + view.width && rect.right > view.left - view.width;
3428
+ }
3429
+ return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
3430
+ }