domotion-svg 0.4.1 → 0.4.2

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.
@@ -56,7 +56,18 @@ 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;
61
72
  }
62
73
  export interface TapOverlay {
@@ -245,45 +245,119 @@ function transitionDuration(f) {
245
245
  return 0;
246
246
  return f.transition.duration;
247
247
  }
248
+ /**
249
+ * Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
250
+ * browser textarea does: break on spaces, char-break a word longer than the
251
+ * field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
252
+ * line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
253
+ * overlays with no `bgWidth`. DM-840.
254
+ */
255
+ function wrapTypingText(text, maxChars) {
256
+ const lines = [];
257
+ for (const paragraph of text.split("\n")) {
258
+ if (maxChars === Infinity) {
259
+ lines.push(paragraph);
260
+ continue;
261
+ }
262
+ if (paragraph === "") {
263
+ lines.push("");
264
+ continue;
265
+ }
266
+ let cur = "";
267
+ for (let word of paragraph.split(" ")) {
268
+ // A single word wider than the line char-breaks across lines.
269
+ while (word.length > maxChars) {
270
+ if (cur !== "") {
271
+ lines.push(cur);
272
+ cur = "";
273
+ }
274
+ lines.push(word.slice(0, maxChars));
275
+ word = word.slice(maxChars);
276
+ }
277
+ if (cur === "")
278
+ cur = word;
279
+ else if ((cur + " " + word).length <= maxChars)
280
+ cur += " " + word;
281
+ else {
282
+ lines.push(cur);
283
+ cur = word;
284
+ }
285
+ }
286
+ lines.push(cur);
287
+ }
288
+ return lines;
289
+ }
248
290
  function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
249
291
  const delay = overlay.delay ?? 300;
250
292
  const speed = overlay.speed ?? 60;
251
293
  const fontSize = overlay.fontSize ?? 14;
252
- const charWidth = fontSize * 0.6;
294
+ const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
295
+ const lineHeight = Math.round(fontSize * 1.35);
253
296
  const color = overlay.color ?? "#e6edf3";
254
297
  const typeStartMs = frameStart + delay;
298
+ const id = `t${frameIdx}`;
299
+ // DM-840: wrap to bgWidth so typed text behaves like a browser field
300
+ // (textarea) — wrapping to the next line instead of running off the right
301
+ // edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
302
+ // width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
303
+ // keep the original single-line behavior (maxChars = Infinity).
304
+ const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
305
+ const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
306
+ const lines = wrapTypingText(overlay.text, maxChars);
307
+ const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
308
+ const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
255
309
  const parts = [];
256
310
  const cssRules = [];
257
- const id = `t${frameIdx}`;
258
- // Background mask
311
+ // ── Timeline — all stops clamped to the frame so the overlay can't leak
312
+ // across the cut into the next frame. `naturalEnd` is when typing finishes
313
+ // at the requested speed; if that runs past the frame we compress the reveal
314
+ // to fit. The fully-typed text then HOLDS until just before the frame ends
315
+ // (the old hard 3 s cap cut long text off mid-type), then fades out.
316
+ const disappearGap = 150;
317
+ const naturalEndMs = typeStartMs + visibleChars * speed;
318
+ const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
319
+ const effTypeDur = Math.max(1, textEndMs - typeStartMs);
320
+ const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
321
+ const disappearMs = Math.min(frameEnd, holdEndMs + 100);
322
+ const textHeight = fontSize + 4;
323
+ const holdEndPct = pct(holdEndMs, totalDuration);
324
+ const disappearPct = pct(disappearMs, totalDuration);
325
+ // Background mask — grown to cover every wrapped line so the typed text
326
+ // always lands on a clean field instead of the captured placeholder.
259
327
  if (overlay.bgColor != null) {
260
- const bgW = overlay.bgWidth ?? overlay.text.length * charWidth + 8;
261
- const bgH = overlay.bgHeight ?? fontSize + 6;
328
+ const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
329
+ const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
262
330
  const bgStartPct = pct(typeStartMs, totalDuration);
263
- const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
264
331
  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
332
  cssRules.push(`
266
- @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${bgEndPct}, 100% { opacity: 0; } }
333
+ @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
267
334
  .${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
268
335
  }
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>`);
336
+ // Typewriter reveal: one <text> per wrapped line, each unveiled by a
337
+ // width-growing clip during the slice of the type timeline when that line's
338
+ // characters are typed (line N starts after line N-1 finishes), so the caret
339
+ // advances down the field exactly as it would in the browser.
340
+ let cumChars = 0;
341
+ lines.forEach((line, li) => {
342
+ const lineY = overlay.y + li * lineHeight;
343
+ // +1 cell of slack so the last glyph never clips: the real monospace
344
+ // advance is slightly wider than the 0.6em estimate, and the trailing cell
345
+ // is where the caret would sit just after the typed character anyway.
346
+ const lineWidth = line.length * charWidth + charWidth;
347
+ 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);
350
+ cumChars += line.length;
351
+ parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
352
+ 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>`);
353
+ cssRules.push(`
354
+ @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
355
+ .${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
356
+ });
357
+ // Whole-overlay visibility — shared by every line's <text>.
280
358
  const typeStartPct = pct(typeStartMs, totalDuration);
281
- const typeEndPct = pct(textEndMs, totalDuration);
282
- const holdEndPct = pct(holdEndMs, totalDuration);
283
359
  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; } }
