@swiftbrowser/web 0.1.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 (84) hide show
  1. package/README.md +600 -0
  2. package/app/index.html +125 -0
  3. package/index.html +56 -0
  4. package/node/dist/node/apps.d.ts +58 -0
  5. package/node/dist/node/apps.d.ts.map +1 -0
  6. package/node/dist/node/apps.js +60 -0
  7. package/node/dist/node/apps.js.map +1 -0
  8. package/node/dist/node/config.d.ts +42 -0
  9. package/node/dist/node/config.d.ts.map +1 -0
  10. package/node/dist/node/config.js +99 -0
  11. package/node/dist/node/config.js.map +1 -0
  12. package/node/dist/node/examples.d.ts +39 -0
  13. package/node/dist/node/examples.d.ts.map +1 -0
  14. package/node/dist/node/examples.js +116 -0
  15. package/node/dist/node/examples.js.map +1 -0
  16. package/node/dist/node/index.d.ts +47 -0
  17. package/node/dist/node/index.d.ts.map +1 -0
  18. package/node/dist/node/index.js +52 -0
  19. package/node/dist/node/index.js.map +1 -0
  20. package/node/dist/plugins/app-manifests.d.ts +61 -0
  21. package/node/dist/plugins/app-manifests.d.ts.map +1 -0
  22. package/node/dist/plugins/app-manifests.js +168 -0
  23. package/node/dist/plugins/app-manifests.js.map +1 -0
  24. package/node/dist/plugins/asset-catalogs.d.ts +119 -0
  25. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
  26. package/node/dist/plugins/asset-catalogs.js +488 -0
  27. package/node/dist/plugins/asset-catalogs.js.map +1 -0
  28. package/node/dist/plugins/bundle-resources.d.ts +49 -0
  29. package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
  30. package/node/dist/plugins/bundle-resources.js +172 -0
  31. package/node/dist/plugins/bundle-resources.js.map +1 -0
  32. package/node/dist/plugins/swift-wasm.d.ts +33 -0
  33. package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
  34. package/node/dist/plugins/swift-wasm.js +241 -0
  35. package/node/dist/plugins/swift-wasm.js.map +1 -0
  36. package/node/dist/src/assets.d.ts +60 -0
  37. package/node/dist/src/assets.d.ts.map +1 -0
  38. package/node/dist/src/assets.js +54 -0
  39. package/node/dist/src/assets.js.map +1 -0
  40. package/node/dist/src/devEvents.d.ts +31 -0
  41. package/node/dist/src/devEvents.d.ts.map +1 -0
  42. package/node/dist/src/devEvents.js +9 -0
  43. package/node/dist/src/devEvents.js.map +1 -0
  44. package/package.json +56 -0
  45. package/plugins/app-manifests.ts +192 -0
  46. package/plugins/asset-catalogs.ts +558 -0
  47. package/plugins/bundle-resources.ts +195 -0
  48. package/plugins/swift-wasm.ts +259 -0
  49. package/public/_headers +23 -0
  50. package/public/apple-touch-icon.png +0 -0
  51. package/public/icon.svg +14 -0
  52. package/public/manifest.webmanifest +14 -0
  53. package/src/animation.ts +521 -0
  54. package/src/assets.ts +86 -0
  55. package/src/chartDom.ts +481 -0
  56. package/src/devEvents.ts +35 -0
  57. package/src/deviceMode.ts +80 -0
  58. package/src/ghost.ts +25 -0
  59. package/src/interaction.ts +1113 -0
  60. package/src/landing.css +261 -0
  61. package/src/landing.ts +141 -0
  62. package/src/layout/chart.ts +477 -0
  63. package/src/layout/controls.ts +116 -0
  64. package/src/layout/engine.ts +3581 -0
  65. package/src/layout/index.ts +91 -0
  66. package/src/layout/measure.ts +86 -0
  67. package/src/layout/text.ts +124 -0
  68. package/src/layout/types.ts +153 -0
  69. package/src/layout/typography.ts +188 -0
  70. package/src/layoutDom.ts +549 -0
  71. package/src/layoutEvents.ts +110 -0
  72. package/src/locale.ts +64 -0
  73. package/src/main.ts +554 -0
  74. package/src/mock.ts +752 -0
  75. package/src/nodeQueries.ts +60 -0
  76. package/src/protocol.ts +1421 -0
  77. package/src/renderer.ts +2882 -0
  78. package/src/styles.css +2834 -0
  79. package/src/styles.ts +373 -0
  80. package/src/swipe.ts +110 -0
  81. package/src/symbols.ts +595 -0
  82. package/src/typography.ts +76 -0
  83. package/src/vite-env.d.ts +26 -0
  84. package/src/wasm.ts +400 -0
