domotion-svg 0.17.0 → 0.19.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 (42) hide show
  1. package/FEATURES.md +5 -1
  2. package/README.md +3 -1
  3. package/dist/animation/animator.d.ts +5 -0
  4. package/dist/animation/animator.js +19 -32
  5. package/dist/animation/composite.js +4 -4
  6. package/dist/animation/cursor-overlay.d.ts +8 -10
  7. package/dist/animation/cursor-overlay.js +61 -40
  8. package/dist/capture/index.d.ts +54 -0
  9. package/dist/capture/index.js +92 -24
  10. package/dist/capture/script/cross-origin.d.ts +17 -0
  11. package/dist/capture/script/cross-origin.js +83 -0
  12. package/dist/capture/script/index.js +259 -4
  13. package/dist/capture/script/walker/counter-prewalk.d.ts +1 -1
  14. package/dist/capture/script/walker/counter-prewalk.js +6 -3
  15. package/dist/capture/script/walker/masks-clips.js +16 -4
  16. package/dist/capture/script/walker/replaced-elements.js +6 -1
  17. package/dist/capture/script.generated.js +1 -1
  18. package/dist/cli/capture.js +96 -42
  19. package/dist/cli/common.d.ts +5 -6
  20. package/dist/cli/common.js +5 -24
  21. package/dist/cli/index.js +12 -0
  22. package/dist/cli/scrubber.js +18 -0
  23. package/dist/post-processing/optimize.js +7 -0
  24. package/dist/render/element-tree-to-svg.d.ts +7 -0
  25. package/dist/render/element-tree-to-svg.js +855 -763
  26. package/dist/render/format.d.ts +14 -0
  27. package/dist/render/format.js +17 -0
  28. package/dist/render/mask.js +271 -252
  29. package/dist/render/text.js +224 -198
  30. package/dist/render/vertical-text.js +2 -3
  31. package/dist/scroll/composer.d.ts +5 -0
  32. package/dist/scroll/composer.js +52 -33
  33. package/dist/scrubber/client.bundle.generated.js +1 -1
  34. package/dist/scrubber/client.d.ts +2 -0
  35. package/dist/scrubber/client.js +181 -5
  36. package/dist/scrubber/server.d.ts +90 -0
  37. package/dist/scrubber/server.js +139 -2
  38. package/dist/tree-ops/viewbox-culling.js +1 -1
  39. package/dist/utils/transparent-background.d.ts +22 -0
  40. package/dist/utils/transparent-background.js +40 -0
  41. package/llms.txt +3 -3
  42. package/package.json +8 -4
package/FEATURES.md CHANGED
@@ -57,7 +57,11 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
57
57
  - [x] **replaced-video-poster**: `<video poster=…>` paused — poster image captured
58
58
  - [x] **replaced-canvas-overlay**: `<canvas>` under a positioned `<div z-index:10>` overlay — overlay does NOT bleed into the canvas snapshot
59
59
  - [x] **replaced-canvas-fixed-overlay**: `<canvas>` under a sibling-positioned `<div>` painting on top — sibling does NOT bleed into the canvas snapshot
60
- - [x] **replaced-iframe-same-origin**: same-origin `<iframe>` (srcdoc) — iframe content paints into the snapshot
60
+ - [x] **replaced-iframe-same-origin** (DM-1441): same-origin `<iframe>` (srcdoc) — content **recurses into native SVG** (crisp/scalable/selectable), not rastered; pixel-identical to the prior snapshot. See [docs/81](docs/81-iframe-recursion.md).
61
+ - [x] **iframe-recursion-bordered** (DM-1441): same-origin iframe recursion through a non-zero border + padding on the iframe — inner document's origin lands at the iframe **content box** and the inner subtree clips to it.
62
+ - [x] **iframe-canvas-bg-fill** (DM-1448): recursed iframe **taller than its content** — the inner document's propagated canvas background (html-bg-or-else-body-bg) fills the whole inner viewport, so the strip below the content paints the canvas color instead of showing through to the outer page.
63
+ - [x] **iframe-inner-clip-mask** (DM-1446): recursed iframe whose inner content uses `clip-path: url(#id)` + `mask-image: url(#id)` defined in the **iframe's own** `<defs>` — fragment refs resolve against `el.ownerDocument`, so the `<clipPath>`/`<mask>` defs hoist and paint (clipped circle + masked square-with-hole, 0.00%). (Inner `element(#id)` paint refs are frame-aware — DM-1447 — though CSS `element()` itself is unsupported in Chromium / dormant, DM-1450; tall-iframe canvas-bg fill shipped in DM-1448.)
64
+ - [x] **cross-origin iframe recursion** (DM-1442): `--cross-origin-frames "*"|host[:port],…` (config: `captureCrossOriginFrames`) recurses **allowlisted** cross-origin frames into native SVG by launching Chromium with web security disabled; non-allowlisted frames stay raster. Default off + a stderr security warning (disabling web security disables CORS). Unit + e2e tested (`tests/cross-origin-iframe-recursion.e2e.test.ts`). See [docs/81](docs/81-iframe-recursion.md).
61
65
  - [x] **snapshot-isolation-pseudo-overlay** (DM-458, `tests/snapshot-isolation.tsx`): canvas covered by a sibling's `::after` pseudo overlay. Inspection-style — decodes the captured snapshot's PNG data URI and asserts no overlay-color pixels leaked through. Catches regressions in the hide-everything-else stylesheet that a comparison-style fixture wouldn't.
