domotion-svg 0.1.1 → 0.2.2

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 (258) hide show
  1. package/README.md +8 -0
  2. package/dist/{animator.d.ts → animation/animator.d.ts} +14 -2
  3. package/dist/{animator.js → animation/animator.js} +92 -23
  4. package/dist/{animator.test.js → animation/animator.test.js} +88 -0
  5. package/dist/animation/index.d.ts +2 -0
  6. package/dist/animation/index.js +7 -0
  7. package/dist/capture/embed.d.ts +97 -0
  8. package/dist/capture/embed.js +272 -0
  9. package/dist/capture/emoji.d.ts +34 -0
  10. package/dist/capture/emoji.js +209 -0
  11. package/dist/{capture.d.ts → capture/index.d.ts} +52 -0
  12. package/dist/{capture.js → capture/index.js} +460 -4
  13. package/dist/capture/script/color-norm.d.ts +3 -0
  14. package/dist/capture/script/color-norm.js +44 -0
  15. package/dist/capture/script/emoji-detect.d.ts +4 -0
  16. package/dist/capture/script/emoji-detect.js +86 -0
  17. package/dist/capture/script/font-metrics.d.ts +4 -0
  18. package/dist/capture/script/font-metrics.js +142 -0
  19. package/dist/capture/script/index.d.ts +4 -0
  20. package/dist/capture/script/index.js +1048 -0
  21. package/dist/capture/script/placeholder-shown.d.ts +3 -0
  22. package/dist/capture/script/placeholder-shown.js +63 -0
  23. package/dist/capture/script/pseudo-rules.d.ts +14 -0
  24. package/dist/capture/script/pseudo-rules.js +244 -0
  25. package/dist/capture/script/utils.d.ts +2 -0
  26. package/dist/capture/script/utils.js +15 -0
  27. package/dist/capture/script/walker/borders-backgrounds.d.ts +54 -0
  28. package/dist/capture/script/walker/borders-backgrounds.js +174 -0
  29. package/dist/capture/script/walker/form-controls.d.ts +51 -0
  30. package/dist/capture/script/walker/form-controls.js +237 -0
  31. package/dist/capture/script/walker/input-value.d.ts +23 -0
  32. package/dist/capture/script/walker/input-value.js +207 -0
  33. package/dist/capture/script/walker/lists-counters.d.ts +16 -0
  34. package/dist/capture/script/walker/lists-counters.js +93 -0
  35. package/dist/capture/script/walker/masks-clips.d.ts +8 -0
  36. package/dist/capture/script/walker/masks-clips.js +138 -0
  37. package/dist/capture/script/walker/pseudo-content.d.ts +92 -0
  38. package/dist/capture/script/walker/pseudo-content.js +580 -0
  39. package/dist/capture/script/walker/pseudo-inject.d.ts +17 -0
  40. package/dist/capture/script/walker/pseudo-inject.js +182 -0
  41. package/dist/capture/script/walker/replaced-elements.d.ts +5 -0
  42. package/dist/capture/script/walker/replaced-elements.js +97 -0
  43. package/dist/capture/script/walker/text-segments.d.ts +67 -0
  44. package/dist/capture/script/walker/text-segments.js +323 -0
  45. package/dist/capture/script/walker/transforms.d.ts +8 -0
  46. package/dist/capture/script/walker/transforms.js +144 -0
  47. package/dist/capture/script/warnings.d.ts +5 -0
  48. package/dist/capture/script/warnings.js +41 -0
  49. package/dist/capture/script.generated.d.ts +1 -0
  50. package/dist/capture/script.generated.js +6 -0
  51. package/dist/{dom-to-svg.d.ts → capture/types.d.ts} +45 -325
  52. package/dist/capture/types.js +18 -0
  53. package/dist/capture/warnings.d.ts +23 -0
  54. package/dist/capture/warnings.js +34 -0
  55. package/dist/cli/animate.d.ts +8 -0
  56. package/dist/cli/animate.js +300 -0
  57. package/dist/cli/capture.d.ts +8 -0
  58. package/dist/cli/capture.js +156 -0
  59. package/dist/cli/common.d.ts +41 -0
  60. package/dist/cli/common.js +126 -0
  61. package/dist/cli/index.js +165 -0
  62. package/dist/cross-origin-font-face.test.js +1 -1
  63. package/dist/dark-mode-capture.test.js +1 -1
  64. package/dist/dark-mode-form-controls.test.js +2 -2
  65. package/dist/embed-remote-images.test.js +1 -1
  66. package/dist/index.d.ts +11 -12
  67. package/dist/index.js +36 -7
  68. package/dist/mask.test.js +1 -1
  69. package/dist/post-processing/gzip.d.ts +10 -0
  70. package/dist/post-processing/gzip.js +13 -0
  71. package/dist/post-processing/index.d.ts +2 -0
  72. package/dist/post-processing/index.js +4 -0
  73. package/dist/post-processing/optimize.test.js +40 -0
  74. package/dist/preserve-aspect-ratio.test.js +1 -1
  75. package/dist/render/borders.d.ts +101 -0
  76. package/dist/render/borders.js +366 -0
  77. package/dist/{border-radius.test.js → render/borders.test.js} +1 -1
  78. package/dist/render/box-shadow.d.ts +16 -0
  79. package/dist/render/box-shadow.js +62 -0
  80. package/dist/render/colors.d.ts +25 -0
  81. package/dist/render/colors.js +167 -0
  82. package/dist/{conic-raster.d.ts → render/conic-raster.d.ts} +2 -1
  83. package/dist/{conic-raster.js → render/conic-raster.js} +2 -1
  84. package/dist/{conic-raster.test.js → render/conic-raster.test.js} +3 -3
  85. package/dist/{coretext-extractor.test.js → render/coretext.test.js} +1 -1
  86. package/dist/render/css-tokens.d.ts +21 -0
  87. package/dist/render/css-tokens.js +46 -0
  88. package/dist/render/element-tree-to-svg.d.ts +132 -0
  89. package/dist/render/element-tree-to-svg.js +3880 -0
  90. package/dist/{form-controls.d.ts → render/form-controls.d.ts} +1 -1
  91. package/dist/{form-controls.js → render/form-controls.js} +1 -1
  92. package/dist/render/format.d.ts +11 -0
  93. package/dist/render/format.js +21 -0
  94. package/dist/render/index.d.ts +2 -0
  95. package/dist/render/index.js +4 -0
  96. package/dist/{text-to-path.d.ts → render/text-to-path.d.ts} +9 -3
  97. package/dist/{text-to-path.js → render/text-to-path.js} +47 -21
  98. package/dist/{text-to-path.test.js → render/text-to-path.test.js} +83 -13
  99. package/dist/{text-renderer.d.ts → render/text.d.ts} +3 -1
  100. package/dist/{text-renderer.js → render/text.js} +104 -8
  101. package/dist/{text-renderer.test.js → render/text.test.js} +83 -4
  102. package/dist/render/transforms.d.ts +14 -0
  103. package/dist/render/transforms.js +56 -0
  104. package/dist/scroll/composer.d.ts +47 -0
  105. package/dist/scroll/composer.js +108 -0
  106. package/dist/scroll/composer.test.d.ts +1 -0
  107. package/dist/scroll/composer.test.js +175 -0
  108. package/dist/scroll/executor.d.ts +130 -0
  109. package/dist/scroll/executor.js +437 -0
  110. package/dist/scroll/executor.test.d.ts +1 -0
  111. package/dist/scroll/executor.test.js +211 -0
  112. package/dist/scroll/index.d.ts +13 -0
  113. package/dist/scroll/index.js +10 -0
  114. package/dist/scroll/pattern.d.ts +116 -0
  115. package/dist/scroll/pattern.js +459 -0
  116. package/dist/scroll/pattern.test.d.ts +1 -0
  117. package/dist/scroll/pattern.test.js +391 -0
  118. package/dist/stacking-context.test.js +3 -3
  119. package/dist/{frame-merge.js → tree-ops/frame-merge.js} +4 -1
  120. package/dist/tree-ops/index.d.ts +5 -0
  121. package/dist/tree-ops/index.js +6 -0
  122. package/dist/{resize-embedded-images.d.ts → tree-ops/resize-embedded-images.d.ts} +1 -1
  123. package/dist/{resize-embedded-images.js → tree-ops/resize-embedded-images.js} +2 -2
  124. package/dist/{resize-embedded-images.test.js → tree-ops/resize-embedded-images.test.js} +2 -2
  125. package/dist/tree-ops/tree-diff.d.ts +72 -0
  126. package/dist/tree-ops/tree-diff.js +196 -0
  127. package/dist/tree-ops/tree-diff.test.d.ts +1 -0
  128. package/dist/tree-ops/tree-diff.test.js +267 -0
  129. package/dist/tree-ops/viewbox-culling.d.ts +63 -0
  130. package/dist/tree-ops/viewbox-culling.js +272 -0
  131. package/dist/tree-ops/viewbox-culling.test.d.ts +1 -0
  132. package/dist/tree-ops/viewbox-culling.test.js +206 -0
  133. package/dist/utils/region-feedback.d.ts +56 -0
  134. package/dist/utils/region-feedback.js +134 -0
  135. package/dist/utils/region-feedback.test.d.ts +1 -0
  136. package/dist/utils/region-feedback.test.js +221 -0
  137. package/dist/webfont-unicode-range.test.js +2 -2
  138. package/package.json +26 -15
  139. package/src/{animator.test.ts → animation/animator.test.ts} +92 -0
  140. package/src/{animator.ts → animation/animator.ts} +107 -26
  141. package/src/animation/index.ts +29 -0
  142. package/src/capture/embed.ts +305 -0
  143. package/src/capture/emoji.ts +216 -0
  144. package/src/{capture.ts → capture/index.ts} +498 -5
  145. package/src/capture/script/color-norm.ts +43 -0
  146. package/src/capture/script/emoji-detect.ts +83 -0
  147. package/src/capture/script/font-metrics.ts +130 -0
  148. package/src/capture/script/index.ts +999 -0
  149. package/src/capture/script/placeholder-shown.ts +54 -0
  150. package/src/capture/script/pseudo-rules.ts +223 -0
  151. package/src/capture/script/utils.ts +18 -0
  152. package/src/capture/script/walker/borders-backgrounds.ts +170 -0
  153. package/src/capture/script/walker/form-controls.ts +242 -0
  154. package/src/capture/script/walker/input-value.ts +208 -0
  155. package/src/capture/script/walker/lists-counters.ts +88 -0
  156. package/src/capture/script/walker/masks-clips.ts +142 -0
  157. package/src/capture/script/walker/pseudo-content.ts +542 -0
  158. package/src/capture/script/walker/pseudo-inject.ts +183 -0
  159. package/src/capture/script/walker/replaced-elements.ts +102 -0
  160. package/src/capture/script/walker/text-segments.ts +315 -0
  161. package/src/capture/script/walker/transforms.ts +146 -0
  162. package/src/capture/script/warnings.ts +39 -0
  163. package/src/capture/script.generated.ts +7 -0
  164. package/src/capture/types.ts +691 -0
  165. package/src/capture/warnings.ts +39 -0
  166. package/src/cli/animate.ts +391 -0
  167. package/src/cli/capture.ts +198 -0
  168. package/src/cli/common.ts +126 -0
  169. package/src/cli/index.ts +170 -0
  170. package/src/cross-origin-font-face.test.ts +1 -1
  171. package/src/dark-mode-capture.test.ts +2 -1
  172. package/src/dark-mode-form-controls.test.ts +3 -2
  173. package/src/embed-remote-images.test.ts +2 -3
  174. package/src/index.ts +82 -12
  175. package/src/kerf-jsx-augmentation.d.ts +1 -1
  176. package/src/mask.test.ts +2 -2
  177. package/src/post-processing/gzip.ts +14 -0
  178. package/src/post-processing/index.ts +5 -0
  179. package/src/post-processing/optimize.test.ts +48 -0
  180. package/src/preserve-aspect-ratio.test.ts +1 -1
  181. package/src/{border-radius.test.ts → render/borders.test.ts} +1 -1
  182. package/src/render/borders.ts +417 -0
  183. package/src/render/box-shadow.ts +65 -0
  184. package/src/render/colors.ts +163 -0
  185. package/src/{conic-raster.test.ts → render/conic-raster.test.ts} +3 -3
  186. package/src/{conic-raster.ts → render/conic-raster.ts} +3 -6
  187. package/src/{coretext-extractor.test.ts → render/coretext.test.ts} +1 -0
  188. package/src/render/css-tokens.ts +44 -0
  189. package/src/render/element-tree-to-svg.ts +3831 -0
  190. package/src/{form-controls.ts → render/form-controls.ts} +2 -2
  191. package/src/render/format.ts +24 -0
  192. package/src/render/index.ts +14 -0
  193. package/src/{text-to-path.test.ts → render/text-to-path.test.ts} +87 -13
  194. package/src/{text-to-path.ts → render/text-to-path.ts} +44 -19
  195. package/src/{text-renderer.test.ts → render/text.test.ts} +99 -5
  196. package/src/{text-renderer.ts → render/text.ts} +99 -9
  197. package/src/render/transforms.ts +41 -0
  198. package/src/scroll/composer.test.ts +199 -0
  199. package/src/scroll/composer.ts +140 -0
  200. package/src/scroll/executor.test.ts +243 -0
  201. package/src/scroll/executor.ts +583 -0
  202. package/src/scroll/index.ts +36 -0
  203. package/src/scroll/pattern.test.ts +474 -0
  204. package/src/scroll/pattern.ts +547 -0
  205. package/src/stacking-context.test.ts +4 -3
  206. package/src/{frame-merge.ts → tree-ops/frame-merge.ts} +6 -1
  207. package/src/tree-ops/index.ts +11 -0
  208. package/src/{resize-embedded-images.test.ts → tree-ops/resize-embedded-images.test.ts} +3 -3
  209. package/src/{resize-embedded-images.ts → tree-ops/resize-embedded-images.ts} +3 -6
  210. package/src/tree-ops/tree-diff.test.ts +295 -0
  211. package/src/tree-ops/tree-diff.ts +234 -0
  212. package/src/tree-ops/viewbox-culling.test.ts +256 -0
  213. package/src/tree-ops/viewbox-culling.ts +313 -0
  214. package/src/utils/region-feedback.test.ts +261 -0
  215. package/src/utils/region-feedback.ts +216 -0
  216. package/src/webfont-unicode-range.test.ts +2 -2
  217. package/dist/chrome.d.ts +0 -45
  218. package/dist/chrome.js +0 -107
  219. package/dist/cli.js +0 -512
  220. package/dist/client/dom.d.ts +0 -10
  221. package/dist/client/dom.js +0 -17
  222. package/dist/dom-to-svg.js +0 -7717
  223. package/dist/jsx-runtime.d.ts +0 -27
  224. package/dist/jsx-runtime.js +0 -96
  225. package/dist/jsx-runtime.test.js +0 -41
  226. package/src/cli.ts +0 -582
  227. package/src/dom-to-svg.ts +0 -8376
  228. /package/dist/{animator.test.d.ts → animation/animator.test.d.ts} +0 -0
  229. /package/dist/{cursor-overlay.d.ts → animation/cursor-overlay.d.ts} +0 -0
  230. /package/dist/{cursor-overlay.js → animation/cursor-overlay.js} +0 -0
  231. /package/dist/{cursor-overlay.test.d.ts → animation/cursor-overlay.test.d.ts} +0 -0
  232. /package/dist/{cursor-overlay.test.js → animation/cursor-overlay.test.js} +0 -0
  233. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  234. /package/dist/{optimize.d.ts → post-processing/optimize.d.ts} +0 -0
  235. /package/dist/{optimize.js → post-processing/optimize.js} +0 -0
  236. /package/dist/{border-radius.test.d.ts → post-processing/optimize.test.d.ts} +0 -0
  237. /package/dist/{conic-raster.test.d.ts → render/borders.test.d.ts} +0 -0
  238. /package/dist/{coretext-extractor.test.d.ts → render/conic-raster.test.d.ts} +0 -0
  239. /package/dist/{coretext-helper.d.ts → render/coretext.d.ts} +0 -0
  240. /package/dist/{coretext-helper.js → render/coretext.js} +0 -0
  241. /package/dist/{gradients.test.d.ts → render/coretext.test.d.ts} +0 -0
  242. /package/dist/{gradients.d.ts → render/gradients.d.ts} +0 -0
  243. /package/dist/{gradients.js → render/gradients.js} +0 -0
  244. /package/dist/{jsx-runtime.test.d.ts → render/gradients.test.d.ts} +0 -0
  245. /package/dist/{gradients.test.js → render/gradients.test.js} +0 -0
  246. /package/dist/{text-to-path.test.d.ts → render/text-to-path.test.d.ts} +0 -0
  247. /package/dist/{text-renderer.test.d.ts → render/text.test.d.ts} +0 -0
  248. /package/dist/{frame-merge.d.ts → tree-ops/frame-merge.d.ts} +0 -0
  249. /package/dist/{frame-merge.test.d.ts → tree-ops/frame-merge.test.d.ts} +0 -0
  250. /package/dist/{frame-merge.test.js → tree-ops/frame-merge.test.js} +0 -0
  251. /package/dist/{resize-embedded-images.test.d.ts → tree-ops/resize-embedded-images.test.d.ts} +0 -0
  252. /package/src/{cursor-overlay.test.ts → animation/cursor-overlay.test.ts} +0 -0
  253. /package/src/{cursor-overlay.ts → animation/cursor-overlay.ts} +0 -0
  254. /package/src/{optimize.ts → post-processing/optimize.ts} +0 -0
  255. /package/src/{coretext-helper.ts → render/coretext.ts} +0 -0
  256. /package/src/{gradients.test.ts → render/gradients.test.ts} +0 -0
  257. /package/src/{gradients.ts → render/gradients.ts} +0 -0
  258. /package/src/{frame-merge.test.ts → tree-ops/frame-merge.test.ts} +0 -0
