domotion-svg 0.5.0 → 0.7.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 (220) 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/emoji.js +28 -2
  9. package/dist/capture/index.js +117 -3
  10. package/dist/capture/script/index.js +72 -1
  11. package/dist/capture/script/pseudo-rules.d.ts +1 -1
  12. package/dist/capture/script/pseudo-rules.js +10 -3
  13. package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -1
  14. package/dist/capture/script/walker/borders-backgrounds.js +22 -9
  15. package/dist/capture/script/walker/masks-clips.d.ts +2 -0
  16. package/dist/capture/script/walker/masks-clips.js +65 -13
  17. package/dist/capture/script/walker/pseudo-inject.js +45 -3
  18. package/dist/capture/script/walker/text-segments.d.ts +2 -2
  19. package/dist/capture/script/walker/text-segments.js +131 -15
  20. package/dist/capture/script/walker/transforms.js +128 -4
  21. package/dist/capture/script.generated.js +1 -1
  22. package/dist/capture/types.d.ts +49 -0
  23. package/dist/cli/animate.d.ts +3 -2
  24. package/dist/cli/animate.js +33 -4
  25. package/dist/cli/capture.js +95 -5
  26. package/dist/cli/common.d.ts +10 -0
  27. package/dist/cli/common.js +35 -0
  28. package/dist/cli/index.d.ts +1 -0
  29. package/dist/cli/index.js +27 -2
  30. package/dist/cli/review.d.ts +18 -0
  31. package/dist/cli/review.js +213 -0
  32. package/dist/cli/svg-to-video-core.d.ts +133 -0
  33. package/dist/cli/svg-to-video-core.js +533 -0
  34. package/dist/cli/svg-to-video.d.ts +14 -0
  35. package/dist/cli/svg-to-video.js +161 -0
  36. package/dist/render/borders.d.ts +50 -0
  37. package/dist/render/borders.js +55 -0
  38. package/dist/render/element-tree-to-svg.d.ts +60 -2
  39. package/dist/render/element-tree-to-svg.js +395 -78
  40. package/dist/render/embedded-font-builder.js +38 -0
  41. package/dist/render/glyph-helper.d.ts +73 -0
  42. package/dist/render/{coretext.js → glyph-helper.js} +68 -21
  43. package/dist/render/helper-acquire.d.ts +67 -0
  44. package/dist/render/helper-acquire.js +189 -0
  45. package/dist/render/index.d.ts +2 -1
  46. package/dist/render/index.js +6 -1
  47. package/dist/render/text-to-path.d.ts +145 -0
  48. package/dist/render/text-to-path.js +963 -95
  49. package/dist/render/text.js +287 -15
  50. package/dist/review/client.bundle.generated.d.ts +1 -0
  51. package/dist/review/client.bundle.generated.js +3 -0
  52. package/dist/review/client.d.ts +10 -0
  53. package/dist/review/client.js +208 -0
  54. package/dist/review/compare-pngs.d.ts +151 -0
  55. package/dist/review/compare-pngs.js +475 -0
  56. package/dist/review/region-overlay.d.ts +57 -0
  57. package/dist/review/region-overlay.js +367 -0
  58. package/dist/review/server.d.ts +45 -0
  59. package/dist/review/server.js +140 -0
  60. package/dist/scroll/composer.d.ts +7 -1
  61. package/dist/scroll/composer.js +14 -10
  62. package/package.json +10 -8
  63. package/dist/animation/animator.test.d.ts +0 -5
  64. package/dist/animation/animator.test.js +0 -418
  65. package/dist/animation/cursor-overlay.test.d.ts +0 -1
  66. package/dist/animation/cursor-overlay.test.js +0 -88
  67. package/dist/cli/animate.test.d.ts +0 -7
  68. package/dist/cli/animate.test.js +0 -185
  69. package/dist/cross-origin-font-face.test.d.ts +0 -1
  70. package/dist/cross-origin-font-face.test.js +0 -107
  71. package/dist/dark-mode-capture.test.d.ts +0 -1
  72. package/dist/dark-mode-capture.test.js +0 -158
  73. package/dist/dark-mode-form-controls.test.d.ts +0 -1
  74. package/dist/dark-mode-form-controls.test.js +0 -218
  75. package/dist/embed-remote-images.test.d.ts +0 -1
  76. package/dist/embed-remote-images.test.js +0 -424
  77. package/dist/kerfjs-imports.test.d.ts +0 -1
  78. package/dist/kerfjs-imports.test.js +0 -36
  79. package/dist/mask.test.d.ts +0 -1
  80. package/dist/mask.test.js +0 -211
  81. package/dist/post-processing/optimize.test.d.ts +0 -1
  82. package/dist/post-processing/optimize.test.js +0 -40
  83. package/dist/preserve-aspect-ratio.test.d.ts +0 -1
  84. package/dist/preserve-aspect-ratio.test.js +0 -38
  85. package/dist/render/borders.test.d.ts +0 -1
  86. package/dist/render/borders.test.js +0 -148
  87. package/dist/render/conic-raster.test.d.ts +0 -1
  88. package/dist/render/conic-raster.test.js +0 -187
  89. package/dist/render/coretext.d.ts +0 -60
  90. package/dist/render/coretext.test.d.ts +0 -1
  91. package/dist/render/coretext.test.js +0 -94
  92. package/dist/render/form-controls.test.d.ts +0 -7
  93. package/dist/render/form-controls.test.js +0 -37
  94. package/dist/render/gradients.test.d.ts +0 -1
  95. package/dist/render/gradients.test.js +0 -184
  96. package/dist/render/text-to-path.test.d.ts +0 -1
  97. package/dist/render/text-to-path.test.js +0 -951
  98. package/dist/render/text.test.d.ts +0 -1
  99. package/dist/render/text.test.js +0 -234
  100. package/dist/scroll/composer.test.d.ts +0 -1
  101. package/dist/scroll/composer.test.js +0 -452
  102. package/dist/scroll/executor.test.d.ts +0 -1
  103. package/dist/scroll/executor.test.js +0 -236
  104. package/dist/scroll/hoist-fixed.test.d.ts +0 -1
  105. package/dist/scroll/hoist-fixed.test.js +0 -103
  106. package/dist/scroll/hoist-sticky.test.d.ts +0 -1
  107. package/dist/scroll/hoist-sticky.test.js +0 -154
  108. package/dist/scroll/pattern.test.d.ts +0 -1
  109. package/dist/scroll/pattern.test.js +0 -438
  110. package/dist/stacking-context.test.d.ts +0 -1
  111. package/dist/stacking-context.test.js +0 -927
  112. package/dist/tree-ops/frame-merge.test.d.ts +0 -6
  113. package/dist/tree-ops/frame-merge.test.js +0 -189
  114. package/dist/tree-ops/resize-embedded-images.test.d.ts +0 -9
  115. package/dist/tree-ops/resize-embedded-images.test.js +0 -255
  116. package/dist/tree-ops/tree-diff.test.d.ts +0 -1
  117. package/dist/tree-ops/tree-diff.test.js +0 -267
  118. package/dist/tree-ops/viewbox-culling.test.d.ts +0 -1
  119. package/dist/tree-ops/viewbox-culling.test.js +0 -240
  120. package/dist/utils/region-feedback.test.d.ts +0 -1
  121. package/dist/utils/region-feedback.test.js +0 -221
  122. package/dist/webfont-unicode-range.test.d.ts +0 -1
  123. package/dist/webfont-unicode-range.test.js +0 -174
  124. package/src/animation/animator.test.ts +0 -445
  125. package/src/animation/animator.ts +0 -927
  126. package/src/animation/cursor-overlay.test.ts +0 -95
  127. package/src/animation/cursor-overlay.ts +0 -295
  128. package/src/animation/index.ts +0 -29
  129. package/src/capture/embed.ts +0 -305
  130. package/src/capture/emoji.ts +0 -226
  131. package/src/capture/index.ts +0 -1309
  132. package/src/capture/script/color-norm.ts +0 -80
  133. package/src/capture/script/emoji-detect.ts +0 -97
  134. package/src/capture/script/font-metrics.ts +0 -130
  135. package/src/capture/script/index.ts +0 -1514
  136. package/src/capture/script/placeholder-shown.ts +0 -54
  137. package/src/capture/script/pseudo-rules.ts +0 -223
  138. package/src/capture/script/utils.ts +0 -18
  139. package/src/capture/script/walker/borders-backgrounds.ts +0 -277
  140. package/src/capture/script/walker/counter-style-resolver.ts +0 -184
  141. package/src/capture/script/walker/form-controls.ts +0 -242
  142. package/src/capture/script/walker/input-value.ts +0 -221
  143. package/src/capture/script/walker/lists-counters.ts +0 -110
  144. package/src/capture/script/walker/masks-clips.ts +0 -181
  145. package/src/capture/script/walker/pseudo-content.ts +0 -784
  146. package/src/capture/script/walker/pseudo-inject.ts +0 -203
  147. package/src/capture/script/walker/replaced-elements.ts +0 -102
  148. package/src/capture/script/walker/text-segments.ts +0 -404
  149. package/src/capture/script/walker/transforms.ts +0 -160
  150. package/src/capture/script/warnings.ts +0 -39
  151. package/src/capture/script.generated.ts +0 -7
  152. package/src/capture/types.ts +0 -891
  153. package/src/capture/warnings.ts +0 -39
  154. package/src/cli/animate.test.ts +0 -225
  155. package/src/cli/animate.ts +0 -956
  156. package/src/cli/capture.ts +0 -200
  157. package/src/cli/common.ts +0 -196
  158. package/src/cli/index.ts +0 -183
  159. package/src/cross-origin-font-face.test.ts +0 -119
  160. package/src/dark-mode-capture.test.ts +0 -178
  161. package/src/dark-mode-form-controls.test.ts +0 -229
  162. package/src/embed-remote-images.test.ts +0 -460
  163. package/src/globals.d.ts +0 -2
  164. package/src/index.ts +0 -82
  165. package/src/kerf-jsx-augmentation.d.ts +0 -36
  166. package/src/kerfjs-imports.test.tsx +0 -45
  167. package/src/mask.test.ts +0 -279
  168. package/src/post-processing/gzip.ts +0 -14
  169. package/src/post-processing/index.ts +0 -5
  170. package/src/post-processing/optimize.test.ts +0 -48
  171. package/src/post-processing/optimize.ts +0 -34
  172. package/src/preserve-aspect-ratio.test.ts +0 -49
  173. package/src/render/borders.test.ts +0 -160
  174. package/src/render/borders.ts +0 -783
  175. package/src/render/box-shadow.ts +0 -65
  176. package/src/render/colors.ts +0 -163
  177. package/src/render/conic-raster.test.ts +0 -213
  178. package/src/render/conic-raster.ts +0 -306
  179. package/src/render/coretext.test.ts +0 -131
  180. package/src/render/coretext.ts +0 -256
  181. package/src/render/css-tokens.ts +0 -44
  182. package/src/render/element-tree-to-svg.ts +0 -5660
  183. package/src/render/embedded-font-builder.ts +0 -221
  184. package/src/render/form-controls.test.ts +0 -42
  185. package/src/render/form-controls.ts +0 -1195
  186. package/src/render/format.ts +0 -24
  187. package/src/render/gradients.test.ts +0 -221
  188. package/src/render/gradients.ts +0 -1047
  189. package/src/render/index.ts +0 -22
  190. package/src/render/opentype.js.d.ts +0 -7
  191. package/src/render/text-to-path.test.ts +0 -1050
  192. package/src/render/text-to-path.ts +0 -2902
  193. package/src/render/text.test.ts +0 -262
  194. package/src/render/text.ts +0 -1017
  195. package/src/render/transforms.ts +0 -41
  196. package/src/scroll/composer.test.ts +0 -505
  197. package/src/scroll/composer.ts +0 -375
  198. package/src/scroll/executor.test.ts +0 -270
  199. package/src/scroll/executor.ts +0 -592
  200. package/src/scroll/hoist-fixed.test.ts +0 -117
  201. package/src/scroll/hoist-fixed.ts +0 -95
  202. package/src/scroll/hoist-sticky.test.ts +0 -173
  203. package/src/scroll/hoist-sticky.ts +0 -193
  204. package/src/scroll/index.ts +0 -36
  205. package/src/scroll/pattern.test.ts +0 -531
  206. package/src/scroll/pattern.ts +0 -610
  207. package/src/stacking-context.test.ts +0 -968
  208. package/src/tree-ops/frame-merge.test.ts +0 -208
  209. package/src/tree-ops/frame-merge.ts +0 -470
  210. package/src/tree-ops/index.ts +0 -11
  211. package/src/tree-ops/resize-embedded-images.test.ts +0 -292
  212. package/src/tree-ops/resize-embedded-images.ts +0 -177
  213. package/src/tree-ops/tree-diff.test.ts +0 -295
  214. package/src/tree-ops/tree-diff.ts +0 -236
  215. package/src/tree-ops/viewbox-culling.test.ts +0 -292
  216. package/src/tree-ops/viewbox-culling.ts +0 -327
  217. package/src/utils/escapeHtml.ts +0 -16
  218. package/src/utils/region-feedback.test.ts +0 -261
  219. package/src/utils/region-feedback.ts +0 -216
  220. 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
