domotion-svg 0.1.1 → 0.3.1

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 (278) hide show
  1. package/FEATURES.md +1 -0
  2. package/README.md +37 -0
  3. package/dist/{animator.d.ts → animation/animator.d.ts} +14 -2
  4. package/dist/{animator.js → animation/animator.js} +103 -23
  5. package/dist/{animator.test.js → animation/animator.test.js} +121 -0
  6. package/dist/{cursor-overlay.js → animation/cursor-overlay.js} +0 -2
  7. package/dist/animation/index.d.ts +2 -0
  8. package/dist/animation/index.js +7 -0
  9. package/dist/capture/embed.d.ts +97 -0
  10. package/dist/capture/embed.js +272 -0
  11. package/dist/capture/emoji.d.ts +34 -0
  12. package/dist/capture/emoji.js +220 -0
  13. package/dist/{capture.d.ts → capture/index.d.ts} +52 -0
  14. package/dist/{capture.js → capture/index.js} +465 -8
  15. package/dist/capture/script/color-norm.d.ts +4 -0
  16. package/dist/capture/script/color-norm.js +86 -0
  17. package/dist/capture/script/emoji-detect.d.ts +4 -0
  18. package/dist/capture/script/emoji-detect.js +100 -0
  19. package/dist/capture/script/font-metrics.d.ts +4 -0
  20. package/dist/capture/script/font-metrics.js +142 -0
  21. package/dist/capture/script/index.d.ts +4 -0
  22. package/dist/capture/script/index.js +1576 -0
  23. package/dist/capture/script/placeholder-shown.d.ts +3 -0
  24. package/dist/capture/script/placeholder-shown.js +63 -0
  25. package/dist/capture/script/pseudo-rules.d.ts +14 -0
  26. package/dist/capture/script/pseudo-rules.js +244 -0
  27. package/dist/capture/script/utils.d.ts +2 -0
  28. package/dist/capture/script/utils.js +15 -0
  29. package/dist/capture/script/walker/borders-backgrounds.d.ts +61 -0
  30. package/dist/capture/script/walker/borders-backgrounds.js +290 -0
  31. package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
  32. package/dist/capture/script/walker/counter-style-resolver.js +218 -0
  33. package/dist/capture/script/walker/form-controls.d.ts +51 -0
  34. package/dist/capture/script/walker/form-controls.js +237 -0
  35. package/dist/capture/script/walker/input-value.d.ts +23 -0
  36. package/dist/capture/script/walker/input-value.js +220 -0
  37. package/dist/capture/script/walker/lists-counters.d.ts +18 -0
  38. package/dist/capture/script/walker/lists-counters.js +113 -0
  39. package/dist/capture/script/walker/masks-clips.d.ts +10 -0
  40. package/dist/capture/script/walker/masks-clips.js +178 -0
  41. package/dist/capture/script/walker/pseudo-content.d.ts +105 -0
  42. package/dist/capture/script/walker/pseudo-content.js +820 -0
  43. package/dist/capture/script/walker/pseudo-inject.d.ts +17 -0
  44. package/dist/capture/script/walker/pseudo-inject.js +202 -0
  45. package/dist/capture/script/walker/replaced-elements.d.ts +5 -0
  46. package/dist/capture/script/walker/replaced-elements.js +97 -0
  47. package/dist/capture/script/walker/text-segments.d.ts +67 -0
  48. package/dist/capture/script/walker/text-segments.js +417 -0
  49. package/dist/capture/script/walker/transforms.d.ts +9 -0
  50. package/dist/capture/script/walker/transforms.js +160 -0
  51. package/dist/capture/script/warnings.d.ts +5 -0
  52. package/dist/capture/script/warnings.js +41 -0
  53. package/dist/capture/script.generated.d.ts +1 -0
  54. package/dist/capture/script.generated.js +6 -0
  55. package/dist/{dom-to-svg.d.ts → capture/types.d.ts} +258 -327
  56. package/dist/capture/types.js +18 -0
  57. package/dist/capture/warnings.d.ts +23 -0
  58. package/dist/capture/warnings.js +34 -0
  59. package/dist/cli/animate.d.ts +8 -0
  60. package/dist/cli/animate.js +436 -0
  61. package/dist/cli/capture.d.ts +8 -0
  62. package/dist/cli/capture.js +156 -0
  63. package/dist/cli/common.d.ts +41 -0
  64. package/dist/cli/common.js +126 -0
  65. package/dist/cli/index.js +165 -0
  66. package/dist/cross-origin-font-face.test.js +1 -1
  67. package/dist/dark-mode-capture.test.js +1 -1
  68. package/dist/dark-mode-form-controls.test.js +2 -2
  69. package/dist/embed-remote-images.test.js +1 -1
  70. package/dist/index.d.ts +11 -12
  71. package/dist/index.js +36 -7
  72. package/dist/mask.test.js +13 -8
  73. package/dist/post-processing/gzip.d.ts +10 -0
  74. package/dist/post-processing/gzip.js +13 -0
  75. package/dist/post-processing/index.d.ts +2 -0
  76. package/dist/post-processing/index.js +4 -0
  77. package/dist/post-processing/optimize.test.js +40 -0
  78. package/dist/preserve-aspect-ratio.test.js +1 -1
  79. package/dist/render/borders.d.ts +97 -0
  80. package/dist/render/borders.js +731 -0
  81. package/dist/{border-radius.test.js → render/borders.test.js} +1 -1
  82. package/dist/render/box-shadow.d.ts +16 -0
  83. package/dist/render/box-shadow.js +62 -0
  84. package/dist/render/colors.d.ts +25 -0
  85. package/dist/render/colors.js +167 -0
  86. package/dist/{conic-raster.d.ts → render/conic-raster.d.ts} +2 -1
  87. package/dist/{conic-raster.js → render/conic-raster.js} +2 -1
  88. package/dist/{conic-raster.test.js → render/conic-raster.test.js} +3 -3
  89. package/dist/{coretext-extractor.test.js → render/coretext.test.js} +1 -1
  90. package/dist/render/css-tokens.d.ts +21 -0
  91. package/dist/render/css-tokens.js +46 -0
  92. package/dist/render/element-tree-to-svg.d.ts +131 -0
  93. package/dist/render/element-tree-to-svg.js +5685 -0
  94. package/dist/render/embedded-font-builder.d.ts +49 -0
  95. package/dist/render/embedded-font-builder.js +149 -0
  96. package/dist/{form-controls.d.ts → render/form-controls.d.ts} +1 -1
  97. package/dist/{form-controls.js → render/form-controls.js} +46 -25
  98. package/dist/render/format.d.ts +11 -0
  99. package/dist/render/format.js +21 -0
  100. package/dist/{gradients.d.ts → render/gradients.d.ts} +15 -0
  101. package/dist/{gradients.js → render/gradients.js} +103 -2
  102. package/dist/{gradients.test.js → render/gradients.test.js} +34 -0
  103. package/dist/render/index.d.ts +2 -0
  104. package/dist/render/index.js +4 -0
  105. package/dist/{text-to-path.d.ts → render/text-to-path.d.ts} +46 -3
  106. package/dist/{text-to-path.js → render/text-to-path.js} +700 -49
  107. package/dist/{text-to-path.test.js → render/text-to-path.test.js} +311 -20
  108. package/dist/{text-renderer.d.ts → render/text.d.ts} +17 -1
  109. package/dist/{text-renderer.js → render/text.js} +443 -43
  110. package/dist/{text-renderer.test.js → render/text.test.js} +83 -4
  111. package/dist/render/transforms.d.ts +14 -0
  112. package/dist/render/transforms.js +56 -0
  113. package/dist/scroll/composer.d.ts +73 -0
  114. package/dist/scroll/composer.js +296 -0
  115. package/dist/scroll/composer.test.d.ts +1 -0
  116. package/dist/scroll/composer.test.js +452 -0
  117. package/dist/scroll/executor.d.ts +132 -0
  118. package/dist/scroll/executor.js +446 -0
  119. package/dist/scroll/executor.test.d.ts +1 -0
  120. package/dist/scroll/executor.test.js +236 -0
  121. package/dist/scroll/hoist-fixed.d.ts +48 -0
  122. package/dist/scroll/hoist-fixed.js +85 -0
  123. package/dist/scroll/hoist-fixed.test.d.ts +1 -0
  124. package/dist/scroll/hoist-fixed.test.js +103 -0
  125. package/dist/scroll/hoist-sticky.d.ts +45 -0
  126. package/dist/scroll/hoist-sticky.js +157 -0
  127. package/dist/scroll/hoist-sticky.test.d.ts +1 -0
  128. package/dist/scroll/hoist-sticky.test.js +154 -0
  129. package/dist/scroll/index.d.ts +13 -0
  130. package/dist/scroll/index.js +10 -0
  131. package/dist/scroll/pattern.d.ts +133 -0
  132. package/dist/scroll/pattern.js +507 -0
  133. package/dist/scroll/pattern.test.d.ts +1 -0
  134. package/dist/scroll/pattern.test.js +438 -0
  135. package/dist/stacking-context.test.js +3 -3
  136. package/dist/{frame-merge.d.ts → tree-ops/frame-merge.d.ts} +10 -0
  137. package/dist/{frame-merge.js → tree-ops/frame-merge.js} +23 -2
  138. package/dist/{frame-merge.test.js → tree-ops/frame-merge.test.js} +45 -0
  139. package/dist/tree-ops/index.d.ts +5 -0
  140. package/dist/tree-ops/index.js +6 -0
  141. package/dist/{resize-embedded-images.d.ts → tree-ops/resize-embedded-images.d.ts} +1 -1
  142. package/dist/{resize-embedded-images.js → tree-ops/resize-embedded-images.js} +2 -2
  143. package/dist/{resize-embedded-images.test.js → tree-ops/resize-embedded-images.test.js} +2 -2
  144. package/dist/tree-ops/tree-diff.d.ts +72 -0
  145. package/dist/tree-ops/tree-diff.js +196 -0
  146. package/dist/tree-ops/tree-diff.test.d.ts +1 -0
  147. package/dist/tree-ops/tree-diff.test.js +267 -0
  148. package/dist/tree-ops/viewbox-culling.d.ts +63 -0
  149. package/dist/tree-ops/viewbox-culling.js +286 -0
  150. package/dist/tree-ops/viewbox-culling.test.d.ts +1 -0
  151. package/dist/tree-ops/viewbox-culling.test.js +240 -0
  152. package/dist/utils/region-feedback.d.ts +56 -0
  153. package/dist/utils/region-feedback.js +134 -0
  154. package/dist/utils/region-feedback.test.d.ts +1 -0
  155. package/dist/utils/region-feedback.test.js +221 -0
  156. package/dist/webfont-unicode-range.test.js +2 -2
  157. package/package.json +31 -15
  158. package/src/{animator.test.ts → animation/animator.test.ts} +127 -0
  159. package/src/{animator.ts → animation/animator.ts} +118 -26
  160. package/src/{cursor-overlay.ts → animation/cursor-overlay.ts} +0 -2
  161. package/src/animation/index.ts +29 -0
  162. package/src/capture/embed.ts +305 -0
  163. package/src/capture/emoji.ts +226 -0
  164. package/src/{capture.ts → capture/index.ts} +513 -19
  165. package/src/capture/script/color-norm.ts +80 -0
  166. package/src/capture/script/emoji-detect.ts +97 -0
  167. package/src/capture/script/font-metrics.ts +130 -0
  168. package/src/capture/script/index.ts +1506 -0
  169. package/src/capture/script/placeholder-shown.ts +54 -0
  170. package/src/capture/script/pseudo-rules.ts +223 -0
  171. package/src/capture/script/utils.ts +18 -0
  172. package/src/capture/script/walker/borders-backgrounds.ts +277 -0
  173. package/src/capture/script/walker/counter-style-resolver.ts +184 -0
  174. package/src/capture/script/walker/form-controls.ts +242 -0
  175. package/src/capture/script/walker/input-value.ts +221 -0
  176. package/src/capture/script/walker/lists-counters.ts +110 -0
  177. package/src/capture/script/walker/masks-clips.ts +181 -0
  178. package/src/capture/script/walker/pseudo-content.ts +784 -0
  179. package/src/capture/script/walker/pseudo-inject.ts +203 -0
  180. package/src/capture/script/walker/replaced-elements.ts +102 -0
  181. package/src/capture/script/walker/text-segments.ts +404 -0
  182. package/src/capture/script/walker/transforms.ts +160 -0
  183. package/src/capture/script/warnings.ts +39 -0
  184. package/src/capture/script.generated.ts +7 -0
  185. package/src/capture/types.ts +891 -0
  186. package/src/capture/warnings.ts +39 -0
  187. package/src/cli/animate.ts +511 -0
  188. package/src/cli/capture.ts +198 -0
  189. package/src/cli/common.ts +126 -0
  190. package/src/cli/index.ts +170 -0
  191. package/src/cross-origin-font-face.test.ts +1 -1
  192. package/src/dark-mode-capture.test.ts +2 -1
  193. package/src/dark-mode-form-controls.test.ts +3 -2
  194. package/src/embed-remote-images.test.ts +2 -3
  195. package/src/index.ts +82 -12
  196. package/src/kerf-jsx-augmentation.d.ts +1 -1
  197. package/src/mask.test.ts +14 -9
  198. package/src/post-processing/gzip.ts +14 -0
  199. package/src/post-processing/index.ts +5 -0
  200. package/src/post-processing/optimize.test.ts +48 -0
  201. package/src/preserve-aspect-ratio.test.ts +1 -1
  202. package/src/{border-radius.test.ts → render/borders.test.ts} +1 -1
  203. package/src/render/borders.ts +783 -0
  204. package/src/render/box-shadow.ts +65 -0
  205. package/src/render/colors.ts +163 -0
  206. package/src/{conic-raster.test.ts → render/conic-raster.test.ts} +3 -3
  207. package/src/{conic-raster.ts → render/conic-raster.ts} +3 -6
  208. package/src/{coretext-extractor.test.ts → render/coretext.test.ts} +1 -0
  209. package/src/render/css-tokens.ts +44 -0
  210. package/src/render/element-tree-to-svg.ts +5644 -0
  211. package/src/render/embedded-font-builder.ts +221 -0
  212. package/src/{form-controls.ts → render/form-controls.ts} +47 -26
  213. package/src/render/format.ts +24 -0
  214. package/src/{gradients.test.ts → render/gradients.test.ts} +46 -0
  215. package/src/{gradients.ts → render/gradients.ts} +94 -2
  216. package/src/render/index.ts +14 -0
  217. package/src/render/opentype.js.d.ts +7 -0
  218. package/src/{text-to-path.test.ts → render/text-to-path.test.ts} +331 -20
  219. package/src/{text-to-path.ts → render/text-to-path.ts} +746 -50
  220. package/src/{text-renderer.test.ts → render/text.test.ts} +99 -5
  221. package/src/{text-renderer.ts → render/text.ts} +438 -44
  222. package/src/render/transforms.ts +41 -0
  223. package/src/scroll/composer.test.ts +505 -0
  224. package/src/scroll/composer.ts +373 -0
  225. package/src/scroll/executor.test.ts +270 -0
  226. package/src/scroll/executor.ts +592 -0
  227. package/src/scroll/hoist-fixed.test.ts +117 -0
  228. package/src/scroll/hoist-fixed.ts +95 -0
  229. package/src/scroll/hoist-sticky.test.ts +173 -0
  230. package/src/scroll/hoist-sticky.ts +193 -0
  231. package/src/scroll/index.ts +36 -0
  232. package/src/scroll/pattern.test.ts +531 -0
  233. package/src/scroll/pattern.ts +610 -0
  234. package/src/stacking-context.test.ts +4 -3
  235. package/src/{frame-merge.test.ts → tree-ops/frame-merge.test.ts} +51 -0
  236. package/src/{frame-merge.ts → tree-ops/frame-merge.ts} +26 -3
  237. package/src/tree-ops/index.ts +11 -0
  238. package/src/{resize-embedded-images.test.ts → tree-ops/resize-embedded-images.test.ts} +3 -3
  239. package/src/{resize-embedded-images.ts → tree-ops/resize-embedded-images.ts} +3 -6
  240. package/src/tree-ops/tree-diff.test.ts +295 -0
  241. package/src/tree-ops/tree-diff.ts +236 -0
  242. package/src/tree-ops/viewbox-culling.test.ts +292 -0
  243. package/src/tree-ops/viewbox-culling.ts +327 -0
  244. package/src/utils/region-feedback.test.ts +261 -0
  245. package/src/utils/region-feedback.ts +216 -0
  246. package/src/webfont-unicode-range.test.ts +2 -2
  247. package/dist/chrome.d.ts +0 -45
  248. package/dist/chrome.js +0 -107
  249. package/dist/cli.js +0 -512
  250. package/dist/client/dom.d.ts +0 -10
  251. package/dist/client/dom.js +0 -17
  252. package/dist/dom-to-svg.js +0 -7717
  253. package/dist/jsx-runtime.d.ts +0 -27
  254. package/dist/jsx-runtime.js +0 -96
  255. package/dist/jsx-runtime.test.js +0 -41
  256. package/src/cli.ts +0 -582
  257. package/src/dom-to-svg.ts +0 -8376
  258. /package/dist/{animator.test.d.ts → animation/animator.test.d.ts} +0 -0
  259. /package/dist/{cursor-overlay.d.ts → animation/cursor-overlay.d.ts} +0 -0
  260. /package/dist/{cursor-overlay.test.d.ts → animation/cursor-overlay.test.d.ts} +0 -0
  261. /package/dist/{cursor-overlay.test.js → animation/cursor-overlay.test.js} +0 -0
  262. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  263. /package/dist/{optimize.d.ts → post-processing/optimize.d.ts} +0 -0
  264. /package/dist/{optimize.js → post-processing/optimize.js} +0 -0
  265. /package/dist/{border-radius.test.d.ts → post-processing/optimize.test.d.ts} +0 -0
  266. /package/dist/{conic-raster.test.d.ts → render/borders.test.d.ts} +0 -0
  267. /package/dist/{coretext-extractor.test.d.ts → render/conic-raster.test.d.ts} +0 -0
  268. /package/dist/{coretext-helper.d.ts → render/coretext.d.ts} +0 -0
  269. /package/dist/{coretext-helper.js → render/coretext.js} +0 -0
  270. /package/dist/{gradients.test.d.ts → render/coretext.test.d.ts} +0 -0
  271. /package/dist/{jsx-runtime.test.d.ts → render/gradients.test.d.ts} +0 -0
  272. /package/dist/{text-to-path.test.d.ts → render/text-to-path.test.d.ts} +0 -0
  273. /package/dist/{text-renderer.test.d.ts → render/text.test.d.ts} +0 -0
  274. /package/dist/{frame-merge.test.d.ts → tree-ops/frame-merge.test.d.ts} +0 -0
  275. /package/dist/{resize-embedded-images.test.d.ts → tree-ops/resize-embedded-images.test.d.ts} +0 -0
  276. /package/src/{cursor-overlay.test.ts → animation/cursor-overlay.test.ts} +0 -0
  277. /package/src/{optimize.ts → post-processing/optimize.ts} +0 -0
  278. /package/src/{coretext-helper.ts → render/coretext.ts} +0 -0
