domotion-svg 0.5.0 → 0.6.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 (216) hide show
  1. package/README.md +33 -3
  2. package/dist/animation/animator.d.ts +26 -2
  3. package/dist/animation/animator.js +125 -3
  4. package/dist/animation/index.d.ts +1 -0
  5. package/dist/animation/index.js +3 -2
  6. package/dist/animation/magic-move.d.ts +61 -0
  7. package/dist/animation/magic-move.js +243 -0
  8. package/dist/capture/index.js +117 -3
  9. package/dist/capture/script/index.js +5 -0
  10. package/dist/capture/script/pseudo-rules.d.ts +1 -1
  11. package/dist/capture/script/pseudo-rules.js +10 -3
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -1
  13. package/dist/capture/script/walker/borders-backgrounds.js +22 -9
  14. package/dist/capture/script/walker/masks-clips.js +35 -12
  15. package/dist/capture/script/walker/text-segments.d.ts +2 -2
  16. package/dist/capture/script/walker/text-segments.js +14 -3
  17. package/dist/capture/script.generated.js +1 -1
  18. package/dist/capture/types.d.ts +24 -0
  19. package/dist/cli/animate.d.ts +3 -2
  20. package/dist/cli/animate.js +33 -4
  21. package/dist/cli/capture.js +95 -5
  22. package/dist/cli/common.d.ts +10 -0
  23. package/dist/cli/common.js +35 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +27 -2
  26. package/dist/cli/review.d.ts +18 -0
  27. package/dist/cli/review.js +213 -0
  28. package/dist/cli/svg-to-video-core.d.ts +133 -0
  29. package/dist/cli/svg-to-video-core.js +533 -0
  30. package/dist/cli/svg-to-video.d.ts +14 -0
  31. package/dist/cli/svg-to-video.js +161 -0
  32. package/dist/render/borders.d.ts +50 -0
  33. package/dist/render/borders.js +55 -0
  34. package/dist/render/element-tree-to-svg.d.ts +60 -2
  35. package/dist/render/element-tree-to-svg.js +273 -71
  36. package/dist/render/embedded-font-builder.js +38 -0
  37. package/dist/render/glyph-helper.d.ts +73 -0
  38. package/dist/render/{coretext.js → glyph-helper.js} +68 -21
  39. package/dist/render/helper-acquire.d.ts +67 -0
  40. package/dist/render/helper-acquire.js +189 -0
  41. package/dist/render/index.d.ts +2 -1
  42. package/dist/render/index.js +6 -1
  43. package/dist/render/text-to-path.d.ts +145 -0
  44. package/dist/render/text-to-path.js +863 -92
  45. package/dist/render/text.js +153 -13
  46. package/dist/review/client.bundle.generated.d.ts +1 -0
  47. package/dist/review/client.bundle.generated.js +3 -0
  48. package/dist/review/client.d.ts +10 -0
  49. package/dist/review/client.js +197 -0
  50. package/dist/review/compare-pngs.d.ts +151 -0
  51. package/dist/review/compare-pngs.js +475 -0
  52. package/dist/review/region-overlay.d.ts +50 -0
  53. package/dist/review/region-overlay.js +361 -0
  54. package/dist/review/server.d.ts +45 -0
  55. package/dist/review/server.js +140 -0
  56. package/dist/scroll/composer.d.ts +7 -1
  57. package/dist/scroll/composer.js +14 -10
  58. package/package.json +10 -8
  59. package/dist/animation/animator.test.d.ts +0 -5
  60. package/dist/animation/animator.test.js +0 -418
  61. package/dist/animation/cursor-overlay.test.d.ts +0 -1
  62. package/dist/animation/cursor-overlay.test.js +0 -88
  63. package/dist/cli/animate.test.d.ts +0 -7
  64. package/dist/cli/animate.test.js +0 -185
  65. package/dist/cross-origin-font-face.test.d.ts +0 -1
  66. package/dist/cross-origin-font-face.test.js +0 -107
  67. package/dist/dark-mode-capture.test.d.ts +0 -1
  68. package/dist/dark-mode-capture.test.js +0 -158
  69. package/dist/dark-mode-form-controls.test.d.ts +0 -1
  70. package/dist/dark-mode-form-controls.test.js +0 -218
  71. package/dist/embed-remote-images.test.d.ts +0 -1
  72. package/dist/embed-remote-images.test.js +0 -424
  73. package/dist/kerfjs-imports.test.d.ts +0 -1
  74. package/dist/kerfjs-imports.test.js +0 -36
  75. package/dist/mask.test.d.ts +0 -1
  76. package/dist/mask.test.js +0 -211
  77. package/dist/post-processing/optimize.test.d.ts +0 -1
  78. package/dist/post-processing/optimize.test.js +0 -40
  79. package/dist/preserve-aspect-ratio.test.d.ts +0 -1
  80. package/dist/preserve-aspect-ratio.test.js +0 -38
  81. package/dist/render/borders.test.d.ts +0 -1
  82. package/dist/render/borders.test.js +0 -148
  83. package/dist/render/conic-raster.test.d.ts +0 -1
  84. package/dist/render/conic-raster.test.js +0 -187
  85. package/dist/render/coretext.d.ts +0 -60
  86. package/dist/render/coretext.test.d.ts +0 -1
  87. package/dist/render/coretext.test.js +0 -94
  88. package/dist/render/form-controls.test.d.ts +0 -7
  89. package/dist/render/form-controls.test.js +0 -37
  90. package/dist/render/gradients.test.d.ts +0 -1
  91. package/dist/render/gradients.test.js +0 -184
  92. package/dist/render/text-to-path.test.d.ts +0 -1
  93. package/dist/render/text-to-path.test.js +0 -951
  94. package/dist/render/text.test.d.ts +0 -1
  95. package/dist/render/text.test.js +0 -234
  96. package/dist/scroll/composer.test.d.ts +0 -1
  97. package/dist/scroll/composer.test.js +0 -452
  98. package/dist/scroll/executor.test.d.ts +0 -1
  99. package/dist/scroll/executor.test.js +0 -236
  100. package/dist/scroll/hoist-fixed.test.d.ts +0 -1
  101. package/dist/scroll/hoist-fixed.test.js +0 -103
  102. package/dist/scroll/hoist-sticky.test.d.ts +0 -1
  103. package/dist/scroll/hoist-sticky.test.js +0 -154
  104. package/dist/scroll/pattern.test.d.ts +0 -1
  105. package/dist/scroll/pattern.test.js +0 -438
  106. package/dist/stacking-context.test.d.ts +0 -1
  107. package/dist/stacking-context.test.js +0 -927
  108. package/dist/tree-ops/frame-merge.test.d.ts +0 -6
  109. package/dist/tree-ops/frame-merge.test.js +0 -189
  110. package/dist/tree-ops/resize-embedded-images.test.d.ts +0 -9
  111. package/dist/tree-ops/resize-embedded-images.test.js +0 -255
  112. package/dist/tree-ops/tree-diff.test.d.ts +0 -1
  113. package/dist/tree-ops/tree-diff.test.js +0 -267
  114. package/dist/tree-ops/viewbox-culling.test.d.ts +0 -1
  115. package/dist/tree-ops/viewbox-culling.test.js +0 -240
  116. package/dist/utils/region-feedback.test.d.ts +0 -1
  117. package/dist/utils/region-feedback.test.js +0 -221
  118. package/dist/webfont-unicode-range.test.d.ts +0 -1
  119. package/dist/webfont-unicode-range.test.js +0 -174
  120. package/src/animation/animator.test.ts +0 -445
  121. package/src/animation/animator.ts +0 -927
  122. package/src/animation/cursor-overlay.test.ts +0 -95
  123. package/src/animation/cursor-overlay.ts +0 -295
  124. package/src/animation/index.ts +0 -29
  125. package/src/capture/embed.ts +0 -305
  126. package/src/capture/emoji.ts +0 -226
  127. package/src/capture/index.ts +0 -1309
  128. package/src/capture/script/color-norm.ts +0 -80
  129. package/src/capture/script/emoji-detect.ts +0 -97
  130. package/src/capture/script/font-metrics.ts +0 -130
  131. package/src/capture/script/index.ts +0 -1514
  132. package/src/capture/script/placeholder-shown.ts +0 -54
  133. package/src/capture/script/pseudo-rules.ts +0 -223
  134. package/src/capture/script/utils.ts +0 -18
  135. package/src/capture/script/walker/borders-backgrounds.ts +0 -277
  136. package/src/capture/script/walker/counter-style-resolver.ts +0 -184
  137. package/src/capture/script/walker/form-controls.ts +0 -242
  138. package/src/capture/script/walker/input-value.ts +0 -221
  139. package/src/capture/script/walker/lists-counters.ts +0 -110
  140. package/src/capture/script/walker/masks-clips.ts +0 -181
  141. package/src/capture/script/walker/pseudo-content.ts +0 -784
  142. package/src/capture/script/walker/pseudo-inject.ts +0 -203
  143. package/src/capture/script/walker/replaced-elements.ts +0 -102
  144. package/src/capture/script/walker/text-segments.ts +0 -404
  145. package/src/capture/script/walker/transforms.ts +0 -160
  146. package/src/capture/script/warnings.ts +0 -39
  147. package/src/capture/script.generated.ts +0 -7
  148. package/src/capture/types.ts +0 -891
  149. package/src/capture/warnings.ts +0 -39
  150. package/src/cli/animate.test.ts +0 -225
  151. package/src/cli/animate.ts +0 -956
  152. package/src/cli/capture.ts +0 -200
  153. package/src/cli/common.ts +0 -196
  154. package/src/cli/index.ts +0 -183
  155. package/src/cross-origin-font-face.test.ts +0 -119
  156. package/src/dark-mode-capture.test.ts +0 -178
  157. package/src/dark-mode-form-controls.test.ts +0 -229
  158. package/src/embed-remote-images.test.ts +0 -460
  159. package/src/globals.d.ts +0 -2
  160. package/src/index.ts +0 -82
  161. package/src/kerf-jsx-augmentation.d.ts +0 -36
  162. package/src/kerfjs-imports.test.tsx +0 -45
  163. package/src/mask.test.ts +0 -279
  164. package/src/post-processing/gzip.ts +0 -14
  165. package/src/post-processing/index.ts +0 -5
  166. package/src/post-processing/optimize.test.ts +0 -48
  167. package/src/post-processing/optimize.ts +0 -34
  168. package/src/preserve-aspect-ratio.test.ts +0 -49
  169. package/src/render/borders.test.ts +0 -160
  170. package/src/render/borders.ts +0 -783
  171. package/src/render/box-shadow.ts +0 -65
  172. package/src/render/colors.ts +0 -163
  173. package/src/render/conic-raster.test.ts +0 -213
  174. package/src/render/conic-raster.ts +0 -306
  175. package/src/render/coretext.test.ts +0 -131
  176. package/src/render/coretext.ts +0 -256
  177. package/src/render/css-tokens.ts +0 -44
  178. package/src/render/element-tree-to-svg.ts +0 -5660
  179. package/src/render/embedded-font-builder.ts +0 -221
  180. package/src/render/form-controls.test.ts +0 -42
  181. package/src/render/form-controls.ts +0 -1195
  182. package/src/render/format.ts +0 -24
  183. package/src/render/gradients.test.ts +0 -221
  184. package/src/render/gradients.ts +0 -1047
  185. package/src/render/index.ts +0 -22
  186. package/src/render/opentype.js.d.ts +0 -7
  187. package/src/render/text-to-path.test.ts +0 -1050
  188. package/src/render/text-to-path.ts +0 -2902
  189. package/src/render/text.test.ts +0 -262
  190. package/src/render/text.ts +0 -1017
  191. package/src/render/transforms.ts +0 -41
  192. package/src/scroll/composer.test.ts +0 -505
  193. package/src/scroll/composer.ts +0 -375
  194. package/src/scroll/executor.test.ts +0 -270
  195. package/src/scroll/executor.ts +0 -592
  196. package/src/scroll/hoist-fixed.test.ts +0 -117
  197. package/src/scroll/hoist-fixed.ts +0 -95
  198. package/src/scroll/hoist-sticky.test.ts +0 -173
  199. package/src/scroll/hoist-sticky.ts +0 -193
  200. package/src/scroll/index.ts +0 -36
  201. package/src/scroll/pattern.test.ts +0 -531
  202. package/src/scroll/pattern.ts +0 -610
  203. package/src/stacking-context.test.ts +0 -968
  204. package/src/tree-ops/frame-merge.test.ts +0 -208
  205. package/src/tree-ops/frame-merge.ts +0 -470
  206. package/src/tree-ops/index.ts +0 -11
  207. package/src/tree-ops/resize-embedded-images.test.ts +0 -292
  208. package/src/tree-ops/resize-embedded-images.ts +0 -177
  209. package/src/tree-ops/tree-diff.test.ts +0 -295
  210. package/src/tree-ops/tree-diff.ts +0 -236
  211. package/src/tree-ops/viewbox-culling.test.ts +0 -292
  212. package/src/tree-ops/viewbox-culling.ts +0 -327
  213. package/src/utils/escapeHtml.ts +0 -16
  214. package/src/utils/region-feedback.test.ts +0 -261
  215. package/src/utils/region-feedback.ts +0 -216
  216. package/src/webfont-unicode-range.test.ts +0 -207
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # Domotion
1
+ <p align="center">
2
+ <img src="examples/output/domotion-word-demo.svg" alt="Domotion — an animated wordmark cycling through twenty neon-retro typographic variants of the word domotion" width="600">
3
+ </p>
2
4
 