package/README.md CHANGED
@@ -16,6 +16,14 @@ Domotion captures real HTML/CSS as it renders in Chromium, then emits a single i
16
16
 
17
17
  Early — extracted in 2026-04 from the slicekit project where it had been incubating as `tools/svg-demo-gen`. APIs may still shift while the project's external surface stabilizes.
18
18
 
19
+ ## Platform support
20
+
21
+ Domotion ships as a normal npm package and is **designed** to work on macOS, Linux, and Windows — the captured SVG is meant to be pixel-faithful to Chromium on whichever platform the capture is running on (CoreText fallback on macOS, fontconfig on Linux, DirectWrite on Windows).
22
+
23
+ **Today it's only actively tested and calibrated on macOS.** Linux and Windows are roadmap items: cross-platform system-font path discovery, per-platform fallback-font chains calibrated against the host Chromium, optional bundled fallback fonts when no local match resolves, and CI coverage on both platforms. The package will install and run on Linux/Windows, but text rendering won't yet match the host Chromium as faithfully as it does on macOS.
24
+
25
+ If you'd like to help with cross-platform support — testing on Linux or Windows, reporting issues you hit, or sending fixes — please open an issue or PR on [GitHub](https://github.com/brianwestphal/domotion). Bug reports against macOS are also welcome.
26
+
19
27
  ## Install
