domotion-svg 0.4.1 → 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
 
@@ -56,8 +56,30 @@ export interface TypingOverlay {
56
56
  speed?: number;
57
57
  /** Background color to mask placeholder text */
58
58
  bgColor?: string;
59
+ /**
60
+ * Field width in px. When set, the typed text WRAPS to this width like a
61
+ * browser textarea — breaking on spaces (char-breaking over-long words),
62
+ * advancing one line-height per wrapped line — instead of running off the
63
+ * right edge on a single line (DM-840). Omit for unbounded single-line text.
64
+ */
59
65
  bgWidth?: number;
66
+ /**
67
+ * Field height in px (used to size the placeholder mask). The mask grows
68
+ * beyond this if the wrapped text needs more lines, so the typed text always
69
+ * sits on a clean background.
70
+ */
60
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
+ };
61
83
  }
62
84
  export interface TapOverlay {
63
85
  kind: "tap";
@@ -111,7 +133,29 @@ export interface SvgOverlay {
111
133
  delay?: number;
112
134
  };
113
135
  }
114
- 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;
115
159
  /**
116
160
  * Animate a CSS property on captured elements that match a selector, while
117
161
  * the frame is held on screen. The selector is resolved against the source
@@ -143,6 +187,17 @@ export interface IntraFrameAnimation {
143
187
  easing?: string;
144
188
  /** Ms after the frame becomes visible before animation starts. Default 0. */
145
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;
146
201
  }