360
+ @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
287
361
  .${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
288
362
  return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
289
363
  }
@@ -287,4 +287,60 @@ describe("animator", () => {
287
287
  // 50.000% boundary — frame 0 fades out and frame 1 fades in at the same instant.
288
288
  expect(svg).toMatch(/50\.000%/);
289
289
  });
290
+ // DM-840: a typing overlay over a bgWidth-constrained field must wrap like a
291
+ // browser textarea instead of running the text off the right edge.
292
+ describe("typing overlay text wrapping (DM-840)", () => {
293
+ const longText = "The quick brown fox jumps over the lazy dog repeatedly";
294
+ function typedSvg(opts) {
295
+ return generateAnimatedSvg({
296
+ width: 400, height: 200,
297
+ frames: [{
298
+ svgContent: `<rect width="400" height="200" fill="#fff"/>`,
299
+ duration: 4000,
300
+ overlays: [{
301
+ kind: "typing", text: longText, x: 20, y: 40,
302
+ fontSize: 14, bgColor: "#fff", bgWidth: opts.bgWidth, bgHeight: 24,
303
+ }],
304
+ }],
305
+ });
306
+ }
307
+ // Pull the text content out of every `<text class="t0-text" …>…</text>`.
308
+ function typedLines(svg) {
309
+ return [...svg.matchAll(/<text class="t0-text"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1]);
310
+ }
311
+ it("wraps the typed text into multiple lines bounded by bgWidth", () => {
312
+ const svg = typedSvg({ bgWidth: 120 });
313
+ const lines = typedLines(svg);
314
+ expect(lines.length).toBeGreaterThan(1);
315
+ // bgWidth 120, charWidth = 14*0.6 = 8.4 → usable (120-4)/8.4 ≈ 13 chars/line.
316
+ const maxChars = Math.floor((120 - 4) / (14 * 0.6));
317
+ for (const line of lines)
318
+ expect(line.length).toBeLessThanOrEqual(maxChars);
319
+ // No glyph escapes the field: each line's right edge stays within the bg.
320
+ expect(lines.join("")).not.toContain(" "); // wrap consumed the break spaces
321
+ });
322
+ it("advances y by a line-height per wrapped line", () => {
323
+ const svg = typedSvg({ bgWidth: 120 });
324
+ const ys = [...svg.matchAll(/<text class="t0-text" x="20" y="([\d.]+)"/g)].map((m) => Number(m[1]));
325
+ expect(ys.length).toBeGreaterThan(1);
326
+ // Strictly increasing baselines, ~lineHeight (round(14*1.35)=19) apart.
327
+ for (let i = 1; i < ys.length; i++) {
328
+ expect(ys[i]).toBeGreaterThan(ys[i - 1]);
329
+ expect(ys[i] - ys[i - 1]).toBe(19);
330
+ }
331
+ });
332
+ it("keeps single-line behavior when no bgWidth is given (backward compatible)", () => {
333
+ const svg = typedSvg({});
334
+ const lines = typedLines(svg);
335
+ expect(lines.length).toBe(1);
336
+ expect(lines[0]).toBe(longText);
337
+ });
338
+ it("grows the bg mask to cover all wrapped lines", () => {
339
+ const svg = typedSvg({ bgWidth: 120 });
340
+ const bg = /<rect class="t0-bg"[^>]*height="([\d.]+)"/.exec(svg);
341
+ expect(bg).not.toBeNull();
342
+ // More than the single-line bgHeight (24) since the text wraps to several lines.
343
+ expect(Number(bg[1])).toBeGreaterThan(24);
344
+ });
345
+ });
290
346
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domotion-svg",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",
@@ -303,4 +303,66 @@ describe("animator", () => {
303
303
  // 50.000% boundary — frame 0 fades out and frame 1 fades in at the same instant.
304
304
  expect(svg).toMatch(/50\.000%/);
305
305
  });