62
66
 
63
67
  ### Showcase Integration Tests
package/README.md CHANGED
@@ -79,6 +79,8 @@ cat demo.html | domotion capture - -o demo.svg
79
79
  domotion capture https://example.com --scroll "down:bottom/8s" -o scroll.svg
80
80
  ```
81
81
 
82
+ Same-origin `<iframe>` content is recursed into the capture as native, selectable SVG rather than flattened to a screenshot; opt into cross-origin frames you trust with `--cross-origin-frames "<hosts>"`.
83
+
82
84
  For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
83
85
 
84
86
  ```bash
@@ -150,7 +152,7 @@ svg-review --expected example.debug/expected.png --actual example.debug/actual.s
150
152
 
151
153
  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.
152
154
 
153
- For an *animated* SVG, the package also ships `svg-scrubber` — a local video-style bench to play / pause / scrub / mark an in-out range, export the current frame as PNG, export the range as MP4, or trim it to a new self-contained animated SVG.
155
+ For an *animated* SVG, the package also ships `svg-scrubber` — a local video-style bench to play / pause / scrub / mark an in-out range, export the current frame as PNG, export the range as MP4, or trim it to a new self-contained animated SVG. Add `--review` to file a focused issue against a moment in the timeline: it writes an importable `.ticket` (frame time, range, and drawn regions) the same way `svg-review` builds a report for a still.
154
156
 
155
157
  ### Scripting API
156
158
 