+ }
@@ -115,9 +115,19 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
115
115
  // identical textareas dedupe to one screenshot.
116
116
  if (el.elementRaster != null) {
117
117
  const er = el.elementRaster;
118
+ // DM-936: include text-decoration + text-underline-position in the
119
+ // dedupe key so 3 identical-text `.vert.pos-{left,right,auto}`
120
+ // columns don't collapse to the same screenshot (the underline
121
+ // paints in different places per pos-* but tag+text+color+size
122
+ // alone hashes them all together → wrong-side underline in 2/3
123
+ // of the columns). Same for text-shadow / writing-mode variants.
124
+ // Several of the keys aren't on the strict CapturedStyles surface;
125
+ // cast through Record so the optional reads compile.
126
+ const sty = el.styles;
127
+ const tdKey = `${sty.textDecorationLine ?? sty.textDecoration ?? ""}|${sty.textUnderlinePosition ?? ""}|${sty.textUnderlineOffset ?? ""}|${sty.textDecorationStyle ?? ""}|${sty.textDecorationColor ?? ""}|${sty.textDecorationThickness ?? ""}|${sty.textShadow ?? ""}|${sty.writingMode ?? ""}`;
118
128
  candidates.push({
119
129
  rect: { x: er.x, y: er.y, width: er.width, height: er.height },
120
- key: `el|${el.tag}|${el.text}|${el.styles.color}|${el.styles.fontSize}|${er.width}x${er.height}`,
130
+ key: `el|${el.tag}|${el.text}|${el.styles.color}|${el.styles.fontSize}|${er.width}x${er.height}|${tdKey}`,
121
131
  setDataUri: (uri) => { er.dataUri = uri; },
122
132
  });
123
133
  }
