domotion-svg 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -267,7 +267,7 @@ export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs, c
267
267
  kf.push(`@keyframes ${posName}{${positions.map((p) => `${pct(p.t / totalDurationMs)}{transform:translate(${num(p.x)}px,${num(p.y)}px)}`).join("")}}`);
268
268
  const posAnim = `${posName} ${totalSec}s linear infinite`;
269
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");
270
+ const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, uid, kf, totalDurationMs)).join("\n");
271
271
  let pointerGroup;
272
272
  if (cursorTimeline != null && cursorTimeline.length > 0) {
273
273
  // DM-1106: one glyph per distinct keyword, each toggled by a DISCRETE opacity
@@ -303,28 +303,49 @@ ${pulseMarkup}
303
303
  }
304
304
  /** Build the SVG fragment for a single click pulse, pushing its keyframes into
305
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) {
310
- const beginSec = (c.t / 1000).toFixed(3);
311
- const durSec = (c.style.pulseDurationMs / 1000).toFixed(3);
306
+ * it matches the old SMIL `r` growth but on the CSS timeline).
307
+ *
308
+ * DM-1510: the pulse animation spans the WHOLE loop (`totalDurationMs`) and runs
309
+ * `infinite`, exactly like the cursor position track — NOT a `durSec`-long
310
+ * one-shot with a start delay. The pulse curve occupies its click window
311
+ * `[c.t, c.t + pulseDurationMs]` as a fraction of the loop and holds invisible
312
+ * (opacity 0, scale reset) the rest of the loop. A one-shot `forwards`
313
+ * animation only played on the first loop iteration, so the click rings
314
+ * vanished after the SVG looped even though the cursor kept moving. */
315
+ function buildPulseFragment(c, idx, uid, kf, totalDurationMs) {
316
+ const totalSec = totalDurationMs / 1000;
317
+ const durMs = c.style.pulseDurationMs;
312
318
  const r0 = 4;
313
319
  const r1 = c.style.pulseRadius;
314
320
  const innerR = r1 * 0.55;
315
321
  const outerName = `co-pulse-${uid}-${idx}o`;
316
322
  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`;
323
+ // Fractions of the full loop where this pulse's window lands. The window's
324
+ // start/peak/end map to the old one-shot's 0% / 15% / 100% keyframes.
325
+ const fStart = c.t / totalDurationMs;
326
+ const fPeak = Math.min(1, (c.t + 0.15 * durMs) / totalDurationMs);
327
+ const fEnd = Math.min(1, (c.t + durMs) / totalDurationMs);
328
+ // Hold scale(1)+opacity 0 before the click (only needed when the click is not
329
+ // at t=0) and after the window (reset for the next loop, unless the window
330
+ // already reaches the loop end).
331
+ const lead = fStart > 0 ? `${pct(fStart)}{transform:scale(1);opacity:0}` : "";
332
+ const tail = fEnd < 1 ? `100%{transform:scale(1);opacity:0}` : "";
333
+ const peak = (op) => (fPeak < fEnd ? `${pct(fPeak)}{opacity:${op}}` : "");
334
+ kf.push(`@keyframes ${outerName}{0%{transform:scale(1);opacity:0}${lead}${peak(0.9)}${pct(fEnd)}{transform:scale(${num(r1 / r0)});opacity:0}${tail}}`);
335
+ kf.push(`@keyframes ${innerName}{0%{transform:scale(1);opacity:0}${lead}${peak(0.95)}${pct(fEnd)}{transform:scale(${num((r1 - 1) / r0)});opacity:0}${tail}}`);
336
+ const ringStyle = (name) => `transform-box:fill-box;transform-origin:center;vector-effect:non-scaling-stroke;animation:${name} ${totalSec}s linear infinite`;
320
337
  // Right-half-disc fill for secondary clicks.
321
338
  let secondaryHalf = "";
322
339
  if (c.button === "secondary") {
323
340
  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
341
  const halfName = `co-pulse-${uid}-${idx}h`;
325
- kf.push(`@keyframes ${halfName}{0%{opacity:0}20%{opacity:1}100%{opacity:0}}`);
342
+ const fHalfPeak = Math.min(1, (c.t + 0.20 * durMs) / totalDurationMs);
343
+ const leadH = fStart > 0 ? `${pct(fStart)}{opacity:0}` : "";
344
+ const tailH = fEnd < 1 ? `100%{opacity:0}` : "";
345
+ const peakH = fHalfPeak < fEnd ? `${pct(fHalfPeak)}{opacity:1}` : "";
346
+ kf.push(`@keyframes ${halfName}{0%{opacity:0}${leadH}${peakH}${pct(fEnd)}{opacity:0}${tailH}}`);
326
347
  secondaryHalf = `
327
- <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${durSec}s linear ${beginSec}s 1 forwards" />`;
348
+ <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0" style="animation:${halfName} ${totalSec}s linear infinite" />`;
328
349
  }
329
350
  return ` <g class="cursor-click cursor-click-${idx}">
330
351
  <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)}" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domotion-svg",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
5
5
  "license": "MIT",
6
6
  "author": "Brian Westphal",