306
+
307
+ // DM-840: a typing overlay over a bgWidth-constrained field must wrap like a
308
+ // browser textarea instead of running the text off the right edge.
309
+ describe("typing overlay text wrapping (DM-840)", () => {
310
+ const longText = "The quick brown fox jumps over the lazy dog repeatedly";
311
+
312
+ function typedSvg(opts: { bgWidth?: number }): string {
313
+ return generateAnimatedSvg({
314
+ width: 400, height: 200,
315
+ frames: [{
316
+ svgContent: `<rect width="400" height="200" fill="#fff"/>`,
317
+ duration: 4000,
318
+ overlays: [{
319
+ kind: "typing", text: longText, x: 20, y: 40,
320
+ fontSize: 14, bgColor: "#fff", bgWidth: opts.bgWidth, bgHeight: 24,
321
+ }],
322
+ }],
323
+ });
324
+ }
325
+
326
+ // Pull the text content out of every `<text class="t0-text" …>…</text>`.
327
+ function typedLines(svg: string): string[] {
328
+ return [...svg.matchAll(/<text class="t0-text"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1]);
329
+ }
330
+
331
+ it("wraps the typed text into multiple lines bounded by bgWidth", () => {
332
+ const svg = typedSvg({ bgWidth: 120 });
333
+ const lines = typedLines(svg);
334
+ expect(lines.length).toBeGreaterThan(1);
335
+ // bgWidth 120, charWidth = 14*0.6 = 8.4 → usable (120-4)/8.4 ≈ 13 chars/line.
336
+ const maxChars = Math.floor((120 - 4) / (14 * 0.6));
337
+ for (const line of lines) expect(line.length).toBeLessThanOrEqual(maxChars);
338
+ // No glyph escapes the field: each line's right edge stays within the bg.
339
+ expect(lines.join("")).not.toContain(" "); // wrap consumed the break spaces
340
+ });
341
+
342
+ it("advances y by a line-height per wrapped line", () => {
343
+ const svg = typedSvg({ bgWidth: 120 });
344
+ const ys = [...svg.matchAll(/<text class="t0-text" x="20" y="([\d.]+)"/g)].map((m) => Number(m[1]));
345
+ expect(ys.length).toBeGreaterThan(1);
346
+ // Strictly increasing baselines, ~lineHeight (round(14*1.35)=19) apart.
347
+ for (let i = 1; i < ys.length; i++) {
348
+ expect(ys[i]).toBeGreaterThan(ys[i - 1]);
349
+ expect(ys[i] - ys[i - 1]).toBe(19);
350
+ }
351
+ });
352
+
353
+ it("keeps single-line behavior when no bgWidth is given (backward compatible)", () => {
354
+ const svg = typedSvg({});
355
+ const lines = typedLines(svg);
356
+ expect(lines.length).toBe(1);
357
+ expect(lines[0]).toBe(longText);
358
+ });
359
+
360
+ it("grows the bg mask to cover all wrapped lines", () => {
361
+ const svg = typedSvg({ bgWidth: 120 });
362
+ const bg = /<rect class="t0-bg"[^>]*height="([\d.]+)"/.exec(svg);
363
+ expect(bg).not.toBeNull();
364
+ // More than the single-line bgHeight (24) since the text wraps to several lines.
365
+ expect(Number(bg![1])).toBeGreaterThan(24);
366
+ });
367
+ });
306
368
  });
