@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,521 @@
1
+ /**
2
+ * Animation for the ops protocol (docs/ops-protocol.md, "Phase 3 additions").
3
+ *
4
+ * The renderer records what a render pass did (which elements were updated,
5
+ * what their styles were before, which were inserted, which were removed and
6
+ * where they were) and hands the record to the `Animator` at `commit`. When
7
+ * the commit carries an `animation` (`withAnimation`) or an element's
8
+ * `animationToken` changed in an `update` (`.animation(_:value:)`), the
9
+ * Animator animates the changes of that pass with the Web Animations API:
10
+ *
11
+ * - style changes (opacity, colors, border radius, font size, transform and
12
+ * filter; frames are tweened from the layout engine's boxes) tween from
13
+ * the recorded value to the new one;
14
+ * - inserted elements play their `transition` forwards;
15
+ * - removed elements leave a visual clone (stripped of element ids) at their
16
+ * old position which plays the `transition` backwards and is then dropped.
17
+ *
18
+ * `Animation` maps to CSS easings; `spring` becomes a `linear()` easing
19
+ * sampled from a damped spring where supported.
20
+ */
21
+
22
+ import type { Animation, Edge, Transition, TransitionPart } from './protocol';
23
+ import { ghostClone } from './ghost';
24
+
25
+ /**
26
+ * Style properties tweened between the value before the pass and after.
27
+ * Geometry (left/top/width/height) is animated from the layout engine's
28
+ * frames instead (see `FrameChange`).
29
+ */
30
+ export const ANIMATED_PROPERTIES = [
31
+ 'opacity',
32
+ 'backgroundColor',
33
+ 'color',
34
+ 'borderRadius',
35
+ 'fontSize',
36
+ // Phase 6 visual effects (offset / rotation / scale, shadow).
37
+ 'transform',
38
+ 'filter',
39
+ ] as const;
40
+ export type AnimatedProperty = (typeof ANIMATED_PROPERTIES)[number];
41
+
42
+ export type StyleSnapshot = Partial<Record<AnimatedProperty, string>>;
43
+
44
+ export interface UpdatedRecord {
45
+ el: HTMLElement;
46
+ /** Computed values before the update (null when the element was not rendered). */
47
+ before: StyleSnapshot | null;
48
+ /** `animationToken` changed in this pass; `animation` is the subtree's. */
49
+ tokenChanged: boolean;
50
+ animation?: Animation;
51
+ }
52
+
53
+ export interface RemovedRecord {
54
+ el: HTMLElement;
55
+ /** Ancestors (nearest first) at the time of removal, for token lookup. */
56
+ ancestors: HTMLElement[];
57
+ /** Position relative to the animation root, before removal. */
58
+ rect: DOMRect | null;
59
+ transition: Transition | undefined;
60
+ }
61
+
62
+ export interface PassRecord {
63
+ updated: Map<HTMLElement, UpdatedRecord>;
64
+ /** Elements attached by `insert` in this pass that were detached before. */
65
+ inserted: Set<HTMLElement>;
66
+ removed: RemovedRecord[];
67
+ }
68
+
69
+ export function emptyPass(): PassRecord {
70
+ return { updated: new Map(), inserted: new Set(), removed: [] };
71
+ }
72
+
73
+ export interface FrameRect {
74
+ x: number;
75
+ y: number;
76
+ width: number;
77
+ height: number;
78
+ }
79
+
80
+ /** An element whose layout box changed in the pass (`from` is null for a new element). */
81
+ export interface FrameChange {
82
+ el: HTMLElement;
83
+ kind: string;
84
+ from: FrameRect | null;
85
+ to: FrameRect;
86
+ }
87
+
88
+ export const DEFAULT_DURATION = 0.35;
89
+ export const EASE_IN_OUT = 'cubic-bezier(0.42, 0, 0.58, 1)';
90
+ export const EASE_IN = 'cubic-bezier(0.42, 0, 1, 1)';
91
+ export const EASE_OUT = 'cubic-bezier(0, 0, 0.58, 1)';
92
+ /** Samples of the spring curve in a `linear()` easing. */
93
+ export const SPRING_SAMPLES = 60;
94
+
95
+ const supportsLinearEasing: boolean =
96
+ typeof CSS !== 'undefined' && typeof CSS.supports === 'function'
97
+ ? CSS.supports('animation-timing-function', 'linear(0, 0.5, 1)')
98
+ : false;
99
+
100
+ export interface Timing {
101
+ /** Milliseconds. */
102
+ duration: number;
103
+ delay: number;
104
+ easing: string;
105
+ /**
106
+ * Phase 8 `Animation.repeat`: how many times the tween plays (Infinity for
107
+ * `forever`); absent for a plain animation (WAAPI's default of 1).
108
+ */
109
+ iterations?: number;
110
+ /** Phase 8: `alternate` when the repeat autoreverses; absent otherwise. */
111
+ direction?: PlaybackDirection;
112
+ }
113
+
114
+ /**
115
+ * CSS timing for a protocol `Animation`. The `repeat` fields apply to the
116
+ * style and frame tweens of a commit (`Animator.animateChanges` /
117
+ * `animateFrame`); transitions play once, see `baseTiming`.
118
+ */
119
+ export function timingFor(animation: Animation | undefined): Timing {
120
+ const a = animation ?? { type: 'default' };
121
+ const duration = Math.max(0, (a.duration ?? DEFAULT_DURATION) * 1000);
122
+ const delay = Math.max(0, (a.delay ?? 0) * 1000);
123
+ const timing: Timing = { duration, delay, easing: easingFor(a) };
124
+ const repeat = a.repeat;
125
+ if (repeat && typeof repeat === 'object') {
126
+ if (repeat.count === 'forever') timing.iterations = Infinity;
127
+ else if (typeof repeat.count === 'number' && Number.isFinite(repeat.count) && repeat.count > 0) {
128
+ timing.iterations = repeat.count;
129
+ }
130
+ if (repeat.autoreverses === true && timing.iterations !== undefined) timing.direction = 'alternate';
131
+ }
132
+ return timing;
133
+ }
134
+
135
+ /** `timing` without its repeat: enter / exit transitions play once. */
136
+ export function baseTiming(timing: Timing): Timing {
137
+ return { duration: timing.duration, delay: timing.delay, easing: timing.easing };
138
+ }
139
+
140
+ /** CSS `animation-timing-function` for a protocol `Animation`. */
141
+ export function easingFor(animation: Animation | undefined): string {
142
+ switch (animation?.type) {
143
+ case 'linear':
144
+ return 'linear';
145
+ case 'easeIn':
146
+ return EASE_IN;
147
+ case 'easeOut':
148
+ return EASE_OUT;
149
+ case 'spring':
150
+ return supportsLinearEasing
151
+ ? springEasing(animation.duration ?? 0.5, animation.bounce ?? 0)
152
+ : EASE_IN_OUT;
153
+ case 'default':
154
+ case 'easeInOut':
155
+ default:
156
+ return EASE_IN_OUT;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * `linear()` easing of a damped spring with SwiftUI's `Spring(duration:bounce:)`
162
+ * parameterization: natural frequency ω = 2π / duration, damping ratio
163
+ * ζ = 1 − bounce (so bounce 0 is critically damped and 0.3 overshoots). The
164
+ * curve is sampled at `SPRING_SAMPLES` points over the perceptual duration,
165
+ * by which time the spring has settled to within ~1%.
166
+ */
167
+ export function springEasing(duration: number, bounce: number, samples = SPRING_SAMPLES): string {
168
+ const d = duration > 0 ? duration : 0.5;
169
+ const zeta = Math.min(1, Math.max(0, 1 - bounce));
170
+ const omega = (2 * Math.PI) / d;
171
+ const points: string[] = [];
172
+ for (let i = 0; i < samples; i++) {
173
+ const t = (i / (samples - 1)) * d;
174
+ points.push(springPosition(t, omega, zeta).toFixed(4));
175
+ }
176
+ points[0] = '0';
177
+ points[samples - 1] = '1';
178
+ return `linear(${points.join(', ')})`;
179
+ }
180
+
181
+ function springPosition(t: number, omega: number, zeta: number): number {
182
+ if (zeta >= 1) {
183
+ // Critically damped.
184
+ return 1 - Math.exp(-omega * t) * (1 + omega * t);
185
+ }
186
+ const omegaD = omega * Math.sqrt(1 - zeta * zeta);
187
+ const envelope = Math.exp(-zeta * omega * t);
188
+ return 1 - envelope * (Math.cos(omegaD * t) + ((zeta * omega) / omegaD) * Math.sin(omegaD * t));
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Transitions
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /** SwiftUI's default transition for views inserted or removed with animation. */
196
+ export const DEFAULT_TRANSITION: Transition = { kind: 'opacity' };
197
+
198
+ interface TransitionFrame {
199
+ opacity?: number;
200
+ transform?: string;
201
+ }
202
+
203
+ /** Keyframe of the "gone" state of a transition (enter start / exit end). */
204
+ export function transitionStart(transition: Transition | undefined, exit: boolean): TransitionFrame {
205
+ const acc: { opacity?: number; transforms: string[] } = { transforms: [] };
206
+ collectTransition(transition ?? DEFAULT_TRANSITION, exit, acc, 0);
207
+ const frame: TransitionFrame = {};
208
+ if (acc.opacity !== undefined) frame.opacity = acc.opacity;
209
+ if (acc.transforms.length) frame.transform = acc.transforms.join(' ');
210
+ return frame;
211
+ }
212
+
213
+ /** Nesting limit for `asymmetric` parts (they may nest; a malformed cycle must not recurse forever). */
214
+ const MAX_TRANSITION_DEPTH = 8;
215
+
216
+ function collectTransition(
217
+ transition: Transition,
218
+ exit: boolean,
219
+ acc: { opacity?: number; transforms: string[] },
220
+ depth: number,
221
+ ): void {
222
+ if (depth > MAX_TRANSITION_DEPTH) return;
223
+ const parts: TransitionPart[] = Array.isArray(transition) ? transition : [transition];
224
+ for (const part of parts) {
225
+ if (!part || typeof part !== 'object') continue;
226
+ switch (part.kind) {
227
+ case 'opacity':
228
+ acc.opacity = 0;
229
+ break;
230
+ case 'scale':
231
+ acc.opacity = 0;
232
+ acc.transforms.push(`scale(${part.scale ?? 0.5})`);
233
+ break;
234
+ case 'slide':
235
+ // In from the leading edge, out through the trailing edge.
236
+ acc.transforms.push(edgeTranslation(exit ? 'trailing' : 'leading'));
237
+ break;
238
+ case 'move':
239
+ acc.transforms.push(edgeTranslation(part.edge ?? 'bottom'));
240
+ break;
241
+ case 'offset':
242
+ acc.transforms.push(`translate(${part.x ?? 0}px, ${part.y ?? 0}px)`);
243
+ break;
244
+ case 'asymmetric': {
245
+ // Phase 6: `.asymmetric(insertion:removal:)` plays one transition
246
+ // when the view appears and another when it disappears.
247
+ const chosen = exit ? part.removal : part.insertion;
248
+ if (chosen) collectTransition(chosen, exit, acc, depth + 1);
249
+ break;
250
+ }
251
+ case 'identity':
252
+ default:
253
+ break;
254
+ }
255
+ }
256
+ }
257
+
258
+ function edgeTranslation(edge: Edge): string {
259
+ switch (edge) {
260
+ case 'top':
261
+ return 'translateY(-100%)';
262
+ case 'bottom':
263
+ return 'translateY(100%)';
264
+ case 'leading':
265
+ return 'translateX(-100%)';
266
+ case 'trailing':
267
+ default:
268
+ return 'translateX(100%)';
269
+ }
270
+ }
271
+
272
+ function isIdentity(frame: TransitionFrame): boolean {
273
+ return frame.opacity === undefined && frame.transform === undefined;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Animator
278
+ // ---------------------------------------------------------------------------
279
+
280
+ export const EXIT_CLONE_CLASS = 'sb-exit-clone';
281
+
282
+ export interface AnimatorOptions {
283
+ /** Kinds (by `data-sb-kind`) that animate their own enter/exit (navigation, sheets). */
284
+ selfAnimatingKinds?: ReadonlySet<string>;
285
+ }
286
+
287
+ export class Animator {
288
+ /** The element clones of removed elements are appended to (the screen). */
289
+ private readonly root: HTMLElement;
290
+ private readonly selfAnimating: ReadonlySet<string>;
291
+
292
+ constructor(root: HTMLElement, options: AnimatorOptions = {}) {
293
+ this.root = root;
294
+ this.selfAnimating = options.selfAnimatingKinds ?? new Set();
295
+ }
296
+
297
+ /** Computed values of the animated properties, or null when not rendered. */
298
+ snapshot(el: HTMLElement): StyleSnapshot | null {
299
+ if (!el.isConnected) return null;
300
+ const cs = getComputedStyle(el);
301
+ if (cs.display === 'none') return null;
302
+ const snap: StyleSnapshot = {};
303
+ for (const prop of ANIMATED_PROPERTIES) snap[prop] = cs[prop];
304
+ return snap;
305
+ }
306
+
307
+ /** Rectangle of `el` relative to the animation root. */
308
+ rectOf(el: HTMLElement): DOMRect | null {
309
+ if (!el.isConnected) return null;
310
+ const r = el.getBoundingClientRect();
311
+ if (r.width === 0 && r.height === 0) return null;
312
+ const base = this.root.getBoundingClientRect();
313
+ return new DOMRect(r.left - base.left, r.top - base.top, r.width, r.height);
314
+ }
315
+
316
+ /**
317
+ * Animate the pass. `commitAnimation` is the commit's own animation
318
+ * (`withAnimation`); subtrees whose `animationToken` changed use their own
319
+ * animation instead, as `.animation(_:value:)` overrides the transaction.
320
+ */
321
+ commit(pass: PassRecord, commitAnimation: Animation | undefined, frames: readonly FrameChange[] = []): void {
322
+ const tokenRoots = new Map<HTMLElement, Animation | undefined>();
323
+ for (const [el, rec] of pass.updated) {
324
+ if (rec.tokenChanged) tokenRoots.set(el, rec.animation);
325
+ }
326
+ if (!commitAnimation && tokenRoots.size === 0) return;
327
+
328
+ const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
329
+ for (const el of chain) {
330
+ if (tokenRoots.has(el)) return tokenRoots.get(el) ?? { type: 'default' };
331
+ }
332
+ return commitAnimation ?? false;
333
+ };
334
+
335
+ // Style changes.
336
+ for (const [el, rec] of pass.updated) {
337
+ if (!rec.before || !el.isConnected) continue;
338
+ const animation = animationFor(ancestorsAndSelf(el));
339
+ if (animation === false) continue;
340
+ this.animateChanges(el, rec.before, animation);
341
+ }
342
+
343
+ // Frame changes: tween left/top/width/height from the previous box. The
344
+ // inline values are relative to the parent's origin, so apply the delta.
345
+ for (const change of frames) {
346
+ const { el, from, to } = change;
347
+ if (!from || !el.isConnected || pass.inserted.has(el)) continue;
348
+ if (this.selfAnimating.has(change.kind)) continue;
349
+ const animation = animationFor(ancestorsAndSelf(el));
350
+ if (animation === false) continue;
351
+ this.animateFrame(el, from, to, animation);
352
+ }
353
+
354
+ // Inserted elements: only subtree roots (a nested fade would compound).
355
+ for (const el of pass.inserted) {
356
+ if (!el.isConnected || pass.updated.has(el)) continue;
357
+ if (hasAncestorIn(el, pass.inserted)) continue;
358
+ if (this.selfAnimating.has(el.dataset['sbKind'] ?? '')) continue;
359
+ const animation = animationFor(ancestorsAndSelf(el));
360
+ if (animation === false) continue;
361
+ this.enter(el, transitionOf(el), animation);
362
+ }
363
+
364
+ // Removed elements: leave a clone that plays the transition backwards.
365
+ for (const rec of pass.removed) {
366
+ if (!rec.rect) continue;
367
+ if (this.selfAnimating.has(rec.el.dataset['sbKind'] ?? '')) continue;
368
+ const animation = animationFor([rec.el, ...rec.ancestors]);
369
+ if (animation === false) continue;
370
+ this.exit(rec, animation);
371
+ }
372
+ }
373
+
374
+ /** Tween every animated property whose computed value changed. */
375
+ animateChanges(el: HTMLElement, before: StyleSnapshot, animation: Animation | undefined): void {
376
+ const after = this.snapshot(el);
377
+ if (!after) return;
378
+ const from: Keyframe = {};
379
+ const to: Keyframe = {};
380
+ let changed = false;
381
+ for (const prop of ANIMATED_PROPERTIES) {
382
+ const a = before[prop];
383
+ const b = after[prop];
384
+ if (a === undefined || b === undefined || a === b) continue;
385
+ from[prop] = a;
386
+ to[prop] = b;
387
+ changed = true;
388
+ }
389
+ if (!changed) return;
390
+ const timing = timingFor(animation);
391
+ el.animate([from, to], { ...timing, fill: 'none' });
392
+ }
393
+
394
+ /** Tween an element's box from its previous layout frame to the current one. */
395
+ animateFrame(el: HTMLElement, from: FrameRect, to: FrameRect, animation: Animation | undefined): void {
396
+ const dx = to.x - from.x;
397
+ const dy = to.y - from.y;
398
+ const dw = to.width - from.width;
399
+ const dh = to.height - from.height;
400
+ if (!dx && !dy && !dw && !dh) return;
401
+ const left = parseFloat(el.style.left) || 0;
402
+ const top = parseFloat(el.style.top) || 0;
403
+ const start: Keyframe = {};
404
+ const end: Keyframe = {};
405
+ if (dx) {
406
+ start['left'] = `${left - dx}px`;
407
+ end['left'] = `${left}px`;
408
+ }
409
+ if (dy) {
410
+ start['top'] = `${top - dy}px`;
411
+ end['top'] = `${top}px`;
412
+ }
413
+ if (dw) {
414
+ start['width'] = `${from.width}px`;
415
+ end['width'] = `${to.width}px`;
416
+ }
417
+ if (dh) {
418
+ start['height'] = `${from.height}px`;
419
+ end['height'] = `${to.height}px`;
420
+ }
421
+ const timing = timingFor(animation);
422
+ el.animate([start, end], { ...timing, fill: 'none' });
423
+ }
424
+
425
+ /** Play `transition` forwards on a freshly inserted element. */
426
+ enter(el: HTMLElement, transition: Transition | undefined, animation: Animation | undefined): void {
427
+ const start = transitionStart(transition, false);
428
+ if (isIdentity(start)) return;
429
+ const from: Keyframe = {};
430
+ const to: Keyframe = {};
431
+ if (start.opacity !== undefined) {
432
+ from['opacity'] = start.opacity;
433
+ to['opacity'] = 1;
434
+ }
435
+ if (start.transform !== undefined) {
436
+ // A box with its own transform (offset, rotation, scale) keeps it: the
437
+ // transition's transform is applied on top of it.
438
+ const own = el.style.transform;
439
+ from['transform'] = own ? `${own} ${start.transform}` : start.transform;
440
+ to['transform'] = own || 'none';
441
+ }
442
+ const timing = baseTiming(timingFor(animation));
443
+ el.animate([from, to], { ...timing, fill: 'backwards' });
444
+ }
445
+
446
+ /**
447
+ * Re-insert a visual clone of a removed element at its old position and
448
+ * play its transition backwards; the clone is dropped when done.
449
+ */
450
+ exit(rec: RemovedRecord, animation: Animation | undefined): HTMLElement | null {
451
+ const end = transitionStart(rec.transition, true);
452
+ if (isIdentity(end) || !rec.rect) return null;
453
+ const clone = ghostClone(rec.el);
454
+ clone.classList.add(EXIT_CLONE_CLASS);
455
+ clone.setAttribute('aria-hidden', 'true');
456
+ clone.toggleAttribute('inert', true);
457
+ const s = clone.style;
458
+ s.position = 'absolute';
459
+ s.left = `${rec.rect.left}px`;
460
+ s.top = `${rec.rect.top}px`;
461
+ s.width = `${rec.rect.width}px`;
462
+ s.height = `${rec.rect.height}px`;
463
+ s.margin = '0';
464
+ s.pointerEvents = 'none';
465
+ this.root.append(clone);
466
+
467
+ const from: Keyframe = {};
468
+ const to: Keyframe = {};
469
+ if (end.opacity !== undefined) {
470
+ from['opacity'] = getComputedStyle(clone).opacity;
471
+ to['opacity'] = end.opacity;
472
+ }
473
+ if (end.transform !== undefined) {
474
+ const own = clone.style.transform;
475
+ from['transform'] = own || 'none';
476
+ to['transform'] = own ? `${own} ${end.transform}` : end.transform;
477
+ }
478
+ const timing = baseTiming(timingFor(animation));
479
+ const anim = clone.animate([from, to], { ...timing, fill: 'forwards' });
480
+ const done = (): void => clone.remove();
481
+ anim.finished.then(done, done);
482
+ window.setTimeout(done, timing.duration + timing.delay + 100);
483
+ return clone;
484
+ }
485
+ }
486
+
487
+ /** Transitions are stored by the renderer per element (see `setTransition`). */
488
+ const TRANSITIONS = new WeakMap<HTMLElement, Transition>();
489
+
490
+ export function setTransition(el: HTMLElement, transition: Transition | undefined): void {
491
+ if (transition) TRANSITIONS.set(el, transition);
492
+ else TRANSITIONS.delete(el);
493
+ }
494
+
495
+ /**
496
+ * The transition of a subtree root: its own, or the one on a single-child
497
+ * chain of `styled` boxes below it (`Text.transition(.opacity).padding()`
498
+ * is `styled{padding} > styled{transition} > text`), else the default.
499
+ */
500
+ export function transitionOf(root: HTMLElement): Transition | undefined {
501
+ let el: HTMLElement | null = root;
502
+ while (el) {
503
+ const own = TRANSITIONS.get(el);
504
+ if (own) return own;
505
+ if (!el.classList.contains('sb-styled')) break;
506
+ const next: Element | null = el.firstElementChild;
507
+ el = next instanceof HTMLElement && el.children.length === 1 ? next : null;
508
+ }
509
+ return undefined;
510
+ }
511
+
512
+ function* ancestorsAndSelf(el: HTMLElement): Generator<HTMLElement> {
513
+ for (let node: HTMLElement | null = el; node; node = node.parentElement) yield node;
514
+ }
515
+
516
+ function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean {
517
+ for (let node = el.parentElement; node; node = node.parentElement) {
518
+ if (set.has(node)) return true;
519
+ }
520
+ return false;
521
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Asset catalog manifest (docs/ops-protocol.md, "Phase 5 additions").
3
+ *
4
+ * An Xcode asset catalog (`Examples/<App>/Assets.xcassets`) holds the images
5
+ * and colors a SwiftUI app refers to by name (`Image("dough/plain-full")`,
6
+ * `Color("dough/plain-bg")`). The Swift side only ever sends the name; the
7
+ * dev server and the build turn every catalog into one JSON manifest plus the
8
+ * image files (plugins/asset-catalogs.ts), and the page fetches the manifest
9
+ * before loading the app. The layout engine reads intrinsic sizes from it,
10
+ * the renderer resolves `src` and color values.
11
+ *
12
+ * This module is shared by the browser code and the Node plugin, so it must
13
+ * stay free of DOM and Node APIs.
14
+ */
15
+
16
+ export type AssetImageKind = 'image' | 'symbol';
17
+
18
+ export interface AssetImage {
19
+ /** Absolute URL path of the image file (`/catalog/<App>/<file>`). */
20
+ src: string;
21
+ /** Intrinsic size in points (pixel size ÷ `scale`). Irrelevant for `symbol`. */
22
+ width: number;
23
+ height: number;
24
+ /** Scale of the file the manifest picked (2 for `@2x`), so pixels = points × scale. */
25
+ scale: number;
26
+ /** `image` for an `.imageset`; `symbol` for an `.symbolset` (an SVG drawn as a template). */
27
+ kind: AssetImageKind;
28
+ }
29
+
30
+ export interface AssetColor {
31
+ /** CSS color for the light appearance (`rgba(r, g, b, a)`). */
32
+ light: string;
33
+ /** CSS color for the dark appearance; absent when the colorset has none. */
34
+ dark?: string;
35
+ }
36
+
37
+ export interface AssetCatalog {
38
+ /** Keyed by catalog name, namespaced with `/` (`dough/plain-full`). */
39
+ images: Record<string, AssetImage>;
40
+ colors: Record<string, AssetColor>;
41
+ }
42
+
43
+ /** The catalog of an app without one (and of the mock app). */
44
+ export const EMPTY_CATALOG: Readonly<AssetCatalog> = Object.freeze({ images: {}, colors: {} });
45
+
46
+ /** URL directory under which manifests and files are served. */
47
+ export const CATALOG_DIR = 'catalog';
48
+
49
+ /** URL path of an app's manifest (`/catalog/<App>.json`). */
50
+ export function catalogPath(app: string): string {
51
+ return `/${CATALOG_DIR}/${app}.json`;
52
+ }
53
+
54
+ /**
55
+ * File name an asset is served under: the namespaced name with every `/`
56
+ * replaced by `__`, plus the extension (`dough__plain-full.png`). Flattening
57
+ * keeps every file of a catalog in one directory.
58
+ */
59
+ export function assetFileName(name: string, ext: string): string {
60
+ // `/` becomes `__`; spaces and anything else outside [A-Za-z0-9_.-] become
61
+ // `_`, so the served path needs no URL encoding and matches the request
62
+ // pattern the dev middleware accepts.
63
+ return `${name.replace(/\//g, '__').replace(/[^A-Za-z0-9_.-]/g, '_')}.${ext}`;
64
+ }
65
+
66
+ /** URL path of an asset file (`/catalog/<App>/dough__plain-full.png`). */
67
+ export function assetFilePath(app: string, name: string, ext: string): string {
68
+ return `/${CATALOG_DIR}/${app}/${assetFileName(name, ext)}`;
69
+ }
70
+
71
+ /**
72
+ * CSS custom property that carries a catalog color: `--sb-asset-color-<slug>`.
73
+ * The slug keeps letters, digits, `-` and `_`; `/` becomes `__` (like file
74
+ * names) and anything else `_`.
75
+ */
76
+ export function assetColorVar(name: string): string {
77
+ const slug = name.replace(/\//g, '__').replace(/[^A-Za-z0-9_-]/g, '_');
78
+ return `--sb-asset-color-${slug}`;
79
+ }
80
+
81
+ /** Cheap structural check for a fetched manifest; anything else is treated as empty. */
82
+ export function isAssetCatalog(value: unknown): value is AssetCatalog {
83
+ if (typeof value !== 'object' || value === null) return false;
84
+ const { images, colors } = value as { images?: unknown; colors?: unknown };
85
+ return typeof images === 'object' && images !== null && typeof colors === 'object' && colors !== null;
86
+ }