domotion-svg 0.4.2 → 0.5.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.
package/README.md CHANGED
@@ -59,7 +59,7 @@ For a multi-frame animated SVG, write a small JSON config and run `domotion anim
59
59
  domotion animate ./demo.json
60
60
  ```
61
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.
62
+ The config describes each frame (input, duration, transition) plus a declarative surface for interaction demos: continuous-session frames that carry client-side state across steps (omit `input` / set `"continue": true`), DOM-mutation and interaction actions, richer readiness waits (`waitForText` / `waitForGone` / `waitForCount`), typing / tap / svg / blink overlays that can anchor to an element's box, an on-screen `cursor` (explicit or `"auto"`), `vars` + `${}` interpolation, and a small `evaluate` escape hatch. See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
63
63
 
64
64
  ### Scripting API
65
65
 
@@ -69,6 +69,17 @@ export interface TypingOverlay {
69
69
  * sits on a clean background.
70
70
  */
71
71
  bgHeight?: number;
72
+ /**
73
+ * DM-870: render a blinking insertion caret. The bar sweeps the type
74
+ * position while typing, then parks at the end of the text and blinks
75
+ * (opacity 1↔0) until the frame ends. `true` uses defaults (the typing
76
+ * `color`, 2px wide, ~530ms cadence); an object overrides them.
77
+ */
78
+ caret?: boolean | {
79
+ color?: string;
80
+ width?: number;
81
+ blinkMs?: number;
82
+ };
72
83
  }
73
84
  export interface TapOverlay {
74
85
  kind: "tap";
@@ -122,7 +133,29 @@ export interface SvgOverlay {
122
133
  delay?: number;
123
134
  };
124
135
  }
125
- export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay;
136
+ /**
137
+ * DM-871: a standalone blinking bar/box, for carets/dots not tied to a typing
138
+ * overlay — a recording dot, an attention pulse on a focused field, a cursor.
139
+ * Renders a rect that toggles opacity on a `periodMs` cycle for the frame's
140
+ * hold (sugar over a rect + a repeating opacity animation).
141
+ */
142
+ export interface BlinkOverlay {
143
+ kind: "blink";
144
+ /** Top-left corner in the captured frame's coordinate space. */
145
+ x: number;
146
+ y: number;
147
+ width: number;
148
+ height: number;
149
+ /** Full on/off cycle in ms (default 1000). */
150
+ periodMs?: number;
151
+ /** Fill color (default a light gray). */
152
+ color?: string;
153
+ /** Corner radius — set to half the width/height for a dot. */
154
+ radius?: number;
155
+ /** Ms after the frame becomes visible before blinking starts. Default 0. */
156
+ delay?: number;
157
+ }
158
+ export type AnimationOverlay = TypingOverlay | TapOverlay | SvgOverlay | BlinkOverlay;
126
159
  /**
127
160
  * Animate a CSS property on captured elements that match a selector, while
128
161
  * the frame is held on screen. The selector is resolved against the source
@@ -154,6 +187,17 @@ export interface IntraFrameAnimation {
154
187
  easing?: string;
155
188
  /** Ms after the frame becomes visible before animation starts. Default 0. */
156
189
  delay?: number;
190
+ /**
191
+ * DM-869: repeat count. A positive integer or `"infinite"`. When set, the
192
+ * animation loops on its own `duration` clock (CSS `animation-iteration-count`)
193
+ * rather than playing once — turning a property animation into a blink / pulse
194
+ * / breathe. The loop is only visible while the frame is on screen (the frame
195
+ * group's visibility gating). `"infinite"` is the robust choice for a looping
196
+ * scene; a finite count aligns to the frame's first appearance.
197
+ */
198
+ repeat?: number | "infinite";
199
+ /** DM-869: when true, the loop ping-pongs `from`→`to`→`from` (CSS `animation-direction: alternate`). */
200
+ alternate?: boolean;
157
201
  }