3
5
  DOM-to-animated-SVG renderer. Captures HTML/CSS rendered in headless Chromium and converts the captured tree into a self-contained SVG with optional CSS animations — pixel-faithful to what Chromium painted, scales crisply at any size, and embeds without external assets.
4
6
 
@@ -39,6 +41,9 @@ yourself to keep the first job's runtime down.
39
41
  The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
40
42
 
41
43
  ```bash
44
+ # Zero-install: run the published CLI straight from npm.
45
+ npx domotion-svg capture https://example.com -o example.svg
46
+
42
47
  # Capture a URL as SVG.
43
48
  domotion capture https://example.com -o example.svg
44
49
 
@@ -61,19 +66,44 @@ domotion animate ./demo.json
61
66
 
62
67
  The config describes each frame (input, duration, transition) plus a declarative surface for interaction demos: continuous-session frames that carry client-side state across steps (omit `input` / set `"continue": true`), DOM-mutation and interaction actions, richer readiness waits (`waitForText` / `waitForGone` / `waitForCount`), typing / tap / svg / blink overlays that can anchor to an element's box, an on-screen `cursor` (explicit or `"auto"`), `vars` + `${}` interpolation, and a small `evaluate` escape hatch. See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
63
68
 
69
+ ### Export to video
70
+
71
+ The package also ships a standalone `svg-to-video` CLI that renders an animated SVG (a `domotion animate` output, or any CSS-/SMIL-animated SVG) to a video file. It steps the animation timeline frame by frame in Chromium for frame-accurate timing, then pipes the frames to **ffmpeg** (a required external dependency — install via `brew` / `apt` / `winget`).
72
+
73
+ ```bash
74
+ # h264/mp4 at 30fps, contained to 1280px wide.
75
+ svg-to-video demo.svg -o demo.mp4 --width 1280
76
+
77
+ # 60fps VP9/webm with looping background music.
78
+ svg-to-video demo.svg -o demo.webm --format vp9 --fps 60 --music bed.mp3
79
+ ```
80
+
81
+ Supports target size (`--width`/`--height`, aspect-preserving), `--fps`, `--format` / `--container`, supersampling (`--scale`), background music / foreground audio / captions, and a disk-space pre-flight. See `svg-to-video --help`.
82
+
83
+ ### Reviewing a regression
84
+
85
+ If a capture comes out looking different from how Chromium painted the source page, the package ships an `svg-review` CLI to help you file a focused bug report. Capture once with `--debug` to get a reproduction bundle (HAR + the Chromium screenshot of the source + the SVG we produced), then open the bundle in the local review UI:
86
+
87
+ ```sh
88
+ domotion capture https://example.com --debug -o example.svg
89
+ svg-review --expected example.debug/expected.png --actual example.debug/actual.svg
90
+ ```
91
+
92
+ The browser opens a single review card showing the expected / actual / diff PNGs. Arrow keys cycle through the three at full size; drag on any image to mark a problem region and caption it. The side panel builds a GitHub-issue-ready Markdown block as you go — copy it, then file the issue at <https://github.com/brianwestphal/domotion/issues/new> and attach `expected.png` + `actual.svg` so a maintainer can reproduce.
93
+
64
94
  ### Scripting API
65
95
 
66
96
  When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
67
97
 
68
98
  ```ts
