@swiftbrowser/web 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -32,7 +32,9 @@ src/bitmaps.ts UIImage bitmaps: draws recipes on each image's <canvas>, thr
32
32
  src/bitmapStore.ts The bitmap store: keeps `imagedata` sources, decodes them, draws recipes (bitmapPlan.ts, imageOps.ts); no DOM
33
33
  src/bitmapWorker.ts The store in a worker, answering drawings with ImageBitmaps
34
34
  src/imageOps.ts Core Image's steps (sepia, crystallize, edges, blur, pixellate, unsharp mask, vignette, crop) on rasters; no DOM
35
- src/styles.css iOS tokens, device frame, device mode, element styles
35
+ src/hostContent.ts `host` elements: the registry of the page's own DOM elements the renderer adopts (docs/ops-protocol.md, "Host elements")
36
+ src/pointerEffects.ts Hover events, tooltips, cursors and symbol effects for page-side producers; frame reports' safe-area overlap (docs/ops-protocol.md, "Pointer, effects, matched geometry and frames on screen")
37
+ src/styles.css iOS tokens, device frame, device mode, element styles, host boxes and islands
36
38
  node/ Node side: apps.ts (the AppSpec registry type), config.ts (Vite config from specs),
37
39
  examples.ts (Examples/* as specs, build-wasm.sh runner, descriptions),
38
40
  index.ts (createDevServer / buildSite, the package entry); compiled to node/dist/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -14,7 +14,9 @@
14
14
  ".": {
15
15
  "types": "./node/dist/node/index.d.ts",
16
16
  "default": "./node/dist/node/index.js"
17
- }
17
+ },
18
+ "./src/styles.css": "./src/styles.css",
19
+ "./src/*": "./src/*.ts"
18
20
  },
19
21
  "files": [
20
22
  "node/dist",
package/src/animation.ts CHANGED
@@ -56,6 +56,11 @@ export interface UpdatedRecord {
56
56
  /** `animationToken` changed in this pass; `animation` is the subtree's. */
57
57
  tokenChanged: boolean;
58
58
  animation?: Animation;
59
+ /**
60
+ * The change animates by default (an inspector shown or hidden): with the
61
+ * commit's animation when it has one, else with `default`.
62
+ */
63
+ implicit?: boolean;
59
64
  }
60
65
 