147
202
  export interface AnimationConfig {
148
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;
@@ -245,46 +250,167 @@ function transitionDuration(f) {
245
250
  return 0;
246
251
  return f.transition.duration;
247
252
  }
253
+ /**
254
+ * Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
255
+ * browser textarea does: break on spaces, char-break a word longer than the
256
+ * field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
257
+ * line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
258
+ * overlays with no `bgWidth`. DM-840.
259
+ */
260
+ function wrapTypingText(text, maxChars) {
261
+ const lines = [];
262
+ for (const paragraph of text.split("\n")) {
263
+ if (maxChars === Infinity) {
264
+ lines.push(paragraph);
265
+ continue;
266
+ }
267
+ if (paragraph === "") {
268
+ lines.push("");
269
+ continue;
270
+ }
271
+ let cur = "";
272
+ for (let word of paragraph.split(" ")) {
273
+ // A single word wider than the line char-breaks across lines.
274
+ while (word.length > maxChars) {
275
+ if (cur !== "") {
276
+ lines.push(cur);
277
+ cur = "";
278
+ }
279
+ lines.push(word.slice(0, maxChars));
280
+ word = word.slice(maxChars);
281
+ }
282
+ if (cur === "")
283
+ cur = word;
284
+ else if ((cur + " " + word).length <= maxChars)
285
+ cur += " " + word;
286
+ else {
287
+ lines.push(cur);
288
+ cur = word;
289
+ }
290
+ }
291
+ lines.push(cur);
292
+ }
293
+ return lines;
294
+ }
248
295
  function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
249
296
  const delay = overlay.delay ?? 300;
250
297
  const speed = overlay.speed ?? 60;
251
298
  const fontSize = overlay.fontSize ?? 14;
252
- const charWidth = fontSize * 0.6;
299
+ const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
300
+ const lineHeight = Math.round(fontSize * 1.35);
253
301
  const color = overlay.color ?? "#e6edf3";
254
302
  const typeStartMs = frameStart + delay;
303
+ const id = `t${frameIdx}`;
304
+ // DM-840: wrap to bgWidth so typed text behaves like a browser field
305
+ // (textarea) — wrapping to the next line instead of running off the right
306
+ // edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
307
+ // width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
308
+ // keep the original single-line behavior (maxChars = Infinity).
309
+ const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
310
+ const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
311
+ const lines = wrapTypingText(overlay.text, maxChars);
312
+ const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
313
+ const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
255
314
  const parts = [];
256
315
  const cssRules = [];
257
- const id = `t${frameIdx}`;
258
- // Background mask
316
+ // ── Timeline — all stops clamped to the frame so the overlay can't leak
317
+ // across the cut into the next frame. `naturalEnd` is when typing finishes
318
+ // at the requested speed; if that runs past the frame we compress the reveal
319
+ // to fit. The fully-typed text then HOLDS until just before the frame ends
320
+ // (the old hard 3 s cap cut long text off mid-type), then fades out.
321
+ const disappearGap = 150;
322
+ const naturalEndMs = typeStartMs + visibleChars * speed;
323
+ const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
324
+ const effTypeDur = Math.max(1, textEndMs - typeStartMs);
325
+ const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
326
+ const disappearMs = Math.min(frameEnd, holdEndMs + 100);
327
+ const textHeight = fontSize + 4;
328
+ const holdEndPct = pct(holdEndMs, totalDuration);
329
+ const disappearPct = pct(disappearMs, totalDuration);
330
+ // Background mask — grown to cover every wrapped line so the typed text
331
+ // always lands on a clean field instead of the captured placeholder.
259
332
  if (overlay.bgColor != null) {
260
- const bgW = overlay.bgWidth ?? overlay.text.length * charWidth + 8;
261
- const bgH = overlay.bgHeight ?? fontSize + 6;
333
+ const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
334
+ const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
262
335
  const bgStartPct = pct(typeStartMs, totalDuration);
263
- const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
264
336
  parts.push(` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`);
265
337
  cssRules.push(`
266
- @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${bgEndPct}, 100% { opacity: 0; } }
338
+ @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
267
339
  .${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
268
340
  }
269
- // Render full text with an animated clip that reveals characters one-by-one.
270
- // The overlay must disappear by the time the frame ends otherwise it'll
271
- // leak across the cut boundary and overlap the next frame's content.
272
- const textEndMs = typeStartMs + overlay.text.length * speed;
273
- const holdEndMs = Math.min(frameStart + 3000, frameEnd);
274
- const fullTextWidth = overlay.text.length * charWidth + 4;
275
- const textHeight = fontSize + 4;
276
- const clipId = `${id}-clip`;
277
- // Clip rect animation: width grows from 0 to full text width
278
- parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-reveal" x="${overlay.x}" y="${overlay.y - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
279
- parts.push(` <text class="${id}-text" x="${overlay.x}" y="${overlay.y}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(overlay.text)}</text>`);
341
+ // Typewriter reveal: one <text> per wrapped line, each unveiled by a
342
+ // width-growing clip during the slice of the type timeline when that line's
343
+ // characters are typed (line N starts after line N-1 finishes), so the caret
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 = [];
347
+ let cumChars = 0;
348
+ lines.forEach((line, li) => {
349
+ const lineY = overlay.y + li * lineHeight;
350
+ // +1 cell of slack so the last glyph never clips: the real monospace
351
+ // advance is slightly wider than the 0.6em estimate, and the trailing cell
352
+ // is where the caret would sit just after the typed character anyway.
353
+ const lineWidth = line.length * charWidth + charWidth;
354
+ const clipId = `${id}-clip${li}`;
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 });
360
+ cumChars += line.length;
361
+ parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
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>`);
363
+ cssRules.push(`
364
+ @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
365
+ .${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
366
+ });
367
+ // Whole-overlay visibility — shared by every line's <text>.
280
368
  const typeStartPct = pct(typeStartMs, totalDuration);
281
- const typeEndPct = pct(textEndMs, totalDuration);
282
- const holdEndPct = pct(holdEndMs, totalDuration);
283
369
  cssRules.push(`
284
- @keyframes ${id}-reveal { 0%, ${typeStartPct} { width: 0; } ${typeEndPct} { width: ${fullTextWidth}px; } ${holdEndPct} { width: ${fullTextWidth}px; } ${pct(holdEndMs + 100, totalDuration)}, 100% { width: 0; } }
285
- .${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
286
- @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
370
+ @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
287
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
+ }
288
414
  return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
289
415
  }
290
416
  function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec) {
@@ -306,6 +432,29 @@ function renderTapOverlay(overlay, frameIdx, frameStart, totalDuration, totalSec
306
432
  .${id}-dot { animation: ${id}-dot ${totalSec.toFixed(2)}s infinite; }`;
307
433
  return { svgMarkup, css };
308
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
+ }
309
458
  function pct(ms, total) {
310
459
  return `${((ms / total) * 100).toFixed(2)}%`;
311
460
  }
@@ -409,50 +558,6 @@ function offsetForDirection(dir, w, h, _outFrom) {
409
558
  return `translate(-${w}px, 0)`;
410
559
  return `translate(${w}px, 0)`; // right
411
560
  }
412
- /**
413
- * Compose the animated SVG using the frame-merge pipeline. Every element in
414
- * every frame is reduced to one render with a visibility timeline. Stable
415
- * elements (prompt, background, typed characters that stay on screen) emit
416
- * once with opacity: 1 throughout; changing elements get step-end keyframes
417
- * that flip their opacity at the appropriate frame boundaries.
418
- */
419
- function composeMergedSvg(config, frameTiming, totalSec) {
420
- const { width, height, frames } = config;
421
- const framesSvg = frames.map((f) => f.svgContent);
422
- const { css, merged } = mergeFrames(framesSvg, frameTiming, "t");
423
- const sharedDefsMarkup = config.sharedDefs ?? "";
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");
427
- // Cursor overlay (DM-277). Same emission as the unmerged path — the
428
- // overlay sits above the merged frame group, clipped to the viewport.
429
- const totalDuration = totalSec * 1000;
430
- let overlayMarkup = "";
431
- if (config.cursorOverlay != null && config.cursorOverlay.events.length > 0) {
432
- const frameStarts = [];
433
- let acc = 0;
434
- for (const f of frames) {
435
- frameStarts.push(acc);
436
- acc += f.duration + transitionDuration(f);
437
- }
438
- const resolved = resolveCursorScript(config.cursorOverlay, totalDuration, frameStarts, config.resolveSelector ?? null);
439
- overlayMarkup = "\n" + cursorOverlayMarkup(resolved.positions, resolved.clicks, resolved.style, totalDuration);
440
- }
441
- return `<?xml version="1.0" encoding="UTF-8"?>
442
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
443
- <defs>
444
- <clipPath id="viewport-clip"><rect width="${width}" height="${height}" /></clipPath>${sharedDefsMarkup}
445
- </defs>
446
- <style>
447
- ${config.fontFaceCss != null && config.fontFaceCss !== "" ? config.fontFaceCss + "\n" : ""} :root { --scene-dur: ${totalSec.toFixed(2)}s; }
448
- ${css}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
449
- </style>
450
- <g clip-path="url(#viewport-clip)">
451
- <rect width="${width}" height="${height}" fill="#0d1117" />
452
- ${merged}${overlayMarkup}
453
- </g>
454
- </svg>`;
455
- }
456
561
  /**
457
562
  * Compile each frame's intra-frame animations into CSS. Each animation gets
458
563
  * a uniquely-named keyframe block whose timing is mapped onto the global
@@ -486,15 +591,33 @@ function buildIntraFrameAnimationCss(frames, frameTiming, totalSec) {
486
591
  return `${a.property}: ${val};`;
487
592
  };
488
593
  const animName = `f${i}-${a.animId}-${ai}`;
489
- // Hold `from` until startPct, animate from→to during [startPct, endPct],
490
- // hold `to` afterwards. Pre-frame holds use 0% as the from anchor.
491
- 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} {
492
614
  0% { ${propValue(a.from)} }
493
615
  ${startPct.toFixed(3)}% { ${propValue(a.from)} }
494
616
  ${endPct.toFixed(3)}% { ${propValue(a.to)} }
495
617
  100% { ${propValue(a.to)} }
496
618
  }
497
619
  .anim-${a.animId} { animation: ${animName} ${totalSec.toFixed(2)}s infinite; animation-timing-function: ${easing}; }`);
620
+ }
498
621
  }
499
622
  }
500
623
  return out.length === 0 ? "" : "\n" + out.join("\n");