20
28
 
21
29
  ```bash
@@ -8,6 +8,18 @@ import { type CursorOverlay, type SelectorResolver } from "./cursor-overlay.js";
8
8
  export interface AnimationFrame {
9
9
  /** SVG content for this frame (from dom-to-svg) */
10
10
  svgContent: string;
11
+ /**
12
+ * Per-element viewBox-cull keyframes CSS (DM-603). The caller runs
13
+ * `cullElementsOutsideViewBox()` on the captured tree before `elementTreeToSvg()` — that
14
+ * mutates `displayNone` / `cullClass` on each element (which the renderer
15
+ * surfaces) and returns the keyframes blocks that map each `cull-N` class
16
+ * to its visible window. The animator splices this CSS into the scene-wide
17
+ * `<style>` block.
18
+ *
19
+ * When omitted, no culling happens — callers passing pre-rendered
20
+ * `svgContent` strings without the cull CSS get unchanged behavior.
21
+ */
22
+ cullCss?: string;
11
23
  /** Duration this frame is shown (ms) */
12
24
  duration: number;
13
25
  /** Transition to next frame */
@@ -22,7 +34,7 @@ export interface AnimationFrame {
22
34
  duration: number;
23
35
  };
24
36
  /** Overlays: typing, tap ripple */
25
- overlays?: Overlay[];
37
+ overlays?: AnimationOverlay[];
26
38
  /**
27
39
  * Intra-frame property animations. Run during this frame's hold time.
28
40
  * The CLI / `DemoRecorder` resolves selectors against the DOM at capture
@@ -99,7 +111,7 @@ export interface SvgOverlay {
99
111
  delay?: number;
100
112
  };
101
113
  }
102
- export type Overlay = TypingOverlay | TapOverlay | SvgOverlay;
114
+ export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay;
103
115
  /**
104
116
  * Animate a CSS property on captured elements that match a selector, while
105
117
  * the frame is held on screen. The selector is resolved against the source
@@ -4,7 +4,7 @@
4
4
  * Takes captured SVG frame content and composes them into a single
5
5
  * animated SVG with CSS keyframe transitions.
6
6
  */
7
- import { mergeFrames } from "./frame-merge.js";
7
+ import { mergeFrames } from "../tree-ops/frame-merge.js";
8
8
  import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
9
9
  export function generateAnimatedSvg(config) {
10
10
  const { width, height, frames } = config;
@@ -50,13 +50,27 @@ export function generateAnimatedSvg(config) {
50
50
  const transEndPct = pct(timeOffset + frame.duration + transDur, totalDuration);
51
51
  const prevFrame = i > 0 ? frames[i - 1] : null;
52
52
  const entersViaPush = prevFrame?.transition?.type === "push-left";
53
+ const entersViaScroll = prevFrame?.transition?.type === "scroll";
54
+ // Both push-left and scroll overlap their transition with the next
55
+ // frame's entry — the next frame is already sliding in while the current
56
+ // one slides out, so its show window starts at `timeOffset - prevTransDur`
57
+ // rather than at `startPct`.
58
+ const entersViaOverlap = entersViaPush || entersViaScroll;
53
59
  const prevTransDur = prevFrame != null ? transitionDuration(prevFrame) : 300;
54
- const enterStartPct = entersViaPush
60
+ const enterStartPct = entersViaOverlap
55
61
  ? pct(timeOffset - prevTransDur, totalDuration)
56
62
  : startPct;
57
63
  if (transType === "push-left") {
58
64
  // Push: slide in from right, slide out to left
59
65
  frameGroups.push(` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${frame.svgContent}\n </g></g>`);
66
+ // DM-599: parallel `fd-${i}` animation snaps `display` between none /
67
+ // inline at the visibility boundary so the browser can skip painting
68
+ // this frame's content while it's fully off-screen between cycles.
69
+ // Window is [enterStartPct .. transEndPct] (when the slide has fully
70
+ // exited the viewBox); 0.01% pad on each side keeps the snap inside the
71
+ // existing opacity:0 bookend.
72
+ const visStart = enterStartPct;
73
+ const visEnd = transEndPct;
60
74
  keyframes.push(`