61
66
  export interface RemovedRecord {
@@ -65,6 +70,17 @@ export interface RemovedRecord {
65
70
  /** Position relative to the animation root, before removal. */
66
71
  rect: DOMRect | null;
67
72
  transition: Transition | undefined;
73
+ /** `.matchedGeometryEffect` boxes in the removed subtree (itself included): their ids and where they were. */
74
+ matched?: { id: string; el: HTMLElement; rect: DOMRect }[];
75
+ }
76
+
77
+ /** A text whose content changed under a `contentTransition`: a copy of how it looked, and where. */
78
+ export interface ContentChange {
79
+ el: HTMLElement;
80
+ clone: HTMLElement;
81
+ rect: DOMRect;
82
+ kind: 'numericText' | 'opacity' | 'interpolate';
83
+ countsDown: boolean;
68
84
  }
69
85
 
70
86
  export interface PassRecord {
@@ -72,10 +88,11 @@ export interface PassRecord {
72
88
  /** Elements attached by `insert` in this pass that were detached before. */
73
89
  inserted: Set<HTMLElement>;
74
90
  removed: RemovedRecord[];
91
+ contentChanges: ContentChange[];
75
92
  }
76
93
 
77
94
  export function emptyPass(): PassRecord {
78
- return { updated: new Map(), inserted: new Set(), removed: [] };
95
+ return { updated: new Map(), inserted: new Set(), removed: [], contentChanges: [] };
79
96
  }
80
97
 
81
98
  export interface FrameRect {
@@ -401,11 +418,13 @@ export class Animator {
401
418
  * Animate the pass. `commitAnimation` is the commit's own animation
402
419
  * (`withAnimation`); subtrees whose `animationToken` changed use their own
403
420
  * animation instead, as `.animation(_:value:)` overrides the transaction.
421
+ * An `implicit` change (an inspector shown or hidden) animates even without
422
+ * one, with the commit's animation or `default`.
404
423
  */
405
424
  commit(pass: PassRecord, commitAnimation: Animation | undefined, frames: readonly FrameChange[] = []): void {
406
425
  const tokenRoots = new Map<HTMLElement, Animation | undefined>();
407
426
  for (const [el, rec] of pass.updated) {
408
- if (rec.tokenChanged) tokenRoots.set(el, rec.animation);
427
+ if (rec.tokenChanged) tokenRoots.set(el, rec.implicit ? commitAnimation : rec.animation);
409
428
  }
410
429
  if (!commitAnimation && tokenRoots.size === 0) {
411
430
  // Not animated: frames still tweening toward a box this pass replaced
@@ -414,6 +433,13 @@ export class Animator {
414
433
  return;
415
434
  }
416
435
 
436
+ // Matched geometry: a box inserted with the id of a removed one starts
437
+ // from the removed one's frame, and the removed one's exit travels to
438
+ // the new frame. Both frames are read now, before the tweens below hold
439
+ // ancestors at their old geometry.
440
+ const matchedPairs = this.matchedPairs(pass);
441
+ const matchedTo = new Map<RemovedRecord, { from: DOMRect; to: DOMRect }>();
442
+
417
443
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
418
444
  for (const el of chain) {
419
445
  if (tokenRoots.has(el)) return tokenRoots.get(el) ?? { type: 'default' };
@@ -475,6 +501,22 @@ export class Animator {
475
501
  this.animateFrame(change.el, start, change.to, animation);
476
502
  }
477
503
 
504
+ // Matched geometry. The inserted box starts from the removed one's frame
505
+ // relative to where it is drawn now: its ancestors' tweens have started
506
+ // and carry it along, so the two together move it on screen. The removed
507
+ // box's exit clone sits at the root and travels to the final frame, read
508
+ // before any tween began.
509
+ if (matchedPairs.length > 0) {
510
+ const base = this.rootRect();
511
+ for (const pair of matchedPairs) {
512
+ const animation = animationFor([pair.box, ...ancestorsOf(pair.box)]);
513
+ if (animation === false) continue;
514
+ const drawn = this.rectOf(pair.box, base) ?? pair.to;
515
+ this.animateMatched(pair.box, pair.from, drawn, animation);
516
+ if (pair.rec.rect) matchedTo.set(pair.rec, { from: pair.from, to: pair.to });
517
+ }
518
+ }
519
+
478
520
  // Inserted elements: only subtree roots (a nested fade would compound).
479
521
  for (const el of pass.inserted) {
480
522
  if (!el.isConnected || pass.updated.has(el)) continue;
@@ -485,14 +527,116 @@ export class Animator {
485
527
  this.enter(el, transitionOf(el), animation);
486
528
  }
487
529
 
530
+ // Content transitions: the old text leaves as the new one arrives.
531
+ for (const change of pass.contentChanges) {
532
+ if (!change.el.isConnected) continue;
533
+ const animation = animationFor(ancestorsAndSelf(change.el));
534
+ if (animation === false) continue;
535
+ this.transitionContent(change, animation);
536
+ }
537
+
488
538
  // Removed elements: leave a clone that plays the transition backwards.
489
539
  for (const rec of pass.removed) {
490
540
  if (!rec.rect) continue;
491
541
  if (this.selfAnimating.has(rec.el.dataset['sbKind'] ?? '')) continue;
492
542
  const animation = animationFor([rec.el, ...rec.ancestors]);
493
543
  if (animation === false) continue;
494
- this.exit(rec, animation);
544
+ const clone = this.exit(rec, animation);
545
+ const move = matchedTo.get(rec);
546
+ if (clone && move) this.moveClone(clone, move.from, move.to, animation);
547
+ }
548
+ }
549
+
550
+ /** The matched boxes this pass inserted, with the frame of the removed box of the same id and their own. */
551
+ private matchedPairs(pass: PassRecord): { box: HTMLElement; rec: RemovedRecord; from: DOMRect; to: DOMRect }[] {
552
+ const removed = new Map<string, { rec: RemovedRecord; rect: DOMRect }>();
553
+ for (const rec of pass.removed) {
554
+ for (const m of rec.matched ?? []) removed.set(m.id, { rec, rect: m.rect });
495
555
  }
556
+ if (removed.size === 0) return [];
557
+ const pairs: { box: HTMLElement; rec: RemovedRecord; from: DOMRect; to: DOMRect }[] = [];
558
+ const base = this.rootRect();
559
+ for (const el of pass.inserted) {
560
+ if (!el.isConnected || hasAncestorIn(el, pass.inserted)) continue;
561
+ const boxes = el.dataset['sbMatched'] !== undefined ? [el] : [];
562
+ boxes.push(...el.querySelectorAll<HTMLElement>('[data-sb-matched]'));
563
+ for (const box of boxes) {
564
+ const source = removed.get(box.dataset['sbMatched']!);
565
+ const to = source ? this.rectOf(box, base) : null;
566
+ if (source && to) pairs.push({ box, rec: source.rec, from: source.rect, to });
567
+ }
568
+ }
569
+ return pairs;
570
+ }
571
+
572
+ /**
573
+ * `.contentTransition`: a copy of the old text at its old place leaves
574
+ * (upward for `numericText`, downward when counting down) while the new
575
+ * text comes in from the other side; `opacity` crossfades in place.
576
+ */
577
+ transitionContent(change: ContentChange, animation: Animation | undefined): void {
578
+ const clone = change.clone;
579
+ clone.classList.add(EXIT_CLONE_CLASS);
580
+ clone.setAttribute('aria-hidden', 'true');
581
+ clone.toggleAttribute('inert', true);
582
+ const s = clone.style;
583
+ s.position = 'absolute';
584
+ s.left = `${change.rect.left}px`;
585
+ s.top = `${change.rect.top}px`;
586
+ s.width = `${change.rect.width}px`;
587
+ s.height = `${change.rect.height}px`;
588
+ s.margin = '0';
589
+ s.pointerEvents = 'none';
590
+ this.root.append(clone);
591
+ const timing = baseTiming(timingFor(animation));
592
+ const rolls = change.kind === 'numericText';
593
+ const away = change.countsDown ? '0 45%' : '0 -45%';
594
+ const toward = change.countsDown ? '0 -45%' : '0 45%';
595
+ const out = clone.animate(
596
+ [
597
+ { opacity: 1, translate: '0 0', filter: 'blur(0px)' },
598
+ { opacity: 0, translate: rolls ? away : '0 0', filter: rolls ? 'blur(2px)' : 'blur(0px)' },
599
+ ],
600
+ { ...timing, fill: 'forwards' },
601
+ );
602
+ change.el.animate(
603
+ [
604
+ { opacity: 0, translate: rolls ? toward : '0 0', filter: rolls ? 'blur(2px)' : 'blur(0px)' },
605
+ { opacity: 1, translate: '0 0', filter: 'blur(0px)' },
606
+ ],
607
+ { ...timing, fill: 'backwards' },
608
+ );
609
+ const done = (): void => clone.remove();
610
+ out.finished.then(done, done);
611
+ window.setTimeout(done, timing.duration + timing.delay + 100);
612
+ }
613
+
614
+ /** FLIP a matched box from the removed one's frame to its own. */
615
+ animateMatched(el: HTMLElement, from: DOMRect, to: DOMRect, animation: Animation | undefined): void {
616
+ const sx = to.width > 0 ? from.width / to.width : 1;
617
+ const sy = to.height > 0 ? from.height / to.height : 1;
618
+ const own = el.style.transform;
619
+ const start = `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${sx}, ${sy})`;
620
+ const timing = baseTiming(timingFor(animation));
621
+ el.animate(
622
+ [
623
+ { transformOrigin: '0 0', transform: own ? `${start} ${own}` : start },
624
+ { transformOrigin: '0 0', transform: own || 'none' },
625
+ ],
626
+ { ...timing, fill: 'backwards' },
627
+ );
628
+ }
629
+
630
+ /** Move a removed box's exit clone so its matched box ends on the new frame. */
631
+ private moveClone(clone: HTMLElement, from: DOMRect, to: DOMRect, animation: Animation | undefined): void {
632
+ const sx = from.width > 0 ? to.width / from.width : 1;
633
+ const sy = from.height > 0 ? to.height / from.height : 1;
634
+ const timing = baseTiming(timingFor(animation));
635
+ clone.style.transformOrigin = `${from.left - parseFloat(clone.style.left || '0')}px ${from.top - parseFloat(clone.style.top || '0')}px`;
636
+ clone.animate(
637
+ [{ transform: 'none' }, { transform: `translate(${to.left - from.left}px, ${to.top - from.top}px) scale(${sx}, ${sy})` }],
638
+ { ...timing, fill: 'forwards', composite: 'add' },
639
+ );
496
640
  }
497
641
 
498
642
  /** Tween every animated property whose computed value changed. */
@@ -746,3 +890,10 @@ function jumpAbove(
746
890
  }
747
891
  return x || y ? { x, y } : null;
748
892
  }
893
+
894
+ /** The element's ancestors, nearest first. */
895
+ function ancestorsOf(el: HTMLElement): HTMLElement[] {
896
+ const out: HTMLElement[] = [];
897
+ for (let a = el.parentElement; a; a = a.parentElement) out.push(a);
898
+ return out;
899
+ }