158
202
  export interface AnimationConfig {
159
203
  width: number;
@@ -4,7 +4,6 @@
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 "../tree-ops/frame-merge.js";
8
7
  import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
9
8
  export function generateAnimatedSvg(config) {
10
9
  const { width, height, frames } = config;
@@ -25,19 +24,20 @@ export function generateAnimatedSvg(config) {
25
24
  t += f.duration + td;
26
25
  }
27
26
  }
28
- // Fast path: if every transition is crossfade (or default) or `cut`, merge
29
- // all frames into a single de-duplicated tree with per-element visibility
30
- // timelines. `cut` is just `crossfade` with duration 0 — same merge logic
31
- // applies; it ends up as step-end keyframes flipping at exact frame
32
- // boundaries.
33
- const allMergeable = frames.every((f) => {
34
- const type = f.transition?.type;
35
- return type == null || type === "crossfade" || type === "cut";
36
- });
37
- const anyOverlays = frames.some((f) => f.overlays != null && f.overlays.length > 0);
38
- if (allMergeable && frames.length > 1 && !anyOverlays) {
39
- return composeMergedSvg(config, frameTiming, totalSec);
40
- }
27
+ // Every sequence composites: each frame is emitted as a complete, internally
28
+ // z-ordered `<g class="f f-N">` sub-SVG and switched/faded by opacity.
29
+ //
30
+ // There used to be an element-merge fast path (`mergeFrames`) for cut-only
31
+ // sequences that flattened all frames into one de-duplicated tree to save
32
+ // bytes. DM-854 took crossfade off it (it dropped per-frame z-order and
33
+ // step-end-switched instead of fading); DM-865 then showed it also mis-renders
34
+ // *near-identical* frames the same DOM evolved across frames, as produced by
35
+ // continuous-session capture — because differing text in a shared element slot
36
+ // can't be gated per frame (a bare text node carries no class, and a `<tspan>`
37
+ // with `visibility:hidden` still advances layout, shifting the surviving
38
+ // glyph). Compositing has neither problem. The dedup size win can be recovered
39
+ // later as `<defs>`/symbol-level glyph sharing across intact frame groups,
40
+ // which preserves each frame's layout (tracked with DM-854/DM-865).
41
41
  const frameGroups = [];
42
42
  const keyframes = [];
43
43
  let timeOffset = 0;
@@ -74,15 +74,15 @@ export function generateAnimatedSvg(config) {
74
74
  keyframes.push(`
75
75
  @keyframes fp-${i} {
76
76
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { transform: translateX(${entersViaPush ? width : 0}px); }
77
- ${startPct}% { transform: translateX(0); }
78
- ${holdEndPct}% { transform: translateX(0); }
79
- ${transEndPct}% { transform: translateX(-${width}px); }
77
+ ${startPct} { transform: translateX(0); }
78
+ ${holdEndPct} { transform: translateX(0); }
79
+ ${transEndPct} { transform: translateX(-${width}px); }
80
80
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateX(-${width}px); }
81
81
  }
82
82
  @keyframes fv-${i} {
83
83
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
84
- ${enterStartPct}% { opacity: 1; }
85
- ${transEndPct}% { opacity: 1; }
84
+ ${enterStartPct} { opacity: 1; }
85
+ ${transEndPct} { opacity: 1; }
86
86
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
87
87
  }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
88
88
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
@@ -103,15 +103,15 @@ export function generateAnimatedSvg(config) {
103
103
  keyframes.push(`
104
104
  @keyframes fp-${i} {
105
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); }
106
+ ${startPct} { transform: translateY(0); }
107
+ ${holdEndPct} { transform: translateY(0); }
108
+ ${transEndPct} { transform: translateY(-${height}px); }
109
109
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { transform: translateY(-${height}px); }
110
110
  }
111
111
  @keyframes fv-${i} {
112
112
  0%, ${Math.max(0, parseFloat(enterStartPct) - 0.1).toFixed(2)}% { opacity: 0; }
113
- ${enterStartPct}% { opacity: 1; }
114
- ${transEndPct}% { opacity: 1; }
113
+ ${enterStartPct} { opacity: 1; }
114
+ ${transEndPct} { opacity: 1; }
115
115
  ${Math.min(100, parseFloat(transEndPct) + 0.1).toFixed(2)}%, 100% { opacity: 0; }
116
116
  }${buildDisplayKeyframes(`fd-${i}`, visStart, visEnd)}
117
117
  .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
@@ -191,6 +191,11 @@ export function generateAnimatedSvg(config) {
191
191
  frameGroups.push(svgMarkup);
192
192
  keyframes.push(css);
193
193
  }
194
+ else if (overlay.kind === "blink") {
195
+ const { svgMarkup, css } = renderBlinkOverlay(overlay, i, timeOffset, timeOffset + frame.duration, totalDuration, totalSec);
196
+ frameGroups.push(svgMarkup);
197
+ keyframes.push(css);
198
+ }
194
199
  }