61
75
  @keyframes fp-${i} {
62
76
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
@@ -70,21 +84,38 @@ export function generateAnimatedSvg(config) {
70
84
  ${enterStartPct}% { opacity: 1; }
71
85
  ${transEndPct}% { opacity: 1; }
72
86
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
73
- }
74
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }
87
+ }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
88
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
75
89
  .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
76
90
  }
77
91
  else if (transType === "scroll") {
78
- // Scroll: keep visible, no fade during scroll, fade only at end
79
- frameGroups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
80
- const fadeEndPct = pct(timeOffset + frame.duration + transDur + 200, totalDuration);
81
- const prevEnd = i > 0 ? `${Math.max(0, parseFloat(startPct) - 0.1).toFixed(2)}%,` : "";
92
+ // DM-609: `scroll` now means real geometric scroll between two frames
93
+ // (was opacity-only — see DM-604 §10a "replace with new geometric
94
+ // semantics"). Vertical equivalent of `push-left`: incoming frame
95
+ // slides up from the bottom of the viewport, outgoing slides up off
96
+ // the top. Uses height instead of width and translateY instead of
97
+ // translateX, otherwise identical machinery (incl. the cull-friendly
98
+ // `fd-${i}` display animation).
99
+ const entersViaScroll = prevFrame?.transition?.type === "scroll";
100
+ frameGroups.push(` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${frame.svgContent}\n </g></g>`);
101
+ const visStart = enterStartPct;
102
+ const visEnd = transEndPct;
82
103
  keyframes.push(`
83
- @keyframes fv-${i} {
84
- 0%, ${prevEnd} ${fadeEndPct}, 100% { opacity: 0; }
85
- ${startPct}, ${transEndPct} { opacity: 1; }
104
+ @keyframes fp-${i} {
105
+ 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateY(${entersViaScroll ? height : 0}px); }
106
+ ${startPct}% { transform: translateY(0); }
107
+ ${holdEndPct}% { transform: translateY(0); }
108
+ ${transEndPct}% { transform: translateY(-${height}px); }
109
+ ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
86
110
  }
87
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }`);
111
+ @keyframes fv-${i} {
112
+ 0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
113
+ ${enterStartPct}% { opacity: 1; }
114
+ ${transEndPct}% { opacity: 1; }
115
+ ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
116
+ }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
117
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
118
+ .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`);
88
119
  }
89
120
  else {
90
121
  // Crossfade or cut: opacity in/out.
@@ -105,14 +136,16 @@ export function generateAnimatedSvg(config) {
105
136
  const endNum = parseFloat(transEndPct);
106
137
  const beforeStart = Math.max(0, startNum - 0.001).toFixed(3);
107
138
  const afterEnd = Math.min(100, endNum + 0.001).toFixed(3);
139
+ // DM-599: cut already uses step-end on the opacity animation, so we
140
+ // fold display into the same keyframes block — both snap together.
108
141
  keyframes.push(`
109
142
  @keyframes fv-${i} {
110
- 0% { opacity: 0; }
111
- ${beforeStart}% { opacity: 0; }
112
- ${startNum.toFixed(3)}% { opacity: 1; }
113
- ${endNum.toFixed(3)}% { opacity: 1; }
114
- ${afterEnd}% { opacity: 0; }
115
- 100% { opacity: 0; }
143
+ 0% { opacity: 0; display: none; }
144
+ ${beforeStart}% { opacity: 0; display: none; }
145
+ ${startNum.toFixed(3)}% { opacity: 1; display: inline; }
146
+ ${endNum.toFixed(3)}% { opacity: 1; display: inline; }
147
+ ${afterEnd}% { opacity: 0; display: none; }
148
+ 100% { opacity: 0; display: none; }
116
149
  }
117
150
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
118
151
  }