@@ -0,0 +1,2882 @@
1
+ /**
2
+ * DOM renderer for the ops protocol (docs/ops-protocol.md).
3
+ *
4
+ * The renderer owns a `Map<ElementId, HTMLElement>` and a mirror of the
5
+ * element tree as `LayoutNode`s; id 0 is the root container supplied by the
6
+ * host page. Every op is applied to the DOM immediately; at `commit` the
7
+ * layout engine (src/layout) computes a frame for every element and
8
+ * `layoutDom.ts` positions the elements absolutely from it (a `sb:commit`
9
+ * CustomEvent is dispatched on the root so tests and tooling can observe pass
10
+ * boundaries). Animated passes are handed to the `Animator`.
11
+ *
12
+ * Most kinds are a single DOM element whose children are the element's
13
+ * children. Compound kinds (`navstack`, `navscreen`, `section`, `toggle`,
14
+ * `navlink`, `tabview`) own some chrome of their own (a navigation bar, a
15
+ * switch, a chevron, a tab bar...) and expose a *slot* element that receives
16
+ * the protocol children, so `insert` indices stay exact.
17
+ *
18
+ * Phase 4: after every layout pass the renderer compares each `geometry`
19
+ * element's frame size with the size it last reported and sends one
20
+ * `geometry` event per changed element (batched after the ops being applied),
21
+ * so Swift's re-render converges as soon as the size is stable.
22
+ *
23
+ * Phase 6 (docs/phase6/chrome.md): `menu` opens the same popover a `menu`
24
+ * picker does, built from its item children when it opens; `toolbar` and
25
+ * `searchfield` are navscreen children the engine places in the bar;
26
+ * `tapgesture` sends a `tap` for a click anywhere on it that no inner
27
+ * control claimed.
28
+ *
29
+ * Phase 7: `navsplit` holds two `navstack` columns with a hairline between
30
+ * them (`.sb-navsplit-separator`, placed from the engine's `separator` rect);
31
+ * a `navlink` with `selected: true` carries `data-sb-selected` (a rounded fill
32
+ * background; the engine drops its chevron inside a split view's sidebar);
33
+ * the candidates a `viewthatfits` did not choose get the `hidden` attribute.
34
+ */
35
+
36
+ import {
37
+ Animator,
38
+ EXIT_CLONE_CLASS,
39
+ emptyPass,
40
+ setTransition,
41
+ transitionOf,
42
+ type PassRecord,
43
+ type UpdatedRecord,
44
+ } from './animation';
45
+ import {
46
+ CanvasTextMeasurer,
47
+ LayoutPass,
48
+ type LayoutEnvironment,
49
+ type LayoutNode,
50
+ type LayoutResult,
51
+ type Rect,
52
+ } from './layout';
53
+ import { LayoutReporter } from './layoutEvents';
54
+ import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
55
+ import { ghostClone } from './ghost';
56
+ import { InteractionController, type InteractionHost } from './interaction';
57
+ import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
58
+ import {
59
+ INTERACTION_KINDS,
60
+ ROOT_ID,
61
+ isAssetImage,
62
+ type Animation,
63
+ type ButtonProps,
64
+ type ChartItemProps,
65
+ type ChartProps,
66
+ type ColorProps,
67
+ type ColorScheme,
68
+ type DecoratedProps,
69
+ type DynamicTypeSize,
70
+ type ElementId,
71
+ type ElementKind,
72
+ type ElementProps,
73
+ type ElementPropsByKind,
74
+ type FontWeight,
75
+ type GaugeProps,
76
+ type GeometryEvent,
77
+ type GestureProps,
78
+ type GridProps,
79
+ type GridRowProps,
80
+ type HStackProps,
81
+ type LayoutEvent,
82
+ type LazyVGridProps,
83
+ type ImageProps,
84
+ type ListProps,
85
+ type ListRowProps,
86
+ type NavLinkProps,
87
+ type NavScreenProps,
88
+ type Op,
89
+ type PickerProps,
90
+ type RendererEvent,
91
+ type ScrollViewProps,
92
+ type SearchFieldProps,
93
+ type SectionProps,
94
+ type ShapeProps,
95
+ type SheetProps,
96
+ type SpacerProps,
97
+ type Style,
98
+ type StyledProps,
99
+ type SwipeActionsProps,
100
+ type TabProps,
101
+ type TabViewProps,
102
+ type TextFieldProps,
103
+ type TextProps,
104
+ type ToggleProps,
105
+ type ToolbarProps,
106
+ type ViewThatFitsProps,
107
+ type VStackProps,
108
+ type ZStackProps,
109
+ // Phase 8: alerts, controls, effects
110
+ type AlertProps,
111
+ type DatePickerProps,
112
+ type SliderProps,
113
+ type StepperProps,
114
+ type TextEditorProps,
115
+ type RootProps,
116
+ } from './protocol';
117
+ import {
118
+ combineDateSeconds,
119
+ dateInputValue,
120
+ formatPickerDate,
121
+ formatPickerTime,
122
+ sameUTCDay,
123
+ sliderFraction,
124
+ snapSliderValue,
125
+ timeInputValue,
126
+ } from './layout';
127
+ import { renderSymbol } from './symbols';
128
+ import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
129
+ import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
130
+ import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
131
+
132
+ export type EventCallback = (event: RendererEvent) => void;
133
+
134
+ export class RendererError extends Error {
135
+ constructor(message: string, readonly op?: Op) {
136
+ super(op ? `${message} (op: ${JSON.stringify(op)})` : message);
137
+ this.name = 'RendererError';
138
+ }
139
+ }
140
+
141
+ const KIND_ATTR = 'sbKind';
142
+ const ID_ATTR = 'sbId';
143
+ /** `animationToken` of a `styled` box, kept across `update` to detect changes. */
144
+ const ANIMATION_TOKEN = 'sbAnimationToken';
145
+
146
+ /** Duration of the navigation push/pop slide; keep in sync with styles.css (`--sb-nav-easing`). */
147
+ export const NAV_TRANSITION_MS = 400;
148
+ /** Duration of the sheet present/dismiss slide; keep in sync with styles.css. */
149
+ export const SHEET_TRANSITION_MS = 400;
150
+ // Phase 8: the sheet drag (and every other pointer gesture) lives in src/interaction.ts.
151
+ export { SHEET_DISMISS_DRAG } from './interaction';
152
+
153
+ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
154
+ 'text',
155
+ 'vstack',
156
+ 'hstack',
157
+ 'spacer',
158
+ 'button',
159
+ 'styled',
160
+ 'zstack',
161
+ 'image',
162
+ 'divider',
163
+ 'scrollview',
164
+ 'list',
165
+ 'section',
166
+ 'textfield',
167
+ 'toggle',
168
+ 'navstack',
169
+ 'navscreen',
170
+ 'navlink',
171
+ 'color',
172
+ 'shape',
173
+ 'sheet',
174
+ 'tabview',
175
+ 'tab',
176
+ 'picker',
177
+ 'geometry',
178
+ 'layout',
179
+ 'grid',
180
+ 'gridrow',
181
+ 'lazyvgrid',
182
+ 'gauge',
183
+ 'decorated',
184
+ 'menu',
185
+ 'toolbar',
186
+ 'searchfield',
187
+ 'tapgesture',
188
+ // Phase 7
189
+ 'navsplit',
190
+ 'viewthatfits',
191
+ 'chart',
192
+ 'chartitem',
193
+ // Phase 8: gestures, rows, navigation
194
+ ...INTERACTION_KINDS,
195
+ // Phase 8: alerts, controls
196
+ 'alert',
197
+ 'stepper',
198
+ 'slider',
199
+ 'datepicker',
200
+ 'texteditor',
201
+ ]);
202
+
203
+ /** Duration of the alert / dialog fade (Phase 8); keep in sync with styles.css. */
204
+ 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;
207
+
208
+ /** Width of the menu a `menu` picker (or a Phase 6 `menu`) opens, like an iOS context menu. */
209
+ export const PICKER_MENU_WIDTH = 250;
210
+ /** Row height inside the picker menu. */
211
+ export const PICKER_MENU_ROW = 44;
212
+ /** Height of a section header caption row in the popover (Phase 6). */
213
+ const MENU_CAPTION_HEIGHT = 32;
214
+ /** Thickness of a group separator in the popover (Phase 6). */
215
+ const MENU_SEPARATOR_HEIGHT = 8;
216
+ /** Gap between the control and its menu. */
217
+ const PICKER_MENU_GAP = 4;
218
+ /** Minimum distance between the menu and the screen edge. */
219
+ const PICKER_MENU_MARGIN = 8;
220
+
221
+ /** Kinds whose enter/exit is animated by their own CSS, not by transitions. */
222
+ const SELF_ANIMATING: ReadonlySet<string> = new Set(['navscreen', 'sheet', 'alert']);
223
+
224
+ /** `styled` animations by element (cleared on `update`). */
225
+ const ANIMATIONS = new WeakMap<HTMLElement, Animation>();
226
+
227
+ /** iPhone 15 Pro logical screen, used when the root is not rendered (tests, hidden tabs). */
228
+ const DEFAULT_SCREEN = { width: 393, height: 852 };
229
+ const DEFAULT_SAFE_AREA = { top: 59, leading: 0, bottom: 34, trailing: 0 };
230
+
231
+ /** The parts of the layout environment the page controls. */
232
+ export interface RendererEnvironment {
233
+ dynamicTypeSize: DynamicTypeSize;
234
+ colorScheme: ColorScheme;
235
+ }
236
+
237
+ export class Renderer {
238
+ /** All live elements by id, including the root (id 0). */
239
+ readonly elements = new Map<ElementId, HTMLElement>();
240
+ /** The element tree as the layout engine reads it; id 0 is the root. */
241
+ readonly nodes = new Map<ElementId, LayoutNode>();
242
+ /** Parent id of every attached element. */
243
+ readonly parents = new Map<ElementId, ElementId>();
244
+ private readonly root: HTMLElement;
245
+ private readonly onEvent: EventCallback;
246
+ /** Animates style changes, frame changes and transitions of animated passes. */
247
+ readonly animator: Animator;
248
+ /** Number of `commit` ops applied so far. */
249
+ commits = 0;
250
+ /** Number of layout passes run so far. */
251
+ layouts = 0;
252
+ /** The most recent layout result (null before the first commit). */
253
+ layoutResult: LayoutResult | null = null;
254
+ /** What the current render pass did so far (handed to the Animator at `commit`). */
255
+ private pass: PassRecord = emptyPass();
256
+ /**
257
+ * Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
258
+ * swipe back, the sheet drag, pull to refresh) and arbitrates between them.
259
+ */
260
+ readonly interaction: InteractionController;
261
+ private readonly measurer = new CanvasTextMeasurer();
262
+ /**
263
+ * Phase 9: the layout pass lives across commits and is told what changed
264
+ * (`update`, `insert`, `remove`), so a commit lays out only the subtrees
265
+ * it touched. Replaced whenever the environment (screen size, type size,
266
+ * color scheme, fonts) changes, which invalidates everything.
267
+ */
268
+ private engine: LayoutPass | null = null;
269
+ private engineEnvKey = '';
270
+ private environment: RendererEnvironment = { dynamicTypeSize: 'large', colorScheme: 'light' };
271
+ /** Phase 5: the app's asset catalog manifest (empty until `setAssetCatalog`). */
272
+ private assets: AssetCatalog = EMPTY_CATALOG;
273
+ /** The `<style>` holding the catalog colors as custom properties (see `setAssetCatalog`). */
274
+ private assetStyle: HTMLStyleElement | null = null;
275
+ /** Safe-area insets; read from the device tokens on first use unless `setSafeArea` set them. */
276
+ private safeArea: LayoutEnvironment['safeArea'] | null = null;
277
+ /** DOM boxes applied by the last layout, by id (see layoutDom.ts). */
278
+ private boxes = new Map<ElementId, Rect>();
279
+ /** Elements whose inline style was reset since the last layout. */
280
+ private readonly dirty = new Set<ElementId>();
281
+ /** The open popover (of a `menu` picker or a `menu` element), if any. */
282
+ private menu: OpenMenu | null = null;
283
+ /** Size last reported for every `geometry` element (whole px). */
284
+ private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
285
+ /** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
286
+ private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
287
+ /** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
288
+ private readonly layoutReporter = new LayoutReporter();
289
+ /** Nesting depth of `applyOps` (events are flushed when it returns to 0). */
290
+ private applying = 0;
291
+
292
+ private readonly onClick = (event: MouseEvent): void => {
293
+ // `closest` picks the innermost control, so a button inside a
294
+ // `tapgesture` (or a menu label) handles the click alone.
295
+ const target = (event.target as HTMLElement | null)?.closest<HTMLElement>(
296
+ '.sb-button, .sb-navlink, .sb-navbar-back, .sb-switch, .sb-sheet-scrim, ' +
297
+ '.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
298
+ '.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
299
+ // 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]',
301
+ );
302
+ if (!target || !this.root.contains(target)) return;
303
+ if (this.interaction.handleClick(target)) {
304
+ event.preventDefault();
305
+ return;
306
+ }
307
+ if (target.classList.contains('sb-stepper-button')) {
308
+ // Phase 8: a half of the −/+ control; a dimmed half (`canDecrement` /
309
+ // `canIncrement` false) swallows the click.
310
+ const stepper = target.closest<HTMLElement>('.sb-stepper');
311
+ const id = Number(stepper?.dataset[ID_ATTR]);
312
+ const value = Number(target.dataset['sbStep']);
313
+ if (!stepper || !Number.isInteger(id) || !Number.isInteger(value)) return;
314
+ event.preventDefault();
315
+ if (target.getAttribute('aria-disabled') === 'true') return;
316
+ this.onEvent({ type: 'step', id, value });
317
+ return;
318
+ }
319
+ if (target.classList.contains('sb-alert-scrim') || target.classList.contains('sb-alert-ok')) {
320
+ // Phase 8: the renderer-drawn OK, or the scrim of a dialog (an alert
321
+ // with buttons is modal; without any it dismisses too) ask Swift to
322
+ // dismiss with the alert's own id.
323
+ const alert = target.closest<HTMLElement>('.sb-alert');
324
+ const id = Number(alert?.dataset[ID_ATTR]);
325
+ if (!alert || !Number.isInteger(id)) return;
326
+ event.preventDefault();
327
+ if (target.classList.contains('sb-alert-scrim')) {
328
+ const hasButtons = alert.querySelector('.sb-alert-buttons > [data-sb-id]') !== null;
329
+ if (alert.dataset['sbStyle'] !== 'dialog' && hasButtons) return;
330
+ }
331
+ this.onEvent({ type: 'tap', id });
332
+ return;
333
+ }
334
+ if (target.classList.contains('sb-tabbar-item')) {
335
+ // A tab bar item asks Swift to select the tab; `selected` is authoritative.
336
+ const tabview = target.closest<HTMLElement>('.sb-tabview');
337
+ const id = Number(tabview?.dataset[ID_ATTR]);
338
+ const index = Number(target.dataset['sbIndex']);
339
+ if (!tabview || !Number.isInteger(id) || !Number.isInteger(index)) return;
340
+ event.preventDefault();
341
+ this.onEvent({ type: 'select', id, value: index });
342
+ return;
343
+ }
344
+ if (target.classList.contains('sb-segment') || target.classList.contains('sb-picker-option')) {
345
+ const picker = target.closest<HTMLElement>('.sb-picker');
346
+ const id = Number(picker?.dataset[ID_ATTR]);
347
+ const index = Number(target.dataset['sbIndex']);
348
+ if (!picker || !Number.isInteger(id) || !Number.isInteger(index)) return;
349
+ event.preventDefault();
350
+ // Move the pill / checkmark right away; Swift's `update` confirms (or reverts) it.
351
+ setPickerSelection(picker, index);
352
+ this.onEvent({ type: 'select', id, value: index });
353
+ return;
354
+ }
355
+ if (target.classList.contains('sb-picker-menu-item')) {
356
+ // A popover row: a picker option (`select`) or a button / navlink (`tap`).
357
+ if (!this.menu) return;
358
+ const action = target.dataset['sbAction'];
359
+ const id = Number(target.dataset['sbTarget']);
360
+ const index = Number(target.dataset['sbIndex']);
361
+ if (!Number.isInteger(id)) return;
362
+ event.preventDefault();
363
+ this.closeMenu();
364
+ if (action === 'tap') this.onEvent({ type: 'tap', id });
365
+ else if (Number.isInteger(index)) this.onEvent({ type: 'select', id, value: index });
366
+ return;
367
+ }
368
+ if (target.classList.contains('sb-picker') || target.classList.contains('sb-menu')) {
369
+ const id = Number(target.dataset[ID_ATTR]);
370
+ if (!Number.isInteger(id)) return;
371
+ event.preventDefault();
372
+ if (this.menu?.anchorId === id) this.closeMenu();
373
+ else if (target.classList.contains('sb-menu')) this.openMenu(target, id, this.menuRows(id));
374
+ else this.openMenu(target, id, this.pickerRows(id));
375
+ return;
376
+ }
377
+ if (target.classList.contains('sb-sheet-scrim')) {
378
+ // A tap outside the card asks Swift to dismiss the sheet.
379
+ const sheet = target.closest<HTMLElement>('.sb-sheet');
380
+ const id = Number(sheet?.dataset[ID_ATTR]);
381
+ if (!sheet || !Number.isInteger(id)) return;
382
+ event.preventDefault();
383
+ this.onEvent({ type: 'tap', id });
384
+ return;
385
+ }
386
+ if (target.classList.contains('sb-navbar-back')) {
387
+ const id = Number(target.dataset['sbTarget']);
388
+ if (!Number.isInteger(id)) return;
389
+ event.preventDefault();
390
+ this.onEvent({ type: 'tap', id });
391
+ return;
392
+ }
393
+ if (target.classList.contains('sb-switch')) {
394
+ const toggle = target.closest<HTMLElement>('.sb-toggle');
395
+ const id = Number(toggle?.dataset[ID_ATTR]);
396
+ if (!toggle || !Number.isInteger(id)) return;
397
+ event.preventDefault();
398
+ const value = target.getAttribute('aria-checked') !== 'true';
399
+ // Flip optimistically so the switch animates right away; Swift's
400
+ // `update` confirms (or reverts) the state.
401
+ setSwitchState(toggle, value);
402
+ this.onEvent({ type: 'toggle', id, value });
403
+ return;
404
+ }
405
+ const id = Number(target.dataset[ID_ATTR]);
406
+ if (!Number.isInteger(id)) return;
407
+ event.preventDefault();
408
+ this.onEvent({ type: 'tap', id });
409
+ };
410
+
411
+ /**
412
+ * Phase 12: after a commit, the bound text fields (`data-sb-focus`) take or
413
+ * give up focus to match what Swift asked for. The `focus` event the move
414
+ * raises is echoed back so the state and the DOM agree.
415
+ */
416
+ private syncFocus(): void {
417
+ const active = document.activeElement;
418
+ for (const input of this.root.querySelectorAll<HTMLInputElement>('.sb-textfield[data-sb-focus]')) {
419
+ const wanted = input.dataset['sbFocus'] === 'true';
420
+ if (wanted && active !== input) input.focus();
421
+ else if (!wanted && active === input) input.blur();
422
+ }
423
+ }
424
+
425
+ /** Phase 12: a bound field (`data-sb-focus`) reports focus changes. */
426
+ 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;
429
+ if (input.dataset['sbFocus'] === undefined || !this.root.contains(input)) return;
430
+ const id = Number(input.dataset[ID_ATTR]);
431
+ if (!Number.isInteger(id)) return;
432
+ const focused = event.type === 'focusin';
433
+ if (input.dataset['sbFocus'] === String(focused)) return;
434
+ input.dataset['sbFocus'] = String(focused);
435
+ this.onEvent({ type: 'focus', id, value: focused });
436
+ };
437
+
438
+ private readonly onKeyDown = (event: KeyboardEvent): void => {
439
+ if (event.key === 'Escape' && this.menu) {
440
+ event.preventDefault();
441
+ this.closeMenu();
442
+ return;
443
+ }
444
+ if (event.key !== 'Enter' && event.key !== ' ') return;
445
+ const target = event.target as HTMLElement | null;
446
+ if (!target) return;
447
+ // Phase 12: Enter in a text field under `.onSubmit`.
448
+ if (event.key === 'Enter' && target.classList.contains('sb-textfield') && target.dataset['sbSubmit'] === 'true') {
449
+ const id = Number(target.dataset[ID_ATTR]);
450
+ if (Number.isInteger(id)) {
451
+ event.preventDefault();
452
+ this.onEvent({ type: 'submit', id });
453
+ }
454
+ return;
455
+ }
456
+ if (
457
+ !target.classList.contains('sb-navlink') &&
458
+ !target.classList.contains('sb-menu') &&
459
+ !(target.classList.contains('sb-picker') && target.dataset['sbStyle'] === 'menu')
460
+ ) {
461
+ return;
462
+ }
463
+ event.preventDefault();
464
+ target.click();
465
+ };
466
+
467
+ /**
468
+ * While a picker menu is open, a click anywhere outside it closes the menu;
469
+ * inside the screen the click is swallowed so nothing under the pointer
470
+ * fires (iOS dismisses a menu without passing the tap through).
471
+ */
472
+ private readonly onDocumentClick = (event: MouseEvent): void => {
473
+ const menu = this.menu;
474
+ if (!menu) return;
475
+ const target = event.target as Node | null;
476
+ if (target && menu.el.contains(target)) return;
477
+ this.closeMenu();
478
+ if (target && this.root.contains(target)) {
479
+ event.stopPropagation();
480
+ event.preventDefault();
481
+ }
482
+ };
483
+
484
+ private readonly onInput = (event: Event): void => {
485
+ const input = event.target as HTMLInputElement | null;
486
+ if (!input || !this.root.contains(input)) return;
487
+ // A `textfield` (or a Phase 8 `texteditor`) is the input itself; a
488
+ // `searchfield` wraps its input.
489
+ const isField = input.classList.contains('sb-textfield') || input.classList.contains('sb-texteditor');
490
+ const isSearch = input.classList.contains('sb-searchfield-input');
491
+ if (!isField && !isSearch) return;
492
+ const owner = isField ? input : input.closest<HTMLElement>('.sb-searchfield');
493
+ const id = Number(owner?.dataset[ID_ATTR]);
494
+ if (!Number.isInteger(id)) return;
495
+ this.onEvent({ type: 'text', id, value: input.value });
496
+ };
497
+
498
+ /**
499
+ * Phase 8: the native `<input type="date|time">` over a `datepicker`
500
+ * capsule committed a value. The changed component replaces its part of
501
+ * the picker's `seconds`; the other part is kept.
502
+ */
503
+ private readonly onChange = (event: Event): void => {
504
+ const input = event.target as HTMLInputElement | null;
505
+ if (!input || !input.classList.contains('sb-datepicker-input') || !this.root.contains(input)) return;
506
+ const picker = input.closest<HTMLElement>('.sb-datepicker');
507
+ const id = Number(picker?.dataset[ID_ATTR]);
508
+ if (!picker || !Number.isInteger(id) || input.value === '') return;
509
+ const seconds = Number(picker.dataset['sbSeconds']) || 0;
510
+ const value = combineDateSeconds(seconds, input.type === 'date' ? { date: input.value } : { time: input.value });
511
+ if (value === seconds) return;
512
+ // Show the new value right away; Swift's `update` confirms (or reverts) it.
513
+ picker.dataset['sbSeconds'] = String(value);
514
+ setDatePickerValue(picker, value);
515
+ this.onEvent({ type: 'date', id, value });
516
+ };
517
+
518
+ // Phase 8 slider: a pointer on the slider moves the thumb to it and follows
519
+ // it; `slide` events are coalesced to one per animation frame.
520
+ private sliderDrag: SliderDrag | null = null;
521
+
522
+ private beginSliderDrag(slider: HTMLElement, event: PointerEvent): void {
523
+ const track = slider.querySelector<HTMLElement>(':scope > .sb-slider-track');
524
+ const id = Number(slider.dataset[ID_ATTR]);
525
+ if (!track || !Number.isInteger(id)) return;
526
+ event.preventDefault();
527
+ const drag: SliderDrag = { slider, track, id, pointerId: event.pointerId, pending: null, sent: null, frame: null };
528
+ this.sliderDrag = drag;
529
+ window.addEventListener('pointermove', this.onSliderMove);
530
+ window.addEventListener('pointerup', this.onSliderUp);
531
+ window.addEventListener('pointercancel', this.onSliderUp);
532
+ this.slideTo(drag, event.clientX);
533
+ }
534
+
535
+ private slideTo(drag: SliderDrag, clientX: number): void {
536
+ const rect = drag.track.getBoundingClientRect();
537
+ // The presenting tree may be scaled (behind a sheet): measure the thumb in screen px too.
538
+ const scale = drag.track.offsetWidth > 0 ? rect.width / drag.track.offsetWidth : 1;
539
+ const thumb = SLIDER_THUMB * scale;
540
+ const travel = rect.width - thumb;
541
+ const fraction = travel > 0 ? (clientX - rect.left - thumb / 2) / travel : 0;
542
+ const min = Number(drag.slider.dataset['sbMin']) || 0;
543
+ const max = Number(drag.slider.dataset['sbMax']);
544
+ const stepText = drag.slider.dataset['sbStep'];
545
+ const step = stepText ? Number(stepText) : null;
546
+ const value = snapSliderValue(fraction, min, Number.isFinite(max) ? max : 1, step);
547
+ setSliderPosition(drag.slider, sliderFraction(value, min, Number.isFinite(max) ? max : 1));
548
+ drag.pending = value;
549
+ if (drag.frame === null) {
550
+ drag.frame = window.requestAnimationFrame(() => {
551
+ drag.frame = null;
552
+ this.flushSlide(drag);
553
+ });
554
+ }
555
+ }
556
+
557
+ private flushSlide(drag: SliderDrag): void {
558
+ if (drag.pending === null || drag.pending === drag.sent) return;
559
+ drag.sent = drag.pending;
560
+ this.onEvent({ type: 'slide', id: drag.id, value: drag.pending });
561
+ }
562
+
563
+ private readonly onSliderMove = (event: PointerEvent): void => {
564
+ const drag = this.sliderDrag;
565
+ if (!drag || event.pointerId !== drag.pointerId) return;
566
+ this.slideTo(drag, event.clientX);
567
+ };
568
+
569
+ private readonly onSliderUp = (event: PointerEvent): void => {
570
+ const drag = this.sliderDrag;
571
+ if (!drag || event.pointerId !== drag.pointerId) return;
572
+ this.sliderDrag = null;
573
+ window.removeEventListener('pointermove', this.onSliderMove);
574
+ window.removeEventListener('pointerup', this.onSliderUp);
575
+ window.removeEventListener('pointercancel', this.onSliderUp);
576
+ if (drag.frame !== null) window.cancelAnimationFrame(drag.frame);
577
+ drag.frame = null;
578
+ this.flushSlide(drag);
579
+ // The click that follows is the slide itself, not a tap on an enclosing control.
580
+ this.swallowNextClick();
581
+ };
582
+
583
+ /** Suppress the click that follows a drag so a control under the pointer does not fire. */
584
+ private swallowNextClick(): void {
585
+ const swallow = (click: Event): void => {
586
+ click.stopPropagation();
587
+ click.preventDefault();
588
+ };
589
+ this.root.addEventListener('click', swallow, { capture: true, once: true });
590
+ window.setTimeout(() => this.root.removeEventListener('click', swallow, { capture: true }), 0);
591
+ }
592
+
593
+ // Phase 8 slider: the pointer down that starts a drag (the gesture
594
+ // controller in src/interaction.ts leaves `.sb-slider` alone).
595
+ private readonly onSliderPointerDown = (event: PointerEvent): void => {
596
+ if (event.button !== 0 || this.sliderDrag) return;
597
+ const target = event.target as HTMLElement | null;
598
+ const slider = target?.closest<HTMLElement>('.sb-slider');
599
+ if (slider && this.root.contains(slider)) this.beginSliderDrag(slider, event);
600
+ };
601
+
602
+ constructor(root: HTMLElement, onEvent: EventCallback) {
603
+ this.root = root;
604
+ this.onEvent = onEvent;
605
+ root.dataset[ID_ATTR] = String(ROOT_ID);
606
+ root.dataset[KIND_ATTR] = 'root';
607
+ this.elements.set(ROOT_ID, root);
608
+ this.nodes.set(ROOT_ID, { id: ROOT_ID, kind: 'root', props: {}, children: [] });
609
+ this.animator = new Animator(root, { selfAnimatingKinds: SELF_ANIMATING });
610
+ // Delegated listeners instead of one per control: elements can be
611
+ // created detached and moved around freely.
612
+ root.addEventListener('click', this.onClick);
613
+ root.addEventListener('keydown', this.onKeyDown);
614
+ root.addEventListener('input', this.onInput);
615
+ root.addEventListener('focusin', this.onFocusChange);
616
+ root.addEventListener('focusout', this.onFocusChange);
617
+ // Phase 8: pointer gestures (the sheet drag among them) are delegated too.
618
+ const host: InteractionHost = {
619
+ root,
620
+ send: (event) => this.onEvent(event),
621
+ nodeOf: (id) => this.nodes.get(id),
622
+ openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
623
+ isMenuOpen: (id) => this.menu?.anchorId === id,
624
+ closeMenu: () => this.closeMenu(),
625
+ };
626
+ this.interaction = new InteractionController(host);
627
+ root.addEventListener('change', this.onChange);
628
+ root.addEventListener('pointerdown', this.onSliderPointerDown);
629
+ }
630
+
631
+ /** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
632
+ applyOps(ops: readonly Op[]): void {
633
+ this.applying += 1;
634
+ try {
635
+ this.applyAll(ops);
636
+ } finally {
637
+ this.applying -= 1;
638
+ }
639
+ // Geometry events go out after the whole batch (one batch per layout
640
+ // pass); Swift's answer re-enters `applyOps` with the next pass.
641
+ if (this.applying === 0) this.flushGeometry();
642
+ }
643
+
644
+ private applyAll(ops: readonly Op[]): void {
645
+ for (const op of ops) {
646
+ switch (op.op) {
647
+ case 'create':
648
+ this.create(op.id, op.kind, op.props, op);
649
+ break;
650
+ case 'update':
651
+ this.update(op.id, op.props, op);
652
+ break;
653
+ case 'insert':
654
+ this.insert(op.parent, op.id, op.index, op);
655
+ break;
656
+ case 'remove':
657
+ this.remove(op.id, op);
658
+ break;
659
+ case 'defaults':
660
+ // Consumed by the loader (wasm.ts); nothing to render.
661
+ break;
662
+ case 'commit': {
663
+ const pass = this.pass;
664
+ this.pass = emptyPass();
665
+ const changes = this.layout();
666
+ this.animator.commit(pass, op.animation, changes);
667
+ this.commits += 1;
668
+ this.syncFocus();
669
+ this.root.dispatchEvent(
670
+ new CustomEvent('sb:commit', { detail: { commits: this.commits, animation: op.animation ?? null } }),
671
+ );
672
+ break;
673
+ }
674
+ default: {
675
+ const unknown: never = op;
676
+ throw new RendererError(`Unknown op ${JSON.stringify(unknown)}`);
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ /** Detach everything under the root and forget all non-root elements. */
683
+ reset(): void {
684
+ this.root.replaceChildren();
685
+ this.elements.clear();
686
+ this.elements.set(ROOT_ID, this.root);
687
+ this.nodes.clear();
688
+ this.nodes.set(ROOT_ID, { id: ROOT_ID, kind: 'root', props: {}, children: [] });
689
+ this.parents.clear();
690
+ this.boxes.clear();
691
+ this.dirty.clear();
692
+ this.layoutResult = null;
693
+ this.engine = null;
694
+ this.pass = emptyPass();
695
+ this.closeMenu();
696
+ this.reportedGeometry.clear();
697
+ this.layoutReporter.clear();
698
+ this.pendingGeometry = [];
699
+ refreshSheets(this.root);
700
+ }
701
+
702
+ // -------------------------------------------------------------------------
703
+ // Layout
704
+ // -------------------------------------------------------------------------
705
+
706
+ /**
707
+ * Set the device environment (Dynamic Type size, color scheme) and re-lay
708
+ * the tree out. The page calls this on boot and whenever a control changes.
709
+ */
710
+ setEnvironment(environment: Partial<RendererEnvironment>): void {
711
+ const next = { ...this.environment, ...environment };
712
+ if (next.dynamicTypeSize === this.environment.dynamicTypeSize && next.colorScheme === this.environment.colorScheme) {
713
+ return;
714
+ }
715
+ this.environment = next;
716
+ if (this.commits > 0) this.layout();
717
+ }
718
+
719
+ /**
720
+ * Phase 5: give the renderer the app's asset catalog manifest (src/assets.ts).
721
+ * Named images take their intrinsic size and `src` from it, and every
722
+ * catalog color becomes a `--sb-asset-color-<slug>` custom property
723
+ * declared for `.device[data-theme="light"]` and `.device[data-theme="dark"]`
724
+ * in one `<style>` element, so named colors follow the theme through plain
725
+ * CSS (`var()`) and need no re-render when it changes. Images already in
726
+ * the tree are re-resolved; the tree is laid out again.
727
+ */
728
+ setAssetCatalog(catalog: AssetCatalog | null): void {
729
+ this.assets = catalog ?? EMPTY_CATALOG;
730
+ const doc = this.root.ownerDocument;
731
+ const css = assetColorsCSS(this.assets);
732
+ if (css) {
733
+ if (!this.assetStyle) {
734
+ this.assetStyle = doc.createElement('style');
735
+ this.assetStyle.dataset['sbAssetColors'] = '';
736
+ (doc.head ?? doc.documentElement).append(this.assetStyle);
737
+ }
738
+ this.assetStyle.textContent = css;
739
+ } else if (this.assetStyle) {
740
+ this.assetStyle.remove();
741
+ this.assetStyle = null;
742
+ }
743
+ for (const [id, node] of this.nodes) {
744
+ if (node.kind !== 'image') continue;
745
+ const el = this.elements.get(id);
746
+ if (el) applyImage(el, node.props as unknown as ImageProps, this.assets);
747
+ }
748
+ // Catalog sizes feed image measurement: start the layout pass over.
749
+ this.engine = null;
750
+ if (this.commits > 0) this.layout();
751
+ }
752
+
753
+ /** The asset catalog in use (empty when the app has none). */
754
+ get assetCatalog(): AssetCatalog {
755
+ return this.assets;
756
+ }
757
+
758
+ /**
759
+ * Use explicit safe-area insets (device mode: the real `env(safe-area-inset-*)`
760
+ * values) instead of the frame's `--device-safe-top/bottom` tokens; `null`
761
+ * goes back to the tokens. Re-lays the tree out when the insets changed.
762
+ */
763
+ setSafeArea(insets: LayoutEnvironment['safeArea'] | null): void {
764
+ const next = insets ? { ...insets } : readSafeArea(this.root);
765
+ const current = this.safeArea;
766
+ if (
767
+ current &&
768
+ current.top === next.top &&
769
+ current.leading === next.leading &&
770
+ current.bottom === next.bottom &&
771
+ current.trailing === next.trailing
772
+ ) {
773
+ return;
774
+ }
775
+ this.safeArea = next;
776
+ if (this.commits > 0) this.layout();
777
+ }
778
+
779
+ /**
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.
782
+ */
783
+ viewportChanged(): void {
784
+ if (this.commits > 0) this.layout();
785
+ }
786
+
787
+ /** The environment the engine lays out for. */
788
+ layoutEnvironment(): LayoutEnvironment {
789
+ const width = this.root.clientWidth || DEFAULT_SCREEN.width;
790
+ const height = this.root.clientHeight || DEFAULT_SCREEN.height;
791
+ if (!this.safeArea) this.safeArea = readSafeArea(this.root);
792
+ return {
793
+ screen: { width, height },
794
+ safeArea: this.safeArea,
795
+ dynamicTypeSize: this.environment.dynamicTypeSize,
796
+ devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
797
+ colorScheme: this.environment.colorScheme,
798
+ assets: this.assets,
799
+ };
800
+ }
801
+
802
+ /**
803
+ * Run the layout engine over the current tree and position every element.
804
+ * Returns the elements whose box changed. Runs at every `commit`; call it
805
+ * directly after something the engine cannot see changed (fonts loaded,
806
+ * device size).
807
+ */
808
+ layout(): BoxChange[] {
809
+ const root = this.nodes.get(ROOT_ID);
810
+ if (!root) return [];
811
+ const env = this.layoutEnvironment();
812
+ const envKey = JSON.stringify({ ...env, assets: undefined });
813
+ if (!this.engine || envKey !== this.engineEnvKey) {
814
+ this.engine = new LayoutPass(env, this.measurer, (node) => {
815
+ const parentId = this.parents.get(node.id);
816
+ return parentId === undefined ? null : (this.nodes.get(parentId) ?? null);
817
+ });
818
+ this.engineEnvKey = envKey;
819
+ }
820
+ const result = this.engine.run(root.children);
821
+ this.layoutResult = result;
822
+ this.layouts += 1;
823
+ const ctx = {
824
+ root: this.root,
825
+ elements: this.elements,
826
+ nodes: this.nodes,
827
+ parents: this.parents,
828
+ boxes: this.boxes,
829
+ dirty: this.dirty,
830
+ env,
831
+ };
832
+ const changes = applyLayout(ctx, result);
833
+ this.boxes = ctx.boxes;
834
+ this.collectGeometry(result);
835
+ this.collectLayouts(result);
836
+ if (this.applying === 0) this.flushGeometry();
837
+ return changes;
838
+ }
839
+
840
+ /**
841
+ * Queue a `geometry` event for every `geometry` element whose laid-out size
842
+ * (whole px) differs from the one last reported. Nothing is queued for an
843
+ * unchanged size, which is what lets the Swift re-render loop converge.
844
+ */
845
+ private collectGeometry(result: LayoutResult): void {
846
+ for (const [id, node] of this.nodes) {
847
+ if (node.kind !== 'geometry') continue;
848
+ const frame = result.frames.get(id);
849
+ if (!frame) continue; // e.g. inside a hidden tab
850
+ const width = Math.round(frame.width);
851
+ const height = Math.round(frame.height);
852
+ const last = this.reportedGeometry.get(id);
853
+ if (last && last.width === width && last.height === height) continue;
854
+ this.reportedGeometry.set(id, { width, height });
855
+ this.pendingGeometry.push({ type: 'geometry', id, width, height });
856
+ }
857
+ }
858
+
859
+ /**
860
+ * Phase 6: queue a `layout` event for every `layout` element whose record
861
+ * (the proposal it was placed with and its children's min / ideal / max)
862
+ * differs from the last one sent. Swift's answer updates the element's
863
+ * props, which lays out again; an unchanged record sends nothing, so the
864
+ * loop converges.
865
+ */
866
+ private collectLayouts(result: LayoutResult): void {
867
+ for (const event of this.layoutReporter.collect(result)) this.pendingGeometry.push(event);
868
+ }
869
+
870
+ /** Phase 6: the last `layout` event sent for every `layout` element (for tests). */
871
+ get reportedLayouts(): ReadonlyMap<ElementId, LayoutEvent> {
872
+ return this.layoutReporter.reported;
873
+ }
874
+
875
+ private flushGeometry(): void {
876
+ if (this.pendingGeometry.length === 0) return;
877
+ const events = this.pendingGeometry;
878
+ this.pendingGeometry = [];
879
+ // One `batch` per pass: Swift renders once for all of them.
880
+ if (events.length === 1) this.onEvent(events[0]!);
881
+ else this.onEvent({ type: 'batch', events });
882
+ }
883
+
884
+ // -------------------------------------------------------------------------
885
+ // Popover menus (a `menu` picker's options, or a Phase 6 `menu`'s items)
886
+ // -------------------------------------------------------------------------
887
+
888
+ /** The rows of a `menu` picker's popover: one radio row per option. */
889
+ private pickerRows(id: ElementId): MenuRow[] {
890
+ const node = this.nodes.get(id);
891
+ const props = (node?.props ?? {}) as Partial<PickerProps>;
892
+ const options = Array.isArray(props.options) ? props.options.map(String) : [];
893
+ const selected = clampIndex(props.selected, options.length);
894
+ return options.map((title, index) => ({ kind: 'select', id, index, title, checked: index === selected }));
895
+ }
896
+
897
+ /**
898
+ * The rows of a `menu` element's popover, built from its current items
899
+ * (every child but the label) so conditional items are reflected: buttons
900
+ * and navlinks become tap rows, pickers a checked section, dividers and
901
+ * sections separators and captions, texts disabled captions.
902
+ */
903
+ private menuRows(id: ElementId): MenuRow[] {
904
+ const node = this.nodes.get(id);
905
+ return node ? collectMenuRows(node.children.slice(1)) : [];
906
+ }
907
+
908
+ /** Open a popover of `rows` under (or above) `anchor`, which is element `id`. */
909
+ private openMenu(anchor: HTMLElement, id: ElementId, rows: MenuRow[]): void {
910
+ this.closeMenu();
911
+ const menu = div('sb-picker-menu');
912
+ menu.setAttribute('role', 'menu');
913
+ menu.dataset['sbFor'] = String(id);
914
+ let height = 0;
915
+ for (const row of rows) {
916
+ menu.append(menuRowElement(row));
917
+ height += row.kind === 'caption' ? MENU_CAPTION_HEIGHT : row.kind === 'separator' ? MENU_SEPARATOR_HEIGHT : PICKER_MENU_ROW;
918
+ }
919
+ // Anchor to the control: trailing edges aligned, below it when there is
920
+ // room, else above; clamped inside the screen.
921
+ const rootRect = this.root.getBoundingClientRect();
922
+ const anchorRect = anchor.getBoundingClientRect();
923
+ const width = Math.min(PICKER_MENU_WIDTH, Math.max(0, rootRect.width - 2 * PICKER_MENU_MARGIN));
924
+ let left = anchorRect.right - rootRect.left - width;
925
+ left = Math.max(PICKER_MENU_MARGIN, Math.min(left, rootRect.width - width - PICKER_MENU_MARGIN));
926
+ let top = anchorRect.bottom - rootRect.top + PICKER_MENU_GAP;
927
+ if (top + height > rootRect.height - PICKER_MENU_MARGIN) {
928
+ top = anchorRect.top - rootRect.top - PICKER_MENU_GAP - height;
929
+ menu.dataset['sbPlacement'] = 'above';
930
+ } else {
931
+ menu.dataset['sbPlacement'] = 'below';
932
+ }
933
+ top = Math.max(PICKER_MENU_MARGIN, top);
934
+ menu.style.left = `${Math.round(left)}px`;
935
+ menu.style.top = `${Math.round(top)}px`;
936
+ menu.style.width = `${Math.round(width)}px`;
937
+ this.root.append(menu);
938
+ anchor.setAttribute('aria-expanded', 'true');
939
+ this.menu = { el: menu, anchor, anchorId: id };
940
+ document.addEventListener('click', this.onDocumentClick, true);
941
+ }
942
+
943
+ /**
944
+ * Phase 8: open the popover of a `contextmenu` element (a long press or a
945
+ * right click on its content), its items (every child but the content)
946
+ * listed like a `menu`'s. Choosing one sends `tap` with the item's id.
947
+ */
948
+ openContextMenu(anchor: HTMLElement, id: ElementId): void {
949
+ if (this.menu?.anchorId === id) return;
950
+ const node = this.nodes.get(id);
951
+ if (!node || !this.root.contains(anchor)) return;
952
+ this.openMenu(anchor, id, collectMenuRows(node.children.slice(1)));
953
+ }
954
+
955
+ /** Close the open popover (no-op when none is open). */
956
+ closeMenu(): void {
957
+ const menu = this.menu;
958
+ if (!menu) return;
959
+ this.menu = null;
960
+ document.removeEventListener('click', this.onDocumentClick, true);
961
+ menu.el.remove();
962
+ menu.anchor.setAttribute('aria-expanded', 'false');
963
+ }
964
+
965
+ /** The open popover element (for tests), or null. */
966
+ get openMenuElement(): HTMLElement | null {
967
+ return this.menu?.el ?? null;
968
+ }
969
+
970
+ /** Drop cached text widths (after web fonts load) and lay out again. */
971
+ fontsChanged(): void {
972
+ this.measurer.clearCache();
973
+ this.engine = null;
974
+ if (this.commits > 0) this.layout();
975
+ }
976
+
977
+ dispose(): void {
978
+ this.closeMenu();
979
+ this.root.removeEventListener('click', this.onClick);
980
+ this.root.removeEventListener('keydown', this.onKeyDown);
981
+ this.root.removeEventListener('input', this.onInput);
982
+ this.root.removeEventListener('focusin', this.onFocusChange);
983
+ this.root.removeEventListener('focusout', this.onFocusChange);
984
+ this.interaction.dispose();
985
+ this.root.removeEventListener('change', this.onChange);
986
+ this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
987
+ this.reset();
988
+ }
989
+
990
+ // -------------------------------------------------------------------------
991
+ // Ops
992
+ // -------------------------------------------------------------------------
993
+
994
+ private create(id: ElementId, kind: ElementKind, props: ElementProps, op: Op): void {
995
+ if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be created', op);
996
+ if (this.elements.has(id)) throw new RendererError(`Element ${id} already exists`, op);
997
+ if (!ALL_KINDS.has(kind)) throw new RendererError(`Unknown element kind ${String(kind)}`, op);
998
+ const el = createShell(kind);
999
+ el.className = `sb-${kind}`;
1000
+ el.dataset[ID_ATTR] = String(id);
1001
+ el.dataset[KIND_ATTR] = kind;
1002
+ this.elements.set(id, el);
1003
+ this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
1004
+ applyProps(el, kind, props, this.assets);
1005
+ }
1006
+
1007
+ private update(id: ElementId, props: ElementProps, op: Op): void {
1008
+ if (id === ROOT_ID) {
1009
+ // Phase 12: the root carries no element props; an update of it is the
1010
+ // app's `.preferredColorScheme`, which the page applies as its theme.
1011
+ const scheme = (props as RootProps).colorScheme;
1012
+ if (scheme === 'light' || scheme === 'dark' || scheme === null) {
1013
+ this.root.dispatchEvent(new CustomEvent('sb:color-scheme', { detail: { colorScheme: scheme } }));
1014
+ return;
1015
+ }
1016
+ throw new RendererError('Element 0 is the root and has no props', op);
1017
+ }
1018
+ const el = this.elements.get(id);
1019
+ if (!el) throw new RendererError(`Cannot update unknown element ${id}`, op);
1020
+ const kind = el.dataset[KIND_ATTR] as ElementKind;
1021
+ const node = this.nodes.get(id);
1022
+ if (node) {
1023
+ node.props = asRecord(props);
1024
+ // Phase 9: the next layout re-measures this subtree and its ancestors;
1025
+ // a screen's title also feeds the next screen's back label.
1026
+ if (node.kind === 'navscreen') {
1027
+ const stackId = this.parents.get(id);
1028
+ const stack = stackId === undefined ? undefined : this.nodes.get(stackId);
1029
+ if (stack) this.engine?.invalidateDown(stack);
1030
+ }
1031
+ this.engine?.invalidate(node);
1032
+ }
1033
+ // Remember how the element looked before, in case the pass is animated.
1034
+ const previous = this.pass.updated.get(el);
1035
+ const before = previous ? previous.before : this.animator.snapshot(el);
1036
+ const oldToken = el.dataset[ANIMATION_TOKEN];
1037
+ // `update` replaces the props entirely, so drop whatever the previous
1038
+ // props produced before applying the new ones. The layout box is kept:
1039
+ // the engine re-lays out at `commit`, and removals recorded later in the
1040
+ // pass measure their old position against intact ancestors.
1041
+ const { left, top, width, height } = el.style;
1042
+ el.removeAttribute('style');
1043
+ if (left) el.style.left = left;
1044
+ if (top) el.style.top = top;
1045
+ if (width) el.style.width = width;
1046
+ if (height) el.style.height = height;
1047
+ for (const key of Object.keys(el.dataset)) {
1048
+ if (key !== ID_ATTR && key !== KIND_ATTR) delete el.dataset[key];
1049
+ }
1050
+ ANIMATIONS.delete(el);
1051
+ setTransition(el, undefined);
1052
+ if (kind === 'text') el.textContent = '';
1053
+ applyProps(el, kind, props, this.assets);
1054
+ const newToken = el.dataset[ANIMATION_TOKEN];
1055
+ const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
1056
+ const record: UpdatedRecord = { el, before, tokenChanged };
1057
+ const animation = ANIMATIONS.get(el);
1058
+ if (animation) record.animation = animation;
1059
+ this.pass.updated.set(el, record);
1060
+ this.dirty.add(id);
1061
+ if (kind === 'navscreen') {
1062
+ const stack = el.parentElement?.closest<HTMLElement>('.sb-navstack');
1063
+ if (stack) refreshNavStack(stack);
1064
+ }
1065
+ if (kind === 'sheet' && el.parentElement) refreshSheets(el.parentElement);
1066
+ if (kind === 'tabview') refreshTabBar(el);
1067
+ if (kind === 'tab') {
1068
+ const tabview = tabViewOf(el);
1069
+ if (tabview) refreshTabBar(tabview);
1070
+ }
1071
+ // The popover shows what its control had when it opened; drop it when
1072
+ // the control (or, for a `menu`, anything inside it) changes.
1073
+ if (this.menu && (this.menu.anchorId === id || this.menu.anchor.contains(el))) this.closeMenu();
1074
+ }
1075
+
1076
+ private insert(parentId: ElementId, id: ElementId, index: number, op: Op): void {
1077
+ if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be inserted', op);
1078
+ const parent = this.elements.get(parentId);
1079
+ if (!parent) throw new RendererError(`Cannot insert into unknown parent ${parentId}`, op);
1080
+ const el = this.elements.get(id);
1081
+ if (!el) throw new RendererError(`Cannot insert unknown element ${id}`, op);
1082
+ if (el === parent || el.contains(parent)) {
1083
+ throw new RendererError(`Inserting ${id} into ${parentId} would create a cycle`, op);
1084
+ }
1085
+ if (!Number.isInteger(index) || index < 0) {
1086
+ throw new RendererError(`Invalid child index ${index}`, op);
1087
+ }
1088
+ const container = slotOf(parent);
1089
+ // Moves: `index` is the element's position in the parent's child list
1090
+ // *after* the op is applied. Detaching first makes the index arithmetic
1091
+ // identical for fresh inserts and for moves within the same parent.
1092
+ const previousParent = el.parentElement;
1093
+ const wasAttached = el.isConnected;
1094
+ el.remove();
1095
+ const reference = container.children[index] ?? null;
1096
+ container.insertBefore(el, reference);
1097
+ if (!wasAttached) this.pass.inserted.add(el);
1098
+ // Mirror the move in the layout tree.
1099
+ const node = this.nodes.get(id);
1100
+ const parentNode = this.nodes.get(parentId);
1101
+ if (node && parentNode) {
1102
+ const oldParentId = this.parents.get(id);
1103
+ const oldParent = oldParentId !== undefined ? this.nodes.get(oldParentId) : undefined;
1104
+ if (oldParent) {
1105
+ const at = oldParent.children.indexOf(node);
1106
+ if (at >= 0) oldParent.children.splice(at, 1);
1107
+ this.engine?.invalidateUp(oldParent);
1108
+ }
1109
+ parentNode.children.splice(Math.min(index, parentNode.children.length), 0, node);
1110
+ this.parents.set(id, parentId);
1111
+ // Phase 9: the subtree's context may differ under its new parent (or
1112
+ // position); the parents on both sides may change size.
1113
+ this.engine?.invalidateUp(parentNode);
1114
+ this.engine?.invalidateDown(node);
1115
+ }
1116
+ if (el.classList.contains('sb-navscreen') && parent.classList.contains('sb-navstack')) {
1117
+ if (container.children.length > 1 && previousParent !== container) animatePush(el);
1118
+ refreshNavStack(parent);
1119
+ }
1120
+ if (previousParent) {
1121
+ const oldStack = previousParent.closest<HTMLElement>('.sb-navstack');
1122
+ if (oldStack && oldStack !== parent) refreshNavStack(oldStack);
1123
+ if (previousParent !== container && previousParent.querySelector(':scope > .sb-sheet') === null) {
1124
+ refreshSheets(previousParent);
1125
+ }
1126
+ }
1127
+ if (el.classList.contains('sb-sheet')) {
1128
+ if (!wasAttached) animateSheetIn(el);
1129
+ refreshSheets(container);
1130
+ }
1131
+ // Phase 8: an alert fades in (and scales from 1.1) on its own.
1132
+ if (el.classList.contains('sb-alert') && !wasAttached) animateAlertIn(el);
1133
+ if (el.classList.contains('sb-tab')) {
1134
+ if (parent.classList.contains('sb-tabview')) refreshTabBar(parent);
1135
+ const oldTabView = previousParent && previousParent !== container ? tabViewOf(previousParent) : null;
1136
+ if (oldTabView && oldTabView !== parent) refreshTabBar(oldTabView);
1137
+ }
1138
+ }
1139
+
1140
+ private remove(id: ElementId, op: Op): void {
1141
+ if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be removed', op);
1142
+ const el = this.elements.get(id);
1143
+ if (!el) throw new RendererError(`Cannot remove unknown element ${id}`, op);
1144
+ const parent = el.parentElement;
1145
+ const stack = parent?.classList.contains('sb-navstack-screens')
1146
+ ? parent.parentElement
1147
+ : null;
1148
+ if (stack && el.classList.contains('sb-navscreen') && el === parent?.lastElementChild && el.previousElementSibling) {
1149
+ // Phase 8: after a swipe back the clone starts where the drag left the
1150
+ // screen (`--sb-nav-from`), and the screen below is handed back to the
1151
+ // stylesheet so it finishes its slide to 0 as the new top screen.
1152
+ this.interaction.screenRemoved(el);
1153
+ animatePop(stack, el);
1154
+ }
1155
+ const isSheet = el.classList.contains('sb-sheet');
1156
+ if (isSheet && parent) animateSheetOut(parent, el);
1157
+ // Phase 8: a dismissed alert leaves a fading ghost, like a sheet.
1158
+ const isAlert = el.classList.contains('sb-alert');
1159
+ if (isAlert && parent) animateAlertOut(parent, el);
1160
+ const tabview = el.classList.contains('sb-tab') ? tabViewOf(el) : null;
1161
+ if (this.menu && (el === this.menu.anchor || el.contains(this.menu.anchor) || this.menu.anchor.contains(el))) {
1162
+ this.closeMenu();
1163
+ }
1164
+ if (parent && !isSheet && !isAlert) {
1165
+ // Keep what the Animator needs for an exit transition; it decides at
1166
+ // `commit` whether the pass is animated.
1167
+ const ancestors: HTMLElement[] = [];
1168
+ 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) });
1170
+ }
1171
+ this.pass.inserted.delete(el);
1172
+ this.pass.updated.delete(el);
1173
+ el.remove();
1174
+ // Phase 9: the layout pass drops the subtree's output; the parent re-measures.
1175
+ const removedNode = this.nodes.get(id);
1176
+ if (removedNode) {
1177
+ const parentId = this.parents.get(id);
1178
+ const parentNode = parentId === undefined ? undefined : this.nodes.get(parentId);
1179
+ if (parentNode) this.engine?.invalidateUp(parentNode);
1180
+ this.engine?.remove(removedNode);
1181
+ }
1182
+ this.forget(id);
1183
+ for (const descendant of el.querySelectorAll<HTMLElement>('[data-sb-id]')) {
1184
+ this.forget(Number(descendant.dataset[ID_ATTR]));
1185
+ }
1186
+ if (stack) refreshNavStack(stack);
1187
+ if (isSheet && parent) refreshSheets(parent);
1188
+ if (tabview) refreshTabBar(tabview);
1189
+ }
1190
+
1191
+ /** Forget an element in every map; detaches its node from the tree. */
1192
+ private forget(id: ElementId): void {
1193
+ this.elements.delete(id);
1194
+ this.boxes.delete(id);
1195
+ this.dirty.delete(id);
1196
+ this.reportedGeometry.delete(id);
1197
+ this.layoutReporter.forget(id);
1198
+ const node = this.nodes.get(id);
1199
+ const parentId = this.parents.get(id);
1200
+ const parentNode = parentId !== undefined ? this.nodes.get(parentId) : undefined;
1201
+ if (node && parentNode) {
1202
+ const at = parentNode.children.indexOf(node);
1203
+ if (at >= 0) parentNode.children.splice(at, 1);
1204
+ }
1205
+ this.nodes.delete(id);
1206
+ this.parents.delete(id);
1207
+ }
1208
+ }
1209
+
1210
+ interface OpenMenu {
1211
+ el: HTMLElement;
1212
+ /** The control that opened it (a `menu` picker or a `menu` element). */
1213
+ anchor: HTMLElement;
1214
+ anchorId: ElementId;
1215
+ }
1216
+
1217
+ /** One row of a popover menu. */
1218
+ type MenuRow =
1219
+ | { kind: 'tap'; id: ElementId; title: string; symbol: string | null; destructive: boolean; disabled: boolean }
1220
+ | { kind: 'select'; id: ElementId; index: number; title: string; checked: boolean }
1221
+ | { kind: 'caption'; text: string }
1222
+ | { kind: 'text'; text: string }
1223
+ | { kind: 'separator' };
1224
+
1225
+ // `textOf`, `symbolOf` and `unwrapStyledNode` live in src/nodeQueries.ts
1226
+ // (Phase 8 shares them with the swipe actions).
1227
+
1228
+ /** Whether a `styled` wrapper in the chain down to the element disables it. */
1229
+ function disabledOf(node: LayoutNode): boolean {
1230
+ let current: LayoutNode | undefined = node;
1231
+ while (current && current.kind === 'styled') {
1232
+ const style = current.props['style'];
1233
+ if (typeof style === 'object' && style !== null && (style as Record<string, unknown>)['disabled'] === true) return true;
1234
+ current = current.children.length === 1 ? current.children[0] : undefined;
1235
+ }
1236
+ return false;
1237
+ }
1238
+
1239
+ /**
1240
+ * Turn a `menu`'s items into popover rows. A picker's options form their
1241
+ * own group, separated from neighbouring rows like iOS does; explicit
1242
+ * `divider`s are separators too (never doubled).
1243
+ */
1244
+ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): MenuRow[] {
1245
+ let pendingSeparator = false;
1246
+ const separate = (): void => {
1247
+ if (rows.length > 0 && rows[rows.length - 1]!.kind !== 'separator') rows.push({ kind: 'separator' });
1248
+ };
1249
+ for (const item of items) {
1250
+ const base = unwrapStyledNode(item);
1251
+ if (pendingSeparator && base.kind !== 'divider') {
1252
+ separate();
1253
+ pendingSeparator = false;
1254
+ }
1255
+ switch (base.kind) {
1256
+ case 'button':
1257
+ case 'navlink':
1258
+ rows.push({
1259
+ kind: 'tap',
1260
+ id: base.id,
1261
+ title: textOf(base),
1262
+ symbol: symbolOf(base),
1263
+ destructive: base.props['role'] === 'destructive',
1264
+ disabled: disabledOf(item),
1265
+ });
1266
+ break;
1267
+ case 'picker': {
1268
+ const raw = base.props['options'];
1269
+ const options = Array.isArray(raw) ? raw.map(String) : [];
1270
+ const selected = clampIndex(base.props['selected'], options.length);
1271
+ separate();
1272
+ options.forEach((title, index) => rows.push({ kind: 'select', id: base.id, index, title, checked: index === selected }));
1273
+ pendingSeparator = true;
1274
+ break;
1275
+ }
1276
+ case 'divider':
1277
+ separate();
1278
+ break;
1279
+ case 'section': {
1280
+ const header = base.props['header'];
1281
+ if (typeof header === 'string' && header.length > 0) rows.push({ kind: 'caption', text: header });
1282
+ collectMenuRows(base.children, rows);
1283
+ break;
1284
+ }
1285
+ case 'text':
1286
+ rows.push({ kind: 'text', text: textOf(base) });
1287
+ break;
1288
+ case 'menu': {
1289
+ // A nested menu: its label as a caption, its items flattened.
1290
+ const [label, ...nested] = base.children;
1291
+ if (label) rows.push({ kind: 'caption', text: textOf(label) });
1292
+ collectMenuRows(nested, rows);
1293
+ break;
1294
+ }
1295
+ default:
1296
+ // Groups, stacks and the like: their children in order.
1297
+ collectMenuRows(base.children, rows);
1298
+ break;
1299
+ }
1300
+ }
1301
+ return rows;
1302
+ }
1303
+
1304
+ /** Build the DOM for one popover row (the renderer's click handler reads the data attributes). */
1305
+ function menuRowElement(row: MenuRow): HTMLElement {
1306
+ switch (row.kind) {
1307
+ case 'select': {
1308
+ const item = document.createElement('button');
1309
+ item.type = 'button';
1310
+ item.className = 'sb-picker-menu-item';
1311
+ item.setAttribute('role', 'menuitemradio');
1312
+ item.setAttribute('aria-checked', String(row.checked));
1313
+ item.dataset['sbAction'] = 'select';
1314
+ item.dataset['sbTarget'] = String(row.id);
1315
+ item.dataset['sbIndex'] = String(row.index);
1316
+ const check = span('sb-picker-menu-check');
1317
+ const glyph = renderSymbol('checkmark');
1318
+ if (glyph) check.append(glyph);
1319
+ const title = span('sb-picker-menu-title');
1320
+ title.textContent = row.title;
1321
+ item.append(check, title);
1322
+ return item;
1323
+ }
1324
+ case 'tap': {
1325
+ const item = document.createElement('button');
1326
+ item.type = 'button';
1327
+ item.className = 'sb-picker-menu-item';
1328
+ item.setAttribute('role', 'menuitem');
1329
+ item.dataset['sbAction'] = 'tap';
1330
+ item.dataset['sbTarget'] = String(row.id);
1331
+ if (row.destructive) item.dataset['sbRole'] = 'destructive';
1332
+ if (row.disabled) item.disabled = true;
1333
+ const title = span('sb-picker-menu-title');
1334
+ title.textContent = row.title;
1335
+ item.append(title);
1336
+ if (row.symbol) {
1337
+ const symbol = span('sb-picker-menu-symbol');
1338
+ const glyph = renderSymbol(row.symbol);
1339
+ if (glyph) symbol.append(glyph);
1340
+ symbol.dataset['sbSymbol'] = row.symbol;
1341
+ item.append(symbol);
1342
+ }
1343
+ return item;
1344
+ }
1345
+ case 'caption': {
1346
+ const caption = div('sb-picker-menu-caption');
1347
+ caption.textContent = row.text;
1348
+ return caption;
1349
+ }
1350
+ case 'text': {
1351
+ const text = div('sb-picker-menu-static');
1352
+ text.setAttribute('aria-disabled', 'true');
1353
+ text.textContent = row.text;
1354
+ return text;
1355
+ }
1356
+ case 'separator': {
1357
+ const separator = div('sb-picker-menu-separator');
1358
+ separator.setAttribute('role', 'separator');
1359
+ return separator;
1360
+ }
1361
+ }
1362
+ }
1363
+
1364
+ /** Props as the engine reads them (untrusted JSON, so a plain record). */
1365
+ function asRecord(props: unknown): Record<string, unknown> {
1366
+ return typeof props === 'object' && props !== null ? (props as Record<string, unknown>) : {};
1367
+ }
1368
+
1369
+ /** Safe-area insets from the page's device tokens (`--device-safe-top/bottom`). */
1370
+ function readSafeArea(root: HTMLElement): LayoutEnvironment['safeArea'] {
1371
+ const cs = getComputedStyle(root);
1372
+ const read = (name: string, fallback: number): number => {
1373
+ const value = parseFloat(cs.getPropertyValue(name));
1374
+ return Number.isFinite(value) ? value : fallback;
1375
+ };
1376
+ return {
1377
+ top: read('--device-safe-top', DEFAULT_SAFE_AREA.top),
1378
+ leading: 0,
1379
+ bottom: read('--device-safe-bottom', DEFAULT_SAFE_AREA.bottom),
1380
+ trailing: 0,
1381
+ };
1382
+ }
1383
+
1384
+ /** Convenience factory mirroring the function-style API in the brief. */
1385
+ export function createRenderer(root: HTMLElement, onEvent: EventCallback): Renderer {
1386
+ return new Renderer(root, onEvent);
1387
+ }
1388
+
1389
+ // ---------------------------------------------------------------------------
1390
+ // Element shells and slots
1391
+ // ---------------------------------------------------------------------------
1392
+
1393
+ /** Slot elements of compound kinds, registered by `createShell`. */
1394
+ const SLOTS = new WeakMap<HTMLElement, HTMLElement>();
1395
+
1396
+ /** The DOM node that receives an element's protocol children. */
1397
+ export function slotOf(el: HTMLElement): HTMLElement {
1398
+ return SLOTS.get(el) ?? el;
1399
+ }
1400
+
1401
+ function div(className: string): HTMLDivElement {
1402
+ const el = document.createElement('div');
1403
+ el.className = className;
1404
+ return el;
1405
+ }
1406
+
1407
+ /** Create the element and any chrome it owns; props are applied separately. */
1408
+ function createShell(kind: ElementKind): HTMLElement {
1409
+ switch (kind) {
1410
+ case 'button': {
1411
+ const el = document.createElement('button');
1412
+ el.type = 'button';
1413
+ return el;
1414
+ }
1415
+ case 'text':
1416
+ return document.createElement('span');
1417
+ case 'textfield': {
1418
+ const el = document.createElement('input');
1419
+ el.autocomplete = 'off';
1420
+ el.spellcheck = false;
1421
+ return el;
1422
+ }
1423
+ case 'image': {
1424
+ const el = document.createElement('span');
1425
+ el.setAttribute('role', 'img');
1426
+ return el;
1427
+ }
1428
+ case 'divider': {
1429
+ const el = document.createElement('div');
1430
+ el.setAttribute('role', 'separator');
1431
+ return el;
1432
+ }
1433
+ case 'navstack': {
1434
+ const el = document.createElement('div');
1435
+ const slot = div('sb-slot sb-navstack-screens');
1436
+ el.append(slot);
1437
+ SLOTS.set(el, slot);
1438
+ return el;
1439
+ }
1440
+ case 'list': {
1441
+ // The inner box is sized to the content so the list scrolls it.
1442
+ const el = document.createElement('div');
1443
+ const slot = div('sb-slot sb-list-content');
1444
+ el.append(slot);
1445
+ SLOTS.set(el, slot);
1446
+ return el;
1447
+ }
1448
+ case 'scrollview': {
1449
+ const el = document.createElement('div');
1450
+ const slot = div('sb-slot sb-scroll-content');
1451
+ el.append(slot);
1452
+ SLOTS.set(el, slot);
1453
+ return el;
1454
+ }
1455
+ case 'navscreen': {
1456
+ const el = document.createElement('div');
1457
+ const bar = div('sb-navbar');
1458
+ const back = document.createElement('button');
1459
+ back.type = 'button';
1460
+ back.className = 'sb-navbar-back';
1461
+ const chevron = renderSymbol('chevron.left');
1462
+ if (chevron) back.append(chevron);
1463
+ back.append(span('sb-navbar-back-label'));
1464
+ const inlineTitle = span('sb-navbar-title');
1465
+ bar.append(back, inlineTitle, div('sb-navbar-trailing'));
1466
+ const body = div('sb-navscreen-body');
1467
+ const slot = div('sb-slot sb-navscreen-content');
1468
+ body.append(div('sb-navbar-large-title'), slot);
1469
+ // Phase 8: a 24px strip along the leading edge of a pushed screen
1470
+ // that, on touch devices, keeps the browser from scrolling so a swipe
1471
+ // back can start there (styles.css; the controller forwards plain taps).
1472
+ const edge = div('sb-navscreen-edge');
1473
+ edge.setAttribute('aria-hidden', 'true');
1474
+ el.append(bar, body, edge);
1475
+ SLOTS.set(el, slot);
1476
+ return el;
1477
+ }
1478
+ case 'section': {
1479
+ const el = document.createElement('div');
1480
+ const slot = div('sb-slot sb-section-rows');
1481
+ el.append(div('sb-section-header'), slot, div('sb-section-footer'));
1482
+ SLOTS.set(el, slot);
1483
+ return el;
1484
+ }
1485
+ case 'toggle': {
1486
+ const el = document.createElement('div');
1487
+ const label = div('sb-slot sb-toggle-label');
1488
+ const sw = document.createElement('button');
1489
+ sw.type = 'button';
1490
+ sw.className = 'sb-switch';
1491
+ sw.setAttribute('role', 'switch');
1492
+ sw.setAttribute('aria-checked', 'false');
1493
+ sw.append(span('sb-switch-knob'));
1494
+ el.append(label, sw);
1495
+ SLOTS.set(el, label);
1496
+ return el;
1497
+ }
1498
+ case 'navlink': {
1499
+ const el = document.createElement('div');
1500
+ el.setAttribute('role', 'button');
1501
+ el.tabIndex = 0;
1502
+ const chevron = renderSymbol('chevron.right');
1503
+ const slot = div('sb-slot sb-navlink-label');
1504
+ el.append(slot);
1505
+ SLOTS.set(el, slot);
1506
+ if (chevron) {
1507
+ chevron.classList.add('sb-navlink-chevron');
1508
+ el.append(chevron);
1509
+ }
1510
+ return el;
1511
+ }
1512
+ case 'sheet': {
1513
+ // A full-screen layer: a scrim over the presenting content and the
1514
+ // card (grabber + content slot) sliding up from the bottom.
1515
+ const el = document.createElement('div');
1516
+ el.setAttribute('role', 'dialog');
1517
+ el.setAttribute('aria-modal', 'true');
1518
+ const scrim = div('sb-sheet-scrim');
1519
+ const card = div('sb-sheet-card');
1520
+ const grabber = div('sb-sheet-grabber');
1521
+ grabber.setAttribute('aria-hidden', 'true');
1522
+ const slot = div('sb-slot sb-sheet-content');
1523
+ card.append(grabber, slot);
1524
+ el.append(scrim, card);
1525
+ SLOTS.set(el, slot);
1526
+ return el;
1527
+ }
1528
+ case 'color':
1529
+ case 'shape': {
1530
+ const el = document.createElement('div');
1531
+ el.setAttribute('aria-hidden', 'true');
1532
+ return el;
1533
+ }
1534
+ case 'tabview': {
1535
+ // The tabs go into the content slot; the bar is chrome drawn on top.
1536
+ const el = document.createElement('div');
1537
+ const slot = div('sb-slot sb-tabview-content');
1538
+ const bar = div('sb-tabbar');
1539
+ bar.setAttribute('role', 'tablist');
1540
+ el.append(slot, bar);
1541
+ SLOTS.set(el, slot);
1542
+ return el;
1543
+ }
1544
+ case 'tab': {
1545
+ const el = document.createElement('div');
1546
+ el.setAttribute('role', 'tabpanel');
1547
+ return el;
1548
+ }
1549
+ case 'picker':
1550
+ // Segments or the label/value row are built from the props.
1551
+ return document.createElement('div');
1552
+ case 'gauge': {
1553
+ // The labels are protocol children (in the slot); the track and its
1554
+ // fill are chrome positioned from the engine's `track` / `fill` rects.
1555
+ const el = document.createElement('div');
1556
+ el.setAttribute('role', 'meter');
1557
+ el.setAttribute('aria-valuemin', '0');
1558
+ el.setAttribute('aria-valuemax', '1');
1559
+ const slot = div('sb-slot sb-gauge-labels');
1560
+ const track = div('sb-gauge-track');
1561
+ track.setAttribute('aria-hidden', 'true');
1562
+ const fill = div('sb-gauge-fill');
1563
+ fill.setAttribute('aria-hidden', 'true');
1564
+ el.append(slot, track, fill);
1565
+ SLOTS.set(el, slot);
1566
+ return el;
1567
+ }
1568
+ case 'menu': {
1569
+ // Phase 6: the label (first child) is the visible control; the other
1570
+ // children are the items, hidden inline and listed in the popover.
1571
+ const el = document.createElement('div');
1572
+ el.setAttribute('role', 'button');
1573
+ el.setAttribute('aria-haspopup', 'menu');
1574
+ el.setAttribute('aria-expanded', 'false');
1575
+ el.tabIndex = 0;
1576
+ return el;
1577
+ }
1578
+ case 'toolbar': {
1579
+ const el = document.createElement('div');
1580
+ el.setAttribute('role', 'toolbar');
1581
+ return el;
1582
+ }
1583
+ case 'chart': {
1584
+ // Phase 7: the marks, grid, ticks, text labels and legend are chrome
1585
+ // (src/chartDom.ts, drawn from the engine's `plot` and label rects);
1586
+ // the `chartitem` children live in the slot above it so annotations
1587
+ // draw over the marks.
1588
+ const el = document.createElement('div');
1589
+ el.setAttribute('role', 'img');
1590
+ const chrome = div('sb-chart-chrome');
1591
+ chrome.setAttribute('aria-hidden', 'true');
1592
+ const slot = div('sb-slot sb-chart-items');
1593
+ el.append(chrome, slot);
1594
+ SLOTS.set(el, slot);
1595
+ return el;
1596
+ }
1597
+ case 'chartitem':
1598
+ return document.createElement('div');
1599
+ case 'searchfield': {
1600
+ // Phase 6: a rounded field with a magnifier; the input sends `text` events.
1601
+ const el = document.createElement('div');
1602
+ el.setAttribute('role', 'search');
1603
+ const glyph = renderSymbol('magnifyingglass');
1604
+ if (glyph) el.append(glyph);
1605
+ const input = document.createElement('input');
1606
+ input.type = 'text';
1607
+ input.className = 'sb-searchfield-input';
1608
+ input.autocomplete = 'off';
1609
+ input.spellcheck = false;
1610
+ input.setAttribute('aria-label', 'Search');
1611
+ el.append(input);
1612
+ return el;
1613
+ }
1614
+ case 'tapgesture':
1615
+ return document.createElement('div');
1616
+ case 'navsplit': {
1617
+ // Phase 7: the two columns go into the slot; the separator is chrome
1618
+ // positioned from the engine's `separator` rect.
1619
+ const el = document.createElement('div');
1620
+ const slot = div('sb-slot sb-navsplit-columns');
1621
+ const separator = div('sb-navsplit-separator');
1622
+ separator.setAttribute('aria-hidden', 'true');
1623
+ el.append(slot, separator);
1624
+ SLOTS.set(el, slot);
1625
+ return el;
1626
+ }
1627
+ case 'viewthatfits':
1628
+ return document.createElement('div');
1629
+ // Phase 8: gestures, rows, navigation
1630
+ case 'gesture':
1631
+ case 'contextmenu':
1632
+ return document.createElement('div');
1633
+ case 'swipeactions':
1634
+ case 'listrow': {
1635
+ // The protocol children go into a content slot that paints the row
1636
+ // background and slides aside; the action buttons the controller
1637
+ // builds (src/interaction.ts) sit behind it. A `listrow` also owns
1638
+ // the edit-mode control (selection circle / minus) at `chrome.editControl`.
1639
+ const el = document.createElement('div');
1640
+ const slot = div('sb-slot sb-swipe-content');
1641
+ el.append(slot);
1642
+ SLOTS.set(el, slot);
1643
+ if (kind === 'listrow') {
1644
+ const control = document.createElement('button');
1645
+ control.type = 'button';
1646
+ control.className = 'sb-listrow-control';
1647
+ control.hidden = true;
1648
+ control.tabIndex = -1;
1649
+ const circle = span('sb-listrow-circle');
1650
+ const check = renderSymbol('checkmark');
1651
+ if (check) {
1652
+ check.classList.add('sb-listrow-check');
1653
+ circle.append(check);
1654
+ }
1655
+ const minus = renderSymbol('minus');
1656
+ if (minus) {
1657
+ minus.classList.add('sb-listrow-minus');
1658
+ circle.append(minus);
1659
+ }
1660
+ control.append(circle);
1661
+ el.append(control);
1662
+ }
1663
+ return el;
1664
+ }
1665
+ case 'alert': {
1666
+ // Phase 8: a full-screen layer: the scrim, then a layer (scaled by the
1667
+ // appear animation about the card's center) holding the card(s), the
1668
+ // title and message, the button slot and the renderer-drawn OK. The
1669
+ // engine positions all of them in screen coordinates.
1670
+ const el = document.createElement('div');
1671
+ el.setAttribute('role', 'alertdialog');
1672
+ el.setAttribute('aria-modal', 'true');
1673
+ const scrim = div('sb-alert-scrim');
1674
+ const layer = div('sb-alert-layer');
1675
+ const card = div('sb-alert-card');
1676
+ card.setAttribute('aria-hidden', 'true');
1677
+ const cancelCard = div('sb-alert-cancel-card');
1678
+ cancelCard.setAttribute('aria-hidden', 'true');
1679
+ const slot = div('sb-slot sb-alert-buttons');
1680
+ const ok = document.createElement('button');
1681
+ ok.type = 'button';
1682
+ ok.className = 'sb-alert-ok';
1683
+ ok.textContent = 'OK';
1684
+ layer.append(card, cancelCard, div('sb-alert-title'), div('sb-alert-message'), slot, ok);
1685
+ el.append(scrim, layer);
1686
+ SLOTS.set(el, slot);
1687
+ return el;
1688
+ }
1689
+ case 'stepper': {
1690
+ // Phase 8: the label (slot) leads; the −/+ control is chrome at the engine's `control` rect.
1691
+ const el = document.createElement('div');
1692
+ const slot = div('sb-slot sb-stepper-label');
1693
+ const control = div('sb-stepper-control');
1694
+ const half = (step: number, symbol: string, label: string): HTMLButtonElement => {
1695
+ const button = document.createElement('button');
1696
+ button.type = 'button';
1697
+ button.className = 'sb-stepper-button';
1698
+ button.dataset['sbStep'] = String(step);
1699
+ button.setAttribute('aria-label', label);
1700
+ const glyph = renderSymbol(symbol);
1701
+ if (glyph) button.append(glyph);
1702
+ else button.textContent = step < 0 ? '−' : '+';
1703
+ return button;
1704
+ };
1705
+ control.append(half(-1, 'minus', 'Decrement'), half(1, 'plus', 'Increment'));
1706
+ el.append(slot, control);
1707
+ SLOTS.set(el, slot);
1708
+ return el;
1709
+ }
1710
+ case 'slider': {
1711
+ // Phase 8: the labels are protocol children; track, fill and thumb are chrome.
1712
+ const el = document.createElement('div');
1713
+ el.setAttribute('role', 'slider');
1714
+ const slot = div('sb-slot sb-slider-labels');
1715
+ const track = div('sb-slider-track');
1716
+ const fill = div('sb-slider-fill');
1717
+ const thumb = div('sb-slider-thumb');
1718
+ for (const part of [track, fill, thumb]) part.setAttribute('aria-hidden', 'true');
1719
+ el.append(slot, track, fill, thumb);
1720
+ SLOTS.set(el, slot);
1721
+ return el;
1722
+ }
1723
+ case 'datepicker': {
1724
+ // Phase 8: the label (slot) leads; a date and a time capsule (text plus
1725
+ // an invisible native input over it) are chrome at `date` / `time`.
1726
+ const el = document.createElement('div');
1727
+ const slot = div('sb-slot sb-datepicker-label');
1728
+ const capsule = (type: 'date' | 'time'): HTMLElement => {
1729
+ const box = div(`sb-datepicker-capsule sb-datepicker-${type}`);
1730
+ const text = span('sb-datepicker-text');
1731
+ const input = document.createElement('input');
1732
+ input.type = type;
1733
+ input.className = 'sb-datepicker-input';
1734
+ input.setAttribute('aria-label', type === 'date' ? 'Date' : 'Time');
1735
+ box.append(text, input);
1736
+ return box;
1737
+ };
1738
+ el.append(slot, capsule('date'), capsule('time'));
1739
+ SLOTS.set(el, slot);
1740
+ return el;
1741
+ }
1742
+ case 'texteditor': {
1743
+ const el = document.createElement('textarea');
1744
+ el.autocomplete = 'off';
1745
+ el.spellcheck = false;
1746
+ return el;
1747
+ }
1748
+ default:
1749
+ return document.createElement('div');
1750
+ }
1751
+ }
1752
+
1753
+ function span(className: string): HTMLSpanElement {
1754
+ const el = document.createElement('span');
1755
+ el.className = className;
1756
+ return el;
1757
+ }
1758
+
1759
+ // ---------------------------------------------------------------------------
1760
+ // Navigation
1761
+ // ---------------------------------------------------------------------------
1762
+
1763
+ /**
1764
+ * Recompute per-screen state for a `navstack`: only the last screen is
1765
+ * interactive, and every screen at depth > 0 shows a back button labelled
1766
+ * with the previous screen's title.
1767
+ */
1768
+ function refreshNavStack(stack: HTMLElement): void {
1769
+ const screens = Array.from(slotOf(stack).children) as HTMLElement[];
1770
+ screens.forEach((screen, i) => {
1771
+ const isTop = i === screens.length - 1;
1772
+ screen.toggleAttribute('inert', !isTop);
1773
+ screen.setAttribute('aria-hidden', String(!isTop));
1774
+ screen.dataset['sbNavPosition'] = isTop ? 'top' : 'below';
1775
+ const previous = screens[i - 1];
1776
+ const back = screen.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-back');
1777
+ const label = back?.querySelector<HTMLElement>('.sb-navbar-back-label');
1778
+ const depth = Number(screen.dataset['sbDepth'] ?? '0');
1779
+ if (back && label) {
1780
+ const show = depth > 0;
1781
+ back.hidden = !show;
1782
+ // The layout engine picks the label (previous title, "Back" or none)
1783
+ // so it never collides with the title; fall back until it has run.
1784
+ label.textContent = label.dataset['sbLayoutLabel'] ?? (previous?.dataset['sbTitle'] || 'Back');
1785
+ back.dataset['sbTarget'] = screen.dataset[ID_ATTR] ?? '';
1786
+ }
1787
+ });
1788
+ }
1789
+
1790
+ /**
1791
+ * Run `done` once the CSS animation on `target` itself has ended (not one
1792
+ * bubbling up from a descendant). A late first frame (heavy layout) can push
1793
+ * the end past `expectedMs`, so the fallback waits for the running animation
1794
+ * objects instead of cutting the animation short.
1795
+ */
1796
+ function afterAnimation(target: HTMLElement, expectedMs: number, done: () => void): void {
1797
+ let finished = false;
1798
+ const finish = (): void => {
1799
+ if (finished) return;
1800
+ finished = true;
1801
+ target.removeEventListener('animationend', onEnd);
1802
+ target.removeEventListener('animationcancel', onEnd);
1803
+ done();
1804
+ };
1805
+ const onEnd = (event: Event): void => {
1806
+ if (event.target === target) finish();
1807
+ };
1808
+ target.addEventListener('animationend', onEnd);
1809
+ target.addEventListener('animationcancel', onEnd);
1810
+ window.setTimeout(() => {
1811
+ const running = target.getAnimations().filter((a) => a.playState === 'running');
1812
+ if (running.length === 0) {
1813
+ finish();
1814
+ return;
1815
+ }
1816
+ Promise.allSettled(running.map((a) => a.finished)).then(finish, finish);
1817
+ window.setTimeout(finish, expectedMs * 3);
1818
+ }, expectedMs + 50);
1819
+ }
1820
+
1821
+ function animatePush(screen: HTMLElement): void {
1822
+ screen.classList.remove('sb-navscreen--push');
1823
+ // Force a style flush so re-adding the class restarts the animation.
1824
+ void screen.offsetWidth;
1825
+ screen.classList.add('sb-navscreen--push');
1826
+ afterAnimation(screen, NAV_TRANSITION_MS, () => screen.classList.remove('sb-navscreen--push'));
1827
+ }
1828
+
1829
+ /**
1830
+ * The protocol removes a popped screen immediately; to animate it out a
1831
+ * visual clone (stripped of element ids so it is invisible to the renderer)
1832
+ * slides away on top of the stack and is dropped afterwards.
1833
+ */
1834
+ function animatePop(stack: HTMLElement, screen: HTMLElement): void {
1835
+ const clone = ghostClone(screen);
1836
+ clone.removeAttribute('inert');
1837
+ clone.setAttribute('aria-hidden', 'true');
1838
+ clone.classList.add('sb-navscreen--pop');
1839
+ stack.append(clone);
1840
+ afterAnimation(clone, NAV_TRANSITION_MS, () => clone.remove());
1841
+ }
1842
+
1843
+ // ---------------------------------------------------------------------------
1844
+ // Tab views (Phase 4)
1845
+ // ---------------------------------------------------------------------------
1846
+
1847
+ /** The `tabview` element whose content slot holds `el` (a tab or the slot itself), or null. */
1848
+ function tabViewOf(el: HTMLElement): HTMLElement | null {
1849
+ const slot = el.classList.contains('sb-tabview-content') ? el : el.parentElement;
1850
+ if (!slot?.classList.contains('sb-tabview-content')) return null;
1851
+ const tabview = slot.parentElement;
1852
+ return tabview?.classList.contains('sb-tabview') ? tabview : null;
1853
+ }
1854
+
1855
+ function clampIndex(value: unknown, count: number): number {
1856
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
1857
+ return count === 0 ? 0 : Math.min(count - 1, Math.max(0, n));
1858
+ }
1859
+
1860
+ /**
1861
+ * Rebuild the tab bar items from the `tab` children (title + symbol) and mark
1862
+ * the selected tab: only it is visible and interactive. The layout engine
1863
+ * confirms the hidden set at `commit`; this keeps the DOM right in between.
1864
+ */
1865
+ function refreshTabBar(tabview: HTMLElement): void {
1866
+ const bar = tabview.querySelector<HTMLElement>(':scope > .sb-tabbar');
1867
+ if (!bar) return;
1868
+ const tabs = Array.from(slotOf(tabview).children).filter((c) => c.classList.contains('sb-tab')) as HTMLElement[];
1869
+ const selected = clampIndex(Number(tabview.dataset['sbSelected'] ?? 0), tabs.length);
1870
+ while (bar.children.length > tabs.length) bar.lastElementChild?.remove();
1871
+ while (bar.children.length < tabs.length) {
1872
+ const item = document.createElement('button');
1873
+ item.type = 'button';
1874
+ item.className = 'sb-tabbar-item';
1875
+ item.setAttribute('role', 'tab');
1876
+ item.append(span('sb-tabbar-icon'), span('sb-tabbar-title'));
1877
+ bar.append(item);
1878
+ }
1879
+ tabs.forEach((tab, i) => {
1880
+ const item = bar.children[i] as HTMLElement;
1881
+ const title = tab.dataset['sbTitle'] ?? '';
1882
+ const symbol = tab.dataset['sbSymbol'] ?? '';
1883
+ const isSelected = i === selected;
1884
+ item.dataset['sbIndex'] = String(i);
1885
+ item.setAttribute('aria-selected', String(isSelected));
1886
+ item.setAttribute('aria-label', title);
1887
+ item.toggleAttribute('data-sb-selected', isSelected);
1888
+ const icon = item.querySelector<HTMLElement>(':scope > .sb-tabbar-icon');
1889
+ if (icon && icon.dataset['sbSymbol'] !== symbol) {
1890
+ icon.dataset['sbSymbol'] = symbol;
1891
+ const svg = symbol ? renderSymbol(symbol) : null;
1892
+ if (svg) icon.replaceChildren(svg);
1893
+ else icon.replaceChildren();
1894
+ icon.toggleAttribute('data-sb-missing', Boolean(symbol) && !svg);
1895
+ icon.hidden = !symbol;
1896
+ }
1897
+ const label = item.querySelector<HTMLElement>(':scope > .sb-tabbar-title');
1898
+ if (label && label.textContent !== title) label.textContent = title;
1899
+ setTabHidden(tab, !isSelected);
1900
+ });
1901
+ }
1902
+
1903
+ // ---------------------------------------------------------------------------
1904
+ // Sheets
1905
+ // ---------------------------------------------------------------------------
1906
+
1907
+ /**
1908
+ * Recompute sheet state under `container` (normally the root): only the last
1909
+ * sheet is interactive, and the container records the top sheet's detent so
1910
+ * styles.css can scale and dim the presenting content behind a `large` one.
1911
+ */
1912
+ function refreshSheets(container: HTMLElement): void {
1913
+ // Exit clones of dismissed sheets are not sheets any more.
1914
+ const sheets = Array.from(container.children).filter(
1915
+ (c) => c.classList.contains('sb-sheet') && !c.classList.contains(EXIT_CLONE_CLASS),
1916
+ ) as HTMLElement[];
1917
+ sheets.forEach((sheet, i) => {
1918
+ const isTop = i === sheets.length - 1;
1919
+ sheet.dataset['sbSheetPosition'] = isTop ? 'top' : 'below';
1920
+ sheet.toggleAttribute('inert', !isTop);
1921
+ });
1922
+ const top = sheets[sheets.length - 1];
1923
+ if (top) container.dataset['sbSheet'] = top.dataset['sbDetent'] ?? 'large';
1924
+ else delete container.dataset['sbSheet'];
1925
+ }
1926
+
1927
+ function animateSheetIn(sheet: HTMLElement): void {
1928
+ sheet.classList.remove('sb-sheet--in');
1929
+ void sheet.offsetWidth;
1930
+ sheet.classList.add('sb-sheet--in');
1931
+ const card = sheet.querySelector<HTMLElement>(':scope > .sb-sheet-card') ?? sheet;
1932
+ afterAnimation(card, SHEET_TRANSITION_MS, () => sheet.classList.remove('sb-sheet--in'));
1933
+ }
1934
+
1935
+ /**
1936
+ * Like `animatePop`: the protocol removes a dismissed sheet at once, so a
1937
+ * clone (without element ids) slides down and fades its scrim, then goes.
1938
+ */
1939
+ function animateSheetOut(container: HTMLElement, sheet: HTMLElement): void {
1940
+ const clone = ghostClone(sheet);
1941
+ clone.setAttribute('aria-hidden', 'true');
1942
+ clone.toggleAttribute('inert', true);
1943
+ clone.classList.add('sb-sheet--out', EXIT_CLONE_CLASS);
1944
+ const card = clone.querySelector<HTMLElement>(':scope > .sb-sheet-card');
1945
+ if (card) {
1946
+ // The slide starts where a drag left the card (`--sb-sheet-drag`).
1947
+ card.style.transition = '';
1948
+ card.style.transform = '';
1949
+ }
1950
+ container.append(clone);
1951
+ afterAnimation(card ?? clone, SHEET_TRANSITION_MS, () => clone.remove());
1952
+ }
1953
+
1954
+ function applySheet(el: HTMLElement, props: SheetProps): void {
1955
+ const detents = Array.isArray(props.detents) && props.detents.length ? props.detents : ['large'];
1956
+ // 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';
1958
+ el.dataset['sbDetent'] = first;
1959
+ el.dataset['sbDetents'] = detents.join(' ');
1960
+ }
1961
+
1962
+ // Phase 8: alerts appear with a 0.25s fade (the alert card scales from 1.1,
1963
+ // see styles.css) and leave with a fade played by a ghost clone, like sheets.
1964
+ function animateAlertIn(alert: HTMLElement): void {
1965
+ alert.classList.remove('sb-alert--in');
1966
+ void alert.offsetWidth;
1967
+ alert.classList.add('sb-alert--in');
1968
+ const layer = alert.querySelector<HTMLElement>(':scope > .sb-alert-layer') ?? alert;
1969
+ afterAnimation(layer, ALERT_TRANSITION_MS, () => alert.classList.remove('sb-alert--in'));
1970
+ }
1971
+
1972
+ function animateAlertOut(container: HTMLElement, alert: HTMLElement): void {
1973
+ const clone = ghostClone(alert);
1974
+ clone.setAttribute('aria-hidden', 'true');
1975
+ clone.toggleAttribute('inert', true);
1976
+ clone.classList.remove('sb-alert--in');
1977
+ clone.classList.add('sb-alert--out', EXIT_CLONE_CLASS);
1978
+ container.append(clone);
1979
+ afterAnimation(clone, ALERT_TRANSITION_MS, () => clone.remove());
1980
+ }
1981
+
1982
+ interface SliderDrag {
1983
+ slider: HTMLElement;
1984
+ track: HTMLElement;
1985
+ id: ElementId;
1986
+ pointerId: number;
1987
+ /** The latest snapped value, sent at the next animation frame. */
1988
+ pending: number | null;
1989
+ /** The last value sent, so an unchanged frame sends nothing. */
1990
+ sent: number | null;
1991
+ frame: number | null;
1992
+ }
1993
+
1994
+ function applyAlert(el: HTMLElement, props: AlertProps): void {
1995
+ el.dataset['sbStyle'] = props.style === 'dialog' ? 'dialog' : 'alert';
1996
+ // The title and message are drawn at layout from the engine's centered lines.
1997
+ el.setAttribute('aria-label', props.title ?? '');
1998
+ }
1999
+
2000
+ function applyStepper(el: HTMLElement, props: StepperProps): void {
2001
+ const halves = el.querySelectorAll<HTMLElement>(':scope > .sb-stepper-control > .sb-stepper-button');
2002
+ halves[0]?.setAttribute('aria-disabled', String(props.canDecrement === false));
2003
+ halves[1]?.setAttribute('aria-disabled', String(props.canIncrement === false));
2004
+ }
2005
+
2006
+ function applySlider(el: HTMLElement, props: SliderProps): void {
2007
+ const min = Number.isFinite(props.min) ? props.min : 0;
2008
+ const max = Number.isFinite(props.max) && props.max > min ? props.max : min + 1;
2009
+ const value = Number.isFinite(props.value) ? props.value : min;
2010
+ const step = props.step != null && Number.isFinite(props.step) && props.step > 0 ? props.step : null;
2011
+ el.dataset['sbMin'] = String(min);
2012
+ el.dataset['sbMax'] = String(max);
2013
+ el.dataset['sbStep'] = step === null ? '' : String(step);
2014
+ el.dataset['sbValue'] = String(value);
2015
+ el.setAttribute('aria-valuemin', String(min));
2016
+ el.setAttribute('aria-valuemax', String(max));
2017
+ el.setAttribute('aria-valuenow', String(value));
2018
+ }
2019
+
2020
+ /**
2021
+ * Move the thumb and fill of a slider to `fraction` (0...1) right away, from
2022
+ * the track's current box; the engine's next layout places them from `value`.
2023
+ */
2024
+ function setSliderPosition(slider: HTMLElement, fraction: number): void {
2025
+ const track = slider.querySelector<HTMLElement>(':scope > .sb-slider-track');
2026
+ const fill = slider.querySelector<HTMLElement>(':scope > .sb-slider-fill');
2027
+ const thumb = slider.querySelector<HTMLElement>(':scope > .sb-slider-thumb');
2028
+ if (!track || !fill || !thumb) return;
2029
+ const travel = Math.max(0, track.offsetWidth - SLIDER_THUMB);
2030
+ const center = track.offsetLeft + SLIDER_THUMB / 2 + fraction * travel;
2031
+ thumb.style.left = `${center - SLIDER_THUMB / 2}px`;
2032
+ fill.style.width = `${Math.max(0, center - track.offsetLeft)}px`;
2033
+ }
2034
+
2035
+ function applyDatePicker(el: HTMLElement, props: DatePickerProps): void {
2036
+ const seconds = Number.isFinite(props.seconds) ? props.seconds : 0;
2037
+ const components = props.components === 'date' || props.components === 'hourAndMinute' ? props.components : 'dateAndTime';
2038
+ el.dataset['sbSeconds'] = String(seconds);
2039
+ el.dataset['sbComponents'] = components;
2040
+ el.dataset['sbMin'] = props.min != null && Number.isFinite(props.min) ? String(props.min) : '';
2041
+ el.dataset['sbMax'] = props.max != null && Number.isFinite(props.max) ? String(props.max) : '';
2042
+ if (props.labelsHidden === true) el.dataset['sbLabelsHidden'] = '';
2043
+ setDatePickerValue(el, seconds);
2044
+ }
2045
+
2046
+ /**
2047
+ * Show `seconds` in the capsules (US English, UTC) and in the native inputs;
2048
+ * `min` / `max` (from the element's data) bound the date input, and the time
2049
+ * input only when they fall on the shown day.
2050
+ */
2051
+ function setDatePickerValue(el: HTMLElement, seconds: number): void {
2052
+ const bound = (key: 'sbMin' | 'sbMax'): number | null => {
2053
+ const text = el.dataset[key];
2054
+ return text ? Number(text) : null;
2055
+ };
2056
+ const min = bound('sbMin');
2057
+ const max = bound('sbMax');
2058
+ for (const type of ['date', 'time'] as const) {
2059
+ const capsule = el.querySelector<HTMLElement>(`:scope > .sb-datepicker-${type}`);
2060
+ if (!capsule) continue;
2061
+ const text = capsule.querySelector<HTMLElement>(':scope > .sb-datepicker-text');
2062
+ const input = capsule.querySelector<HTMLInputElement>(':scope > .sb-datepicker-input');
2063
+ const label = type === 'date' ? formatPickerDate(seconds) : formatPickerTime(seconds);
2064
+ if (text && text.textContent !== label) text.textContent = label;
2065
+ if (!input) continue;
2066
+ const format = type === 'date' ? dateInputValue : timeInputValue;
2067
+ const value = format(seconds);
2068
+ if (input.value !== value) input.value = value;
2069
+ const limit = (b: number | null): string =>
2070
+ b !== null && Number.isFinite(b) && (type === 'date' || sameUTCDay(b, seconds)) ? format(b) : '';
2071
+ input.min = limit(min);
2072
+ input.max = limit(max);
2073
+ }
2074
+ }
2075
+
2076
+ /** Like `applyTextField`: the caret survives an echo of the text the editor shows. */
2077
+ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): void {
2078
+ const text = props.text ?? '';
2079
+ if (area.value !== text) area.value = text;
2080
+ }
2081
+
2082
+ // ---------------------------------------------------------------------------
2083
+ // Props → DOM
2084
+ // ---------------------------------------------------------------------------
2085
+
2086
+ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
2087
+ switch (kind) {
2088
+ case 'text':
2089
+ applyText(el, props as TextProps);
2090
+ break;
2091
+ case 'vstack':
2092
+ applyVStack(el, props as VStackProps);
2093
+ applyScrollTarget(el, (props as VStackProps).scrollTarget === true);
2094
+ break;
2095
+ case 'hstack':
2096
+ applyHStack(el, props as HStackProps);
2097
+ applyScrollTarget(el, (props as HStackProps).scrollTarget === true);
2098
+ break;
2099
+ case 'spacer':
2100
+ applySpacer(el, props as SpacerProps);
2101
+ break;
2102
+ case 'button':
2103
+ applyButton(el, (props as ButtonProps) ?? {});
2104
+ break;
2105
+ case 'styled':
2106
+ applyStyle(el, (props as StyledProps).style ?? {});
2107
+ break;
2108
+ case 'zstack':
2109
+ applyZStack(el, props as ZStackProps);
2110
+ break;
2111
+ case 'image':
2112
+ applyImage(el, props as ImageProps, assets);
2113
+ break;
2114
+ case 'divider':
2115
+ case 'navstack':
2116
+ case 'geometry':
2117
+ case 'menu':
2118
+ case 'tapgesture':
2119
+ case 'navsplit':
2120
+ // No props.
2121
+ break;
2122
+ case 'navlink':
2123
+ // Phase 7: a selected row (inside a `List(selection:)`) gets a rounded fill.
2124
+ el.toggleAttribute('data-sb-selected', (props as NavLinkProps | undefined)?.selected === true);
2125
+ break;
2126
+ case 'viewthatfits': {
2127
+ const axes = (props as ViewThatFitsProps | undefined)?.axes;
2128
+ el.dataset['sbAxes'] = axes === 'horizontal' || axes === 'vertical' ? axes : 'both';
2129
+ break;
2130
+ }
2131
+ case 'toolbar': {
2132
+ const placement = (props as ToolbarProps).placement;
2133
+ el.dataset['sbPlacement'] =
2134
+ placement === 'leading' || placement === 'principal' || placement === 'bottom' ? placement : 'trailing';
2135
+ break;
2136
+ }
2137
+ case 'searchfield':
2138
+ applySearchField(el, props as SearchFieldProps);
2139
+ break;
2140
+ case 'list': {
2141
+ const list = props as ListProps;
2142
+ el.dataset['sbListStyle'] = list.style ?? 'insetGrouped';
2143
+ // Phase 8: edit mode (the engine shifts `listrow` content) and pull to refresh.
2144
+ el.toggleAttribute('data-sb-editing', list.editing === true);
2145
+ applyRefreshable(el, list.refreshable === true, list.refreshing === true);
2146
+ break;
2147
+ }
2148
+ case 'tabview': {
2149
+ const selected = (props as TabViewProps).selected;
2150
+ el.dataset['sbSelected'] = String(Number.isFinite(selected) ? Math.max(0, Math.floor(selected)) : 0);
2151
+ break;
2152
+ }
2153
+ case 'tab': {
2154
+ const { title, systemImage } = props as TabProps;
2155
+ el.dataset['sbTitle'] = title ?? '';
2156
+ el.dataset['sbSymbol'] = systemImage ?? '';
2157
+ el.setAttribute('aria-label', title ?? '');
2158
+ break;
2159
+ }
2160
+ case 'picker':
2161
+ applyPicker(el, props as PickerProps);
2162
+ break;
2163
+ case 'scrollview': {
2164
+ const scroll = props as ScrollViewProps;
2165
+ el.dataset['sbAxes'] = scroll.axes ?? 'vertical';
2166
+ applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
2167
+ if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
2168
+ else delete el.dataset['sbSnap'];
2169
+ break;
2170
+ }
2171
+ case 'section':
2172
+ applySection(el, props as SectionProps);
2173
+ break;
2174
+ case 'textfield':
2175
+ applyTextField(el as HTMLInputElement, props as TextFieldProps);
2176
+ break;
2177
+ case 'toggle':
2178
+ setSwitchState(el, Boolean((props as ToggleProps).isOn));
2179
+ break;
2180
+ case 'navscreen':
2181
+ applyNavScreen(el, props as NavScreenProps);
2182
+ break;
2183
+ case 'color':
2184
+ applyColor(el, props as ColorProps);
2185
+ break;
2186
+ case 'shape':
2187
+ applyShape(el, props as ShapeProps);
2188
+ break;
2189
+ case 'sheet':
2190
+ applySheet(el, props as SheetProps);
2191
+ break;
2192
+ case 'layout':
2193
+ // Pure layout: `proposal`, `size` and `frames` are read by the engine.
2194
+ break;
2195
+ case 'grid': {
2196
+ const grid = props as GridProps;
2197
+ el.dataset['sbAlignment'] = grid.alignment ?? 'center';
2198
+ break;
2199
+ }
2200
+ case 'gridrow': {
2201
+ const row = props as GridRowProps;
2202
+ if (row.alignment) el.dataset['sbAlignment'] = row.alignment;
2203
+ break;
2204
+ }
2205
+ case 'lazyvgrid': {
2206
+ const grid = props as LazyVGridProps;
2207
+ el.dataset['sbAlignment'] = grid.alignment ?? 'center';
2208
+ el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
2209
+ break;
2210
+ }
2211
+ case 'gauge':
2212
+ applyGauge(el, props as GaugeProps);
2213
+ break;
2214
+ case 'decorated': {
2215
+ const decorated = props as DecoratedProps;
2216
+ el.dataset['sbRole'] = decorated.role === 'background' ? 'background' : 'overlay';
2217
+ el.dataset['sbAlignment'] = decorated.alignment ?? 'center';
2218
+ break;
2219
+ }
2220
+ case 'chart': {
2221
+ // Phase 7: the chrome is (re)drawn at layout from the node's props, so
2222
+ // an `update` with new marks redraws at the next commit; nothing
2223
+ // animates. Only a summary lands on the element itself.
2224
+ const chart = props as ChartProps;
2225
+ el.dataset['sbMarks'] = String(Array.isArray(chart.marks) ? chart.marks.length : 0);
2226
+ break;
2227
+ }
2228
+ case 'chartitem': {
2229
+ const item = props as ChartItemProps;
2230
+ el.dataset['sbRole'] = item.role ?? 'annotation';
2231
+ el.dataset['sbPosition'] = item.position ?? 'overlay';
2232
+ break;
2233
+ }
2234
+ // Phase 8: gestures, rows, navigation. The attributes are what the
2235
+ // controller (src/interaction.ts) and the stylesheet read; the engine
2236
+ // reads the props from the node tree.
2237
+ case 'gesture': {
2238
+ const gesture = (props as GestureProps | undefined) ?? {};
2239
+ el.toggleAttribute('data-sb-drag', gesture.drag === true);
2240
+ el.toggleAttribute('data-sb-long-press', gesture.longPress === true);
2241
+ break;
2242
+ }
2243
+ case 'swipeactions': {
2244
+ const swipe = (props as SwipeActionsProps | undefined) ?? { trailing: 0, leading: 0 };
2245
+ el.dataset['sbTrailing'] = String(Math.max(0, Math.floor(Number(swipe.trailing) || 0)));
2246
+ el.dataset['sbLeading'] = String(Math.max(0, Math.floor(Number(swipe.leading) || 0)));
2247
+ break;
2248
+ }
2249
+ case 'contextmenu':
2250
+ break;
2251
+ case 'listrow': {
2252
+ const row = (props as ListRowProps | undefined) ?? {};
2253
+ el.toggleAttribute('data-sb-selectable', row.selectable === true);
2254
+ el.toggleAttribute('data-sb-selected', row.selected === true);
2255
+ el.toggleAttribute('data-sb-deletable', row.deletable === true);
2256
+ if (row.selectable) el.setAttribute('aria-selected', String(row.selected === true));
2257
+ else el.removeAttribute('aria-selected');
2258
+ const control = el.querySelector<HTMLElement>(':scope > .sb-listrow-control');
2259
+ control?.setAttribute('aria-label', row.selectable ? (row.selected ? 'Deselect' : 'Select') : 'Delete');
2260
+ break;
2261
+ }
2262
+ // Phase 8: alerts and controls.
2263
+ case 'alert':
2264
+ applyAlert(el, (props as AlertProps) ?? {});
2265
+ break;
2266
+ case 'stepper':
2267
+ applyStepper(el, (props as StepperProps) ?? {});
2268
+ break;
2269
+ case 'slider':
2270
+ applySlider(el, (props as SliderProps) ?? {});
2271
+ break;
2272
+ case 'datepicker':
2273
+ applyDatePicker(el, (props as DatePickerProps) ?? {});
2274
+ break;
2275
+ case 'texteditor':
2276
+ applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
2277
+ break;
2278
+ default: {
2279
+ const unknown: never = kind;
2280
+ throw new RendererError(`Unknown element kind ${String(unknown)}`);
2281
+ }
2282
+ }
2283
+ }
2284
+
2285
+ function applyText(el: HTMLElement, props: TextProps): void {
2286
+ el.textContent = props.text ?? '';
2287
+ }
2288
+
2289
+ /**
2290
+ * Phase 8 `.refreshable`: a `list` / `scrollview` gets a 40px spinner row
2291
+ * (`.sb-refresh`, positioned by the stylesheet above the content) that shows
2292
+ * while `refreshing`; the controller sends `refresh` after a pull past 60.
2293
+ */
2294
+ function applyRefreshable(el: HTMLElement, refreshable: boolean, refreshing: boolean): void {
2295
+ el.toggleAttribute('data-sb-refreshable', refreshable);
2296
+ el.toggleAttribute('data-sb-refreshing', refreshable && refreshing);
2297
+ let indicator = el.querySelector<HTMLElement>(':scope > .sb-refresh');
2298
+ if (!refreshable) {
2299
+ indicator?.remove();
2300
+ return;
2301
+ }
2302
+ if (!indicator) {
2303
+ indicator = div('sb-refresh');
2304
+ indicator.setAttribute('aria-hidden', 'true');
2305
+ indicator.append(div('sb-refresh-spinner'));
2306
+ el.prepend(indicator);
2307
+ }
2308
+ }
2309
+
2310
+ /** Phase 6: the gauge's value (0...1) and style; the bar geometry comes from the engine. */
2311
+ function applyGauge(el: HTMLElement, props: GaugeProps): void {
2312
+ const value = Number.isFinite(props.value) ? Math.min(1, Math.max(0, props.value)) : 0;
2313
+ el.dataset['sbStyle'] = props.style ?? 'automatic';
2314
+ el.dataset['sbValue'] = String(value);
2315
+ el.setAttribute('aria-valuenow', String(value));
2316
+ }
2317
+
2318
+ function applyColor(el: HTMLElement, props: ColorProps): void {
2319
+ el.style.backgroundColor = props.color ? colorToCSS(props.color) : 'transparent';
2320
+ }
2321
+
2322
+ /** Radius that turns a box into each shape (the layout engine sizes it). */
2323
+ const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string> = {
2324
+ rectangle: () => '0px',
2325
+ roundedRectangle: (r) => `${Math.max(0, r)}px`,
2326
+ circle: () => '50%',
2327
+ capsule: () => '9999px',
2328
+ ellipse: () => '50%',
2329
+ // Phase 6: `ContainerRelativeShape` draws like a rounded rectangle.
2330
+ containerRelative: (r) => `${Math.max(0, r)}px`,
2331
+ };
2332
+
2333
+ /**
2334
+ * `fill: null` with no stroke paints the current foreground color (a bare
2335
+ * `Circle()`); with a stroke it is `Shape.stroke(_:)`, which only outlines.
2336
+ * Phase 6: `fill` and `stroke.color` are ShapeStyles (gradients, materials,
2337
+ * hierarchical fills). A gradient stroke uses its first stop (CSS borders are
2338
+ * flat). The stroke is a CSS border, which lies inside the box: that is
2339
+ * `.strokeBorder` (`inset: true`); a plain `.stroke` is drawn the same way,
2340
+ * and `data-sb-stroke` records which one it was.
2341
+ */
2342
+ function applyShape(el: HTMLElement, props: ShapeProps): void {
2343
+ const shape = props.shape in SHAPE_RADIUS ? props.shape : 'rectangle';
2344
+ const s = el.style;
2345
+ el.dataset['sbShape'] = shape;
2346
+ s.borderRadius = SHAPE_RADIUS[shape](Number(props.cornerRadius) || 0);
2347
+ const stroke = props.stroke;
2348
+ if (props.fill) applyPaint(el, styleToCSS(props.fill, 'paint'));
2349
+ else if (!stroke) s.background = 'var(--sb-foreground-paint, currentColor)'; // a gradient foreground paints the shape too
2350
+ else s.backgroundColor = 'transparent';
2351
+ if (stroke) {
2352
+ const width = Number.isFinite(stroke.lineWidth) ? Math.max(0, stroke.lineWidth) : 1;
2353
+ const color = stroke.color ? styleToCSS(stroke.color, 'paint').solid : 'currentColor';
2354
+ s.border = `${width}px solid ${color}`;
2355
+ el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
2356
+ }
2357
+ }
2358
+
2359
+ /** Paint an area with a resolved ShapeStyle: a color, a gradient image or a material over a blurred backdrop. */
2360
+ function applyPaint(el: HTMLElement, paint: PaintCSS): void {
2361
+ const s = el.style;
2362
+ if (paint.kind === 'gradient') {
2363
+ s.backgroundColor = 'transparent';
2364
+ s.backgroundImage = paint.value;
2365
+ } else {
2366
+ s.backgroundColor = paint.value;
2367
+ }
2368
+ if (paint.backdropFilter) {
2369
+ s.setProperty('backdrop-filter', paint.backdropFilter);
2370
+ s.setProperty('-webkit-backdrop-filter', paint.backdropFilter);
2371
+ }
2372
+ }
2373
+
2374
+ // Stacks, spacers and zstacks are pure layout: the engine reads their props
2375
+ // from the node tree; the attributes only make the DOM easy to inspect.
2376
+ function applyVStack(el: HTMLElement, props: VStackProps): void {
2377
+ el.dataset['alignment'] = props.alignment;
2378
+ el.dataset['sbSpacing'] = props.spacing == null ? 'default' : String(props.spacing);
2379
+ }
2380
+
2381
+ function applyHStack(el: HTMLElement, props: HStackProps): void {
2382
+ el.dataset['alignment'] = props.alignment;
2383
+ el.dataset['sbSpacing'] = props.spacing == null ? 'default' : String(props.spacing);
2384
+ }
2385
+
2386
+ function applySpacer(el: HTMLElement, props: SpacerProps): void {
2387
+ el.dataset['sbMinLength'] = props.minLength == null ? 'default' : String(props.minLength);
2388
+ }
2389
+
2390
+ function applyButton(el: HTMLElement, props: ButtonProps): void {
2391
+ el.dataset['sbStyle'] = props.style ?? 'automatic';
2392
+ if (props.role) el.dataset['sbRole'] = props.role;
2393
+ }
2394
+
2395
+ function applyZStack(el: HTMLElement, props: ZStackProps): void {
2396
+ el.dataset['alignment'] = props.alignment;
2397
+ }
2398
+
2399
+ /**
2400
+ * An `image` is an SF Symbol (`systemName`, drawn from the open icon set) or,
2401
+ * Phase 5, a catalog image (`name`): a bitmap in an `<img>` that fills the
2402
+ * frame the layout engine computed, or a `.symbolset` SVG used as a mask over
2403
+ * the current color so it tints like a symbol. Names the catalog does not
2404
+ * know get the same dashed placeholder as unmapped symbols, with the name as
2405
+ * the tooltip, so the gap is easy to spot and fix.
2406
+ */
2407
+ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): void {
2408
+ const missing = (name: string): void => {
2409
+ el.replaceChildren();
2410
+ el.title = name;
2411
+ el.setAttribute('aria-label', name);
2412
+ el.dataset['sbMissing'] = '';
2413
+ };
2414
+ const found = (name: string, child: Element): void => {
2415
+ el.replaceChildren(child);
2416
+ el.removeAttribute('title');
2417
+ el.setAttribute('aria-label', name);
2418
+ delete el.dataset['sbMissing'];
2419
+ };
2420
+ if (isAssetImage(props)) {
2421
+ const name = props.name ?? '';
2422
+ delete el.dataset['sbSymbol'];
2423
+ el.dataset['sbAsset'] = name;
2424
+ if (props.resizable) el.dataset['sbResizable'] = '';
2425
+ else delete el.dataset['sbResizable'];
2426
+ const asset = assets.images[name];
2427
+ if (!asset) {
2428
+ missing(name);
2429
+ return;
2430
+ }
2431
+ if (asset.kind === 'symbol') {
2432
+ const mask = document.createElement('span');
2433
+ mask.className = 'sb-image-mask';
2434
+ const url = `url("${asset.src}")`;
2435
+ mask.style.setProperty('mask-image', url);
2436
+ mask.style.setProperty('-webkit-mask-image', url);
2437
+ found(name, mask);
2438
+ return;
2439
+ }
2440
+ // Reuse the <img>: replacing it on every update left the box empty
2441
+ // until the new bitmap decoded, which made the brand header's truck
2442
+ // frames flicker at 20 fps. A changed source is decoded off-screen
2443
+ // first and swapped in only once it is ready.
2444
+ const existing = el.firstElementChild;
2445
+ if (existing instanceof HTMLImageElement) {
2446
+ el.removeAttribute('title');
2447
+ el.setAttribute('aria-label', name);
2448
+ delete el.dataset['sbMissing'];
2449
+ // `src` the property is absolute; compare the attribute as written.
2450
+ if (existing.getAttribute('src') !== asset.src && existing.dataset['sbPending'] !== asset.src) {
2451
+ swapImageSource(existing, asset.src);
2452
+ }
2453
+ return;
2454
+ }
2455
+ const img = document.createElement('img');
2456
+ img.src = asset.src;
2457
+ img.alt = '';
2458
+ img.draggable = false;
2459
+ img.decoding = 'async';
2460
+ found(name, img);
2461
+ return;
2462
+ }
2463
+ const name = props.systemName ?? '';
2464
+ delete el.dataset['sbAsset'];
2465
+ delete el.dataset['sbResizable'];
2466
+ el.dataset['sbSymbol'] = name;
2467
+ const svg = renderSymbol(name);
2468
+ if (svg) found(name, svg);
2469
+ else missing(name);
2470
+ }
2471
+
2472
+ /**
2473
+ * Points `img` at `src` once the bitmap is decoded, so the previous frame
2474
+ * stays visible meanwhile; a newer request supersedes a pending one.
2475
+ */
2476
+ function swapImageSource(img: HTMLImageElement, src: string): void {
2477
+ img.dataset['sbPending'] = src;
2478
+ const probe = new Image();
2479
+ probe.src = src;
2480
+ const commit = (): void => {
2481
+ if (img.dataset['sbPending'] !== src) return;
2482
+ delete img.dataset['sbPending'];
2483
+ img.setAttribute('src', src);
2484
+ };
2485
+ if (typeof probe.decode === 'function') probe.decode().then(commit, commit);
2486
+ else probe.complete ? commit() : (probe.onload = probe.onerror = commit);
2487
+ }
2488
+
2489
+ function applySection(el: HTMLElement, props: SectionProps): void {
2490
+ const header = el.querySelector<HTMLElement>(':scope > .sb-section-header');
2491
+ const footer = el.querySelector<HTMLElement>(':scope > .sb-section-footer');
2492
+ if (header) {
2493
+ header.textContent = props.header ?? '';
2494
+ header.hidden = !props.header;
2495
+ }
2496
+ if (footer) {
2497
+ footer.textContent = props.footer ?? '';
2498
+ footer.hidden = !props.footer;
2499
+ }
2500
+ }
2501
+
2502
+ function applyTextField(input: HTMLInputElement, props: TextFieldProps): void {
2503
+ input.type = props.secure ? 'password' : 'text';
2504
+ input.placeholder = props.placeholder ?? '';
2505
+ input.dataset['sbStyle'] = props.style ?? 'plain';
2506
+ const text = props.text ?? '';
2507
+ // Leave the caret alone when Swift echoes the value the field already shows.
2508
+ if (input.value !== text) input.value = text;
2509
+ // Phase 12: a bound field reports focus changes and follows the bound value;
2510
+ // the move happens once the element is in the document (see `syncFocus`).
2511
+ if (typeof props.focused === 'boolean') {
2512
+ input.dataset['sbFocus'] = String(props.focused);
2513
+ } else {
2514
+ delete input.dataset['sbFocus'];
2515
+ }
2516
+ if (props.submit === true) input.dataset['sbSubmit'] = 'true';
2517
+ else delete input.dataset['sbSubmit'];
2518
+ if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
2519
+ else input.removeAttribute('enterkeyhint');
2520
+ }
2521
+
2522
+ /** Phase 12 `.scrollTargetLayout()`: the stack's children snap (styles.css). */
2523
+ function applyScrollTarget(el: HTMLElement, target: boolean): void {
2524
+ if (target) el.dataset['sbScrollTarget'] = 'true';
2525
+ else delete el.dataset['sbScrollTarget'];
2526
+ }
2527
+
2528
+ /** Like `applyTextField`: the caret survives an echo of the value the field shows. */
2529
+ function applySearchField(el: HTMLElement, props: SearchFieldProps): void {
2530
+ const input = el.querySelector<HTMLInputElement>(':scope > .sb-searchfield-input');
2531
+ if (!input) return;
2532
+ const placeholder = typeof props.placeholder === 'string' && props.placeholder.length > 0 ? props.placeholder : 'Search';
2533
+ input.placeholder = placeholder;
2534
+ input.setAttribute('aria-label', placeholder);
2535
+ const text = props.text ?? '';
2536
+ if (input.value !== text) input.value = text;
2537
+ }
2538
+
2539
+ function setSwitchState(toggle: HTMLElement, isOn: boolean): void {
2540
+ toggle.dataset['sbOn'] = String(isOn);
2541
+ const sw = toggle.querySelector<HTMLElement>(':scope > .sb-switch');
2542
+ sw?.setAttribute('aria-checked', String(isOn));
2543
+ }
2544
+
2545
+ function applyNavScreen(el: HTMLElement, props: NavScreenProps): void {
2546
+ const title = props.title ?? '';
2547
+ const depth = Number.isFinite(props.depth) ? props.depth : 0;
2548
+ const mode = props.displayMode ?? 'automatic';
2549
+ const large = mode === 'large' || (mode === 'automatic' && depth === 0);
2550
+ el.dataset['sbTitle'] = title;
2551
+ el.dataset['sbDepth'] = String(depth);
2552
+ el.dataset['sbDisplay'] = large ? 'large' : 'inline';
2553
+ const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
2554
+ const largeTitle = el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
2555
+ if (inlineTitle) inlineTitle.textContent = large ? '' : title;
2556
+ if (largeTitle) {
2557
+ largeTitle.textContent = large ? title : '';
2558
+ largeTitle.hidden = !large;
2559
+ }
2560
+ el.setAttribute('aria-label', title);
2561
+ }
2562
+
2563
+ /**
2564
+ * Segmented: one `.sb-segment` radio button per option, the selected one
2565
+ * carrying the pill. Menu: the label leading, the selected option plus a
2566
+ * `chevron.up.chevron.down` glyph trailing; the element itself is the button
2567
+ * that opens the menu. Inline (Phase 6): a dim label row when the label is
2568
+ * non-empty, then one `.sb-picker-option` radio row per option with a
2569
+ * trailing checkmark on the selected one.
2570
+ */
2571
+ function applyPicker(el: HTMLElement, props: PickerProps): void {
2572
+ const options = Array.isArray(props.options) ? props.options.map(String) : [];
2573
+ const selected = clampIndex(props.selected, options.length);
2574
+ const style = props.style === 'segmented' ? 'segmented' : props.style === 'inline' ? 'inline' : 'menu';
2575
+ const label = props.label ?? '';
2576
+ el.dataset['sbStyle'] = style;
2577
+ el.dataset['sbSelected'] = String(selected);
2578
+ el.dataset['sbLabel'] = label;
2579
+ el.replaceChildren();
2580
+ if (style === 'inline') {
2581
+ el.setAttribute('role', 'radiogroup');
2582
+ el.setAttribute('aria-label', label);
2583
+ el.removeAttribute('tabindex');
2584
+ el.removeAttribute('aria-haspopup');
2585
+ el.removeAttribute('aria-expanded');
2586
+ if (label) {
2587
+ const labelRow = div('sb-picker-inline-label');
2588
+ labelRow.textContent = label;
2589
+ el.append(labelRow);
2590
+ }
2591
+ options.forEach((option, index) => {
2592
+ const row = document.createElement('button');
2593
+ row.type = 'button';
2594
+ row.className = 'sb-picker-option';
2595
+ row.setAttribute('role', 'radio');
2596
+ row.setAttribute('aria-checked', String(index === selected));
2597
+ row.dataset['sbIndex'] = String(index);
2598
+ const title = span('sb-picker-option-title');
2599
+ title.textContent = option;
2600
+ const check = span('sb-picker-option-check');
2601
+ const glyph = renderSymbol('checkmark');
2602
+ if (glyph) check.append(glyph);
2603
+ row.append(title, check);
2604
+ el.append(row);
2605
+ });
2606
+ return;
2607
+ }
2608
+ if (style === 'segmented') {
2609
+ el.setAttribute('role', 'radiogroup');
2610
+ el.setAttribute('aria-label', label);
2611
+ el.removeAttribute('tabindex');
2612
+ el.removeAttribute('aria-haspopup');
2613
+ el.removeAttribute('aria-expanded');
2614
+ // The gray track sits at the control's frame (which, in a list row, is
2615
+ // narrower than the row box the element occupies).
2616
+ el.append(div('sb-segment-track'));
2617
+ options.forEach((option, index) => {
2618
+ const segment = document.createElement('button');
2619
+ segment.type = 'button';
2620
+ segment.className = 'sb-segment';
2621
+ segment.setAttribute('role', 'radio');
2622
+ segment.setAttribute('aria-checked', String(index === selected));
2623
+ segment.dataset['sbIndex'] = String(index);
2624
+ const text = span('sb-segment-title');
2625
+ text.textContent = option;
2626
+ segment.append(text);
2627
+ el.append(segment);
2628
+ });
2629
+ return;
2630
+ }
2631
+ el.setAttribute('role', 'button');
2632
+ el.setAttribute('aria-haspopup', 'menu');
2633
+ el.setAttribute('aria-expanded', 'false');
2634
+ el.tabIndex = 0;
2635
+ const labelEl = span('sb-picker-label');
2636
+ labelEl.textContent = label;
2637
+ const value = span('sb-picker-value');
2638
+ const valueText = span('sb-picker-value-text');
2639
+ valueText.textContent = options[selected] ?? '';
2640
+ value.append(valueText);
2641
+ const chevron = renderSymbol('chevron.up.chevron.down');
2642
+ if (chevron) value.append(chevron);
2643
+ el.append(labelEl, value);
2644
+ el.setAttribute('aria-label', `${label}, ${options[selected] ?? ''}`);
2645
+ }
2646
+
2647
+ /** Move a segmented picker's pill or an inline picker's checkmark (optimistically, before Swift confirms). */
2648
+ function setPickerSelection(picker: HTMLElement, index: number): void {
2649
+ picker.dataset['sbSelected'] = String(index);
2650
+ picker.querySelectorAll<HTMLElement>(':scope > .sb-segment, :scope > .sb-picker-option').forEach((segment, i) => {
2651
+ segment.setAttribute('aria-checked', String(i === index));
2652
+ });
2653
+ }
2654
+
2655
+ const FONT_WEIGHT: Record<FontWeight, string> = {
2656
+ ultraLight: '100',
2657
+ thin: '200',
2658
+ light: '300',
2659
+ regular: '400',
2660
+ medium: '500',
2661
+ semibold: '600',
2662
+ bold: '700',
2663
+ heavy: '800',
2664
+ black: '900',
2665
+ };
2666
+
2667
+ const FONT_FAMILY_MONO =
2668
+ 'ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", "Roboto Mono", Consolas, monospace';
2669
+ const FONT_FAMILY_SERIF = 'ui-serif, "New York", Georgia, "Times New Roman", serif';
2670
+ const FONT_FAMILY_ROUNDED =
2671
+ 'ui-rounded, "SF Pro Rounded", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, system-ui, sans-serif';
2672
+
2673
+ /**
2674
+ * Visual styling of a `styled` box. Layout fields (`padding`, `frame`,
2675
+ * `layoutPriority`, `fixedSize`, `lineLimit`, `multilineTextAlignment`) are
2676
+ * read by the layout engine from the node tree; they only leave data
2677
+ * attributes here. Font and color settings are set as inline CSS so children
2678
+ * inherit them (the engine resolves text fonts itself).
2679
+ */
2680
+ export function applyStyle(el: HTMLElement, style: Style): void {
2681
+ const s = el.style;
2682
+
2683
+ if (style.padding) {
2684
+ const { top = 0, leading = 0, bottom = 0, trailing = 0 } = style.padding;
2685
+ el.dataset['sbPadding'] = `${top} ${trailing} ${bottom} ${leading}`;
2686
+ }
2687
+
2688
+ if (style.font) {
2689
+ const { size, weight, design, textStyle, relativeTo } = style.font;
2690
+ if (size != null) {
2691
+ // A fixed size; `relativeTo` scales it like that text style (the
2692
+ // scale factor is a CSS variable the page sets per Dynamic Type size).
2693
+ s.fontSize = relativeTo ? `calc(${size}px * var(--sb-ts-${relativeTo}-scale, 1))` : `${size}px`;
2694
+ // Do not inherit a text style's px line height from an ancestor.
2695
+ s.lineHeight = '1.3';
2696
+ if (relativeTo) el.dataset['sbRelativeTo'] = relativeTo;
2697
+ } else if (textStyle) {
2698
+ // Text styles follow Dynamic Type through CSS variables (see
2699
+ // typography.ts); the fallbacks are the Large sizes.
2700
+ const large = resolveTextStyle(textStyle, DEFAULT_TYPE_SIZE);
2701
+ s.fontSize = `var(${textStyleVar(textStyle, 'size')}, ${large.fontSize}px)`;
2702
+ s.lineHeight = `var(${textStyleVar(textStyle, 'lh')}, ${large.lineHeight}px)`;
2703
+ el.dataset['sbTextStyle'] = textStyle;
2704
+ const implied = TEXT_STYLE_WEIGHT[textStyle];
2705
+ if (!weight && implied) s.fontWeight = FONT_WEIGHT[implied];
2706
+ }
2707
+ if (weight) s.fontWeight = FONT_WEIGHT[weight] ?? '400';
2708
+ if (design === 'monospaced') s.fontFamily = FONT_FAMILY_MONO;
2709
+ else if (design === 'rounded') s.fontFamily = FONT_FAMILY_ROUNDED;
2710
+ else if (design === 'serif') s.fontFamily = FONT_FAMILY_SERIF;
2711
+ else if (design === 'default') s.fontFamily = 'var(--sb-font-family)';
2712
+ }
2713
+
2714
+ if (style.foreground) {
2715
+ const paint = styleToCSS(style.foreground, 'text');
2716
+ // Buttons default to the accent color; a foreground set by an ancestor
2717
+ // styled box wins, as it does in SwiftUI.
2718
+ s.color = paint.solid;
2719
+ s.setProperty('--sb-button-color', paint.solid);
2720
+ if (paint.kind === 'gradient') {
2721
+ // Text paints the gradient through its own background clipped to the
2722
+ // glyphs (see `.sb-text` in styles.css); the custom properties inherit
2723
+ // down to every text in the subtree. `currentColor` consumers
2724
+ // (symbols, strokes) fall back to the first stop set above.
2725
+ s.setProperty('--sb-text-gradient', paint.value);
2726
+ s.setProperty('--sb-text-clip', 'text');
2727
+ s.setProperty('--sb-text-color', 'transparent');
2728
+ s.setProperty('--sb-foreground-paint', paint.value);
2729
+ el.dataset['sbGradientText'] = '';
2730
+ } else {
2731
+ // A flat foreground ends a gradient inherited from an ancestor.
2732
+ s.setProperty('--sb-text-gradient', 'initial');
2733
+ s.setProperty('--sb-text-clip', 'initial');
2734
+ s.setProperty('--sb-text-color', 'initial');
2735
+ s.setProperty('--sb-foreground-paint', 'initial');
2736
+ }
2737
+ }
2738
+
2739
+ if (style.background) {
2740
+ applyPaint(el, styleToCSS(style.background, 'paint'));
2741
+ // A painted background is a view of its own: hittable (styles.css).
2742
+ el.dataset['sbBackground'] = '';
2743
+ } else {
2744
+ delete el.dataset['sbBackground'];
2745
+ }
2746
+
2747
+ if (style.frame) {
2748
+ const { width, height, minWidth, maxWidth, minHeight, maxHeight, alignment } = style.frame;
2749
+ const parts: string[] = [];
2750
+ if (width != null) parts.push(`w${width}`);
2751
+ if (height != null) parts.push(`h${height}`);
2752
+ if (minWidth != null) parts.push(`minw${minWidth}`);
2753
+ if (maxWidth != null) parts.push(`maxw${maxWidth}`);
2754
+ if (minHeight != null) parts.push(`minh${minHeight}`);
2755
+ if (maxHeight != null) parts.push(`maxh${maxHeight}`);
2756
+ el.dataset['sbFrame'] = parts.join(' ');
2757
+ el.dataset['sbFrameAlignment'] = alignment ?? 'center';
2758
+ }
2759
+
2760
+ if (style.cornerRadius != null) {
2761
+ s.borderRadius = `${style.cornerRadius}px`;
2762
+ s.overflow = 'hidden';
2763
+ }
2764
+
2765
+ // Phase 6 visual effects: CSS only, the layout engine never sees them.
2766
+ const effects = effectsCSS(style);
2767
+ if (effects.transform) s.transform = effects.transform;
2768
+ if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
2769
+ if (effects.filter) s.filter = effects.filter;
2770
+ // Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
2771
+ // nothing and takes no input; `.mask { }` clips the box to a style's alpha.
2772
+ if (effects.hidden) {
2773
+ s.visibility = 'hidden';
2774
+ s.pointerEvents = 'none';
2775
+ el.dataset['sbHidden'] = '';
2776
+ }
2777
+ if (effects.maskImage) {
2778
+ for (const prefix of ['', '-webkit-']) {
2779
+ s.setProperty(`${prefix}mask-image`, effects.maskImage);
2780
+ s.setProperty(`${prefix}mask-size`, '100% 100%');
2781
+ s.setProperty(`${prefix}mask-repeat`, 'no-repeat');
2782
+ }
2783
+ }
2784
+ if (style.clipped) {
2785
+ s.overflow = 'hidden';
2786
+ el.dataset['sbClipped'] = '';
2787
+ }
2788
+ // Phase 8: `.allowsHitTesting(false)` lets input through the subtree.
2789
+ if (style.hitTesting === false) {
2790
+ s.pointerEvents = 'none';
2791
+ el.dataset['sbHitTesting'] = 'false';
2792
+ }
2793
+
2794
+ let opacity = style.opacity;
2795
+ if (style.disabled) {
2796
+ // `.disabled(true)`: dim the subtree and block input.
2797
+ opacity = (opacity ?? 1) * 0.4;
2798
+ s.pointerEvents = 'none';
2799
+ el.dataset['sbDisabled'] = '';
2800
+ el.setAttribute('aria-disabled', 'true');
2801
+ } else {
2802
+ el.removeAttribute('aria-disabled');
2803
+ }
2804
+ if (opacity != null) {
2805
+ s.opacity = String(opacity);
2806
+ }
2807
+
2808
+ // Phase 3 text and layout hints (the engine reads them from the node tree).
2809
+ if (style.layoutPriority != null && Number.isFinite(style.layoutPriority)) {
2810
+ el.dataset['sbLayoutPriority'] = String(style.layoutPriority);
2811
+ }
2812
+ if (style.lineLimit != null && style.lineLimit > 0) {
2813
+ el.dataset['sbLineLimit'] = String(Math.floor(style.lineLimit));
2814
+ }
2815
+ if (style.multilineTextAlignment) el.dataset['sbTextAlign'] = style.multilineTextAlignment;
2816
+ if (style.fixedSize) {
2817
+ const { horizontal, vertical } = style.fixedSize;
2818
+ el.dataset['sbFixedSize'] = `${horizontal ? 'h' : ''}${vertical ? 'v' : ''}`;
2819
+ }
2820
+ // Phase 5: `.aspectRatio` is pure layout (the engine sizes the child); the
2821
+ // box reports the child's size either way, so `fill` has nothing to clip
2822
+ // here (SwiftUI needs `.clipped()` for that too).
2823
+ if (style.aspectRatio) {
2824
+ const { ratio, contentMode } = style.aspectRatio;
2825
+ el.dataset['sbAspectRatio'] = `${ratio ?? 'auto'} ${contentMode ?? 'fit'}`;
2826
+ }
2827
+ if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
2828
+ if (style.animation) ANIMATIONS.set(el, style.animation);
2829
+ if (style.transition) setTransition(el, style.transition);
2830
+
2831
+ // Phase 6 (docs/phase6/layout-containers.md).
2832
+ if (style.tint) {
2833
+ // `.tint`: gauges, switches and prominent buttons below read `--sb-tint`.
2834
+ s.setProperty('--sb-tint', colorToCSS(style.tint));
2835
+ }
2836
+ if (style.position) {
2837
+ const { x, y } = style.position;
2838
+ el.dataset['sbPosition'] = `${x} ${y}`;
2839
+ }
2840
+ if (style.gridCellColumns != null && Number.isFinite(style.gridCellColumns)) {
2841
+ el.dataset['sbGridCellColumns'] = String(Math.max(1, Math.floor(style.gridCellColumns)));
2842
+ }
2843
+ if (style.gridCellAnchor) {
2844
+ const { x, y } = style.gridCellAnchor;
2845
+ el.dataset['sbGridCellAnchor'] = `${x} ${y}`;
2846
+ }
2847
+ }
2848
+
2849
+ // Colors and ShapeStyles resolve in src/styles.ts; re-exported for callers of the renderer.
2850
+ export { colorToCSS, styleToCSS } from './styles';
2851
+
2852
+ /**
2853
+ * The `<style>` text declaring every catalog color as a custom property per
2854
+ * theme (the device sets `data-theme`, see styles.css). A colorset without a
2855
+ * dark variant keeps its light value in dark mode, as on iOS. Empty string
2856
+ * for a catalog without colors.
2857
+ */
2858
+ export function assetColorsCSS(catalog: AssetCatalog): string {
2859
+ const names = Object.keys(catalog.colors).sort();
2860
+ if (names.length === 0) return '';
2861
+ const declarations = (scheme: 'light' | 'dark'): string =>
2862
+ names
2863
+ .map((name) => {
2864
+ const color = catalog.colors[name];
2865
+ if (!color) return '';
2866
+ const value = scheme === 'dark' ? (color.dark ?? color.light) : color.light;
2867
+ return ` ${assetColorVar(name)}: ${sanitizeColor(value)};\n`;
2868
+ })
2869
+ .join('');
2870
+ return (
2871
+ `.device[data-theme="light"] {\n${declarations('light')}}\n` +
2872
+ `.device[data-theme="dark"] {\n${declarations('dark')}}\n`
2873
+ );
2874
+ }
2875
+
2876
+ /** A manifest color value is injected into a stylesheet: keep only CSS color syntax characters. */
2877
+ function sanitizeColor(value: string): string {
2878
+ return /^[A-Za-z0-9#(),.%\s/-]+$/.test(value) ? value : 'magenta';
2879
+ }
2880
+
2881
+ // Re-exported so callers can build typed fixtures without importing protocol.ts.
2882
+ export type { Op, ElementKind, ElementPropsByKind, RendererEvent };