domotion-svg 0.2.2 → 0.3.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.
Files changed (115) hide show
  1. package/FEATURES.md +1 -0
  2. package/README.md +29 -0
  3. package/dist/animation/animator.js +25 -14
  4. package/dist/animation/animator.test.js +54 -21
  5. package/dist/animation/cursor-overlay.js +0 -2
  6. package/dist/capture/emoji.js +29 -18
  7. package/dist/capture/index.js +5 -4
  8. package/dist/capture/script/color-norm.d.ts +1 -0
  9. package/dist/capture/script/color-norm.js +43 -1
  10. package/dist/capture/script/emoji-detect.js +14 -0
  11. package/dist/capture/script/index.js +593 -65
  12. package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
  13. package/dist/capture/script/walker/borders-backgrounds.js +123 -7
  14. package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
  15. package/dist/capture/script/walker/counter-style-resolver.js +218 -0
  16. package/dist/capture/script/walker/input-value.js +14 -1
  17. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  18. package/dist/capture/script/walker/lists-counters.js +22 -2
  19. package/dist/capture/script/walker/masks-clips.d.ts +2 -0
  20. package/dist/capture/script/walker/masks-clips.js +41 -1
  21. package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
  22. package/dist/capture/script/walker/pseudo-content.js +301 -61
  23. package/dist/capture/script/walker/pseudo-inject.js +20 -0
  24. package/dist/capture/script/walker/text-segments.js +98 -4
  25. package/dist/capture/script/walker/transforms.d.ts +1 -0
  26. package/dist/capture/script/walker/transforms.js +16 -0
  27. package/dist/capture/script.generated.js +1 -1
  28. package/dist/capture/types.d.ts +213 -2
  29. package/dist/cli/animate.js +151 -15
  30. package/dist/mask.test.js +12 -7
  31. package/dist/render/borders.d.ts +9 -13
  32. package/dist/render/borders.js +379 -14
  33. package/dist/render/element-tree-to-svg.d.ts +11 -12
  34. package/dist/render/element-tree-to-svg.js +2046 -241
  35. package/dist/render/embedded-font-builder.d.ts +49 -0
  36. package/dist/render/embedded-font-builder.js +149 -0
  37. package/dist/render/form-controls.js +45 -24
  38. package/dist/render/gradients.d.ts +15 -0
  39. package/dist/render/gradients.js +103 -2
  40. package/dist/render/gradients.test.js +34 -0
  41. package/dist/render/text-to-path.d.ts +38 -1
  42. package/dist/render/text-to-path.js +654 -29
  43. package/dist/render/text-to-path.test.js +230 -9
  44. package/dist/render/text.d.ts +14 -0
  45. package/dist/render/text.js +344 -40
  46. package/dist/scroll/composer.d.ts +26 -0
  47. package/dist/scroll/composer.js +199 -11
  48. package/dist/scroll/composer.test.js +293 -16
  49. package/dist/scroll/executor.d.ts +3 -1
  50. package/dist/scroll/executor.js +15 -6
  51. package/dist/scroll/executor.test.js +25 -0
  52. package/dist/scroll/hoist-fixed.d.ts +48 -0
  53. package/dist/scroll/hoist-fixed.js +85 -0
  54. package/dist/scroll/hoist-fixed.test.d.ts +1 -0
  55. package/dist/scroll/hoist-fixed.test.js +103 -0
  56. package/dist/scroll/hoist-sticky.d.ts +45 -0
  57. package/dist/scroll/hoist-sticky.js +157 -0
  58. package/dist/scroll/hoist-sticky.test.d.ts +1 -0
  59. package/dist/scroll/hoist-sticky.test.js +154 -0
  60. package/dist/scroll/pattern.d.ts +22 -5
  61. package/dist/scroll/pattern.js +55 -7
  62. package/dist/scroll/pattern.test.js +48 -1
  63. package/dist/tree-ops/frame-merge.d.ts +10 -0
  64. package/dist/tree-ops/frame-merge.js +23 -5
  65. package/dist/tree-ops/frame-merge.test.js +45 -0
  66. package/dist/tree-ops/tree-diff.js +1 -1
  67. package/dist/tree-ops/viewbox-culling.js +32 -18
  68. package/dist/tree-ops/viewbox-culling.test.js +40 -6
  69. package/package.json +8 -2
  70. package/src/animation/animator.test.ts +56 -21
  71. package/src/animation/animator.ts +25 -14
  72. package/src/animation/cursor-overlay.ts +0 -2
  73. package/src/capture/emoji.ts +28 -18
  74. package/src/capture/index.ts +15 -14
  75. package/src/capture/script/color-norm.ts +38 -1
  76. package/src/capture/script/emoji-detect.ts +14 -0
  77. package/src/capture/script/index.ts +555 -48
  78. package/src/capture/script/walker/borders-backgrounds.ts +114 -7
  79. package/src/capture/script/walker/counter-style-resolver.ts +184 -0
  80. package/src/capture/script/walker/input-value.ts +14 -1
  81. package/src/capture/script/walker/lists-counters.ts +24 -2
  82. package/src/capture/script/walker/masks-clips.ts +40 -1
  83. package/src/capture/script/walker/pseudo-content.ts +297 -55
  84. package/src/capture/script/walker/pseudo-inject.ts +20 -0
  85. package/src/capture/script/walker/text-segments.ts +93 -4
  86. package/src/capture/script/walker/transforms.ts +14 -0
  87. package/src/capture/script.generated.ts +1 -1
  88. package/src/capture/types.ts +202 -2
  89. package/src/cli/animate.ts +135 -15
  90. package/src/mask.test.ts +12 -7
  91. package/src/render/borders.ts +383 -17
  92. package/src/render/element-tree-to-svg.ts +2051 -238
  93. package/src/render/embedded-font-builder.ts +221 -0
  94. package/src/render/form-controls.ts +45 -24
  95. package/src/render/gradients.test.ts +46 -0
  96. package/src/render/gradients.ts +94 -2
  97. package/src/render/opentype.js.d.ts +7 -0
  98. package/src/render/text-to-path.test.ts +246 -9
  99. package/src/render/text-to-path.ts +702 -31
  100. package/src/render/text.ts +344 -40
  101. package/src/scroll/composer.test.ts +322 -16
  102. package/src/scroll/composer.ts +246 -13
  103. package/src/scroll/executor.test.ts +27 -0
  104. package/src/scroll/executor.ts +19 -10
  105. package/src/scroll/hoist-fixed.test.ts +117 -0
  106. package/src/scroll/hoist-fixed.ts +95 -0
  107. package/src/scroll/hoist-sticky.test.ts +173 -0
  108. package/src/scroll/hoist-sticky.ts +193 -0
  109. package/src/scroll/pattern.test.ts +58 -1
  110. package/src/scroll/pattern.ts +71 -8
  111. package/src/tree-ops/frame-merge.test.ts +51 -0
  112. package/src/tree-ops/frame-merge.ts +24 -6
  113. package/src/tree-ops/tree-diff.ts +3 -1
  114. package/src/tree-ops/viewbox-culling.test.ts +42 -6
  115. package/src/tree-ops/viewbox-culling.ts +32 -18