package/FEATURES.md CHANGED
@@ -26,6 +26,7 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
26
26
  - [x] **border-solid**: Solid borders with color
27
27
  - [x] **border-radius**: Rounded corners
28
28
  - [x] **border-radius-pill**: Fully rounded (pill shape)
29
+ - [x] **inline-box-decoration-break**: wrapped inline backgrounds / borders paint per line-fragment (`slice` + `clone`); first/last fragment own the start/end edges in slice mode, every fragment paints a full box in clone mode
29
30
 
30
31
  ### Layout
31
32
  - [x] **layout-flex-row**: Horizontal flex layout with gap
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
@@ -28,6 +36,35 @@ yourself to keep the first job's runtime down.
28
36
 
29
37
  ## Usage
30
38
 
39
+ The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
40
+
41
+ ```bash
42
+ # Capture a URL as SVG.
43
+ domotion capture https://example.com -o example.svg
44
+
45
+ # Capture a local HTML file at a specific viewport, only the .hero region, optimized.
46
+ domotion capture ./demo.html \
47
+ --width 1200 --height 600 \
48
+ --selector ".hero" \
49
+ --optimize \
50
+ -o hero.svg
51
+
52
+ # Capture HTML piped on stdin.
53
+ cat demo.html | domotion capture - -o demo.svg
54
+ ```
55
+
56
+ For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
57
+
58
+ ```bash
59
+ domotion animate ./demo.json
60
+ ```
61
+
62
+ The config describes each frame (input URL or HTML file, duration, transition, optional pre-capture actions like `click` / `fill` / `scroll` / `hover`). See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
63
+
64
+ ### Scripting API
65
+
66
+ When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
67
+
31
68
  ```ts
32
69
  import { captureElementTree, elementTreeToSvg, launchChromium, wrapSvg } from "domotion-svg";
33
70
 
@@ -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,23 @@ 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 visibility into the same keyframes block — both snap together.
141
+ // DM-641: this used to toggle `display`. The base `.f { display: none }`
142
+ // rule kept the element out of the render tree at t=0, and Chromium
143
+ // doesn't tick infinite animations on out-of-tree elements — so the
144
+ // 0% keyframe never ran and the frame stayed permanently hidden.
145
+ // Switching to `visibility` leaves the element in the render tree
146
+ // (still skips painting, which was the DM-599 goal) so the animation
147
+ // ticks normally.
108
148
  keyframes.push(`
