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
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractStickyWindows } from "./hoist-sticky.js";
3
+ function el(partial, position = "static") {
4
+ return {
5
+ text: partial.text ?? "",
6
+ width: partial.width ?? 100,
7
+ height: partial.height ?? 20,
8
+ children: partial.children ?? [],
9
+ ...partial,
10
+ styles: {
11
+ position,
12
+ ...(partial.styles ?? {}),
13
+ },
14
+ };
15
+ }
16
+ // Each segment-tree below is just a list of top-level siblings; production
17
+ // trees go several levels deep but `extractStickyWindows` walks recursively
18
+ // so shallow trees exercise the same logic.
19
+ describe("extractStickyWindows", () => {
20
+ it("never stuck: sticky element whose y strictly decreases across segments → left inline, no overlays", () => {
21
+ const seg0 = [el({ tag: "div", x: 0, y: 0 }), el({ tag: "nav", x: 0, y: 200, width: 800, height: 60 }, "sticky")];
22
+ const seg1 = [el({ tag: "div", x: 0, y: 0 }), el({ tag: "nav", x: 0, y: 100, width: 800, height: 60 }, "sticky")];
23
+ const seg2 = [el({ tag: "div", x: 0, y: 0 }), el({ tag: "nav", x: 0, y: 0, width: 800, height: 60 }, "sticky")];
24
+ const r = extractStickyWindows([seg0, seg1, seg2]);
25
+ expect(r.overlays).toEqual([]);
26
+ // Trees are unmodified (returned by reference when no strikes apply).
27
+ expect(r.stripped[0]).toBe(seg0);
28
+ expect(r.stripped[1]).toBe(seg1);
29
+ expect(r.stripped[2]).toBe(seg2);
30
+ });
31
+ it("always stuck: sticky element at the same y in every segment → one overlay spanning all, stripped from every tree", () => {
32
+ const stickyEl = (text) => el({ tag: "nav", text, x: 0, y: 0, width: 800, height: 60 }, "sticky");
33
+ const seg0 = [el({ tag: "div", x: 0, y: 0 }), stickyEl("a")];
34
+ const seg1 = [el({ tag: "div", x: 0, y: 0 }), stickyEl("b")];
35
+ const seg2 = [el({ tag: "div", x: 0, y: 0 }), stickyEl("c")];
36
+ const r = extractStickyWindows([seg0, seg1, seg2]);
37
+ expect(r.overlays).toHaveLength(1);
38
+ expect(r.overlays[0].firstSegmentIdx).toBe(0);
39
+ expect(r.overlays[0].lastSegmentIdx).toBe(2);
40
+ // Each segment has the sticky element stripped — only the original div remains.
41
+ for (const tree of r.stripped) {
42
+ expect(tree).toHaveLength(1);
43
+ expect(tree[0].tag).toBe("div");
44
+ }
45
+ });
46
+ it("stuck mid-scroll: sticky scrolls for first K segments then stays → inline for K, overlay for the rest", () => {
47
+ // Seg 0..1: y decreases (in-flow). Seg 2..4: y stays at 60 (stuck).
48
+ const stick = (y) => el({ tag: "nav", x: 0, y, width: 800, height: 50 }, "sticky");
49
+ const seg0 = [el({ tag: "main", x: 0, y: 0 }), stick(300)];
50
+ const seg1 = [el({ tag: "main", x: 0, y: 0 }), stick(180)];
51
+ const seg2 = [el({ tag: "main", x: 0, y: 0 }), stick(60)];
52
+ const seg3 = [el({ tag: "main", x: 0, y: 0 }), stick(60)];
53
+ const seg4 = [el({ tag: "main", x: 0, y: 0 }), stick(60)];
54
+ const r = extractStickyWindows([seg0, seg1, seg2, seg3, seg4]);
55
+ expect(r.overlays).toHaveLength(1);
56
+ expect(r.overlays[0].firstSegmentIdx).toBe(2);
57
+ expect(r.overlays[0].lastSegmentIdx).toBe(4);
58
+ // Segs 0–1: still contain the nav (it's in flow, still scrolling).
59
+ expect(r.stripped[0].find((n) => n.tag === "nav")).not.toBeUndefined();
60
+ expect(r.stripped[1].find((n) => n.tag === "nav")).not.toBeUndefined();
61
+ // Segs 2–4: nav has been stripped (it's now on the overlay).
62
+ expect(r.stripped[2].find((n) => n.tag === "nav")).toBeUndefined();
63
+ expect(r.stripped[3].find((n) => n.tag === "nav")).toBeUndefined();
64
+ expect(r.stripped[4].find((n) => n.tag === "nav")).toBeUndefined();
65
+ });
66
+ it("two stuck windows: sticks, un-sticks, sticks again → two overlay entries", () => {
67
+ // Seg 0: in-flow (y=400). Seg 1–2: stuck (y=0). Seg 3: in-flow (y=200).
68
+ // Seg 4–5: stuck again (y=0).
69
+ const stick = (y) => el({ tag: "nav", x: 0, y, width: 800, height: 50 }, "sticky");
70
+ const trees = [
71
+ [stick(400)],
72
+ [stick(0)],
73
+ [stick(0)],
74
+ [stick(200)],
75
+ [stick(0)],
76
+ [stick(0)],
77
+ ];
78
+ const r = extractStickyWindows(trees);
79
+ expect(r.overlays).toHaveLength(2);
80
+ expect(r.overlays.map((o) => [o.firstSegmentIdx, o.lastSegmentIdx])).toEqual([[1, 2], [4, 5]]);
81
+ // Seg 0, 3 retain the nav (in-flow).
82
+ expect(r.stripped[0]).toHaveLength(1);
83
+ expect(r.stripped[3]).toHaveLength(1);
84
+ // Seg 1, 2, 4, 5 have the nav stripped.
85
+ expect(r.stripped[1]).toHaveLength(0);
86
+ expect(r.stripped[2]).toHaveLength(0);
87
+ expect(r.stripped[4]).toHaveLength(0);
88
+ expect(r.stripped[5]).toHaveLength(0);
89
+ });
90
+ it("single-segment 'stuck' (length 1 run) does NOT qualify — element stays inline", () => {
91
+ // A sticky element captured at the same y in only ONE segment is not
92
+ // stuck (could just be a momentary zero-velocity sample); needs ≥ 2
93
+ // consecutive same-y segments to count.
94
+ const stick = (y) => el({ tag: "nav", x: 0, y, width: 800, height: 50 }, "sticky");
95
+ const r = extractStickyWindows([
96
+ [stick(100)],
97
+ [stick(50)],
98
+ [stick(50)], // start of a potential run (but only 1 segment so far)
99
+ [stick(0)], // run breaks here
100
+ ]);
101
+ // segs 1–2 are at the same y → that's a stuck window of length 2. Length-1
102
+ // "windows" wouldn't qualify, but 2 does. This case demonstrates the ≥2
103
+ // threshold actually catches its boundary.
104
+ expect(r.overlays).toHaveLength(1);
105
+ expect(r.overlays[0].firstSegmentIdx).toBe(1);
106
+ expect(r.overlays[0].lastSegmentIdx).toBe(2);
107
+ });
108
+ it("non-sticky elements are untouched even at constant y", () => {
109
+ // A normal element at the same x/y in every segment should NOT be hoisted —
110
+ // it's not sticky.
111
+ const trees = [
112
+ [el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "static")],
113
+ [el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "static")],
114
+ [el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "static")],
115
+ ];
116
+ const r = extractStickyWindows(trees);
117
+ expect(r.overlays).toEqual([]);
118
+ });
119
+ it("doesn't mutate the input trees", () => {
120
+ const stickyChild = el({ tag: "nav", x: 0, y: 0, width: 800, height: 50 }, "sticky");
121
+ const body0 = el({
122
+ tag: "body", x: 0, y: 0, width: 800, height: 600,
123
+ children: [el({ tag: "main", x: 0, y: 60 }), stickyChild],
124
+ });
125
+ const body1 = el({
126
+ tag: "body", x: 0, y: 0, width: 800, height: 600,
127
+ children: [el({ tag: "main", x: 0, y: 60 }), stickyChild],
128
+ });
129
+ const tree0 = [body0];
130
+ const tree1 = [body1];
131
+ const originalChildren0 = body0.children;
132
+ extractStickyWindows([tree0, tree1]);
133
+ expect(body0.children).toBe(originalChildren0);
134
+ expect(body0.children).toHaveLength(2);
135
+ });
136
+ it("matches sticky elements across segments by (tag, size, path-in-tree)", () => {
137
+ // Same tag/size, but different path-in-tree → treated as DIFFERENT
138
+ // logical elements and NOT collapsed.
139
+ const stick1 = (y) => el({ tag: "nav", x: 0, y, width: 800, height: 50 }, "sticky");
140
+ const trees = [
141
+ // Sticky nav as the 0th child.
142
+ [stick1(0), el({ tag: "main", x: 0, y: 50 })],
143
+ // Sticky nav as the 1st child (different path → different identity).
144
+ [el({ tag: "main", x: 0, y: 50 }), stick1(0)],
145
+ [stick1(0), el({ tag: "main", x: 0, y: 50 })],
146
+ ];
147
+ const r = extractStickyWindows(trees);
148
+ // Neither nav matches across segments (path-in-tree differs), so no
149
+ // stuck windows form. The two seg-0 / seg-2 occurrences would
150
+ // otherwise be at the same y but only 2 segments are involved and
151
+ // they're not consecutive — so no run.
152
+ expect(r.overlays).toEqual([]);
153
+ });
154
+ });
@@ -1,11 +1,17 @@
1
1
  /**
2
- * Scroll-pattern grammar — parser (DM-605, design from DM-604).
2
+ * Scroll-pattern grammar — parser.
3
3
  *
4
4
  * Pure parser. Input string → AST. Does NOT evaluate `selector(...)` against
5
- * a DOM, nor execute the scroll plan — that's DM-607 (scroll executor).
5
+ * a DOM, nor execute the scroll plan — that's the responsibility of
6
+ * `src/scroll/executor.ts`.
6
7
  *
7
- * Grammar reference: `dm604-scroll-grammar.txt` (signed-off draft attached
8
- * to DM-604). Key rules in brief:
8
+ * **Canonical grammar reference: `docs/37-scroll-pattern-grammar.md`.** That
9
+ * doc carries the full EBNF, axis-resolution rules, `until` semantics, speed
10
+ * / duration / easing handling, and worked examples. When changing this file
11
+ * (tokenizer, parser, AST shape, error reporting) update doc 37 in the same
12
+ * commit so the surface stays in sync.
13
+ *
14
+ * Key rules in brief:
9
15
  *
10
16
  * pattern = top-segment { "," top-segment }
11
17
  * top-segment = "(" pattern ")" [until]
@@ -42,8 +48,19 @@ export interface ScrollAction {
42
48
  /** Explicit direction prefix; absent when the action's direction is implied. */