@@ -74,6 +74,11 @@ export interface AnimationConfig {
74
74
  width: number;
75
75
  height: number;
76
76
  frames: AnimationFrame[];
77
+ /** DM-1488: accessible name → `role="img"` + `<title>` on the root `<svg>`
78
+ * (for inline-`<svg>` embedding). Omit to leave the output unchanged. */
79
+ title?: string;
80
+ /** DM-1488: accessible long description → `<desc>` on the root `<svg>`. */
81
+ desc?: string;
77
82
  /**
78
83
  * Markup (e.g. `<path id="g0" d="..."/>...`) hoisted into the top-level
79
84
  * `<defs>`. Frames can reference these IDs via `<use href="#...">`. Use for
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
8
8
  import { escapeHtml } from "../utils/escapeHtml.js";
9
+ import { isTransparentBackground } from "../utils/transparent-background.js";
10
+ import { rootSvgA11y } from "../render/format.js";
9
11
  import { DEFAULT_TRANSITION_MS, frameAdvanceMs, transitionDurationMs } from "./frame-timeline.js";
10
12
  import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
11
13
  import { KEYFRAME_EPSILON, padAfter, padBefore } from "../utils/keyframe-pad.js";
@@ -38,7 +40,7 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
38
40
  ${afterH}% { opacity: 0; visibility: hidden; }
39
41
  100% { opacity: 0; visibility: hidden; }
40
42
  }
41
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
43
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
42
44
  // Bridge composite: visible during the transition window only.
43
45
  groups.push(` <g class="f mm-${i}">\n${mm.compositeSvg}\n </g>`);
44
46
  keyframes.push(`
@@ -50,7 +52,7 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
50
52
  ${afterT}% { opacity: 0; visibility: hidden; }
51
53
  100% { opacity: 0; visibility: hidden; }
52
54
  }
53
- .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
55
+ .mm-${i} { animation: mmv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
54
56
  // Per-element slide / fade keyframes within the window (linear interp).
55
57
  // The composite is only visible [holdEnd..transEnd], so the held values
56
58
  // outside that window are never painted — they just pin the endpoints.
@@ -111,18 +113,11 @@ function emitMagicMoveFrame(i, frame, mm, startPct, holdEndPct, transEndPct, tot
111
113
  }
112
114
  return { groups, keyframes };
113
115
  }
114
- /**
115
- * Emit one crossfade or cut frame (the default transition path): the frame
116
- * blob plus its opacity keyframes. `cut` (or zero-duration) uses disjoint
117
- * step-end keyframes so opacity flips instantly with no interpolation smear;
118
- * crossfade overlaps the fade-in with the previous frame's fade-out, with the
119
- * visible window driven by `fadeInStartPct` (precomputed by the caller, which
120
- * knows the overlap state). Extracted from `generateAnimatedSvg` (DM-1089).
121
- */
122
- function emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec,
116
+ function emitCrossfadeOrCutFrame(i, frame, transType, transDur, win, totalSec,
123
117
  /** DM-1148: the last frame, when the loop must NOT cross-dissolve, holds
124
118
  * opacity 1 to 100% instead of fading out over its transition window. */
125
119
  holdToEnd) {
120
+ const { startPct, holdEndPct, transEndPct, fadeInStartPct } = win;
126
121
  const groups = [];
127
122
  const keyframes = [];
128
123
  groups.push(` <g class="f f-${i}">\n${frame.svgContent}\n </g>`);
@@ -141,7 +136,7 @@ holdToEnd) {
141
136
  ${afterEnd}% { opacity: 0; visibility: hidden; }
142
137
  100% { opacity: 0; visibility: hidden; }
143
138
  }
144
- .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
139
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s step-end infinite; }`);
145
140
  }
146
141
  else {
147
142
  const prevEnd = i > 0
@@ -212,18 +207,9 @@ export function dedupeFrameIds(frames) {
212
207
  return { ...frame, svgContent: out };
213
208
  });
214
209
  }
215
- /**
216
- * Push-left (horizontal) / scroll (vertical) slide frame. Both emit the SAME
217
- * clipped frame group and a `slideKeyframes` track they differ only in the
218
- * EXIT slide axis (`X`/`Y`) and the slid extent (viewport width / height). The
219
- * group rect always spans `width × height`. The frame's ENTRANCE is composed
220
- * separately from `enter` (DM-1414): a slide frame after a crossfade fades in,
221
- * after a different-axis slide slides in on the predecessor's axis, etc.
222
- * Extracted from generateAnimatedSvg (DM-1375), mirroring emit*Frame.
223
- */
224
- function emitSlideFrame(i, svgContent, exitAxis, exitSize, enter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame) {
225
- const group = ` <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${svgContent}\n </g></g>`;
226
- const keyframe = slideKeyframes(i, exitAxis, exitSize, enter, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec, holdLastFrame);
210
+ function emitSlideFrame(i, svgContent, exitAxis, exitSize, enter, dims, win, totalSec, holdLastFrame) {
211
+ const group = ` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${dims.width}" height="${dims.height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${svgContent}\n </g></g>`;
212
+ const keyframe = slideKeyframes(i, exitAxis, exitSize, enter, win.enterStartPct, win.startPct, win.holdEndPct, win.transEndPct, win.enterStartPct, win.transEndPct, totalSec, holdLastFrame);
227
213
  return { group, keyframe };
228
214
  }
229
215
  /**
@@ -361,7 +347,7 @@ export function generateAnimatedSvg(config) {
361
347
  // The parallel `fd-${i}` display snap (inside slideKeyframes) lets the
362
348
  // browser skip painting this frame's content while it's fully off-screen
363
349
  // between cycles (DM-599).
364
- const r = emitSlideFrame(i, frame.svgContent, "X", width, slideEnter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame);
350
+ const r = emitSlideFrame(i, frame.svgContent, "X", width, slideEnter, { width, height }, { enterStartPct, startPct, holdEndPct, transEndPct }, totalSec, holdLastFrame);
365
351
  frameGroups.push(r.group);
366
352
  keyframes.push(r.keyframe);
367
353
  }
@@ -369,7 +355,7 @@ export function generateAnimatedSvg(config) {
369
355
  // DM-609: `scroll` is a real geometric scroll — the vertical equivalent of
370
356
  // push-left (translateY over height instead of translateX over width),
371
357
  // otherwise identical machinery. Exit slides up; enter per `slideEnter`.
372
- const r = emitSlideFrame(i, frame.svgContent, "Y", height, slideEnter, width, height, enterStartPct, startPct, holdEndPct, transEndPct, totalSec, holdLastFrame);
358
+ const r = emitSlideFrame(i, frame.svgContent, "Y", height, slideEnter, { width, height }, { enterStartPct, startPct, holdEndPct, transEndPct }, totalSec, holdLastFrame);
373
359
  frameGroups.push(r.group);
374
360
  keyframes.push(r.keyframe);
375
361
  }
@@ -399,7 +385,7 @@ export function generateAnimatedSvg(config) {
399
385
  // holds-then-cuts — so this is a no-op for cut frames.
400
386
  const isCutFrame = transType === "cut" || transDur === 0;
401
387
  const holdToEnd = i === frames.length - 1 && config.loopFade !== true && !isCutFrame;
402
- const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, startPct, holdEndPct, transEndPct, fadeInStartPct, totalSec, holdToEnd);
388
+ const r = emitCrossfadeOrCutFrame(i, frame, transType, transDur, { startPct, holdEndPct, transEndPct, fadeInStartPct }, totalSec, holdToEnd);
403
389
  frameGroups.push(...r.groups);
404
390
  keyframes.push(...r.keyframes);
405
391
  }
@@ -434,11 +420,12 @@ export function generateAnimatedSvg(config) {
434
420
  // Default (none / transparent) emits nothing so the SVG composites over the
435
421
  // host page, matching the single-frame `transparentRootBgRect` path (DM-554).
436
422
  const bg = config.background;
437
- const canvasBgRect = (bg != null && bg !== "" && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
423
+ const canvasBgRect = (bg != null && !isTransparentBackground(bg))
438
424
  ? ` <rect width="${width}" height="${height}" fill="${bg}" />\n`
439
425
  : "";
426
+ const a11y = rootSvgA11y(config.title, config.desc);
440
427
  const out = `<?xml version="1.0" encoding="UTF-8"?>
441
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
428
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"${a11y.roleAttr}>${a11y.markup}
442
429
  <defs>
443
430
  <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
444
431
  </defs>
@@ -879,7 +866,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
879
866
  const enterId = `${id}-enter`;
880
867
  cssRules.push(`
881
868
  @keyframes ${enterId} { 0% { transform: ${fromStr}; } ${pct(enterStart, totalDuration)} { transform: ${fromStr}; } ${pct(enterEnd, totalDuration)} { transform: translate(0, 0); } 100% { transform: translate(0, 0); } }
882
- .${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
869
+ .${id}-enter { animation: ${enterId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
883
870
  }
884
871
  // Slide-out exit. Mirror of enter — translate from (0,0) to off-screen.
885
872
  if (overlay.exit != null) {
@@ -891,7 +878,7 @@ function renderSvgOverlay(overlay, frameIdx, frameStart, frameHoldMs, totalDurat
891
878
  const exitId = `${id}-exit`;
892
879
  cssRules.push(`
893
880
  @keyframes ${exitId} { 0%, ${pct(exitStart, totalDuration)} { transform: translate(0, 0); } ${pct(exitStart + e.duration, totalDuration)} { transform: ${toStr}; } 100% { transform: ${toStr}; } }
894
- .${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
881
+ .${id}-exit { animation: ${exitId} ${totalSec.toFixed(2)}s ${easing} infinite; }`);
895
882
  }
896
883
  // Markup: outer wrapper translates to (x, y) and clips, inner wrapper
897
884
  // carries the visibility class, then the enter/exit transform wrapper, then
@@ -999,7 +986,7 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
999
986
  ${endPct.toFixed(3)}% { ${propValue(a.to)} }
1000
987
  100% { ${propValue(a.to)} }
1001
988
  }
1002
- .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing};${originDecl} }`);
989
+ .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s ${easing} infinite;${originDecl} }`);
1003
990
  }
1004
991
  }
1005
992
  }
@@ -29,6 +29,7 @@
29
29
  * one-transform-animation-per-element rule, layer transforms live on the layer's
30
30
  * own wrapper group, separate from the nested content's animations.
31
31
  */
32
+ import { isTransparentBackground } from "../utils/transparent-background.js";
32
33
  import { namespaceEmbeddedAnimatedSvg } from "./embed-namespace.js";
33
34
  import { offsetEmbeddedAnimatedSvgTimeline } from "./embed-timeline.js";
34
35
  import { parseSvgIntrinsicSize, fmt, clampPct } from "./svg-meta.js";
@@ -163,10 +164,9 @@ export function composeAnimatedLayers(layers, opts) {
163
164
  }
164
165
  groups.push(`<g class="${token}layer"${clipAttr}${groupStyle}>${nested}</g>`);
165
166
  }
166
- // Transparent when empty / `transparent` / a fully-transparent rgba (matched
167
- // whitespace-insensitively, so `rgba(0,0,0,0)` and `rgba(0, 0, 0, 0)` both count).
168
- const bgNorm = (opts.background ?? "").replace(/\s+/g, "").toLowerCase();
169
- const bg = bgNorm === "" || bgNorm === "transparent" || bgNorm === "rgba(0,0,0,0)"
167
+ // Transparent (no backdrop rect) for every transparent CSS form keywords,
168
+ // zero-alpha hex, zero-alpha rgba()/hsla() via the canonical predicate.
169
+ const bg = isTransparentBackground(opts.background ?? "")
170
170
  ? ""
171
171
  : `<rect width="${width}" height="${height}" fill="${opts.background}"/>`;
172
172
  const defs = defsParts.join("");
@@ -153,16 +153,14 @@ resolveCursorAt?: CursorAtResolver | null): {
153
153
  cursorTimeline: CursorTimelineEntry[] | null;
154
154
  };
155
155
  /**
156
- * Emit the `<g class="cursor-overlay">` markup for an already-resolved
157
- * timeline. Returns "" when the timeline has no positions or every keyframe
158
- * is invisible.
159
- *
160
- * When `cursorTimeline` is provided (DM-1106), the pointer's GLYPH switches over
161
- * time to match what was under it: each distinct keyword gets a glyph drawn
162
- * hotspot-at-origin inside the shared position-animated group, with a discrete
163
- * opacity track that turns it on only during its windows (and visibility folds
164
- * in as the all-glyphs-off `null` state). Without a timeline, the single white
165
- * arrow paints (back-compat).
156
+ * DM-1507: the cursor overlay is emitted as CSS `@keyframes`, NOT SMIL. SMIL
157
+ * (`<animateTransform>`/`<animate>`) runs on the SVG's *own* timeline while the
158
+ * frame animations run on the CSS/document timeline — two clocks that Safari
159
+ * pauses/throttles independently when the SVG is offscreen (tab-switch, scrolled
160
+ * away), so the pointer drifted out of sync with the animation on return. Driving
161
+ * the cursor with CSS puts everything on ONE timeline; they pause and resume
162
+ * together, so they can't desync. The `@keyframes` are collected into a `<style>`
163
+ * inside the overlay group and applied via inline `animation:` on each element.
166
164
  */
167
165
  export declare function cursorOverlayMarkup(positions: KeyframePoint[], clicks: ResolvedClick[], style: CursorStyle, totalDurationMs: number, cursorTimeline?: CursorTimelineEntry[] | null): string;
168
166
  export {};
@@ -231,83 +231,104 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
231
231
  * in as the all-glyphs-off `null` state). Without a timeline, the single white
232
232
  * arrow paints (back-compat).
233
233
  */
234
+ /** Small deterministic hash → 6-char base36, used to namespace this overlay's
235
+ * `@keyframes` so a composited SVG with several overlays can't collide. */
236
+ function overlayUid(seed) {
237
+ let h = 0x811c9dc5;
238
+ for (let i = 0; i < seed.length; i++) {
239
+ h ^= seed.charCodeAt(i);
240
+ h = Math.imul(h, 0x01000193);
241
+ }
242
+ return (h >>> 0).toString(36).padStart(6, "0").slice(0, 6);
243
+ }
244
+ /** Format a 0..1 keyframe fraction as a CSS keyframe percentage. */
245
+ function pct(frac) {
246
+ return `${Number((frac * 100).toFixed(4))}%`;
247
+ }
248
+ /**
249
+ * DM-1507: the cursor overlay is emitted as CSS `@keyframes`, NOT SMIL. SMIL
250
+ * (`<animateTransform>`/`<animate>`) runs on the SVG's *own* timeline while the
251
+ * frame animations run on the CSS/document timeline — two clocks that Safari
252
+ * pauses/throttles independently when the SVG is offscreen (tab-switch, scrolled
253
+ * away), so the pointer drifted out of sync with the animation on return. Driving
254
+ * the cursor with CSS puts everything on ONE timeline; they pause and resume
255
+ * together, so they can't desync. The `@keyframes` are collected into a `<style>`
256
+ * inside the overlay group and applied via inline `animation:` on each element.
257
+ */
234
258
  export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs, cursorTimeline = null) {
235
259
  if (positions.length === 0 || totalDurationMs <= 0)
236
260
  return "";
237
261
  const totalSec = totalDurationMs / 1000;
238
- // animateTransform with values + keyTimes drives the cursor's translate.
239
- const valueStrs = [];
240
- const keyTimes = [];
241
- for (const p of positions) {
242
- valueStrs.push(`${num(p.x)},${num(p.y)}`);
243
- keyTimes.push((p.t / totalDurationMs).toFixed(4));
244
- }
245
- // SMIL animateTransform requires keyTimes to start at 0 and end at 1; the
246
- // resolveCursorScript anchor at t=0 and t=totalDurationMs guarantees this.
247
- const posAnim = `<animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />`;
248
- // Pulse SVG fragments — one per click, with timing keyed off `t`.
249
- const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, totalDurationMs)).join("\n");
262
+ const uid = overlayUid(`${totalDurationMs}|${positions.map((p) => `${p.x},${p.y},${p.t}`).join(";")}`);
263
+ const kf = []; // @keyframes collected here, injected into <style>
264
+ // Position track — linear translate along the keyframes (holds are duplicate
265
+ // consecutive values, exactly as under SMIL). keyTimes start at 0 and end at 1.
266
+ const posName = `co-pos-${uid}`;
267
+ kf.push(`@keyframes ${posName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{transform:translate(${num(p.x)}px,${num(p.y)}px)}`).join("")}}`);
268
+ const posAnim = `${posName} ${totalSec}s linear infinite`;
269
+ // Pulse fragments one per click; each pushes its own keyframes into `kf`.
270
+ const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, uid, kf)).join("\n");
250
271
  let pointerGroup;
