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.
- package/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- 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
|
+
});
|
package/dist/scroll/pattern.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scroll-pattern grammar — parser
|
|
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
|
|
5
|
+
* a DOM, nor execute the scroll plan — that's the responsibility of
|
|
6
|
+
* `src/scroll/executor.ts`.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/scroll/pattern.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scroll-pattern grammar — parser
|
|
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
|
|
5
|
+
* a DOM, nor execute the scroll plan — that's the responsibility of
|
|
6
|
+
* `src/scroll/executor.ts`.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
489
|
+
return { value: lengthTokenNum(t), unit: lengthTokenUnit(t) };
|
|
442
490
|
}
|
|
443
491
|
function lengthTokenToSignedLength(t, sign) {
|
|
444
|
-
return { sign, value: t
|
|
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
|
|
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 = [
|
|
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
|
|
359
|
-
//
|
|
360
|
-
// window.
|
|
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};
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
255
|
-
* during [visStart, visEnd] and `
|
|
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% {
|
|
265
|
-
${startMinus}% {
|
|
266
|
-
${visStartPct.toFixed(3)}% {
|
|
267
|
-
${visEndPct.toFixed(3)}% {
|
|
268
|
-
${endPlus}% {
|
|
269
|
-
100% {
|
|
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
|
}
|