43
49
  direction?: "up" | "down" | "left" | "right";
44
50
  target: ScrollTarget;
45
- /** From the `/<duration>` suffix, in milliseconds. Absent → speed-derived. */
51
+ /**
52
+ * From the `/<duration>` suffix, in milliseconds. Absent → derived from
53
+ * speed (either `speedPxPerSec` below, or the executor's inherited
54
+ * `defaultSpeed`). Mutually exclusive with `speedPxPerSec` at parse time.
55
+ */
46
56
  durationMs?: number;
57
+ /**
58
+ * From the `@<n>pxps` suffix, in pixels-per-second. Overrides the
59
+ * executor's inherited `defaultSpeed` for THIS action only. Mutually
60
+ * exclusive with `durationMs` at parse time — the grammar accepts either
61
+ * `/<duration>` OR `@<speed>` on a single action, not both.
62
+ */
63
+ speedPxPerSec?: number;
47
64
  /** From the `[easing-name]` suffix. Absent → default (`ease-out`). */
48
65
  easing?: Easing;
49
66
  }
@@ -1,11 +1,17 @@
1
1
  /**
2
- * Scroll-pattern grammar — parser (DM-605, design from DM-604).
2
+ * Scroll-pattern grammar — parser.
3
3
  *
4
4
  * Pure parser. Input string → AST. Does NOT evaluate `selector(...)` against
5
- * a DOM, nor execute the scroll plan — that's DM-607 (scroll executor).
5
+ * a DOM, nor execute the scroll plan — that's the responsibility of
6
+ * `src/scroll/executor.ts`.
6
7
  *
7
- * Grammar reference: `dm604-scroll-grammar.txt` (signed-off draft attached
8
- * to DM-604). Key rules in brief:
8
+ * **Canonical grammar reference: `docs/37-scroll-pattern-grammar.md`.** That
9
+ * doc carries the full EBNF, axis-resolution rules, `until` semantics, speed
10
+ * / duration / easing handling, and worked examples. When changing this file
11
+ * (tokenizer, parser, AST shape, error reporting) update doc 37 in the same
12
+ * commit so the surface stays in sync.
13
+ *
14
+ * Key rules in brief:
9
15
  *
10
16
  * pattern = top-segment { "," top-segment }
11
17
  * top-segment = "(" pattern ")" [until]
@@ -77,6 +83,11 @@ function tokenize(source) {
77
83
  i++;
78
84
  continue;
79
85
  }
86
+ if (ch === "@") {
87
+ tokens.push({ kind: "at", text: "@", start: i, end: i + 1 });
88
+ i++;
89
+ continue;
90
+ }
80
91
  if (ch === "[") {
81
92
  tokens.push({ kind: "lbracket", text: "[", start: i, end: i + 1 });
82
93
  i++;
@@ -146,6 +157,11 @@ function tokenize(source) {
146
157
  else if (unit === "s" || unit === "ms") {
147
158
  tokens.push({ kind: "duration", text: numText + unit, num, unit, start: numStart, end: i });
148
159
  }
160
+ else if (unit === "pxps") {
161
+ // <n>pxps — per-action speed (pixels per second). Only valid after
162
+ // the `@` speed-suffix marker; the parser enforces that placement.
163
+ tokens.push({ kind: "speed", text: numText + unit, num, unit, start: numStart, end: i });
164
+ }
149
165
  else if (unit === "") {
150
166
  tokens.push({ kind: "number", text: numText, num, start: numStart, end: i });
151
167
  }
@@ -258,16 +274,33 @@ class Parser {
258
274
  }
259
275
  const target = this.parseScrollTarget();
260
276
  let durationMs;
277
+ let speedPxPerSec;
261
278
  let easing;
279
+ // Duration suffix (`/<duration>`) and speed suffix (`@<n>pxps`) are
280
+ // mutually exclusive on a single action — either pin the action's time,
281
+ // or pin its speed, not both. The order in which they're tried doesn't
282
+ // matter since the OTHER branch fires an explicit error if it's seen.
262
283
  if (this.peek().kind === "slash") {
263
284
  this.consume("slash");
264
285
  const dur = this.consume("duration");
265
286
  durationMs = durationToMs(dur);
287
+ if (this.peek().kind === "at") {
288
+ const conflict = this.peek();
289
+ throw new ScrollPatternError("Scroll action cannot carry both a `/<duration>` and `@<speed>` suffix", this.source, conflict.start);
290
+ }
291
+ }
292
+ else if (this.peek().kind === "at") {
293
+ this.consume("at");
294
+ const sp = this.consume("speed");
295
+ if (!(sp.num != null && sp.num > 0)) {
296
+ throw new ScrollPatternError(`Speed must be a positive number of px/s (got "${sp.text}")`, this.source, sp.start);
297
+ }
298
+ speedPxPerSec = sp.num;
266
299
  }
267
300
  if (this.peek().kind === "lbracket") {
268
301
  easing = this.parseEasingSuffix();
269
302
  }
270
- return { kind: "scroll", direction, target, durationMs, easing };
303
+ return { kind: "scroll", direction, target, durationMs, speedPxPerSec, easing };
271
304
  }
272
305
  parseScrollTarget() {
273
306
  const tok = this.peek();
@@ -437,11 +470,26 @@ function durationToMs(t) {
437
470
  return t.num;
438
471
  throw new ScrollPatternError(`Unknown duration unit "${t.unit}"`, "", t.start);
439
472
  }
473
+ function lengthTokenUnit(t) {
474
+ // The tokenizer at `tokenize()` only emits `kind: "length"` when unit is
475
+ // exactly "px" or "%" — this check defends against tokenizer drift so a
476
+ // future unit-set change can't silently slip through as an `unknown` unit.
477
+ if (t.unit !== "px" && t.unit !== "%") {
478
+ throw new ScrollPatternError(`Internal: length token has non-length unit "${String(t.unit)}"`, "", t.start);
479
+ }
480
+ return t.unit;
481
+ }
482
+ function lengthTokenNum(t) {
483
+ if (typeof t.num !== "number" || !Number.isFinite(t.num)) {
484
+ throw new ScrollPatternError(`Internal: length token missing numeric value`, "", t.start);
485
+ }
486
+ return t.num;
487
+ }
440
488
  function lengthTokenToLength(t) {
441
- return { value: t.num, unit: t.unit };
489
+ return { value: lengthTokenNum(t), unit: lengthTokenUnit(t) };
442
490
  }
443
491
  function lengthTokenToSignedLength(t, sign) {
444
- return { sign, value: t.num, unit: t.unit };
492
+ return { sign, value: lengthTokenNum(t), unit: lengthTokenUnit(t) };
445
493
  }
446
494
  // ── Public entry point ─────────────────────────────────────────────────────
447
495
  /**
@@ -157,6 +157,48 @@ describe("parseScrollPattern: /<duration> suffix", () => {
157
157
  expect(() => parseScrollPattern("720px/")).toThrow(ScrollPatternError);
158
158
  });
159
159
  });
160
+ // ── Speed suffix @<n>pxps ────────────────────────────────────────────────────
161
+ describe("parseScrollPattern: @<speed> suffix", () => {
162
+ it("attaches to delta", () => {
163
+ const a = onlyAction("720px@800pxps");
164
+ expect(a.speedPxPerSec).toBe(800);
165
+ expect(a.durationMs).toBeUndefined();
166
+ });
167
+ it("attaches to absolute target with direction prefix", () => {
168
+ const a = onlyAction("down:bottom@1200pxps");
169
+ expect(a.speedPxPerSec).toBe(1200);
170
+ expect(a.target).toEqual({ kind: "absolute", anchor: { kind: "named", name: "bottom" }, offsets: [] });
171
+ expect(a.direction).toBe("down");
172
+ });
173
+ it("attaches to anchor with offset arithmetic", () => {
174
+ const a = onlyAction("up:top + 200px@500pxps");
175
+ expect(a.speedPxPerSec).toBe(500);
176
+ expect(a.durationMs).toBeUndefined();
177
+ });
178
+ it("speed and easing combine", () => {
179
+ const a = onlyAction("720px@600pxps[ease-in]");
180
+ expect(a.speedPxPerSec).toBe(600);
181
+ expect(a.easing).toEqual({ kind: "named", name: "ease-in" });
182
+ });
183
+ it("decimal speed accepted", () => {
184
+ const a = onlyAction("720px@1500.5pxps");
185
+ expect(a.speedPxPerSec).toBe(1500.5);
186
+ });
187
+ it("@<speed> AND /<duration> on same action throws", () => {
188
+ expect(() => parseScrollPattern("720px/3s@600pxps")).toThrow(ScrollPatternError);
189
+ expect(() => parseScrollPattern("720px@600pxps/3s")).toThrow(ScrollPatternError);
190
+ });
191
+ it("missing speed after @ throws", () => {
192
+ expect(() => parseScrollPattern("720px@")).toThrow(ScrollPatternError);
193
+ });
194
+ it("zero or negative speed rejected", () => {
195
+ expect(() => parseScrollPattern("720px@0pxps")).toThrow(ScrollPatternError);
196
+ });
197
+ it("unknown unit after @ throws", () => {
198
+ // `@800px` is invalid — only `pxps` is accepted after `@`.
199
+ expect(() => parseScrollPattern("720px@800px")).toThrow(ScrollPatternError);
200
+ });
201
+ });
160
202
  // ── Easing suffix [...] ────────────────────────────────────────────────────
161
203
  describe("parseScrollPattern: [easing] suffix", () => {
162
204
  it("named easing", () => {
@@ -379,8 +421,13 @@ describe("parseScrollPattern: error reporting", () => {
379
421
  }
380
422
  });
381
423
  it("unexpected character", () => {
424
+ // `#` is not a token in the scroll grammar — must surface as
425
+ // "Unexpected character". (`720px@3s` USED to land here before DM-669
426
+ // wired `@` as the speed-suffix marker; it now errors with the more
427
+ // helpful `Expected speed, got duration` message — see the
428
+ // `@<speed> suffix` describe block above.)
382
429
  try {
383
- parseScrollPattern("720px@3s");
430
+ parseScrollPattern("720px#");
384
431
  throw new Error("should have thrown");
385
432
  }
386
433
  catch (e) {
@@ -57,6 +57,16 @@ export declare function parseSiblings(src: string): ParsedNode[];
57
57
  * including `id`, those two defs would collapse into one and `<use href="#g198"/>`
58
58
  * references would break — see frame-merge.test.ts "preserves distinct ids
59
59
  * for paths with identical d".
60
+ *
61
+ * Resource references (`clip-path`, `mask`, `filter`) are included because
62
+ * they point at frame-scoped defs whose IDs are unique per frame. Two
63
+ * wrappers with different clip-path ids are NOT the same logical element
64
+ * even if every other attribute matches — merging them would (a) drop one
65
+ * frame's clip-path entirely, and (b) push that frame's unique content
66
+ * into the merge bucket of a different frame, which then sorts as one
67
+ * earlier-firstFrame group and lets later-frame siblings (e.g. solid
68
+ * background rects) emit *after* the content they should sit underneath.
69
+ * See frame-merge.test.ts "keeps per-frame body bg ordered before content".
60
70
  */