@@ -123,12 +156,14 @@ export function generateAnimatedSvg(config) {
123
156
  const prevEnd = i > 0
124
157
  ? `${Math.max(0, parseFloat(fadeInStartPct) - 0.01).toFixed(2)}%,`
125
158
  : "";
159
+ // DM-599: visible window spans the full fade — fadeInStart through
160
+ // transEnd (display stays `inline` while opacity interpolates).
126
161
  keyframes.push(`
127
162
  @keyframes fv-${i} {
128
163
  0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
129
164
  ${startPct}, ${holdEndPct} { opacity: 1; }
130
- }
131
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }`);
165
+ }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct)}
166
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
132
167
  }
133
168
  }
134
169
  // Overlays
@@ -156,6 +191,11 @@ export function generateAnimatedSvg(config) {
156
191
  // Compose final SVG with XML declaration for proper UTF-8
157
192
  const sharedDefsMarkup = config.sharedDefs ?? "";
158
193
  const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
194
+ // DM-603: per-frame viewBox-cull keyframes — each frame's caller pre-ran
195
+ // `cullElementsOutsideViewBox()` and we splice the resulting blocks into the scene-wide
196
+ // <style>. The keyframes reference `var(--scene-dur)`; we expose that
197
+ // variable on the root selector below.
198
+ const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
159
199
  // Cursor overlay (DM-277). The frame start times let the resolver pick
160
200
  // which frame's selector matches apply at each event's timestamp.
161
201
  let overlayMarkup = "";
@@ -175,8 +215,9 @@ export function generateAnimatedSvg(config) {
175
215
  <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
176
216
  </defs>
177
217
  <style>
178
- .f { opacity: 0; }
179
- ${keyframes.join("\n")}${animationCss}
218
+ :root { --scene-dur: ${totalSec.toFixed(2)}s; }
219
+ .f { opacity: 0; display: none; }
220
+ ${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
180
221
  </style>
181
222
  <g clip-path="url(#viewport-clip)">
182
223
  <rect width="${width}" height="${height}" fill="#0d1117" />
@@ -261,6 +302,32 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
261
302
  function pct(ms, total) {
262
303
  return `${((ms / total) * 100).toFixed(2)}%`;
263
304
  }
305
+ /**
306
+ * DM-599: build a step-end keyframes block that toggles `display` between
307
+ * `none` and `inline` around a visible window. Used in parallel with the
308
+ * opacity-controlling `fv-*` animation so the browser can skip painting the
309
+ * frame entirely while it's outside its show window (the dominant cost on
310
+ * long multi-frame demos with complex captured content).
311
+ *
312
+ * `visibleStartPct` / `visibleEndPct` accept either a numeric-style string
313
+ * (`"12.34"`) or one with a trailing `%` (`"12.34%"`) — `pct()` returns the
314
+ * latter and the unmerged-path keyframes feed either form.
315
+ */
316
+ function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
317
+ const start = parseFloat(String(visibleStartPct));
318
+ const end = parseFloat(String(visibleEndPct));
319
+ const startMinus = Math.max(0, start - 0.01).toFixed(3);
320
+ const endPlus = Math.min(100, end + 0.01).toFixed(3);
321
+ return `
322
+ @keyframes ${name} {
323
+ 0% { display: none; }
324
+ ${startMinus}% { display: none; }
325
+ ${start.toFixed(3)}% { display: inline; }
326
+ ${end.toFixed(3)}% { display: inline; }
327
+ ${endPlus}% { display: none; }
328
+ 100% { display: none; }
329
+ }`;
330
+ }
264
331
  /**
265
332
  * Render a frame-local SVG overlay. The embedded SVG markup is wrapped in a
266
333
  * `<g transform="translate(x y)" clip-path="..."/>` and an inner
@@ -344,6 +411,8 @@ function composeMergedSvg(config, frameTiming, totalSec) {
344
411
  const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
345
412
  const sharedDefsMarkup = config.sharedDefs ?? "";
346
413
  const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
414
+ // DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
415
+ const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
347
416
  // Cursor overlay (DM-277). Same emission as the unmerged path — the
348
417
  // overlay sits above the merged frame group, clipped to the viewport.
349
418
  const totalDuration = totalSec * 1000;
@@ -365,7 +434,7 @@ function composeMergedSvg(config, frameTiming, totalSec) {
365
434
  </defs>
366
435
  <style>
367
436
  :root { --scene-dur: ${totalSec.toFixed(2)}s; }
368
- ${css}${animationCss}
437
+ ${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
369
438
  </style>
370
439
  <g clip-path="url(#viewport-clip)">
371
440
  <rect width="${width}" height="${height}" fill="#0d1117" />
@@ -50,6 +50,26 @@ describe("animator", () => {
50
50
  });
51
51
  expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(1);
52
52
  });
53
+ it("DM-609: scroll transition is now a vertical push (translateY), not opacity-only", () => {
54
+ // The `scroll` transition used to be opacity-only (a misnomer). Per
55
+ // DM-604 §10a, it's been replaced with real geometric semantics: the
56
+ // outgoing frame slides up off the top while the next slides up from
57
+ // the bottom. We verify by checking for translateY in the emitted CSS.
58
+ const svg = generateAnimatedSvg({
59
+ width: 100, height: 100,
60
+ frames: [
61
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "scroll", duration: 200 } },
62
+ { svgContent: `<rect/>`, duration: 1000 },
63
+ ],
64
+ });
65
+ expect(svg).toContain("translateY");
66
+ // The clipPath wrapper is added too (frames are clipped to viewport
67
+ // during the slide so they don't show outside their region).
68
+ expect(svg).toMatch(/<clipPath id="fc-\d+">/);
69
+ // The fp-N keyframes (transform animation) get emitted alongside fv-N
70
+ // (opacity) and fd-N (display) — mirrors the push-left structure.
71
+ expect(svg).toMatch(/@keyframes fp-0/);
72
+ });
53
73
  it("non-crossfade transitions are unaffected", () => {
54
74
  const svg = generateAnimatedSvg({
55
75
  width: 100,
@@ -152,6 +172,74 @@ describe("animator", () => {
152
172
  expect(svg).toContain("transform: translateY(240px)");
153
173
  expect(svg).toContain("transform: translateY(0px)");
154
174
  });
175
+ it("DM-599: push-left frame gets a paired fd-N display animation alongside fv-N", () => {
176
+ // push-left is unmergeable (the merge fast path only takes crossfade/cut),
177
+ // so it goes through the unmerged emit path that emits per-frame fv-/fp-
178
+ // blocks. The DM-599 optimization adds an fd-N keyframes block that
179
+ // toggles `display: none ↔ inline` so the frame is dropped from paint
180
+ // outside its show window.
181
+ const svg = generateAnimatedSvg({
182
+ width: 100, height: 100,
183
+ frames: [
184
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 200 } },
185
+ { svgContent: `<rect/>`, duration: 1000 },
186
+ ],
187
+ });
188
+ // Both frames get an fd-N keyframes block …
189
+ expect(svg).toMatch(/@keyframes fd-0\s*{/);
190
+ expect(svg).toMatch(/@keyframes fd-1\s*{/);
191
+ // … alongside the existing fv-N opacity block.
192
+ expect(svg).toMatch(/@keyframes fv-0\s*{/);
193
+ // The keyframes flip display between none and inline.
194
+ expect(svg).toMatch(/display:\s*none/);
195
+ expect(svg).toMatch(/display:\s*inline/);
196
+ // The frame's CSS rule lists BOTH animations, with the fd one tagged
197
+ // step-end so the display flip is instant (not snap-at-50% of segment).
198
+ expect(svg).toMatch(/\.f-0\s*{\s*animation:[^}]*fv-0[^}]*,[^}]*fd-0[^}]*step-end/);
199
+ // The base .f rule sets display:none so frames start hidden until the
200
+ // keyframe flips them in.
201
+ expect(svg).toMatch(/\.f\s*{[^}]*display:\s*none/);
202
+ });
203
+ it("DM-599: cut frames fold display into fv-N (same step-end timing)", () => {
204
+ // Three explicit `cut` frames — the all-mergeable check trips and these
205
+ // route through the MERGE pipeline. But a non-mergeable transition mixed
206
+ // in (e.g. push-left) would route this through the unmerged path. We
207
+ // verify the unmerged path's cut branch here by mixing.
208
+ const svg = generateAnimatedSvg({
209
+ width: 100, height: 100,
210
+ frames: [
211
+ { svgContent: `<rect fill="red"/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
212
+ { svgContent: `<rect fill="blue"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
213
+ { svgContent: `<rect fill="green"/>`, duration: 1000 },
214
+ ],
215
+ });
216
+ // The "cut" frame's fv-N keyframes carry the display toggle inline (no
217
+ // separate fd-N block) since cut already uses step-end on fv-N.
218
+ const fv1Match = svg.match(/@keyframes fv-1\s*{[\s\S]*?\n\s*}/);
219
+ expect(fv1Match).not.toBeNull();
220
+ expect(fv1Match[0]).toMatch(/display:\s*none/);
221
+ expect(fv1Match[0]).toMatch(/display:\s*inline/);
222
+ // The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
223
+ expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
224
+ });
225
+ it("DM-599: merged-path keyframes emit display alongside opacity", () => {
226
+ // Two crossfade frames with different content route through the merge
227
+ // pipeline. Per-element visibility classes (tN) now toggle BOTH opacity
228
+ // and display so the browser can skip painting hidden elements.
229
+ const svg = generateAnimatedSvg({
230
+ width: 100, height: 100,
231
+ frames: [
232
+ { svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
233
+ { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
234
+ ],
235
+ });
236
+ // Each tN keyframe stop with opacity:1 also has display:inline; each
237
+ // opacity:0 stop has display:none.
238
+ const tN = svg.match(/@keyframes t\d+\s*{[\s\S]*?\n\s*}/);
239
+ expect(tN).not.toBeNull();
240
+ expect(tN[0]).toMatch(/opacity:\s*1;\s*display:\s*inline/);
241
+ expect(tN[0]).toMatch(/opacity:\s*0;\s*display:\s*none/);
242
+ });
155
243
  it("cut transition: timeline boundary is exactly at the frame edge", () => {
156
244
  // For two frames each held 1000ms with cut transitions and no overlap,
157
245
  // the visibility flip should land at exactly 50% of the scene.
@@ -0,0 +1,2 @@
1
+ export { generateAnimatedSvg, type AnimationConfig, type AnimationFrame, type AnimationOverlay, type TypingOverlay, type TapOverlay, type SvgOverlay, type IntraFrameAnimation, } from "./animator.js";
2
+ export { cursorOverlayMarkup, resolveCursorScript, type CursorOverlay, type CursorEvent, type CursorMoveEvent, type CursorClickEvent, type CursorShowEvent, type CursorHideEvent, type CursorStyle, type SelectorResolver, } from "./cursor-overlay.js";
@@ -0,0 +1,7 @@
1
+ // Public surface of the animation pipeline. The composer
2
+ // (`generateAnimatedSvg`) consumes per-frame element trees + transition /
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.
6
+ export { generateAnimatedSvg, } from "./animator.js";
7
+ export { cursorOverlayMarkup, resolveCursorScript, } from "./cursor-overlay.js";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Image-embed pipeline: turn `<img src>`, `background-image: url(...)`,
3
+ * `mask-image: url(...)`, `border-image-source: url(...)`, and
4
+ * `list-style-image: url(...)` URLs into self-contained data URIs the renderer
5
+ * can inline into the SVG output. Three pieces:
6
+ *
7
+ * 1. `embedAsDataUri` / `_dataUriCache` — synchronous local-file / cached
8
+ * data-URI lookup used by the renderer at emit time.
9
+ * 2. `embedResizedDataUri` / `_resizedDataUriCache` / `_activeHiDPIFactor` —
10
+ * DM-540 size-tagged variant: returns a pre-resized data URI when the
11
+ * resize pre-pass (`resizeEmbeddedImages`) populated the cache for the
12
+ * consumer's CSS box, falling back to the source-resolution URI otherwise.
13
+ * 3. `embedRemoteImages` — async pre-pass that fetches every http(s) URL
14
+ * referenced by the captured tree and stashes the bytes in
15
+ * `_dataUriCache`. Yields a self-contained SVG that renders in image
16
+ * viewers (Preview, QuickLook, etc.) that block remote resources from
17
+ * local files.
18
+ */
19
+ import type { CapturedElement, CaptureWarning } from "./types.js";
20
+ /**
21
+ * @internal — exposed for `resize-embedded-images.ts` (DM-539). The resize
22
+ * pre-pass reads source bytes from this cache (populated by `embedRemoteImages`)
23
+ * and writes per-(URL, size) resized variants into `_resizedDataUriCache`.
24
+ */
25
+ export declare const _dataUriCache: Map<string, string>;
26
+ /**
27
+ * @internal — DM-539. Per-source-URL map of sizeKey → resized data URI. The
28
+ * sizeKey is `${ceil(targetW * hiDPI)}x${ceil(targetH * hiDPI)}`. Populated by
29
+ * `resizeEmbeddedImages` (only when `embedRemoteImagesResize: true`); read by
30
+ * `embedResizedDataUri` (DM-540) when the renderer emits an `<image href>`.
31
+ * Empty at module load — first capture with the resize flag fills it.
32
+ */
33
+ export declare const _resizedDataUriCache: Map<string, Map<string, string>>;
34
+ export declare function setActiveHiDPIFactor(n: number): void;
35
+ /**
36
+ * DM-540 — renderer-side lookup for the image-resize-on-embed pipeline.
37
+ * Returns the resized PNG data URI for `(url, ceil(w * hiDPI), ceil(h * hiDPI))`
38
+ * when the resize pre-pass populated `_resizedDataUriCache` for that key;
39
+ * otherwise falls back to `embedAsDataUri(url)` so the renderer behaves
40
+ * identically to today when resize is disabled (no entries in the resized
41
+ * cache → source-resolution data URI returned).
42
+ */
43
+ export declare function embedResizedDataUri(url: string, consumerW: number, consumerH: number, hiDPIFactor?: number): string;
44
+ export interface EmbedRemoteImagesOptions {
45
+ /**
46
+ * DM-527: append per-URL fetch failures here as `CaptureWarning` entries.
47
+ * If omitted, warnings push to the module-global `lastCaptureWarnings`
48
+ * (visible via `getLastCaptureWarnings` / `logCaptureWarnings`). Concurrent
49
+ * captures should pass an explicit array to avoid racing on the global.
50
+ */
51
+ warnings?: CaptureWarning[];
52
+ /**
53
+ * DM-528: per-URL fetch timeout in ms. A stalled CDN host (slow DNS, slow
54
+ * first-byte, unresponsive origin) would otherwise hang the capture
55
+ * indefinitely — the parallel `Promise.all` won't resolve until every
56
+ * fetch settles. With this timeout the slowest fetch caps total pre-pass
57
+ * time at ~`timeoutMs * (retries + 1) + retryBackoffMs * retries` (since
58
+ * fetches run in parallel). Timed-out fetches produce a `remote-image`
59
+ * warning and the URL stays as-is in the SVG. Default 10000.
60
+ */
61
+ timeoutMs?: number;
62
+ /**
63
+ * DM-529: number of retry attempts for transient failures (5xx response,
64
+ * network error, or timeout). 4xx responses are not retried — those are
65
+ * deterministic and a retry would just consume time. The originating
66
+ * fetch + retries together can take up to
67
+ * `(retries + 1) * timeoutMs + retries * retryBackoffMs` per URL, so keep
68
+ * this small. Default 1.
69
+ */
70
+ retries?: number;
71
+ /**
72
+ * DM-529: delay (ms) between attempts when retrying a transient failure.
73
+ * Default 500.
74
+ */
75
+ retryBackoffMs?: number;
76
+ }
77
+ /**
78
+ * DM-512: fetch every http(s) image URL referenced by the captured tree and
79
+ * stash the resolved bytes as a data: URI in the renderer's data-URI cache.
80
+ * Subsequent calls to `embedAsDataUri` for those URLs return the cached data
81
+ * URI instead of passing the URL through verbatim — yielding a self-contained
82
+ * SVG that loads correctly in image viewers (Preview, Finder QuickLook, etc.)
83
+ * that don't fetch remote resources from local files.
84
+ *
85
+ * Walks the captured tree once collecting URLs from: `imageSrc` (for `<img>`),
86
+ * `pseudoImages[].url` (for `::before`/`::after` content: url(...)), and CSS
87
+ * `url(...)` tokens inside `styles.backgroundImage` / `.maskImage` /
88
+ * `.borderImageSource` / `.listStyleImage`. Dedupes per call.
89
+ *
90
+ * Per-URL fetch failures (network error, non-2xx, missing Content-Type) leave
91
+ * the URL in the tree as-is, so the SVG still references it. DM-527: each
92
+ * failure is surfaced as a `CaptureWarning` (feature: `remote-image`) carrying
93
+ * the URL and the HTTP status / error class, so callers can trace which images
94
+ * didn't inline. The warning is appended to `options.warnings` if supplied,
95
+ * otherwise to `getLastCaptureWarnings()`.
96
+ */
97
+ export declare function embedRemoteImages(tree: CapturedElement[], options?: EmbedRemoteImagesOptions): Promise<void>;