69
- import { captureElementTree, elementTreeToSvg, launchChromium, wrapSvg } from "domotion-svg";
99
+ import { captureElementTree, elementTreeToSvg, launchChromium } from "domotion-svg";
70
100
 
71
101
  const browser = await launchChromium();
72
102
  const page = await browser.newPage();
73
103
  await page.setContent(`<div style="padding:20px;color:white;background:#0d1117">Hello</div>`);
74
104
 
75
105
  const tree = await captureElementTree(page, "body", { x: 0, y: 0, width: 800, height: 200 });
76
- const svg = wrapSvg(elementTreeToSvg(tree, 800, 200), 800, 200);
106
+ const svg = elementTreeToSvg(tree, 800, 200);
77
107
 
78
108
  console.log(svg);
79
109
  await browser.close();
@@ -5,6 +5,7 @@
5
5
  * animated SVG with CSS keyframe transitions.
6
6
  */
7
7
  import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
8
+ import type { MagicMove } from "./magic-move.js";
8
9
  export interface AnimationFrame {
9
10
  /** SVG content for this frame (from dom-to-svg) */
10
11
  svgContent: string;
@@ -28,11 +29,26 @@ export interface AnimationFrame {
28
29
  * `crossfade` (default) overlaps fade-out and fade-in. `push-left` slides
29
30
  * the outgoing frame off and the incoming frame in from the right.
30
31
  * `scroll` keeps both visible during the transition. `cut` is instant —
31
- * no fade, no slide. For `cut`, `duration` is ignored.
32
+ * no fade, no slide. For `cut`, `duration` is ignored. `magic-move` blends
33
+ * shared elements between the two frames — matched elements slide from
34
+ * their old position to their new one while added/removed elements
35
+ * cross-fade (DM-898; see `docs/53-magic-move-transition.md`). It requires
36
+ * the per-frame `magicMove` bridge layer (built caller-side from the
37
+ * element trees); when that's absent it degrades to `crossfade`.
32
38
  */
33
- type: "crossfade" | "push-left" | "scroll" | "cut";
39
+ type: "crossfade" | "push-left" | "scroll" | "cut" | "magic-move";
34
40
  duration: number;
35
41
  };
42
+ /**
43
+ * Magic-move bridge layer for this frame's transition to the next, built by
44
+ * the caller via `buildMagicMove(prevTree, nextTree, …)`. Present only when
45
+ * `transition.type === "magic-move"` and both frames' element trees were
46
+ * available; the animator shows it during the transition window (moved
47
+ * elements slide, added fade in, removed fade out) between the hard-cut prev
48
+ * and next frame blobs. When `transition.type` is `magic-move` but this is
49
+ * null, the animator falls back to `crossfade`.
50
+ */
51
+ magicMove?: MagicMove | null;
36
52
  /** Overlays: typing, tap ripple */
37
53
  overlays?: AnimationOverlay[];
38
54
  /**
@@ -228,5 +244,13 @@ export interface AnimationConfig {
228
244
  * uses `selector`; otherwise pass undefined / null.
229
245
  */
230
246
  resolveSelector?: SelectorResolver;
247
+ /**
248
+ * Canvas background color painted behind every frame (a full-viewport
249
+ * `<rect>`). Mirrors the single-frame path's `transparentRootBgRect`
250
+ * (DM-554): pass the captured page's root background so animated output
251
+ * matches `capture` output. Omitted / `"transparent"` / `"rgba(0, 0, 0, 0)"`
252
+ * → no rect, i.e. a transparent SVG that composites over a host background.
253
+ */
254
+ background?: string;
231
255
  }