@@ -163,7 +173,23 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
163
173
  // a rectangular PNG).
164
174
  const elFs = parseFloat(el.styles.fontSize ?? "") || 0;
165
175
  const fs = seg.fontSize ?? (elFs > 0 ? elFs : Math.max(g.rect.width, g.rect.height));
166
- g.rect.x += (g.rect.width - fs) / 2;
176
+ // DM-919: Chrome's per-emoji paint origin = advance start
177
+ // (rect.x) when there's NO letter-spacing — the bitmap
178
+ // sits flush at the left of the advance. When letter-
179
+ // spacing > 0, Chrome ADDS the letter-spacing to the
180
+ // advance, which captures as a wider rect — the bitmap
181
+ // is still at rect.x (the spacing pads to the right).
182
+ // The original DM-381 centering pass mis-handled this:
183
+ // it shifted the bitmap by `(rect.width - fs) / 2`,
184
+ // moving the emoji right whenever letter-spacing >0.
185
+ // Pull out the captured letter-spacing and subtract it
186
+ // from rect.width FIRST, then center the bitmap inside
187
+ // the REAL advance — handles both the centered emoji-
188
+ // alone case (where rect.w ≈ fs) and the letter-spaced
189
+ // case (where rect.w = fs + letter-spacing).
190
+ const ls = parseFloat(el.styles.letterSpacing ?? "") || 0;
191
+ const advanceW = Math.max(fs, g.rect.width - Math.max(0, ls));
192
+ g.rect.x += (advanceW - fs) / 2;
167
193
  g.rect.y += (g.rect.height - fs) / 2;
168
194
  g.rect.width = fs;
169
195
  g.rect.height = fs;