195
200
  }
196
201
  timeOffset += frame.duration + transDur;
@@ -337,6 +342,8 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
337
342
  // width-growing clip during the slice of the type timeline when that line's
338
343
  // characters are typed (line N starts after line N-1 finishes), so the caret
339
344
  // advances down the field exactly as it would in the browser.
345
+ // DM-870: per-line type timing, collected for the optional caret below.
346
+ const lineTimings = [];
340
347
  let cumChars = 0;
341
348
  lines.forEach((line, li) => {
342
349
  const lineY = overlay.y + li * lineHeight;
@@ -345,8 +352,11 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
345
352
  // is where the caret would sit just after the typed character anyway.
346
353
  const lineWidth = line.length * charWidth + charWidth;
347
354
  const clipId = `${id}-clip${li}`;
348
- const lineStartPct = pct(typeStartMs + (cumChars / visibleChars) * effTypeDur, totalDuration);
349
- const lineEndPct = pct(typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur, totalDuration);
355
+ const lineStartMs = typeStartMs + (cumChars / visibleChars) * effTypeDur;
356
+ const lineEndMs = typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur;
357
+ const lineStartPct = pct(lineStartMs, totalDuration);
358
+ const lineEndPct = pct(lineEndMs, totalDuration);
359
+ lineTimings.push({ li, startMs: lineStartMs, endMs: lineEndMs, len: line.length });
350
360
  cumChars += line.length;
351
361
  parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
352
362
  parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(line)}</text>`);
@@ -359,6 +369,48 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
359
369
  cssRules.push(`
360
370
  @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
361
371
  .${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
372
+ // DM-870: blinking insertion caret. Sweeps the type position while typing
373
+ // (one linear translate segment per wrapped line, jumping to the next line's
374
+ // start), then parks at the end of the last line and blinks (step-end opacity
375
+ // toggle) until the overlay disappears. Two animations on one rect: a linear
376
+ // position track + a step-end opacity blink.
377
+ if (overlay.caret != null && overlay.caret !== false && lineTimings.length > 0) {
378
+ const caretOpts = typeof overlay.caret === "object" ? overlay.caret : {};
379
+ const caretColor = caretOpts.color ?? color;
380
+ const caretW = caretOpts.width ?? 2;
381
+ const blinkMs = caretOpts.blinkMs ?? 530;
382
+ const last = lineTimings[lineTimings.length - 1];
383
+ const endX = last.len * charWidth;
384
+ const endY = last.li * lineHeight;
385
+ // Position track: hold at line 0 start until typing begins, then sweep each
386
+ // line, then hold at the text end through the blink + disappear.
387
+ const posStops = [`0%, ${typeStartPct} { transform: translate(0px, 0px); }`];
388
+ for (const lt of lineTimings) {
389
+ posStops.push(`${pct(lt.startMs, totalDuration)} { transform: translate(0px, ${lt.li * lineHeight}px); }`);
390
+ posStops.push(`${pct(lt.endMs, totalDuration)} { transform: translate(${lt.len * charWidth}px, ${lt.li * lineHeight}px); }`);
391
+ }
392
+ posStops.push(`${holdEndPct}, 100% { transform: translate(${endX}px, ${endY}px); }`);
393
+ // Blink: invisible until typing starts, solid through typing, then toggle
394
+ // on/off every half-period until the overlay disappears.
395
+ const blinkStops = [
396
+ `0%, ${typeStartPct} { opacity: 0; }`,
397
+ `${pct(typeStartMs + 30, totalDuration)} { opacity: 1; }`,
398
+ `${pct(textEndMs, totalDuration)} { opacity: 1; }`,
399
+ ];
400
+ let t = textEndMs + blinkMs / 2;
401
+ let on = false;
402
+ while (t < holdEndMs) {
403
+ blinkStops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
404
+ t += blinkMs / 2;
405
+ on = !on;
406
+ }
407
+ blinkStops.push(`${disappearPct}, 100% { opacity: 0; }`);
408
+ parts.push(` <rect class="${id}-caret" x="${overlay.x}" y="${overlay.y - fontSize + 2}" width="${caretW}" height="${fontSize}" fill="${caretColor}" />`);
409
+ cssRules.push(`
410
+ @keyframes ${id}-caret-pos { ${posStops.join(" ")} }
411
+ @keyframes ${id}-caret-blink { ${blinkStops.join(" ")} }
412
+ .${id}-caret { animation: ${id}-caret-pos ${totalSec.toFixed(2)}s linear infinite, ${id}-caret-blink ${totalSec.toFixed(2)}s step-end infinite; }`);
413
+ }
362
414
  return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
363
415
  }
364
416
  function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
@@ -380,6 +432,29 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
380
432
  .${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
381
433
  return { svgMarkup, css };
382
434
  }
435
+ function renderBlinkOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
436
+ const id = `blink${frameIdx}`;
437
+ const period = overlay.periodMs ?? 1000;
438
+ const color = overlay.color ?? "#e6edf3";
439
+ const startMs = frameStart + (overlay.delay ?? 0);
440
+ const radiusAttr = overlay.radius != null ? ` rx="${overlay.radius}" ry="${overlay.radius}"` : "";
441
+ // Toggle opacity on/off every half-period across the frame's hold, then off.
442
+ // step-end keeps each state until the next stop (a hard blink, not a fade).
443
+ const stops = [`0%, ${pct(startMs, totalDuration)} { opacity: 0; }`];
444
+ let t = startMs;
445
+ let on = true;
446
+ while (t < frameEnd) {
447
+ stops.push(`${pct(t, totalDuration)} { opacity: ${on ? 1 : 0}; }`);
448
+ t += period / 2;
449
+ on = !on;
450
+ }
451
+ stops.push(`${pct(frameEnd, totalDuration)}, 100% { opacity: 0; }`);
452
+ const svgMarkup = ` <rect class="${id}" x="${overlay.x}" y="${overlay.y}" width="${overlay.width}" height="${overlay.height}"${radiusAttr} fill="${color}" />`;
453
+ const css = `
454
+ @keyframes ${id} { ${stops.join(" ")} }
455
+ .${id} { animation: ${id} ${totalSec.toFixed(2)}s step-end infinite; }`;
456
+ return { svgMarkup, css };
457
+ }
383
458
  function pct(ms, total) {
384
459
  return `${((ms / total) * 100).toFixed(2)}%`;
385
460
  }