251
272
  if (cursorTimeline != null && cursorTimeline.length > 0) {
252
- // DM-1106: one glyph per distinct keyword, each toggled by a discrete
253
- // opacity track derived from the keyword timeline. The shared parent group
254
- // carries the position animation; glyphs are hotspot-at-origin so each lands
255
- // correctly regardless of its own hotspot.
273
+ // DM-1106: one glyph per distinct keyword, each toggled by a DISCRETE opacity
274
+ // track (SMIL `calcMode="discrete"` CSS `step-end`, which holds each value
275
+ // until the next keyframe). The parent carries the position animation.
256
276
  const size = 22 * (style.cursorScale || 1);
257
- const tKeyTimes = cursorTimeline.map((e) => (e.t / totalDurationMs).toFixed(4));
258
277
  const kinds = Array.from(new Set(cursorTimeline.map((e) => e.cursor).filter((c) => c != null)));
259
- const glyphLayers = kinds.map((kind) => {
278
+ const glyphLayers = kinds.map((kind, gi) => {
260
279
  const glyph = cursorGlyphSvg(kind, 0, 0, size, style.cursorStroke);
261
- const opVals = cursorTimeline.map((e) => (e.cursor === kind ? "1" : "0"));
262
- return ` <g opacity="0">
263
- <animate attributeName="opacity" values="${opVals.join(";")}" keyTimes="${tKeyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
280
+ const gName = `co-glyph-${uid}-${gi}`;
281
+ kf.push(`@keyframes ${gName}{${cursorTimeline.map((e) => `${pct(e.t / totalDurationMs)}{opacity:${e.cursor === kind ? "1" : "0"}}`).join("")}}`);
282
+ return ` <g opacity="0" style="animation:${gName} ${totalSec}s step-end infinite">
264
283
  ${glyph}
265
284
  </g>`;
266
285
  }).join("\n");
267
- pointerGroup = ` <g class="cursor-pointer">
268
- ${posAnim}
286
+ pointerGroup = ` <g class="cursor-pointer" style="animation:${posAnim}">
269
287
  ${glyphLayers}
270
288
  </g>`;
271
289
  }
272
290
  else {
273
291
  // Legacy single-arrow path (no auto cursor-type resolver supplied).
274
- const visValues = positions.map((p) => (p.visible ? "1" : "0"));
275
- pointerGroup = ` <g class="cursor-arrow" opacity="0">
276
- ${posAnim}
277
- <animate attributeName="opacity" values="${visValues.join(";")}" keyTimes="${keyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
292
+ const visName = `co-vis-${uid}`;
293
+ kf.push(`@keyframes ${visName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{opacity:${p.visible ? "1" : "0"}}`).join("")}}`);
294
+ pointerGroup = ` <g class="cursor-arrow" opacity="0" style="animation:${posAnim},${visName} ${totalSec}s step-end infinite">
278
295
  ${macosCursorPath(style.cursorScale)}
279
296
  </g>`;
280
297
  }
281
298
  return ` <g class="cursor-overlay" pointer-events="none">
299
+ <style>${kf.join("")}</style>
282
300
  ${pointerGroup}
283
301
  ${pulseMarkup}
284
302
  </g>`;
285
303
  }
286
- /** Build the SVG fragment for a single click pulse. */
287
- function buildPulseFragment(c, idx, totalDurationMs) {
304
+ /** Build the SVG fragment for a single click pulse, pushing its keyframes into
305
+ * `kf`. The expanding ring is `transform:scale` with `non-scaling-stroke` (so
306
+ * it matches the old SMIL `r` growth but on the CSS timeline), played once at
307
+ * its click time and frozen afterward (`forwards`), like the prior single-fire
308
+ * SMIL pulse. */
309
+ function buildPulseFragment(c, idx, uid, kf) {
288
310
  const beginSec = (c.t / 1000).toFixed(3);
289
311
  const durSec = (c.style.pulseDurationMs / 1000).toFixed(3);
290
312
  const r0 = 4;
291
313
  const r1 = c.style.pulseRadius;
292
314
  const innerR = r1 * 0.55;
315
+ const outerName = `co-pulse-${uid}-${idx}o`;
316
+ const innerName = `co-pulse-${uid}-${idx}i`;
317
+ kf.push(`@keyframes ${outerName}{0%{transform:scale(1);opacity:0}15%{opacity:0.9}100%{transform:scale(${num(r1 / r0)});opacity:0}}`);
318
+ kf.push(`@keyframes ${innerName}{0%{transform:scale(1);opacity:0}15%{opacity:0.95}100%{transform:scale(${num((r1 - 1) / r0)});opacity:0}}`);
319
+ const ringStyle = (name) => `transform-box:fill-box;transform-origin:center;vector-effect:non-scaling-stroke;animation:${name} ${durSec}s linear ${beginSec}s 1 forwards`;
293
320
  // Right-half-disc fill for secondary clicks.
294
321
  let secondaryHalf = "";
295
322
  if (c.button === "secondary") {
296
323
  const halfPath = `M ${num(c.x)} ${num(c.y - innerR)} A ${num(innerR)} ${num(innerR)} 0 0 1 ${num(c.x)} ${num(c.y + innerR)} Z`;
324
+ const halfName = `co-pulse-${uid}-${idx}h`;
325
+ kf.push(`@keyframes ${halfName}{0%{opacity:0}20%{opacity:1}100%{opacity:0}}`);
297
326
  secondaryHalf = `
298
- <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0">
299
- <animate attributeName="opacity" values="0; 1; 0" keyTimes="0; 0.2; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
300
- </path>`;
327
+ <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${durSec}s linear ${beginSec}s 1 forwards" />`;
301
328
  }
302
329
  return ` <g class="cursor-click cursor-click-${idx}">
303
- <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0">
304
- <animate attributeName="r" values="${r0}; ${r1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
305
- <animate attributeName="opacity" values="0; 0.9; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
306
- </circle>
307
- <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0">
308
- <animate attributeName="r" values="${r0}; ${r1 - 1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
309
- <animate attributeName="opacity" values="0; 0.95; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
310
- </circle>${secondaryHalf}
330
+ <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0" style="${ringStyle(outerName)}" />
331
+ <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0" style="${ringStyle(innerName)}" />${secondaryHalf}
311
332
  </g>`;
312
333
  }
313
334
  /** macOS-style cursor arrow path. The hot point (0, 0) sits at the tip. */
@@ -66,6 +66,16 @@ export interface CaptureOptions {
66
66
  * Values < 1 are clamped to 1.
67
67
  */
68
68
  embedRemoteImagesHiDPIFactor?: number;
69
+ /**
70
+ * DM-1442: opt-in cross-origin `<iframe>` recursion. `"*"` recurses every
71
+ * cross-origin frame; a comma-separated `host[:port]` list recurses only
72
+ * frames whose origin matches an entry (exact host; `:port` requires an exact
73
+ * port, otherwise any port). Undefined / omitted leaves cross-origin frames
74
+ * as raster snapshots (same-origin frames recurse regardless). Enabling this
75
+ * launches Chromium with web security disabled — which also disables CORS, so
76
+ * only use it on trusted pages. See docs/81-iframe-recursion.md.
77
+ */
78
+ captureCrossOriginFrames?: string;
69
79
  }
70
80
  /**
71
81
  * Launch Chromium via Playwright, auto-installing the browser binary on first
@@ -78,6 +88,20 @@ export interface CaptureOptions {
78
88
  * are a normal `chromium.launch()` with no overhead.
79
89
  */
80
90
  export declare function launchChromium(opts?: LaunchOptions): Promise<Browser>;
91
+ /**
92
+ * DM-1442: Chromium launch args needed to make cross-origin `<iframe>`
93
+ * documents readable from the in-page capture script. `--disable-web-security`
94
+ * lifts the Same-Origin Policy on cross-document access; the
95
+ * `--disable-features` flag co-locates frames in one renderer process so the
96
+ * cross-origin `contentDocument` is reachable (some builds need it, harmless on
97
+ * those that don't). Returns `[]` when `value` requests no cross-origin
98
+ * recursion, so callers can spread it unconditionally:
99
+ * `launchChromium({ args: crossOriginFramesLaunchArgs(value) })`.
100
+ *
101
+ * NOTE: disabling web security also disables CORS — only launch this way when
102
+ * capturing your own / trusted pages. Callers should print a visible warning.
103
+ */
104
+ export declare function crossOriginFramesLaunchArgs(value: string | undefined | null): string[];
81
105
  export declare class DemoRecorder {
82
106
  private browser;
83
107
  private context;
@@ -91,6 +115,7 @@ export declare class DemoRecorder {
91
115
  private embedRemoteImagesRetryBackoffMs;
92
116
  private embedRemoteImagesResize;
93
117
  private embedRemoteImagesHiDPIFactor;
118
+ private captureCrossOriginFrames;
94
119
  constructor(baseUrl: string, opts: CaptureOptions);
95
120
  init(opts: CaptureOptions): Promise<void>;
96
121
  /** Navigate to a URL and capture the visible DOM as SVG. */
@@ -213,6 +238,8 @@ export declare function captureElementTree(page: Page, selector: string | undefi
213
238
  y: number;
214
239
  width: number;
215
240
  height: number;
241
+ }, opts?: {
242
+ crossOriginFrames?: string;
216
243
  }): Promise<CapturedElement[]>;
217
244
  /**
218
245
  * Same capture as `captureElementTree` but returns the warnings inline so
@@ -232,10 +259,37 @@ export declare function captureElementTreeWithWarnings(page: Page, selector: str
232
259
  * rotating cross-origin-iframe content. Caller is responsible for
233
260
  * ensuring the source covers the same coordinate space as `viewport`. */
234
261
  rasterizeFromImagePath?: string;
262
+ /** DM-1442: the raw `--cross-origin-frames` allowlist value (`"*"` or a
263
+ * comma-separated `host[:port]` list). Passed into the capture script so
264
+ * cross-origin iframes whose origin is on the list recurse into native SVG
265
+ * instead of staying a raster snapshot. Requires the browser to have been
266
+ * launched with web security disabled (see `crossOriginFramesLaunchArgs`).
267
+ * Same-origin recursion (Phase 1) happens regardless. */
268
+ crossOriginFrames?: string;
235
269
  }): Promise<{
236
270
  tree: CapturedElement[];
237
271
  warnings: CaptureWarning[];
238
272
  }>;
273
+ /**
274
+ * DM-494: Rasterise each element referenced by `mask-image: element(#id)`.
275
+ * Mirrors `rasterizeReplacedElements` (doc 17): hide-everything-else
276
+ * stylesheet → screenshot the target's painted box → encode as a data URI →
277
+ * stash on `tree[0].maskRasters[i].dataUri`. The renderer's `buildMaskDef`
278
+ * picks up the data URI from the root tree's lookup table when it sees an
279
+ * `element()` mask layer.
280
+ *
281
+ * Same-document only — CSS spec doesn't define cross-document `element()`.
282
+ * Always-on (no opt-in flag) — dedupe by referenced id (one screenshot per
283
+ * unique target regardless of how many consumers reference it). Targets that
284
+ * are display:none / 0-area are filtered at capture time and never reach
285
+ * here. See `docs/22-mask-element-paint-references.md`.
286
+ */
287
+ export declare function rasterizeMaskSources(page: Page, tree: CapturedElement[], viewport: {
288
+ x: number;
289
+ y: number;
290
+ width: number;
291
+ height: number;
292
+ }): Promise<void>;
239
293
  /**
240
294
  * Calibrate `fontAscent` on text-bearing elements by scanning a reference
241
295
  * PNG (Chrome's actual paint) for each element's painted ink top, then