@@ -60,7 +60,18 @@ export interface TypingOverlay {
60
60
  speed?: number;
61
61
  /** Background color to mask placeholder text */
62
62
  bgColor?: string;
63
+ /**
64
+ * Field width in px. When set, the typed text WRAPS to this width like a
65
+ * browser textarea — breaking on spaces (char-breaking over-long words),
66
+ * advancing one line-height per wrapped line — instead of running off the
67
+ * right edge on a single line (DM-840). Omit for unbounded single-line text.
68
+ */
63
69
  bgWidth?: number;
70
+ /**
71
+ * Field height in px (used to size the placeholder mask). The mask grows
72
+ * beyond this if the wrapped text needs more lines, so the typed text always
73
+ * sits on a clean background.
74
+ */
64
75
  bgHeight?: number;
65
76
  }
66
77
 
@@ -445,6 +456,35 @@ function transitionDuration(f: AnimationFrame): number {
445
456
  return f.transition.duration;
446
457
  }
447
458
 
459
+ /**
460
+ * Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
461
+ * browser textarea does: break on spaces, char-break a word longer than the
462
+ * field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
463
+ * line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
464
+ * overlays with no `bgWidth`. DM-840.
465
+ */
466
+ function wrapTypingText(text: string, maxChars: number): string[] {
467
+ const lines: string[] = [];
468
+ for (const paragraph of text.split("\n")) {
469
+ if (maxChars === Infinity) { lines.push(paragraph); continue; }
470
+ if (paragraph === "") { lines.push(""); continue; }
471
+ let cur = "";
472
+ for (let word of paragraph.split(" ")) {
473
+ // A single word wider than the line char-breaks across lines.
474
+ while (word.length > maxChars) {
475
+ if (cur !== "") { lines.push(cur); cur = ""; }
476
+ lines.push(word.slice(0, maxChars));
477
+ word = word.slice(maxChars);
478
+ }
479
+ if (cur === "") cur = word;
480
+ else if ((cur + " " + word).length <= maxChars) cur += " " + word;
481
+ else { lines.push(cur); cur = word; }
482
+ }
483
+ lines.push(cur);
484
+ }
485
+ return lines;
486
+ }
487
+
448
488
  function renderTypingOverlay(
449
489
  overlay: TypingOverlay,
450
490
  frameIdx: number,
@@ -456,52 +496,85 @@ function renderTypingOverlay(
456
496
  const delay = overlay.delay ?? 300;
457
497
  const speed = overlay.speed ?? 60;
458
498
  const fontSize = overlay.fontSize ?? 14;
459
- const charWidth = fontSize * 0.6;
499
+ const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
500
+ const lineHeight = Math.round(fontSize * 1.35);
460
501
  const color = overlay.color ?? "#e6edf3";
461
502
  const typeStartMs = frameStart + delay;
503
+ const id = `t${frameIdx}`;
504
+
505
+ // DM-840: wrap to bgWidth so typed text behaves like a browser field
506
+ // (textarea) — wrapping to the next line instead of running off the right
507
+ // edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
508
+ // width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
509
+ // keep the original single-line behavior (maxChars = Infinity).
510
+ const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
511
+ const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
512
+ const lines = wrapTypingText(overlay.text, maxChars);
513
+ const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
514
+ const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
462
515
 
463
516
  const parts: string[] = [];
464
517
  const cssRules: string[] = [];
465
- const id = `t${frameIdx}`;
466
518
 
467
- // Background mask
519
+ // ── Timeline — all stops clamped to the frame so the overlay can't leak
520
+ // across the cut into the next frame. `naturalEnd` is when typing finishes
521
+ // at the requested speed; if that runs past the frame we compress the reveal
522
+ // to fit. The fully-typed text then HOLDS until just before the frame ends
523
+ // (the old hard 3 s cap cut long text off mid-type), then fades out.
524
+ const disappearGap = 150;
525
+ const naturalEndMs = typeStartMs + visibleChars * speed;
526
+ const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
527
+ const effTypeDur = Math.max(1, textEndMs - typeStartMs);
528
+ const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
529
+ const disappearMs = Math.min(frameEnd, holdEndMs + 100);
530
+ const textHeight = fontSize + 4;
531
+ const holdEndPct = pct(holdEndMs, totalDuration);
532
+ const disappearPct = pct(disappearMs, totalDuration);
533
+
534
+ // Background mask — grown to cover every wrapped line so the typed text
535
+ // always lands on a clean field instead of the captured placeholder.
468
536
  if (overlay.bgColor != null) {
469
- const bgW = overlay.bgWidth ?? overlay.text.length * charWidth + 8;
470
- const bgH = overlay.bgHeight ?? fontSize + 6;
537
+ const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
538
+ const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
471
539
  const bgStartPct = pct(typeStartMs, totalDuration);
472
- const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
473
-
474
540
  parts.push(
475
541
  ` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`,
476
542
  );
477
543
  cssRules.push(`
478
- @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${bgEndPct}, 100% { opacity: 0; } }
544
+ @keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
479
545
  .${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
480
546
  }
481
547
 
482
- // Render full text with an animated clip that reveals characters one-by-one.
483
- // The overlay must disappear by the time the frame ends otherwise it'll
484
- // leak across the cut boundary and overlap the next frame's content.
485
- const textEndMs = typeStartMs + overlay.text.length * speed;
486
- const holdEndMs = Math.min(frameStart + 3000, frameEnd);
487
- const fullTextWidth = overlay.text.length * charWidth + 4;
488
- const textHeight = fontSize + 4;
489
- const clipId = `${id}-clip`;
490
-
491
- // Clip rect animation: width grows from 0 to full text width
492
- parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-reveal" x="${overlay.x}" y="${overlay.y - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
493
- parts.push(
494
- ` <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>`,
495
- );
548
+ // Typewriter reveal: one <text> per wrapped line, each unveiled by a
549
+ // width-growing clip during the slice of the type timeline when that line's
550
+ // characters are typed (line N starts after line N-1 finishes), so the caret
551
+ // advances down the field exactly as it would in the browser.
552
+
553
+ let cumChars = 0;
554
+ lines.forEach((line, li) => {
555
+ const lineY = overlay.y + li * lineHeight;
556
+ // +1 cell of slack so the last glyph never clips: the real monospace
557
+ // advance is slightly wider than the 0.6em estimate, and the trailing cell
558
+ // is where the caret would sit just after the typed character anyway.
559
+ const lineWidth = line.length * charWidth + charWidth;
560
+ const clipId = `${id}-clip${li}`;
561
+ const lineStartPct = pct(typeStartMs + (cumChars / visibleChars) * effTypeDur, totalDuration);
562
+ const lineEndPct = pct(typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur, totalDuration);
563
+ cumChars += line.length;
564
+
565
+ parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
566
+ parts.push(
567
+ ` <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>`,
568
+ );
569
+ cssRules.push(`
570
+ @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
571
+ .${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
572
+ });
496
573
 
574
+ // Whole-overlay visibility — shared by every line's <text>.
497
575
  const typeStartPct = pct(typeStartMs, totalDuration);
498
- const typeEndPct = pct(textEndMs, totalDuration);
499
- const holdEndPct = pct(holdEndMs, totalDuration);
500
-
501
576
  cssRules.push(`
502
- @keyframes ${id}-reveal { 0%, ${typeStartPct} { width: 0; } ${typeEndPct} { width: ${fullTextWidth}px; } ${holdEndPct} { width: ${fullTextWidth}px; } ${pct(holdEndMs + 100, totalDuration)}, 100% { width: 0; } }
503
- .${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
504
- @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
577
+ @keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
505
578
  .${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
506
579
 
507
580
  return { svgMarkup: parts.join("\n"), css: cssRules.join("") };