232
256
  export declare function generateAnimatedSvg(config: AnimationConfig): string;
@@ -51,6 +51,11 @@ export function generateAnimatedSvg(config) {
51
51
  const prevFrame = i > 0 ? frames[i - 1] : null;
52
52
  const entersViaPush = prevFrame?.transition?.type === "push-left";
53
53
  const entersViaScroll = prevFrame?.transition?.type === "scroll";
54
+ // DM-898: a frame entered from a magic-move transition appears at its own
55
+ // start (= the predecessor's transition end), NOT overlap-faded — the
56
+ // magic-move bridge layer already covered the window, so a crossfade
57
+ // overlap here would double-show the next frame on top of the bridge.
58
+ const entersViaMagicMove = prevFrame?.transition?.type === "magic-move" && prevFrame?.magicMove != null;
54
59
  // Both push-left and scroll overlap their transition with the next
55
60
  // frame's entry — the next frame is already sliding in while the current
56
61
  // one slides out, so its show window starts at `timeOffset - prevTransDur`
@@ -117,6 +122,117 @@ export function generateAnimatedSvg(config) {
117
122
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
118
123
  .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
119
124
  }
125
+ else if (transType === "magic-move" && frame.magicMove != null) {
126
+ // DM-898: magic-move. Frame i holds [start..holdEnd] then HARD-CUTS out;
127
+ // a bridge composite covers the transition window [holdEnd..transEnd],
128
+ // inside which matched elements slide prev→next, added elements fade in,
129
+ // and removed elements fade out. The next frame cuts in at transEnd
130
+ // (= its own start). The bridge's start state matches the prev frame's
131
+ // final paint and its end state the next frame's initial paint, so both
132
+ // hard cuts are seamless. (When `frame.magicMove` is null the type falls
133
+ // through to the crossfade branch below — the documented fallback.)
134
+ const mm = frame.magicMove;
135
+ const sNum = parseFloat(startPct);
136
+ const hNum = parseFloat(holdEndPct);
137
+ const tNum = parseFloat(transEndPct);
138
+ const beforeS = Math.max(0, sNum - 0.001).toFixed(3);
139
+ const afterH = Math.min(100, hNum + 0.001).toFixed(3);
140
+ const beforeH = Math.max(0, hNum - 0.001).toFixed(3);
141
+ const afterT = Math.min(100, tNum + 0.001).toFixed(3);
142
+ // Frame i blob: visible only during its hold, hard-cut out at hold end.
143
+ frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
144
+ keyframes.push(`
145
+ @keyframes fv-${i} {
146
+ 0% { opacity: 0; visibility: hidden; }
147
+ ${beforeS}% { opacity: 0; visibility: hidden; }
148
+ ${sNum.toFixed(3)}% { opacity: 1; visibility: visible; }
149
+ ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
150
+ ${afterH}% { opacity: 0; visibility: hidden; }
151
+ 100% { opacity: 0; visibility: hidden; }
152
+ }
153
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
154
+ // Bridge composite: visible during the transition window only.
155
+ frameGroups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
156
+ keyframes.push(`
157
+ @keyframes mmv-${i} {
158
+ 0% { opacity: 0; visibility: hidden; }
159
+ ${beforeH}% { opacity: 0; visibility: hidden; }
160
+ ${hNum.toFixed(3)}% { opacity: 1; visibility: visible; }
161
+ ${tNum.toFixed(3)}% { opacity: 1; visibility: visible; }
162
+ ${afterT}% { opacity: 0; visibility: hidden; }
163
+ 100% { opacity: 0; visibility: hidden; }
164
+ }
165
+ .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
166
+ // Per-element slide / fade keyframes within the window (linear interp).
167
+ // The composite is only visible [holdEnd..transEnd], so the held values
168
+ // outside that window are never painted — they just pin the endpoints.
169
+ //
170
+ // A dual-render cross-fade copy (DM-903) is BOTH a slide and a fade, so
171
+ // its element needs two animations. They MUST go in one `animation:`
172
+ // declaration (comma-joined) — two separate `.cls { animation: … }` rules
173
+ // would have the later one silently override the former, dropping the
174
+ // slide. Accumulate per-class animation entries and emit one rule each.
175
+ const animEntries = new Map();
176
+ const addAnim = (cls, name) => {
177
+ const list = animEntries.get(cls) ?? [];
178
+ list.push(`${name} ${totalSec.toFixed(2)}s infinite`);
179
+ animEntries.set(cls, list);
180
+ };
181
+ for (const s of mm.slides) {
182
+ // Interpolate the element's transform `from → to` across the window.
183
+ // The next-appearance copy maps its prev rect → `none` (final next
184
+ // rect); a cross-fade prev copy maps `none` → its next rect, so both
185
+ // copies trace the same path (DM-899 geometry; DM-903 paired copies).
186
+ keyframes.push(`
187
+ @keyframes mms-${s.cls} {
188
+ 0%, ${hNum.toFixed(3)}% { transform: ${s.from}; }
189
+ ${tNum.toFixed(3)}%, 100% { transform: ${s.to}; }
190
+ }`);
191
+ addAnim(s.cls, `mms-${s.cls}`);
192
+ }
193
+ for (const cls of mm.fadeIn) {
194
+ keyframes.push(`
195
+ @keyframes mmf-${cls} {
196
+ 0%, ${hNum.toFixed(3)}% { opacity: 0; }
197
+ ${tNum.toFixed(3)}%, 100% { opacity: 1; }
198
+ }`);
199
+ addAnim(cls, `mmf-${cls}`);
200
+ }
201
+ for (const cls of mm.fadeOut) {
202
+ keyframes.push(`
203
+ @keyframes mmf-${cls} {
204
+ 0%, ${hNum.toFixed(3)}% { opacity: 1; }
205
+ ${tNum.toFixed(3)}%, 100% { opacity: 0; }
206
+ }`);
207
+ addAnim(cls, `mmf-${cls}`);
208
+ }
209
+ for (const [cls, entries] of animEntries) {
210
+ keyframes.push(` .${cls} { animation: ${entries.join(", ")}; }`);
211
+ }
212
+ // DM-901: honor `prefers-reduced-motion: reduce` — pin everything to the
213
+ // NEXT state instead of animating, so the transition degrades to a
214
+ // cut-like reveal for motion-sensitive viewers. Slides drop to their
215
+ // final transform (`none` for the next copy; the prev cross-fade copy is
216
+ // also hidden via its fade-out below). Added / next-appearance fades snap
217
+ // to opacity 1; removed / prev-appearance fades snap to opacity 0. Static
218
+ // CSS, so output stays deterministic; rasterizers default to
219
+ // `no-preference` and play the full move. (DM-903: the fade rules now
220
+ // also matter — without pinning fade-out to 0 the prev-appearance copy
221
+ // would stay visible at full opacity.)
222
+ const reduceRules = [];
223
+ if (mm.slides.length > 0)
224
+ reduceRules.push(`${mm.slides.map((s) => `.${s.cls}`).join(", ")} { animation: none; transform: none; }`);
225
+ if (mm.fadeIn.length > 0)
226
+ reduceRules.push(`${mm.fadeIn.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 1; }`);
227
+ if (mm.fadeOut.length > 0)
228
+ reduceRules.push(`${mm.fadeOut.map((c) => `.${c}`).join(", ")} { animation: none; opacity: 0; }`);
229
+ if (reduceRules.length > 0) {
230
+ keyframes.push(`
231
+ @media (prefers-reduced-motion: reduce) {
232
+ ${reduceRules.join("\n ")}
233
+ }`);
234
+ }
235
+ }
120
236
  else {
121
237
  // Crossfade or cut: opacity in/out.
122
238
  //
@@ -157,7 +273,7 @@ export function generateAnimatedSvg(config) {
157
273
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
158
274
  }
159
275
  else {
160
- const fadeInStartPct = i > 0
276
+ const fadeInStartPct = (i > 0 && !entersViaMagicMove)
161
277
  ? pct(Math.max(0, timeOffset - prevTransDur), totalDuration)
162
278
  : startPct;
163
279
  const prevEnd = i > 0
@@ -221,6 +337,13 @@ export function generateAnimatedSvg(config) {
221
337
  const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
222
338
  overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
223
339
  }
340
+ // Canvas background rect — only when a non-transparent background is given.
341
+ // Default (none / transparent) emits nothing so the SVG composites over the
342
+ // host page, matching the single-frame `transparentRootBgRect` path (DM-554).
343
+ const bg = config.background;
344
+ const canvasBgRect = (bg != null && bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
345
+ ? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
346
+ : "";
224
347
  const out = `<?xml version="1.0" encoding="UTF-8"?>
225
348
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
226
349
  <defs>
@@ -232,8 +355,7 @@ ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss +
232
355
  ${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
233
356
  </style>
234
357
  <g clip-path="url(#viewport-clip)">
235
- <rect width="${width}" height="${height}" fill="#0d1117" />
236
- ${frameGroups.join("\n")}${overlayMarkup}
358
+ ${canvasBgRect}${frameGroups.join("\n")}${overlayMarkup}
237
359
  </g>
238
360
  </svg>`;
239
361
  return out;
@@ -1,2 +1,3 @@
1
+ export { buildMagicMove, type MagicMove, type MagicMoveSlide, } from "./magic-move.js";
1
2
  export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
2
3
  export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
@@ -1,7 +1,8 @@
1
1
  // Public surface of the animation pipeline. The composer
2
2
  // (`generateAnimatedSvg`) consumes per-frame element trees + transition /
3
3
  // overlay config and emits one self-contained SVG with `@keyframes` cross-
4
- // fade / push-left / scroll / cut transitions and optional typing / tap /
5
- // SVG / cursor overlays.
4
+ // fade / push-left / scroll / cut / magic-move transitions and optional
5
+ // typing / tap / SVG / cursor overlays.
6
+ export { buildMagicMove, } from "./magic-move.js";
6
7
  export { generateAnimatedSvg, } from "./animator.js";
7
8
  export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Magic-move transition composer — phase 1 of the Keynote-style magic-move
3
+ * transition (DM-898 / DM-112; spec in `docs/53-magic-move-transition.md`).
4
+ *
5
+ * Builds the "bridge" layer shown during a magic-move transition window: a
6
+ * single composite, rendered from the NEXT frame's element tree, in which
7
+ * - elements that MOVED between the two frames (diff `translated`) slide from
8
+ * their previous position to their next one,
9
+ * - elements only in the NEXT frame (`added`) fade in,
10
+ * - elements only in the PREV frame (`removed`) — appended from the prev tree
11
+ * — fade out,
12
+ * - everything else (`static` / `modified`) renders in place.
13
+ * At the window's start the composite matches the PREV frame's final state, and
14
+ * at its end the NEXT frame's initial state, so the animator hard-cuts the prev
15
+ * frame out at hold-end and the next frame in at the window end with no visible
16
+ * jump (see `generateAnimatedSvg`'s magic-move branch).
17
+ *
18
+ * Rendering happens HERE (caller-side) rather than inside the animator because
19
+ * the glyph/font `<defs>` are accumulated globally during rendering and emitted
20
+ * once by the caller BEFORE `generateAnimatedSvg` runs — re-rendering inside the
21
+ * animator would reference glyphs missing from the already-finalized defs. (This
22
+ * refines the original "animator re-renders" sketch in docs/53.)
23
+ *
24
+ * v1 scope (DM-898): translate only. Size/style morph is DM-899; `data-magic-key`
25
+ * author pairing is DM-900; reduced-motion + deeper nesting hardening is DM-901.
26
+ */
27
+ import type { CapturedElement } from "../capture/types.js";
28
+ /** One element that slides during the transition. The animator interpolates
29
+ * `transform: <from> → <to>` over the window. A `translate(dx,dy)` for a pure
30
+ * move, the full `translate · scale · translate` affine for a size change
31
+ * (DM-899). The next-appearance copy goes `<prev-rect map> → none`; a prev-
32
+ * appearance copy in a cross-fade pair goes `none → <next-rect map>` so both
33
+ * copies trace the same prev→next path while their opacities swap (DM-903). */
34
+ export interface MagicMoveSlide {
35
+ /** CSS class the renderer stamped on the element (`anim-<id>`). */
36
+ cls: string;
37
+ /** CSS transform at the window start. */
38
+ from: string;
39
+ /** CSS transform at the window end (`"none"` for the next-appearance copy). */
40
+ to: string;
41
+ }
42
+ export interface MagicMove {
43
+ /** Composite SVG markup shown for the transition window (no XML preamble). */
44
+ compositeSvg: string;
45
+ /** Elements that translate prev→next. */
46
+ slides: MagicMoveSlide[];
47
+ /** Classes that fade in over the window (`added`). */
48
+ fadeIn: string[];
49
+ /** Classes that fade out over the window (`removed`). */
50
+ fadeOut: string[];
51
+ }
52
+ /**
53
+ * Build the magic-move bridge layer between two captured trees. Returns `null`
54
+ * when there is nothing worth animating (no moved / added / removed elements) —
55
+ * the caller then falls back to `crossfade`.
56
+ *
57
+ * `render` turns a list of element roots into SVG markup (the caller passes a
58
+ * thin `elementTreeToSvg(roots, W, H, prefix, …)` wrapper); injecting it keeps
59
+ * this module renderer-agnostic and unit-testable.
60
+ */
61
+ export declare function buildMagicMove(prevTree: CapturedElement | CapturedElement[], nextTree: CapturedElement | CapturedElement[], render: (roots: CapturedElement[], idPrefix: string) => string, idPrefix: string): MagicMove | null;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Magic-move transition composer — phase 1 of the Keynote-style magic-move
3
+ * transition (DM-898 / DM-112; spec in `docs/53-magic-move-transition.md`).
4
+ *
5
+ * Builds the "bridge" layer shown during a magic-move transition window: a
6
+ * single composite, rendered from the NEXT frame's element tree, in which
7
+ * - elements that MOVED between the two frames (diff `translated`) slide from
8
+ * their previous position to their next one,
9
+ * - elements only in the NEXT frame (`added`) fade in,
10
+ * - elements only in the PREV frame (`removed`) — appended from the prev tree
11
+ * — fade out,
12
+ * - everything else (`static` / `modified`) renders in place.
13
+ * At the window's start the composite matches the PREV frame's final state, and
14
+ * at its end the NEXT frame's initial state, so the animator hard-cuts the prev
15
+ * frame out at hold-end and the next frame in at the window end with no visible
16
+ * jump (see `generateAnimatedSvg`'s magic-move branch).
17
+ *
18
+ * Rendering happens HERE (caller-side) rather than inside the animator because
19
+ * the glyph/font `<defs>` are accumulated globally during rendering and emitted
20
+ * once by the caller BEFORE `generateAnimatedSvg` runs — re-rendering inside the
21
+ * animator would reference glyphs missing from the already-finalized defs. (This
22
+ * refines the original "animator re-renders" sketch in docs/53.)
23
+ *
24
+ * v1 scope (DM-898): translate only. Size/style morph is DM-899; `data-magic-key`
25
+ * author pairing is DM-900; reduced-motion + deeper nesting hardening is DM-901.
26
+ */
27
+ import { diffTrees, entriesOfKind } from "../tree-ops/tree-diff.js";
28
+ function asRoots(t) {
29
+ return Array.isArray(t) ? t : [t];
30
+ }
31
+ /** Resolve a tree path (`[rootIdx, childIdx, …]`, per `diffTrees`) to its element. */
32
+ function elementAtPath(roots, path) {
33
+ let el = roots[path[0]];
34
+ for (let i = 1; i < path.length && el != null; i++)
35
+ el = el.children?.[path[i]];
36
+ return el ?? null;
37
+ }
38
+ /**
39
+ * CSS `transform` that maps an element rendered at its NEXT rect back onto its
40
+ * PREV rect — the window-start state the animator interpolates away to `none`.
41
+ * The element's painted geometry sits at next-space coordinates, so the affine
42
+ * is `translate(prevOrigin) · scale(prevSize/nextSize) · translate(-nextOrigin)`
43
+ * (prepend-translate to next origin, scale about it, then place at prev origin).
44
+ * Pure moves (size unchanged) collapse to a single `translate(dx, dy)` for
45
+ * smaller, more legible output; a zero next-dimension can't be scaled, so it
46
+ * also falls back to translate-only.
47
+ */
48
+ function rectMapTransform(prev, next) {
49
+ const sizeChanged = Math.abs(prev.width - next.width) > 0.5 || Math.abs(prev.height - next.height) > 0.5;
50
+ if (!sizeChanged || next.width <= 0 || next.height <= 0) {
51
+ return `translate(${r(prev.x - next.x)}px, ${r(prev.y - next.y)}px)`;
52
+ }
53
+ const sx = prev.width / next.width;
54
+ const sy = prev.height / next.height;
55
+ return `translate(${r(prev.x)}px, ${r(prev.y)}px) scale(${r5(sx)}, ${r5(sy)}) translate(${r(-next.x)}px, ${r(-next.y)}px)`;
56
+ }
57
+ /** Round to 2dp (px positions) / 5dp (scale factors), trimming trailing zeros. */
58
+ function r(n) { return Number(n.toFixed(2)); }
59
+ function r5(n) { return Number(n.toFixed(5)); }
60
+ /** True iff `a` is a strict prefix of `b` (i.e. `a` is an ancestor path of `b`). */
61
+ function isAncestorPath(a, b) {
62
+ if (a.length >= b.length)
63
+ return false;
64
+ for (let i = 0; i < a.length; i++)
65
+ if (a[i] !== b[i])
66
+ return false;
67
+ return true;
68
+ }
69
+ /**
70
+ * Collect every element carrying a `data-magic-key` (`el.magicKey`), keyed by
71
+ * the attribute value, with its tree path (`[rootIdx, childIdx, …]`, matching
72
+ * `diffTrees`). First occurrence per key wins — a duplicate key within one
73
+ * frame is author error; we pair the first. (DM-900)
74
+ */
75
+ function collectKeyed(roots) {
76
+ const out = new Map();
77
+ const walk = (el, path) => {
78
+ const k = el.magicKey;
79
+ if (k != null && k !== "" && !out.has(k))
80
+ out.set(k, { el, path });
81
+ const kids = el.children ?? [];
82
+ for (let i = 0; i < kids.length; i++)
83
+ walk(kids[i], [...path, i]);
84
+ };
85
+ for (let i = 0; i < roots.length; i++)
86
+ walk(roots[i], [i]);
87
+ return out;
88
+ }
89
+ /**
90
+ * True iff a matched element's PAINT changed between frames — text content or
91
+ * a visible style (`color` / `backgroundColor` / `borderColor` / `borderTopColor`
92
+ * / `opacity`). Gates the DM-903 dual-render cross-fade: geometry-only movers
93
+ * (same paint, just moved/resized) keep a single copy; paint-changed movers
94
+ * render prev + next appearances and cross-fade. The fingerprint matcher keys
95
+ * on (tag, text, children) not style, so a recolored-but-moved element stays
96
+ * `translated` — style equality has to be checked here, not read off the kind.
97
+ */
98
+ function appearanceChanged(prev, next) {
99
+ if ((prev.text ?? "") !== (next.text ?? ""))
100
+ return true;
101
+ const p = prev.styles;
102
+ const q = next.styles;
103
+ if (p == null || q == null)
104
+ return false;
105
+ return p.color !== q.color
106
+ || (p.backgroundColor ?? "") !== (q.backgroundColor ?? "")
107
+ || (p.borderColor ?? "") !== (q.borderColor ?? "")
108
+ || (p.borderTopColor ?? "") !== (q.borderTopColor ?? "")
109
+ || p.opacity !== q.opacity;
110
+ }
111
+ /** True iff the two rects differ in origin or size beyond the diff tolerance. */
112
+ function rectChanged(p, q) {
113
+ return Math.abs(q.x - p.x) > 0.5 || Math.abs(q.y - p.y) > 0.5
114
+ || Math.abs(q.width - p.width) > 0.5 || Math.abs(q.height - p.height) > 0.5;
115
+ }
116
+ /**
117
+ * Build the magic-move bridge layer between two captured trees. Returns `null`
118
+ * when there is nothing worth animating (no moved / added / removed elements) —
119
+ * the caller then falls back to `crossfade`.
120
+ *
121
+ * `render` turns a list of element roots into SVG markup (the caller passes a
122
+ * thin `elementTreeToSvg(roots, W, H, prefix, …)` wrapper); injecting it keeps
123
+ * this module renderer-agnostic and unit-testable.
124
+ */
125
+ export function buildMagicMove(prevTree, nextTree, render, idPrefix) {
126
+ const prevRoots = asRoots(prevTree);
127
+ const nextRoots = asRoots(nextTree);
128
+ const diff = diffTrees(prevRoots, nextRoots);
129
+ // DM-900: author `data-magic-key` force-pairs the same logical element across
130
+ // frames AHEAD of the fingerprint heuristic. A key present in BOTH trees is a
131
+ // forced mover that supersedes whatever diffTrees decided for those elements
132
+ // (the heuristic may have mis-paired them, or — when their content changed —
133
+ // split them into add + remove, which would cross-fade instead of slide).
134
+ const prevKeyed = collectKeyed(prevRoots);
135
+ const nextKeyed = collectKeyed(nextRoots);
136
+ const keyedMovers = [];
137
+ const keyedNextPaths = new Set();
138
+ const keyedPrevPaths = new Set();
139
+ for (const [key, nx] of nextKeyed) {
140
+ const pv = prevKeyed.get(key);
141
+ if (pv == null)
142
+ continue; // key only in next → genuinely added; leave to heuristic
143
+ keyedMovers.push({ nextPath: nx.path, prev: pv.el, next: nx.el });
144
+ keyedNextPaths.add(nx.path.join(","));
145
+ keyedPrevPaths.add(pv.path.join(","));
146
+ }
147
+ // Heuristic movers: any element matched by diffTrees (static / translated /
148
+ // modified all carry prev + next) whose rect changed in ORIGIN or SIZE —
149
+ // re-derived from the rects since diffTrees keys its kind on origin only and
150
+ // size isn't in its fingerprint (a grow-in-place lands as `static`). Skip
151
+ // elements a key already claimed.
152
+ const heuristicMovers = entriesOfKind(diff, "static", "translated", "modified")
153
+ .filter((e) => e.nextPath != null && e.prev != null && e.next != null
154
+ && !keyedNextPaths.has(e.nextPath.join(","))
155
+ && rectChanged(e.prev, e.next))
156
+ .map((e) => ({ nextPath: e.nextPath, prev: e.prev, next: e.next }));
157
+ // Keyed pairs animate only when their rect actually changed (a keyed but
158
+ // unmoved element is just static — the key still guaranteed the pairing).
159
+ const allMovers = [...keyedMovers.filter((m) => rectChanged(m.prev, m.next)), ...heuristicMovers];
160
+ // Only animate the HIGHEST moved ancestor of each changed subtree: when a
161
+ // card moves/grows the diff reports every descendant as changed too, but the
162
+ // ancestor's transform already carries them — animating each would
163
+ // double-apply. Keep a mover only when no other mover is its ancestor.
164
+ const allMoverPaths = allMovers.map((m) => m.nextPath);
165
+ const rootMovers = allMovers.filter((m) => !allMoverPaths.some((p) => isAncestorPath(p, m.nextPath)));
166
+ // Added / removed, minus anything a key force-paired (those slide, not fade).
167
+ const added = entriesOfKind(diff, "added")
168
+ .filter((e) => e.nextPath == null || !keyedNextPaths.has(e.nextPath.join(",")));
169
+ const removed = entriesOfKind(diff, "removed")
170
+ .filter((e) => e.prevPath == null || !keyedPrevPaths.has(e.prevPath.join(",")));
171
+ if (rootMovers.length === 0 && added.length === 0 && removed.length === 0) {
172
+ return null; // nothing to magic-move → caller uses crossfade
173
+ }
174
+ // Clone the next tree so the `animId` annotations we add for the composite
175
+ // don't leak into the next frame's own (already-rendered / to-be-rendered)
176
+ // blob. CapturedElement is plain data, so structuredClone is a safe deep copy.
177
+ const compositeNext = nextRoots.map((r) => structuredClone(r));
178
+ const slides = [];
179
+ const fadeIn = [];
180
+ const fadeOut = [];
181
+ // Prev-appearance copies + removed subtrees, appended after the next tree so
182
+ // they render at their prev coordinates.
183
+ const extraRoots = [];
184
+ let n = 0;
185
+ for (const m of rootMovers) {
186
+ const el = elementAtPath(compositeNext, m.nextPath);
187
+ if (el == null)
188
+ continue;
189
+ const nextId = `${idPrefix}mv${n++}`;
190
+ el.animId = nextId;
191
+ // Next-appearance copy: slide from the prev rect to its final next rect.
192
+ slides.push({ cls: `anim-${nextId}`, from: rectMapTransform(m.prev, m.next), to: "none" });
193
+ // DM-903: when the element's PAINT also changed (text / color / background
194
+ // / border / opacity), a single next-appearance copy would snap the new
195
+ // look on at the window start. Render a SECOND copy at the PREV appearance,
196
+ // co-moving along the same prev→next path, and cross-fade — prev fades out,
197
+ // next fades in. Geometry-only movers keep the cheaper single copy (nothing
198
+ // to cross-fade). The SVG children carry baked-in fills a wrapper can't
199
+ // restyle, so dual-render + cross-fade is how the paint morph is expressed.
200
+ if (appearanceChanged(m.prev, m.next)) {
201
+ fadeIn.push(`anim-${nextId}`);
202
+ const prevClone = structuredClone(m.prev);
203
+ const prevId = `${nextId}p`;
204
+ prevClone.animId = prevId;
205
+ extraRoots.push(prevClone);
206
+ // Prev copy renders at its prev rect; map it FORWARD onto the next rect
207
+ // (rectMapTransform with args swapped) so it traces the same path as the
208
+ // next copy while fading out.
209
+ slides.push({ cls: `anim-${prevId}`, from: "none", to: rectMapTransform(m.next, m.prev) });
210
+ fadeOut.push(`anim-${prevId}`);
211
+ }
212
+ }
213
+ let a = 0;
214
+ for (const e of added) {
215
+ if (e.nextPath == null)
216
+ continue;
217
+ const el = elementAtPath(compositeNext, e.nextPath);
218
+ if (el == null)
219
+ continue;
220
+ const id = `${idPrefix}in${a++}`;
221
+ el.animId = id;
222
+ fadeIn.push(`anim-${id}`);
223
+ }
224
+ // Removed elements aren't in the next tree — append their prev subtrees (at
225
+ // their prev coordinates) so they render where the prev frame had them, and
226
+ // fade them out. Skip a removed entry nested inside another removed subtree
227
+ // (its ancestor already carries it).
228
+ const removedPaths = removed.map((e) => e.prevPath).filter((p) => p != null);
229
+ let o = 0;
230
+ for (const e of removed) {
231
+ if (e.prevPath == null || e.prev == null)
232
+ continue;
233
+ if (removedPaths.some((p) => isAncestorPath(p, e.prevPath)))
234
+ continue;
235
+ const clone = structuredClone(e.prev);
236
+ const id = `${idPrefix}out${o++}`;
237
+ clone.animId = id;
238
+ extraRoots.push(clone);
239
+ fadeOut.push(`anim-${id}`);
240
+ }
241
+ const compositeSvg = render([...compositeNext, ...extraRoots], idPrefix);
242
+ return { compositeSvg, slides, fadeIn, fadeOut };
243
+ }