@@ -483,50 +558,6 @@ function offsetForDirection(dir, w, h, _outFrom) {
483
558
  return `translate(-${w}px, 0)`;
484
559
  return `translate(${w}px, 0)`; // right
485
560
  }
486
- /**
487
- * Compose the animated SVG using the frame-merge pipeline. Every element in
488
- * every frame is reduced to one render with a visibility timeline. Stable
489
- * elements (prompt, background, typed characters that stay on screen) emit
490
- * once with opacity: 1 throughout; changing elements get step-end keyframes
491
- * that flip their opacity at the appropriate frame boundaries.
492
- */
493
- function composeMergedSvg(config, frameTiming, totalSec) {
494
- const { width, height, frames } = config;
495
- const framesSvg = frames.map((f) => f.svgContent);
496
- const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
497
- const sharedDefsMarkup = config.sharedDefs ?? "";
498
- const animationCss = buildIntraFrameAnimationCss(frames, frameTiming, totalSec);
499
- // DM-603: viewBox-cull keyframes from each frame's pre-pass (see unmerged path).
500
- const cullCss = frames.map((f) => f.cullCss ?? "").filter((s) => s !== "").join("\n");
501
- // Cursor overlay (DM-277). Same emission as the unmerged path — the
502
- // overlay sits above the merged frame group, clipped to the viewport.
503
- const totalDuration = totalSec * 1000;
504
- let overlayMarkup = "";
505
- if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
506
- const frameStarts = [];
507
- let acc = 0;
508
- for (const f of frames) {
509
- frameStarts.push(acc);
510
- acc += f.duration + transitionDuration(f);
511
- }
512
- const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
513
- overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
514
- }
515
- return `<?xml version="1.0" encoding="UTF-8"?>
516
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
517
- <defs>
518
- <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
519
- </defs>
520
- <style>
521
- ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
522
- ${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
523
- </style>
524
- <g clip-path="url(#viewport-clip)">
525
- <rect width="${width}" height="${height}" fill="#0d1117" />
526
- ${merged}${overlayMarkup}
527
- </g>
528
- </svg>`;
529
- }
530
561
  /**
531
562
  * Compile each frame's intra-frame animations into CSS. Each animation gets
532
563
  * a uniquely-named keyframe block whose timing is mapped onto the global
@@ -560,15 +591,33 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
560
591
  return `${a.property}: ${val};`;
561
592
  };
562
593
  const animName = `f${i}-${a.animId}-${ai}`;
563
- // Hold `from` until startPct, animate from→to during [startPct, endPct],
564
- // hold `to` afterwards. Pre-frame holds use 0% as the from anchor.
565
- out.push(` @keyframes ${animName} {
594
+ if (a.repeat != null) {
595
+ // DM-869: repeating animation (blink / pulse / breathe). The keyframe is
596
+ // a single from→to cycle on the animation's own `duration` clock, looped
597
+ // via animation-iteration-count + (optional) direction:alternate. The
598
+ // loop is only visible while the frame is on screen (the frame group's
599
+ // visibility gating); `animation-delay` aligns the first cycle to the
600
+ // frame's appearance. `fill-mode: both` holds `from` before the delay.
601
+ const iterations = a.repeat === "infinite" ? "infinite" : String(a.repeat);
602
+ const direction = a.alternate === true ? " alternate" : "";
603
+ out.push(` @keyframes ${animName} {
604
+ 0% { ${propValue(a.from)} }
605
+ 100% { ${propValue(a.to)} }
606
+ }
607
+ .anim-${a.animId} { animation: ${animName} ${a.duration}ms ${iterations}${direction}; animation-timing-function: ${easing}; animation-delay: ${startMs.toFixed(0)}ms; animation-fill-mode: both; }`);
608
+ }
609
+ else {
610
+ // One-shot: hold `from` until startPct, animate from→to during
611
+ // [startPct, endPct], hold `to` afterwards, mapped onto the global scene
612
+ // clock so it replays in sync each scene loop.
613
+ out.push(` @keyframes ${animName} {
566
614
  0% { ${propValue(a.from)} }
567
615
  ${startPct.toFixed(3)}% { ${propValue(a.from)} }
568
616
  ${endPct.toFixed(3)}% { ${propValue(a.to)} }
569
617
  100% { ${propValue(a.to)} }
570
618
  }
571
619
  .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
620
+ }
572
621
  }
573
622
  }
574
623
  return out.length === 0 ? "" : "\n" + out.join("\n");
@@ -20,35 +20,45 @@ describe("animator", () => {
20
20
  expect(topDefs).not.toBeNull();
21
21
  expect(topDefs[0]).toContain(`id="g0"`);
22
22
  });
23
- it("crossfade-only scenes route through the merge pipeline (no per-frame fv- groups)", () => {
24
- // With the merge pipeline, an all-crossfade scene is reduced to a single
25
- // element tree with per-element visibility timelines NOT per-frame
26
- // opacity groups. fv-N keyframes (the old model) should be absent;
27
- // timeline classes (tN) should appear when frames differ.
23
+ it("crossfade transition composites full frames (per-frame opacity fade, not an element merge)", () => {
24
+ // A crossfade transition dissolves between two complete, independently
25
+ // z-ordered sub-SVGs. It must NOT flatten them into one deduped element
26
+ // tree doing so loses per-frame stacking (a later frame's background can
27
+ // occlude its own foreground) and step-end-switches at the midpoint
28
+ // instead of fading. So a crossfade emits per-frame fv- groups with an
29
+ // interpolated opacity fade, and each frame's content survives intact —
30
+ // NOT the merge pipeline's tN timeline classes.
28
31
  const svg = generateAnimatedSvg({
29
32
  width: 100,
30
33
  height: 100,
31
34
  frames: [
32
35
  { svgContent: `<rect fill="red" width="50" height="50"/>`, duration: 1000, transition: { type: "crossfade", duration: 200 } },
33
- { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
36
+ { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000, transition: { type: "crossfade", duration: 200 } },
34
37
  ],
35
38
  });
36
- expect(svg).not.toMatch(/@keyframes fv-/);
37
- expect(svg).toMatch(/@keyframes t\d+/);
39
+ expect(svg).toMatch(/@keyframes fv-/);
40
+ expect(svg).not.toMatch(/@keyframes t\d+/);
41
+ expect(svg).toContain(`<rect fill="red" width="50" height="50"/>`);
42
+ expect(svg).toContain(`<rect fill="blue" width="50" height="50"/>`);
38
43
  expect(svg).toContain("--scene-dur");
39
44
  });
40
- it("crossfade: identical content across frames is rendered once", () => {
41
- // Regression test for SK-662 stable elements should not be re-drawn per
42
- // frame. Two frames with the same <rect> should produce exactly one <rect>.
45
+ it("cut: composites each frame as a complete sub-SVG (DM-865 — no element merge)", () => {
46
+ // DM-865: the element-merge fast path is gone cut composites each frame
47
+ // like crossfade. So identical content across frames is emitted once PER
48
+ // frame (not deduped into one), gated by per-frame fv-N opacity. The merge
49
+ // mis-rendered near-identical (continuous-session) frames; compositing
50
+ // keeps each frame's layout intact.
43
51
  const svg = generateAnimatedSvg({
44
52
  width: 100,
45
53
  height: 100,
46
54
  frames: [
47
- { svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "crossfade", duration: 100 } },
48
- { svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500 },
55
+ { svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "cut", duration: 0 } },
56
+ { svgContent: `<rect width="50" height="50" fill="green"/>`, duration: 500, transition: { type: "cut", duration: 0 } },
49
57
  ],
50
58
  });
51
- expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(1);
59
+ expect((svg.match(/<rect width="50" height="50" fill="green"\/>/g) ?? []).length).toBe(2);
60
+ expect(svg).toMatch(/@keyframes fv-/);
61
+ expect(svg).not.toMatch(/@keyframes t\d+\s*{/);
52
62
  });
53
63
  it("DM-609: scroll transition is now a vertical push (translateY), not opacity-only", () => {
54
64
  // The `scroll` transition used to be opacity-only (a misnomer). Per
@@ -105,7 +115,7 @@ describe("animator", () => {
105
115
  const bDur = b.match(/--scene-dur:\s*([0-9.]+)s/)?.[1];
106
116
  expect(aDur).toBe(bDur);
107
117
  });
108
- it("cut transition: routes through the merge fast path (no fv-N groups)", () => {
118
+ it("cut transition: composites with per-frame fv-N groups (DM-865 no element merge)", () => {
109
119
  const svg = generateAnimatedSvg({
110
120
  width: 100, height: 100,
111
121
  frames: [
@@ -114,8 +124,9 @@ describe("animator", () => {
114
124
  { svgContent: `<rect fill="green" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
115
125
  ],
116
126
  });
117
- expect(svg).not.toMatch(/@keyframes fv-/);
118
- expect(svg).toMatch(/@keyframes t\d+/);
127
+ expect(svg).toMatch(/@keyframes fv-/);
128
+ expect(svg).not.toMatch(/@keyframes t\d+\s*{/);
129
+ expect((svg.match(/class="f f-\d+"/g) ?? []).length).toBe(3);
119
130
  // 3 frames × 1000ms hold + 3 × 0ms cut transitions = 3000ms total.
120
131
  expect(svg).toMatch(/--scene-dur:\s*3\.00s/);
121
132
  });
@@ -152,6 +163,69 @@ describe("animator", () => {
152
163
  // Timing function passes through.
153
164
  expect(svg).toContain("ease-out");
154
165
  });
166
+ it("DM-869: repeating animation loops on its own clock with iteration-count + alternate", () => {
167
+ const svg = generateAnimatedSvg({
168
+ width: 100,
169
+ height: 100,
170
+ frames: [
171
+ {
172
+ svgContent: `<rect class="anim-caret"/>`,
173
+ duration: 2000,
174
+ animations: [{ animId: "caret", property: "opacity", from: "1", to: "0", duration: 530, repeat: "infinite", alternate: true }],
175
+ },
176
+ ],
177
+ });
178
+ // Loops on its own 530ms clock, infinite + alternating — NOT the global scene clock.
179
+ expect(svg).toMatch(/\.anim-caret\s*{[^}]*animation:\s*f0-caret-0\s+530ms\s+infinite\s+alternate/);
180
+ // Simple two-stop from→to cycle (no 4-stop global-clock hold).
181
+ expect(svg).toMatch(/@keyframes f0-caret-0\s*{\s*0%\s*{\s*opacity:\s*1;\s*}\s*100%\s*{\s*opacity:\s*0;\s*}\s*}/);
182
+ });
183
+ it("DM-869: finite repeat emits the count and (without alternate) no direction", () => {
184
+ const svg = generateAnimatedSvg({
185
+ width: 100,
186
+ height: 100,
187
+ frames: [
188
+ {
189
+ svgContent: `<rect class="anim-p"/>`,
190
+ duration: 2000,
191
+ animations: [{ animId: "p", property: "opacity", from: "1", to: "0.3", duration: 400, repeat: 3 }],
192
+ },
193
+ ],
194
+ });
195
+ expect(svg).toMatch(/\.anim-p\s*{[^}]*animation:\s*f0-p-0\s+400ms\s+3\s*;/);
196
+ expect(svg).not.toContain("alternate");
197
+ });
198
+ it("DM-870: typing-overlay caret emits a position-tracked, step-end blinking bar", () => {
199
+ const svg = generateAnimatedSvg({
200
+ width: 200,
201
+ height: 80,
202
+ frames: [{ svgContent: `<rect/>`, duration: 3000, overlays: [{ kind: "typing", text: "hi there", x: 10, y: 40, caret: true }] }],
203
+ });
204
+ expect(svg).toContain(`class="t0-caret"`);
205
+ expect(svg).toMatch(/@keyframes t0-caret-pos/);
206
+ expect(svg).toMatch(/@keyframes t0-caret-blink/);
207
+ // Position track is linear; the blink toggles with step-end (hard on/off).
208
+ expect(svg).toMatch(/\.t0-caret\s*{[^}]*linear[^}]*step-end/);
209
+ });
210
+ it("DM-870: typing overlay without the caret option emits no caret", () => {
211
+ const svg = generateAnimatedSvg({
212
+ width: 200,
213
+ height: 80,
214
+ frames: [{ svgContent: `<rect/>`, duration: 3000, overlays: [{ kind: "typing", text: "hi", x: 10, y: 40 }] }],
215
+ });
216
+ expect(svg).not.toContain("-caret");
217
+ });
218
+ it("DM-871: blink overlay emits a step-end toggling rect", () => {
219
+ const svg = generateAnimatedSvg({
220
+ width: 100,
221
+ height: 100,
222
+ frames: [{ svgContent: `<rect/>`, duration: 2000, overlays: [{ kind: "blink", x: 10, y: 10, width: 12, height: 12, periodMs: 800, color: "#ef4444", radius: 6 }] }],
223
+ });
224
+ expect(svg).toMatch(/<rect class="blink0"[^>]*fill="#ef4444"/);
225
+ expect(svg).toContain('rx="6"');
226
+ expect(svg).toMatch(/@keyframes blink0/);
227
+ expect(svg).toMatch(/\.blink0\s*{[^}]*step-end/);
228
+ });
155
229
  it("intra-frame animation: translateY desugars to transform: translateY()", () => {
156
230
  const svg = generateAnimatedSvg({
157
231
  width: 100, height: 100,
@@ -228,23 +302,21 @@ describe("animator", () => {
228
302
  // The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
229
303
  expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
230
304
  });
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.
305
+ it("DM-599/DM-641: cut fv-N keyframes emit visibility alongside opacity", () => {
306
+ // Cut composites (DM-865); its fv-N keyframes flip BOTH opacity and
307
+ // visibility at the frame boundary (step-end) so the browser can skip
308
+ // painting a frame that's off screen.
235
309
  const svg = generateAnimatedSvg({
236
310
  width: 100, height: 100,
237
311
  frames: [
238
312
  { 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 },
313
+ { svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
240
314
  ],
241
315
  });
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/);
316
+ const fv = svg.match(/@keyframes fv-\d+\s*{[\s\S]*?\n\s*}/);
317
+ expect(fv).not.toBeNull();
318
+ expect(fv[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
319
+ expect(fv[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
248
320
  });
249
321
  it("DM-641: never emits `display: none` keyframes (would park Chromium's animation engine)", () => {
250
322
  // Regression. The repro from the ticket: a multi-frame animation with
@@ -1512,7 +1512,15 @@ export const captureScript = (args) => {
1512
1512
  || (parseFloat(rootCs.borderBottomWidth) || 0) > 0
1513
1513
  || (parseFloat(rootCs.borderLeftWidth) || 0) > 0;
1514
1514
  const rootBg = rootCs.backgroundColor;
1515
- const rootHasBg = rootBg != null && rootBg !== 'rgba(0, 0, 0, 0)' && rootBg !== 'transparent';
1515
+ // DM-855: also capture the root when it carries a gradient/image background,
1516
+ // not just a solid color. `backgroundColor` is `transparent` for a
1517
+ // gradient-only <body>, so checking it alone made us skip capturing the root
1518
+ // element and drop its background entirely. Treating a non-`none`
1519
+ // `background-image` as "has background" captures the root as a normal
1520
+ // element, routing its gradient through the existing element-gradient path.
1521
+ const rootBgImage = rootCs.backgroundImage;
1522
+ const rootHasBgImage = rootBgImage != null && rootBgImage !== 'none' && rootBgImage !== '';
1523
+ const rootHasBg = (rootBg != null && rootBg !== 'rgba(0, 0, 0, 0)' && rootBg !== 'transparent') || rootHasBgImage;
1516
1524
  // DM-365: invalid HTML like <p>foo<div>bar</div>baz</p> auto-closes the <p>
1517
1525
  // when the <div> opens, leaving "baz" as a direct text-node child of <body>.
1518
1526
  // Chrome paints it; we'd miss it if we only walked root.children (Element