61
71
  export declare function structuralFingerprint(n: ParsedNode): string;
62
72
  export interface MergeResult {
@@ -135,12 +135,25 @@ function parseAttrs(s) {
135
135
  * including `id`, those two defs would collapse into one and `<use href="#g198"/>`
136
136
  * references would break — see frame-merge.test.ts "preserves distinct ids
137
137
  * for paths with identical d".
138
+ *
139
+ * Resource references (`clip-path`, `mask`, `filter`) are included because
140
+ * they point at frame-scoped defs whose IDs are unique per frame. Two
141
+ * wrappers with different clip-path ids are NOT the same logical element
142
+ * even if every other attribute matches — merging them would (a) drop one
143
+ * frame's clip-path entirely, and (b) push that frame's unique content
144
+ * into the merge bucket of a different frame, which then sorts as one
145
+ * earlier-firstFrame group and lets later-frame siblings (e.g. solid
146
+ * background rects) emit *after* the content they should sit underneath.
147
+ * See frame-merge.test.ts "keeps per-frame body bg ordered before content".
138
148
  */
139
149
  export function structuralFingerprint(n) {
140
150
  if (n.kind === "text")
141
151
  return `T:${n.text}`;
142
152
  const a = n.attrs ?? {};
143
- const keyAttrs = ["id", "transform", "href", "x", "y", "d", "width", "height", "fill", "role", "class"];
153
+ const keyAttrs = [
154
+ "id", "transform", "href", "x", "y", "d", "width", "height",
155
+ "fill", "role", "class", "clip-path", "mask", "filter",
156
+ ];
144
157
  const pairs = keyAttrs.filter((k) => a[k] != null).map((k) => `${k}=${a[k]}`);
145
158
  return `${n.tag}|${pairs.join(",")}`;
146
159
  }
@@ -355,9 +368,14 @@ function buildTimelineKeyframes(name, visibleFrames, timing) {
355
368
  if (rangeStart != null && prev != null)
356
369
  ranges.push([rangeStart, prev]);
357
370
  // Build keyframe stops. Use step-end so opacity switches instantly.
358
- // DM-599: emit `display: none/inline` alongside opacity so the browser
359
- // can skip painting elements that aren't currently in their visible-frames
360
- // window. Both properties snap together under step-end timing.
371
+ // DM-599: emit a paint-skip toggle alongside opacity so the browser can
372
+ // skip painting elements that aren't currently in their visible-frames
373
+ // window. DM-641: this was `display: none/inline`, which broke for any
374
+ // element whose 0% keyframe is `display: none` — Chromium parks the
375
+ // animation when the element drops out of the render tree and never
376
+ // ticks the keyframe that would bring it back. Switching to
377
+ // `visibility` keeps the element rendered (still no paint) so the
378
+ // animation continues across cycles.
361
379
  const stops = []; // (pct, opacity)
362
380
  stops.push([0, 0]);
363
381
  for (const [lo, hi] of ranges) {
@@ -372,6 +390,6 @@ function buildTimelineKeyframes(name, visibleFrames, timing) {
372
390
  stops.push([Math.min(100, offPct + 0.001), 0]);
373
391
  }
374
392
  stops.push([100, 0]);
375
- const lines = stops.map(([p, o]) => ` ${p.toFixed(3)}% { opacity: ${o}; display: ${o === 1 ? "inline" : "none"}; }`);
393
+ const lines = stops.map(([p, o]) => ` ${p.toFixed(3)}% { opacity: ${o}; visibility: ${o === 1 ? "visible" : "hidden"}; }`);
376
394
  return ` @keyframes ${name} {\n${lines.join("\n")}\n }\n .${name} { animation: ${name} var(--scene-dur) infinite; animation-timing-function: step-end; }`;
377
395
  }
@@ -74,6 +74,34 @@ describe("frame-merge: mergeFrames", () => {
74
74
  const kfCount = (r.css.match(/@keyframes /g) ?? []).length;
75
75
  expect(kfCount).toBe(2);
76
76
  });
77
+ it("keeps per-frame body bg ordered before content (DM-644)", () => {
78
+ // Each frame has a body bg <rect> at top-level position 0 followed by
79
+ // a body-content wrapper <g clip-path="url(#vN-ov)"> at position 1.
80
+ // The bg fill changes per frame (so each bg is its own group); each
81
+ // wrapper carries a frame-scoped clip-path id (so each wrapper is also
82
+ // its own group). The merge must emit them so for *every* frame the
83
+ // bg precedes that frame's content in document order; otherwise the
84
+ // later-frame bgs paint over the earlier-frame content slot, hiding
85
+ // it during the later frame's time window.
86
+ const frames = [
87
+ `<rect width="100" height="100" fill="rgb(10,0,20)"/><g clip-path="url(#v0-ov)"><use href="#tA"/></g>`,
88
+ `<rect width="100" height="100" fill="rgb(0,30,0)"/><g clip-path="url(#v1-ov)"><use href="#tB"/></g>`,
89
+ `<rect width="100" height="100" fill="rgb(16,0,32)"/><g clip-path="url(#v2-ov)"><use href="#tC"/></g>`,
90
+ ];
91
+ const r = mergeFrames(frames, simpleTiming(3));
92
+ // For each frame, the bg fill must occur in the merged output before
93
+ // that frame's clip-path id.
94
+ const findIdx = (needle) => r.merged.indexOf(needle);
95
+ const fills = ["rgb(10,0,20)", "rgb(0,30,0)", "rgb(16,0,32)"];
96
+ const clips = ["url(#v0-ov)", "url(#v1-ov)", "url(#v2-ov)"];
97
+ for (let i = 0; i < 3; i++) {
98
+ const bgIdx = findIdx(fills[i]);
99
+ const contentIdx = findIdx(clips[i]);
100
+ expect(bgIdx, `frame ${i}: bg should appear`).toBeGreaterThanOrEqual(0);
101
+ expect(contentIdx, `frame ${i}: content wrapper should appear`).toBeGreaterThanOrEqual(0);
102
+ expect(bgIdx < contentIdx, `frame ${i}: bg fill ${fills[i]} (idx ${bgIdx}) must precede content clip ${clips[i]} (idx ${contentIdx})`).toBe(true);
103
+ }
104
+ });
77
105
  it("merges wrappers with same fingerprint but differing children (typing case)", () => {
78
106
  // Simulates capturing a text input as text is typed: the wrapper <g> at the
79
107
  // same transform is logically the same text field; children accumulate.
@@ -124,6 +152,23 @@ describe("frame-merge: structuralFingerprint", () => {
124
152
  expect(r.merged).toContain(`id="g113"`);
125
153
  expect(r.merged).toContain(`id="g198"`);
126
154
  });
155
+ it("does not match wrappers with different clip-path ids", () => {
156
+ // DM-644: per-frame body wrappers carry frame-scoped clip-path ids
157
+ // (`url(#v0-ov1)`, `url(#v1-ov1)`, ...). If clip-path is omitted from
158
+ // the fingerprint they merge into one group, and frame-1+ siblings
159
+ // (e.g. body bg rects) end up sorted *after* the merged content.
160
+ const a = parseSiblings(`<g clip-path="url(#v0-ov1)"/>`)[0];
161
+ const b = parseSiblings(`<g clip-path="url(#v1-ov1)"/>`)[0];
162
+ expect(structuralFingerprint(a)).not.toBe(structuralFingerprint(b));
163
+ });
164
+ it("does not match wrappers with different mask/filter refs", () => {
165
+ const mA = parseSiblings(`<g mask="url(#mA)"/>`)[0];
166
+ const mB = parseSiblings(`<g mask="url(#mB)"/>`)[0];
167
+ expect(structuralFingerprint(mA)).not.toBe(structuralFingerprint(mB));
168
+ const fA = parseSiblings(`<g filter="url(#fA)"/>`)[0];
169
+ const fB = parseSiblings(`<g filter="url(#fB)"/>`)[0];
170
+ expect(structuralFingerprint(fA)).not.toBe(structuralFingerprint(fB));
171
+ });
127
172
  it("does not apply visibility classes to defs children", () => {
128
173
  // Regression: glyph defs introduced in later frames were being given
129
174
  // visibility classes (e.g. class="t1"). Then <use href="#gX"/> from a
@@ -172,7 +172,7 @@ export function entriesOfKind(diff, ...kinds) {
172
172
  * per-element keyframes.
173
173
  */
174
174
  export function dominantTranslate(diff) {
175
- const movers = diff.entries.filter((e) => e.kind === "translated");
175
+ const movers = diff.entries.filter((e) => e.kind === "translated" && typeof e.dx === "number" && typeof e.dy === "number");
176
176
  if (movers.length === 0)
177
177
  return null;
178
178
  // Bucket by rounded (dx, dy) and pick the largest bucket.
@@ -221,12 +221,26 @@ export function cullElementsOutsideViewBox(tree, viewportW, viewportH, animation
221
221
  // share a class so we emit one keyframes block per unique interval.
222
222
  const windowToClass = new Map();
223
223
  const cssBlocks = [];
224
+ // Walk bottom-up: recurse FIRST, then decide the element's own cull. A
225
+ // parent can only safely inherit `displayNone` if every descendant is
226
+ // also fully hidden — children of an `overflow: visible` parent paint
227
+ // outside the parent's bbox and stay in-viewport even when the parent's
228
+ // bbox is entirely above/below the viewBox (DM-650: NYT body is
229
+ // height: 100vh, so at scrollY > 0 the body bbox sits exactly above
230
+ // the viewport but its descendants are in-viewport). Returns true if
231
+ // any element in the subtree (including `el` itself) is visible.
224
232
  const walk = (el, inheritedCtx) => {
225
233
  const { ctx, decision } = decideForElement(el, viewportW, viewportH, inheritedCtx, animsById);
226
- if (decision.alwaysHidden) {
234
+ let anyDescendantVisible = false;
235
+ if (el.children != null) {
236
+ for (const child of el.children) {
237
+ if (walk(child, ctx))
238
+ anyDescendantVisible = true;
239
+ }
240
+ }
241
+ if (decision.alwaysHidden && !anyDescendantVisible) {
227
242
  el.displayNone = true;
228
- // Children inherit displayNone implicitly; don't recurse.
229
- return;
243
+ return false;
230
244
  }
231
245
  if (decision.visStartPct != null && decision.visEndPct != null) {
232
246
  const key = `${r3(decision.visStartPct)},${r3(decision.visEndPct)}`;
@@ -236,37 +250,37 @@ export function cullElementsOutsideViewBox(tree, viewportW, viewportH, animation
236
250
  windowToClass.set(key, className);
237
251
  cssBlocks.push(buildCullKeyframes(className, decision.visStartPct, decision.visEndPct));
238
252
  }
239
- // Merge with any existing cullClass (shouldn't happen — each pass
240
- // assigns at most one — but be defensive).
241
253
  el.cullClass = el.cullClass == null || el.cullClass === "" ? className : `${el.cullClass} ${className}`;
242
254
  }
243
- // Recurse into children with the (possibly newly inherited) animation context.
244
- if (el.children != null) {
245
- for (const child of el.children)
246
- walk(child, ctx);
247
- }
255
+ return true;
248
256
  };
249
257
  for (const root of roots)
250
258
  walk(root, null);
251
259
  return { css: cssBlocks.join("\n") };
252
260
  }
253
261
  /**
254
- * Step-end `@keyframes` block + class rule that toggles `display: inline`
255
- * during [visStart, visEnd] and `display: none` outside. The 0.001 % gap
262
+ * Step-end `@keyframes` block + class rule that toggles `visibility: visible`
263
+ * during [visStart, visEnd] and `visibility: hidden` outside. The 0.001 % gap
256
264
  * pattern keeps the discrete snap point inside a sliver-thin keyframe pair
257
265
  * regardless of how the animation timing function is configured on the
258
266
  * element.
267
+ *
268
+ * DM-641: toggling `display` here breaks the same way `fv-${i}` did — when
269
+ * a culled element starts the cycle at `display: none` the animation engine
270
+ * never starts ticking and the element stays hidden forever. Using
271
+ * `visibility` keeps the element in the render tree (still skips painting)
272
+ * so the animation runs every cycle.
259
273
  */
260
274
  function buildCullKeyframes(name, visStartPct, visEndPct) {
261
275
  const startMinus = Math.max(0, visStartPct - 0.001).toFixed(3);
262
276
  const endPlus = Math.min(100, visEndPct + 0.001).toFixed(3);
263
277
  return ` @keyframes ${name} {
264
- 0% { display: none; }
265
- ${startMinus}% { display: none; }
266
- ${visStartPct.toFixed(3)}% { display: inline; }
267
- ${visEndPct.toFixed(3)}% { display: inline; }
268
- ${endPlus}% { display: none; }
269
- 100% { display: none; }
278
+ 0% { visibility: hidden; }
279
+ ${startMinus}% { visibility: hidden; }
280
+ ${visStartPct.toFixed(3)}% { visibility: visible; }
281
+ ${visEndPct.toFixed(3)}% { visibility: visible; }
282
+ ${endPlus}% { visibility: hidden; }
283
+ 100% { visibility: hidden; }
270
284
  }
271
285
  .${name} { animation: ${name} var(--scene-dur) infinite; animation-timing-function: step-end; }`;
272
286
  }