109
149
  @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; }
150
+ 0% { opacity: 0; visibility: hidden; }
151
+ ${beforeStart}% { opacity: 0; visibility: hidden; }
152
+ ${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
153
+ ${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
154
+ ${afterEnd}% { opacity: 0; visibility: hidden; }
155
+ 100% { opacity: 0; visibility: hidden; }
116
156
  }
117
157
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
118
158
  }
@@ -123,12 +163,14 @@ export function generateAnimatedSvg(config) {
123
163
  const prevEnd = i > 0
124
164
  ? `${Math.max(0, parseFloat(fadeInStartPct) - 0.01).toFixed(2)}%,`
125
165
  : "";
166
+ // DM-599: visible window spans the full fade — fadeInStart through
167
+ // transEnd (display stays `inline` while opacity interpolates).
126
168
  keyframes.push(`
127
169
  @keyframes fv-${i} {
128
170
  0%, ${prevEnd} ${transEndPct}, 100% { opacity: 0; }
129
171
  ${startPct}, ${holdEndPct} { opacity: 1; }
130
- }
131
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; }`);
172
+ }${buildDisplayKeyframes(`fd-${i}`, fadeInStartPct, transEndPct)}
173
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }`);
132
174
  }
133
175
  }
134
176
  // Overlays
@@ -156,6 +198,11 @@ export function generateAnimatedSvg(config) {
156
198
  // Compose final SVG with XML declaration for proper UTF-8
157
199
  const sharedDefsMarkup = config.sharedDefs ?? "";
158
200
  const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
201
+ // DM-603: per-frame viewBox-cull keyframes — each frame's caller pre-ran
202
+ // `cullElementsOutsideViewBox()` and we splice the resulting blocks into the scene-wide
203
+ // <style>. The keyframes reference `var(--scene-dur)`; we expose that
204
+ // variable on the root selector below.
205
+ const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
159
206
  // Cursor overlay (DM-277). The frame start times let the resolver pick
160
207
  // which frame's selector matches apply at each event's timestamp.
161
208
  let overlayMarkup = "";
@@ -175,8 +222,9 @@ export function generateAnimatedSvg(config) {
175
222
  <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
176
223
  </defs>
177
224
  <style>
178
- .f { opacity: 0; }
179
- ${keyframes.join("\n")}${animationCss}
225
+ :root { --scene-dur: ${totalSec.toFixed(2)}s; }
226
+ .f { opacity: 0; visibility: hidden; }
227
+ ${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
180
228
  </style>
181
229
  <g clip-path="url(#viewport-clip)">
182
230
  <rect width="${width}" height="${height}" fill="#0d1117" />
@@ -261,6 +309,36 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
261
309
  function pct(ms, total) {
262
310
  return `${((ms / total) * 100).toFixed(2)}%`;
263
311
  }
312
+ /**
313
+ * DM-599: build a step-end keyframes block that toggles `display` between
314
+ * `none` and `inline` around a visible window. Used in parallel with the
315
+ * opacity-controlling `fv-*` animation so the browser can skip painting the
316
+ * frame entirely while it's outside its show window (the dominant cost on
317
+ * long multi-frame demos with complex captured content).
318
+ *
319
+ * `visibleStartPct` / `visibleEndPct` accept either a numeric-style string
320
+ * (`"12.34"`) or one with a trailing `%` (`"12.34%"`) — `pct()` returns the
321
+ * latter and the unmerged-path keyframes feed either form.
322
+ */
323
+ function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
324
+ // DM-641: kept the function name for callers but the toggle is now on
325
+ // `visibility`, not `display`, for the same reason as `fv-${i}` above —
326
+ // animating `display` away from an element starting `display: none` never
327
+ // ticks in Chromium.
328
+ const start = parseFloat(String(visibleStartPct));
329
+ const end = parseFloat(String(visibleEndPct));
330
+ const startMinus = Math.max(0, start - 0.01).toFixed(3);
331
+ const endPlus = Math.min(100, end + 0.01).toFixed(3);
332
+ return `
333
+ @keyframes ${name} {
334
+ 0% { visibility: hidden; }
335
+ ${startMinus}% { visibility: hidden; }
336
+ ${start.toFixed(3)}% { visibility: visible; }
337
+ ${end.toFixed(3)}% { visibility: visible; }
338
+ ${endPlus}% { visibility: hidden; }
339
+ 100% { visibility: hidden; }
340
+ }`;
341
+ }
264
342
  /**
265
343
  * Render a frame-local SVG overlay. The embedded SVG markup is wrapped in a
266
344
  * `<g transform="translate(x y)" clip-path="..."/>` and an inner
@@ -344,6 +422,8 @@ function composeMergedSvg(config, frameTiming, totalSec) {
344
422
  const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
345
423
  const sharedDefsMarkup = config.sharedDefs ?? "";
346
424
  const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
425
+ // DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
426
+ const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
347
427
  // Cursor overlay (DM-277). Same emission as the unmerged path — the
348
428
  // overlay sits above the merged frame group, clipped to the viewport.
349
429
  const totalDuration = totalSec * 1000;
@@ -365,7 +445,7 @@ function composeMergedSvg(config, frameTiming, totalSec) {
365
445
  </defs>
366
446
  <style>
367
447
  :root { --scene-dur: ${totalSec.toFixed(2)}s; }
368
- ${css}${animationCss}
448
+ ${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
369
449
  </style>
370
450
  <g clip-path="url(#viewport-clip)">
371
451
  <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,107 @@ describe("animator", () => {
152
172
  expect(svg).toContain("transform: translateY(240px)");
153
173
  expect(svg).toContain("transform: translateY(0px)");
154
174
  });
175
+ it("DM-599/DM-641: push-left frame gets a paired fd-N visibility 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 a paint-skip property so the frame is dropped from paint
180
+ // outside its show window. DM-641 changed that toggle from `display`
181
+ // (which parks the CSS-animations engine when an element starts out of
182
+ // the render tree) to `visibility` (which keeps the element rendered
183
+ // but skips painting it).
184
+ const svg = generateAnimatedSvg({
185
+ width: 100, height: 100,
186
+ frames: [
187
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 200 } },
188
+ { svgContent: `<rect/>`, duration: 1000 },
189
+ ],
190
+ });
191
+ // Both frames get an fd-N keyframes block …
192
+ expect(svg).toMatch(/@keyframes fd-0\s*{/);
193
+ expect(svg).toMatch(/@keyframes fd-1\s*{/);
194
+ // … alongside the existing fv-N opacity block.
195
+ expect(svg).toMatch(/@keyframes fv-0\s*{/);
196
+ // The keyframes flip visibility (DM-641 — formerly display).
197
+ expect(svg).toMatch(/visibility:\s*hidden/);
198
+ expect(svg).toMatch(/visibility:\s*visible/);
199
+ // DM-641: must NOT emit display toggles anywhere — those broke the
200
+ // infinite animation in Chromium.
201
+ expect(svg).not.toMatch(/display:\s*none/);
202
+ expect(svg).not.toMatch(/display:\s*inline/);
203
+ // The frame's CSS rule lists BOTH animations, with the fd one tagged
204
+ // step-end so the visibility flip is instant.
205
+ expect(svg).toMatch(/\.f-0\s*{\s*animation:[^}]*fv-0[^}]*,[^}]*fd-0[^}]*step-end/);
206
+ // The base .f rule sets visibility:hidden (was display:none pre-DM-641).
207
+ expect(svg).toMatch(/\.f\s*{[^}]*visibility:\s*hidden/);
208
+ });
209
+ it("DM-599/DM-641: cut frames fold visibility into fv-N (same step-end timing)", () => {
210
+ // Three explicit `cut` frames — the all-mergeable check trips and these
211
+ // route through the MERGE pipeline. But a non-mergeable transition mixed
212
+ // in (e.g. push-left) would route this through the unmerged path. We
213
+ // verify the unmerged path's cut branch here by mixing.
214
+ const svg = generateAnimatedSvg({
215
+ width: 100, height: 100,
216
+ frames: [
217
+ { svgContent: `<rect fill="red"/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
218
+ { svgContent: `<rect fill="blue"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
219
+ { svgContent: `<rect fill="green"/>`, duration: 1000 },
220
+ ],
221
+ });
222
+ // The "cut" frame's fv-N keyframes carry the visibility toggle inline
223
+ // (no separate fd-N block) since cut already uses step-end on fv-N.
224
+ const fv1Match = svg.match(/@keyframes fv-1\s*{[\s\S]*?\n\s*}/);
225
+ expect(fv1Match).not.toBeNull();
226
+ expect(fv1Match[0]).toMatch(/visibility:\s*hidden/);
227
+ expect(fv1Match[0]).toMatch(/visibility:\s*visible/);
228
+ // The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
229
+ expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
230
+ });
231
+ it("DM-599/DM-641: merged-path keyframes emit visibility alongside opacity", () => {
232
+ // Two crossfade frames with different content route through the merge
233
+ // pipeline. Per-element visibility classes (tN) now toggle BOTH opacity
234
+ // and visibility so the browser can skip painting hidden elements.
235
+ const svg = generateAnimatedSvg({
236
+ width: 100, height: 100,
237
+ frames: [
238
+ { svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
239
+ { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
240
+ ],
241
+ });
242
+ // Each tN keyframe stop with opacity:1 also has visibility:visible; each
243
+ // opacity:0 stop has visibility:hidden.
244
+ const tN = svg.match(/@keyframes t\d+\s*{[\s\S]*?\n\s*}/);
245
+ expect(tN).not.toBeNull();
246
+ expect(tN[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
247
+ expect(tN[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
248
+ });
249
+ it("DM-641: never emits `display: none` keyframes (would park Chromium's animation engine)", () => {
250
+ // Regression. The repro from the ticket: a multi-frame animation with
251
+ // `cut` transitions produced `@keyframes fv-0 { 0% { opacity:0; display:none } … }`
252
+ // plus `.f { display: none }`, which Chromium would never tick — so
253
+ // EVERY frame stayed permanently hidden when the SVG was loaded into a
254
+ // browser. The fix swapped both sites onto `visibility`. This test pins
255
+ // the fix on every code path that emits keyframes for the animator.
256
+ const cutSvg = generateAnimatedSvg({
257
+ width: 100, height: 100,
258
+ frames: [
259
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
260
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
261
+ { svgContent: `<rect/>`, duration: 1000 },
262
+ ],
263
+ });
264
+ expect(cutSvg).not.toMatch(/display:\s*none/);
265
+ expect(cutSvg).not.toMatch(/display:\s*inline/);
266
+ const pushSvg = generateAnimatedSvg({
267
+ width: 100, height: 100,
268
+ frames: [
269
+ { svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
270
+ { svgContent: `<rect/>`, duration: 1000 },
271
+ ],
272
+ });
273
+ expect(pushSvg).not.toMatch(/display:\s*none/);
274
+ expect(pushSvg).not.toMatch(/display:\s*inline/);
275
+ });
155
276
  it("cut transition: timeline boundary is exactly at the frame edge", () => {
156
277
  // For two frames each held 1000ms with cut transitions and no overlap,
157
278
  // the visibility flip should land at exactly 50% of the scene.
@@ -114,13 +114,11 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
114
114
  return { x: curX + ev.by.dx, y: curY + ev.by.dy };
115
115
  if (ev.selector != null) {
116
116
  if (resolveSelector == null) {
117
- // eslint-disable-next-line no-console
118
117
  console.warn(`cursor-overlay: selector "${ev.selector}" used but no resolveSelector provided; skipping`);
119
118
  return null;
120
119
  }
121
120
  const rect = resolveSelector(ev.selector, frameIndex);
122
121
  if (rect == null) {
123
- // eslint-disable-next-line no-console
124
122
  console.warn(`cursor-overlay: selector "${ev.selector}" matched no element in frame ${frameIndex}; skipping`);
125
123
  return null;
126
124
  }
@@ -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>;