@@ -152,11 +152,24 @@ function parseAttrs(s: string): Record<string, string> {
152
152
  * including `id`, those two defs would collapse into one and `<use href="#g198"/>`
153
153
  * references would break — see frame-merge.test.ts "preserves distinct ids
154
154
  * for paths with identical d".
155
+ *
156
+ * Resource references (`clip-path`, `mask`, `filter`) are included because
157
+ * they point at frame-scoped defs whose IDs are unique per frame. Two
158
+ * wrappers with different clip-path ids are NOT the same logical element
159
+ * even if every other attribute matches — merging them would (a) drop one
160
+ * frame's clip-path entirely, and (b) push that frame's unique content
161
+ * into the merge bucket of a different frame, which then sorts as one
162
+ * earlier-firstFrame group and lets later-frame siblings (e.g. solid
163
+ * background rects) emit *after* the content they should sit underneath.
164
+ * See frame-merge.test.ts "keeps per-frame body bg ordered before content".
155
165
  */
156
166
  export function structuralFingerprint(n: ParsedNode): string {
157
167
  if (n.kind === "text") return `T:${n.text}`;
158
168
  const a = n.attrs ?? {};
159
- const keyAttrs = ["id", "transform", "href", "x", "y", "d", "width", "height", "fill", "role", "class"];
169
+ const keyAttrs = [
170
+ "id", "transform", "href", "x", "y", "d", "width", "height",
171
+ "fill", "role", "class", "clip-path", "mask", "filter",
172
+ ];
160
173
  const pairs = keyAttrs.filter((k) => a[k] != null).map((k) => `${k}=${a[k]}`);
161
174
  return `${n.tag}|${pairs.join(",")}`;
162
175
  }
@@ -312,7 +325,7 @@ function mergeNode(
312
325
  const parts: string[] = [];
313
326
  for (const g of groups) {
314
327
  const visibleFrames = g.occurrences.map((o, i) => (o != null ? i : -1)).filter((i) => i >= 0);
315
- const contents = g.occurrences.filter((o) => o != null) as ParsedNode[];
328
+ const contents = g.occurrences.filter((o): o is ParsedNode => o != null);
316
329
 
317
330
  // Fast path: this element is byte-identical across all its occurrences.
318
331
  const raws = new Set(contents.map((c) => c.raw));
@@ -428,9 +441,14 @@ function buildTimelineKeyframes(name: string, visibleFrames: number[], timing: F
428
441
  if (rangeStart != null && prev != null) ranges.push([rangeStart, prev]);
429
442
 
430
443
  // Build keyframe stops. Use step-end so opacity switches instantly.
431
- // DM-599: emit `display: none/inline` alongside opacity so the browser
432
- // can skip painting elements that aren't currently in their visible-frames
433
- // window. Both properties snap together under step-end timing.
444
+ // DM-599: emit a paint-skip toggle alongside opacity so the browser can
445
+ // skip painting elements that aren't currently in their visible-frames
446
+ // window. DM-641: this was `display: none/inline`, which broke for any
447
+ // element whose 0% keyframe is `display: none` — Chromium parks the
448
+ // animation when the element drops out of the render tree and never
449
+ // ticks the keyframe that would bring it back. Switching to
450
+ // `visibility` keeps the element rendered (still no paint) so the
451
+ // animation continues across cycles.
434
452
  const stops: Array<[number, number]> = []; // (pct, opacity)
435
453
  stops.push([0, 0]);
436
454
  for (const [lo, hi] of ranges) {
@@ -446,7 +464,7 @@ function buildTimelineKeyframes(name: string, visibleFrames: number[], timing: F
446
464
  stops.push([100, 0]);
447
465
 
448
466
  const lines = stops.map(([p, o]) =>
449
- ` ${p.toFixed(3)}% { opacity: ${o}; display: ${o === 1 ? "inline" : "none"}; }`,
467
+ ` ${p.toFixed(3)}% { opacity: ${o}; visibility: ${o === 1 ? "visible" : "hidden"}; }`,
450
468
  );
451
469
  return ` @keyframes ${name} {\n${lines.join("\n")}\n }\n .${name} { animation: ${name} var(--scene-dur) infinite; animation-timing-function: step-end; }`;
452
470
  }
@@ -215,7 +215,9 @@ export function entriesOfKind(diff: TreeDiff, ...kinds: DiffEntryKind[]): DiffEn
215
215
  * per-element keyframes.
216
216
  */
217
217
  export function dominantTranslate(diff: TreeDiff): { dx: number; dy: number; fraction: number } | null {
218
- const movers = diff.entries.filter((e) => e.kind === "translated") as Array<DiffEntry & { dx: number; dy: number }>;
218
+ const movers = diff.entries.filter((e): e is DiffEntry & { dx: number; dy: number } =>
219
+ e.kind === "translated" && typeof e.dx === "number" && typeof e.dy === "number",
220
+ );
219
221
  if (movers.length === 0) return null;
220
222
  // Bucket by rounded (dx, dy) and pick the largest bucket.
221
223
  const buckets = new Map<string, { dx: number; dy: number; n: number }>();
@@ -187,8 +187,9 @@ describe("cullElementsOutsideViewBox — tree walk", () => {
187
187
  expect(tree.children![1].cullClass).toBe("cull-0");
188
188
  // One coalesced keyframes block, not two.
189
189
  expect((css.match(/@keyframes cull-\d+/g) ?? []).length).toBe(1);
190
- expect(css).toContain("display: inline");
191
- expect(css).toContain("display: none");
190
+ // DM-641: keyframes now toggle visibility instead of display.
191
+ expect(css).toContain("visibility: visible");
192
+ expect(css).toContain("visibility: hidden");
192
193
  });
193
194
 
194
195
  it("element fully outside viewBox under an animation that never reaches it: alwaysHidden", () => {
@@ -210,6 +211,42 @@ describe("cullElementsOutsideViewBox — tree walk", () => {
210
211
  expect(css).toBe("");
211
212
  });
212
213
 
214
+ it("DM-650: parent bbox outside viewBox, child bbox inside — parent kept, child kept, all visible", () => {
215
+ // Reproduces NYT-mobile-scroll: at scrollY=844 the body element has
216
+ // height: 100vh (height=844) and rect.top=-844, so its bbox sits
217
+ // exactly above the viewport (b.y + b.h = 0). Children are at their
218
+ // own viewport-relative coordinates and ARE in-viewport. Before the
219
+ // fix, the bottom-up walk culled the body and skipped recursion,
220
+ // hiding the whole subtree → seg renders white.
221
+ const tree: CapturedElement = el({
222
+ x: 0, y: -844, width: 390, height: 844, tag: "body",
223
+ children: [
224
+ el({ x: 0, y: 100, width: 390, height: 200, tag: "div", text: "headline" }),
225
+ el({ x: 0, y: 400, width: 390, height: 100, tag: "p", text: "body copy" }),
226
+ ],
227
+ });
228
+ cullElementsOutsideViewBox(tree, 390, 844, undefined, 0, 1000);
229
+ expect(tree.displayNone).toBeFalsy();
230
+ expect(tree.children![0].displayNone).toBeFalsy();
231
+ expect(tree.children![1].displayNone).toBeFalsy();
232
+ });
233
+
234
+ it("DM-650: parent bbox outside, every child also outside — parent AND children all hidden", () => {
235
+ // Same shape as above but with children also outside viewBox. Now it
236
+ // IS safe to mark the parent displayNone (and every descendant too).
237
+ const tree: CapturedElement = el({
238
+ x: 0, y: -844, width: 390, height: 844, tag: "body",
239
+ children: [
240
+ el({ x: 0, y: -800, width: 390, height: 100, tag: "div" }), // above viewBox
241
+ el({ x: 0, y: -700, width: 390, height: 200, tag: "p" }), // above viewBox
242
+ ],
243
+ });
244
+ cullElementsOutsideViewBox(tree, 390, 844, undefined, 0, 1000);
245
+ expect(tree.displayNone).toBe(true);
246
+ expect(tree.children![0].displayNone).toBe(true);
247
+ expect(tree.children![1].displayNone).toBe(true);
248
+ });
249
+
213
250
  it("respects child's own animId over inherited animation", () => {
214
251
  // Parent has a scroll animation; child has its OWN slide-in animation.
215
252
  // Child's culling should use its OWN animation, not the parent's.
@@ -248,9 +285,8 @@ describe("cullElementsOutsideViewBox — keyframes structure", () => {
248
285
  const { css } = cullElementsOutsideViewBox(tree, VW, VH, [anim], 0, 1000);
249
286
  expect(css).toContain("animation-timing-function: step-end");
250
287
  expect(css).toContain("var(--scene-dur)");
251
- // 0% bookend with display:none.
252
- expect(css).toMatch(/0% \{ display: none/);
253
- // 100% bookend with display:none.
254
- expect(css).toMatch(/100% \{ display: none/);
288
+ // 0% / 100% bookends with visibility:hidden (DM-641 — was display:none).
289
+ expect(css).toMatch(/0% \{ visibility: hidden/);
290
+ expect(css).toMatch(/100% \{ visibility: hidden/);
255
291
  });
256
292
  });
@@ -262,12 +262,25 @@ export function cullElementsOutsideViewBox(
262
262
  const windowToClass = new Map<string, string>();
263
263
  const cssBlocks: string[] = [];
264
264
 
265
- const walk = (el: CapturedElement, inheritedCtx: AnimationFrameContext | null): void => {
265
+ // Walk bottom-up: recurse FIRST, then decide the element's own cull. A
266
+ // parent can only safely inherit `displayNone` if every descendant is
267
+ // also fully hidden — children of an `overflow: visible` parent paint
268
+ // outside the parent's bbox and stay in-viewport even when the parent's
269
+ // bbox is entirely above/below the viewBox (DM-650: NYT body is
270
+ // height: 100vh, so at scrollY > 0 the body bbox sits exactly above
271
+ // the viewport but its descendants are in-viewport). Returns true if
272
+ // any element in the subtree (including `el` itself) is visible.
273
+ const walk = (el: CapturedElement, inheritedCtx: AnimationFrameContext | null): boolean => {
266
274
  const { ctx, decision } = decideForElement(el, viewportW, viewportH, inheritedCtx, animsById);
267
- if (decision.alwaysHidden) {
275
+ let anyDescendantVisible = false;
276
+ if (el.children != null) {
277
+ for (const child of el.children) {
278
+ if (walk(child, ctx)) anyDescendantVisible = true;
279
+ }
280
+ }
281
+ if (decision.alwaysHidden && !anyDescendantVisible) {
268
282
  el.displayNone = true;
269
- // Children inherit displayNone implicitly; don't recurse.
270
- return;
283
+ return false;
271
284
  }
272
285
  if (decision.visStartPct != null && decision.visEndPct != null) {
273
286
  const key = `${r3(decision.visStartPct)},${r3(decision.visEndPct)}`;
@@ -277,14 +290,9 @@ export function cullElementsOutsideViewBox(
277
290
  windowToClass.set(key, className);
278
291
  cssBlocks.push(buildCullKeyframes(className, decision.visStartPct, decision.visEndPct));
279
292
  }
280
- // Merge with any existing cullClass (shouldn't happen — each pass
281
- // assigns at most one — but be defensive).
282
293
  el.cullClass = el.cullClass == null || el.cullClass === "" ? className : `${el.cullClass} ${className}`;
283
294
  }
284
- // Recurse into children with the (possibly newly inherited) animation context.
285
- if (el.children != null) {
286
- for (const child of el.children) walk(child, ctx);
287
- }
295
+ return true;
288
296
  };
289
297
  for (const root of roots) walk(root, null);
290
298
 
@@ -292,22 +300,28 @@ export function cullElementsOutsideViewBox(
292
300
  }
293
301
 
294
302
  /**
295
- * Step-end `@keyframes` block + class rule that toggles `display: inline`
296
- * during [visStart, visEnd] and `display: none` outside. The 0.001 % gap
303
+ * Step-end `@keyframes` block + class rule that toggles `visibility: visible`
304
+ * during [visStart, visEnd] and `visibility: hidden` outside. The 0.001 % gap
297
305
  * pattern keeps the discrete snap point inside a sliver-thin keyframe pair
298
306
  * regardless of how the animation timing function is configured on the
299
307
  * element.
308
+ *
309
+ * DM-641: toggling `display` here breaks the same way `fv-${i}` did — when
310
+ * a culled element starts the cycle at `display: none` the animation engine
311
+ * never starts ticking and the element stays hidden forever. Using
312
+ * `visibility` keeps the element in the render tree (still skips painting)
313
+ * so the animation runs every cycle.
300
314
  */
301
315
  function buildCullKeyframes(name: string, visStartPct: number, visEndPct: number): string {
302
316
  const startMinus = Math.max(0, visStartPct - 0.001).toFixed(3);
303
317
  const endPlus = Math.min(100, visEndPct + 0.001).toFixed(3);
304
318
  return ` @keyframes ${name} {
305
- 0% { display: none; }
306
- ${startMinus}% { display: none; }
307
- ${visStartPct.toFixed(3)}% { display: inline; }
308
- ${visEndPct.toFixed(3)}% { display: inline; }
309
- ${endPlus}% { display: none; }
310
- 100% { display: none; }
319
+ 0% { visibility: hidden; }
320
+ ${startMinus}% { visibility: hidden; }
321
+ ${visStartPct.toFixed(3)}% { visibility: visible; }
322
+ ${visEndPct.toFixed(3)}% { visibility: visible; }
323
+ ${endPlus}% { visibility: hidden; }
324
+ 100% { visibility: hidden; }
311
325
  }
312
326
  .${name} { animation: ${name} var(--scene-dur) infinite; animation-timing-function: step-end; }`;
313
327
  }