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
|
@@ -85,9 +85,9 @@ describe("composeScrollSvg: basic", () => {
|
|
|
85
85
|
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
86
86
|
expect(svg).toMatch(/@keyframes/);
|
|
87
87
|
// Three stops at 20% (1000/5000), 60% (3000/5000), 100%.
|
|
88
|
-
expect(svg).toMatch(/20\.000% \{ transform:
|
|
89
|
-
expect(svg).toMatch(/60\.000% \{ transform:
|
|
90
|
-
expect(svg).toMatch(/100\.000% \{ transform:
|
|
88
|
+
expect(svg).toMatch(/20\.000% \{ transform: translate3d\(0, -/);
|
|
89
|
+
expect(svg).toMatch(/60\.000% \{ transform: translate3d\(0, -/);
|
|
90
|
+
expect(svg).toMatch(/100\.000% \{ transform: translate3d\(0, -/);
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -111,7 +111,7 @@ describe("composeScrollSvg: composite dimensions", () => {
|
|
|
111
111
|
];
|
|
112
112
|
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600, axis: "x" });
|
|
113
113
|
expect(svg).toMatch(/<svg [^>]*viewBox="0 0 2000 600"/); // 1200 + 800 = 2000
|
|
114
|
-
expect(svg).toMatch(/transform:
|
|
114
|
+
expect(svg).toMatch(/transform: translate3d\(-/);
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -124,8 +124,11 @@ describe("composeScrollSvg: capture stacking", () => {
|
|
|
124
124
|
makeSeg(800, 1000, 3000, [el({ tag: "p", x: 0, y: 0, text: "second" })]),
|
|
125
125
|
];
|
|
126
126
|
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// Each segment may carry a `class="<animClass>-sN"` for DM-642
|
|
128
|
+
// per-segment culling, but its `transform="translate(...)"` is still
|
|
129
|
+
// present on the wrapper.
|
|
130
|
+
expect(svg).toMatch(/<g (?:class="[^"]+" )?transform="translate\(0 0\)">/);
|
|
131
|
+
expect(svg).toMatch(/<g (?:class="[^"]+" )?transform="translate\(0 800\)">/);
|
|
129
132
|
});
|
|
130
133
|
|
|
131
134
|
it("axis=x stacks horizontally", () => {
|
|
@@ -134,8 +137,8 @@ describe("composeScrollSvg: capture stacking", () => {
|
|
|
134
137
|
{ scrollX: 400, scrollY: 0, segmentStartMs: 1000, segmentEndMs: 3000, tree: [el({ tag: "div", x: 0, y: 0 })], diffFromPrev: null },
|
|
135
138
|
];
|
|
136
139
|
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600, axis: "x" });
|
|
137
|
-
expect(svg).
|
|
138
|
-
expect(svg).
|
|
140
|
+
expect(svg).toMatch(/<g (?:class="[^"]+" )?transform="translate\(0 0\)">/);
|
|
141
|
+
expect(svg).toMatch(/<g (?:class="[^"]+" )?transform="translate\(400 0\)">/);
|
|
139
142
|
});
|
|
140
143
|
});
|
|
141
144
|
|
|
@@ -150,9 +153,9 @@ describe("composeScrollSvg: keyframe timing", () => {
|
|
|
150
153
|
];
|
|
151
154
|
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
152
155
|
// 1000/5000 = 20%, 4000/5000 = 80%, 5000/5000 = 100%.
|
|
153
|
-
expect(svg).toMatch(/20\.000% \{ transform:
|
|
154
|
-
expect(svg).toMatch(/80\.000% \{ transform:
|
|
155
|
-
expect(svg).toMatch(/100\.000% \{ transform:
|
|
156
|
+
expect(svg).toMatch(/20\.000% \{ transform: translate3d\(0, -0\.000px, 0\);/);
|
|
157
|
+
expect(svg).toMatch(/80\.000% \{ transform: translate3d\(0, -600\.000px, 0\);/);
|
|
158
|
+
expect(svg).toMatch(/100\.000% \{ transform: translate3d\(0, -1200\.000px, 0\);/);
|
|
156
159
|
});
|
|
157
160
|
|
|
158
161
|
it("animation-duration matches the total scene time", () => {
|
|
@@ -175,12 +178,287 @@ describe("composeScrollSvg: min-offset normalisation", () => {
|
|
|
175
178
|
makeSeg(1500, 1000, 3000),
|
|
176
179
|
];
|
|
177
180
|
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
178
|
-
// First capture sits at composite y=0; second at y=1000.
|
|
179
|
-
|
|
180
|
-
expect(svg).
|
|
181
|
+
// First capture sits at composite y=0; second at y=1000. Per-segment
|
|
182
|
+
// wrappers may carry a DM-642 cull class — the transform attr survives.
|
|
183
|
+
expect(svg).toMatch(/<g (?:class="[^"]+" )?transform="translate\(0 0\)">/);
|
|
184
|
+
expect(svg).toMatch(/<g (?:class="[^"]+" )?transform="translate\(0 1000\)">/);
|
|
181
185
|
// Keyframes: first stop at offset 0, last at 1000.
|
|
182
|
-
expect(svg).toMatch(/0\.000% \{ transform:
|
|
183
|
-
expect(svg).toMatch(/100\.000% \{ transform:
|
|
186
|
+
expect(svg).toMatch(/0\.000% \{ transform: translate3d\(0, -0\.000px, 0\);/);
|
|
187
|
+
expect(svg).toMatch(/100\.000% \{ transform: translate3d\(0, -1000\.000px, 0\);/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── Runtime perf optimisations (DM-642) ────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe("composeScrollSvg: runtime-perf optimisations", () => {
|
|
194
|
+
it("hints GPU compositing via translate3d + will-change", () => {
|
|
195
|
+
const segs: ScrollSegmentCapture[] = [
|
|
196
|
+
makeSeg(0, 0, 1000),
|
|
197
|
+
makeSeg(800, 1000, 2000),
|
|
198
|
+
];
|
|
199
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
200
|
+
// The animated transform must use translate3d so Chromium promotes the
|
|
201
|
+
// group to a compositing layer.
|
|
202
|
+
expect(svg).toContain("translate3d(");
|
|
203
|
+
expect(svg).not.toMatch(/transform: translateY\(/);
|
|
204
|
+
// `will-change: transform` is set on the animation class.
|
|
205
|
+
expect(svg).toMatch(/animation: scrl-\w+ [\d.]+s linear infinite; will-change: transform;/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("emits per-segment visibility-keyframes that hide off-window segments", () => {
|
|
209
|
+
// Five segments at uniform 600px (=VH) spacing means each segment is
|
|
210
|
+
// visible for at most ~2 segment-windows; the others are visibility:
|
|
211
|
+
// hidden (DM-641 — was `display: none`, broke infinite anims).
|
|
212
|
+
const segs: ScrollSegmentCapture[] = [
|
|
213
|
+
makeSeg(0, 0, 1000),
|
|
214
|
+
makeSeg(600, 1000, 2000),
|
|
215
|
+
makeSeg(1200, 2000, 3000),
|
|
216
|
+
makeSeg(1800, 3000, 4000),
|
|
217
|
+
makeSeg(2400, 4000, 5000),
|
|
218
|
+
];
|
|
219
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
220
|
+
// At least one segment is gated by a `visibility: hidden` keyframe.
|
|
221
|
+
expect(svg).toMatch(/visibility: hidden/);
|
|
222
|
+
// Must NOT use `display` toggles (DM-641).
|
|
223
|
+
expect(svg).not.toMatch(/display: none/);
|
|
224
|
+
expect(svg).not.toMatch(/display: inline/);
|
|
225
|
+
// One `-sN` class per segment-with-cull.
|
|
226
|
+
const cullClasses = svg.match(/scrl-\w+-s\d+/g) ?? [];
|
|
227
|
+
expect(cullClasses.length).toBeGreaterThan(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not cull a segment that's visible for the entire cycle", () => {
|
|
231
|
+
// Two-segment input where the second has scrollY very close to first =>
|
|
232
|
+
// each segment stays in the viewport across the whole cycle. No cull
|
|
233
|
+
// class should be emitted in that case.
|
|
234
|
+
const segs: ScrollSegmentCapture[] = [
|
|
235
|
+
makeSeg(0, 0, 1000),
|
|
236
|
+
makeSeg(100, 1000, 2000), // 100 < VH (600) so segment 0 never leaves viewport
|
|
237
|
+
];
|
|
238
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
239
|
+
// Segment 0 keeps a plain (un-classed) wrapper because it's always visible.
|
|
240
|
+
expect(svg).toMatch(/<g transform="translate\(0 0\)">/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── Chunked compositing layers (DM-648) ────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe("composeScrollSvg: chunked composite layers", () => {
|
|
247
|
+
function manySegs(count: number, viewportH: number = 600): ScrollSegmentCapture[] {
|
|
248
|
+
const out: ScrollSegmentCapture[] = [];
|
|
249
|
+
for (let i = 0; i < count; i++) {
|
|
250
|
+
out.push(makeSeg(i * viewportH, i * 1000, (i + 1) * 1000));
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
it("8 segments produce 4 chunk wrappers, 2 inner segments each (default chunkSize=2)", () => {
|
|
256
|
+
const svg = composeScrollSvg(manySegs(8), { viewportW: 800, viewportH: 600 });
|
|
257
|
+
const chunkOpens = (svg.match(/<g style="will-change: transform">/g) ?? []).length;
|
|
258
|
+
expect(chunkOpens).toBe(4);
|
|
259
|
+
// Each chunk should have 2 inner segment wrappers; total inner segments = 8.
|
|
260
|
+
const innerSegs = (svg.match(/<g (?:class="[^"]+" )?transform="translate\(0 \d+\)">/g) ?? []).length;
|
|
261
|
+
expect(innerSegs).toBe(8);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("2 segments stay in a single chunk wrapper", () => {
|
|
265
|
+
const svg = composeScrollSvg(manySegs(2), { viewportW: 800, viewportH: 600 });
|
|
266
|
+
const chunkOpens = (svg.match(/<g style="will-change: transform">/g) ?? []).length;
|
|
267
|
+
expect(chunkOpens).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("chunkSize: 1 puts each segment on its own layer", () => {
|
|
271
|
+
const svg = composeScrollSvg(manySegs(5), { viewportW: 800, viewportH: 600, chunkSize: 1 });
|
|
272
|
+
const chunkOpens = (svg.match(/<g style="will-change: transform">/g) ?? []).length;
|
|
273
|
+
expect(chunkOpens).toBe(5);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("trailing partial chunk: 7 segments at chunkSize=2 produce 4 chunks (3 full + 1 of size 1)", () => {
|
|
277
|
+
const svg = composeScrollSvg(manySegs(7), { viewportW: 800, viewportH: 600 });
|
|
278
|
+
const chunkOpens = (svg.match(/<g style="will-change: transform">/g) ?? []).length;
|
|
279
|
+
expect(chunkOpens).toBe(4);
|
|
280
|
+
const innerSegs = (svg.match(/<g (?:class="[^"]+" )?transform="translate\(0 \d+\)">/g) ?? []).length;
|
|
281
|
+
expect(innerSegs).toBe(7);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("DM-642 cull classes still emit on inner segments inside chunks", () => {
|
|
285
|
+
// 5 segments at uniform 600px spacing → segments 2, 3 should be off-window
|
|
286
|
+
// most of the cycle and get cull classes.
|
|
287
|
+
const svg = composeScrollSvg(manySegs(5), { viewportW: 800, viewportH: 600 });
|
|
288
|
+
// At least one `scrl-…-sN` cull class appears, and at least one
|
|
289
|
+
// appears INSIDE a chunk wrapper (substring after the first chunk
|
|
290
|
+
// open before that chunk's close).
|
|
291
|
+
expect(svg).toMatch(/<g style="will-change: transform">[\s\S]*<g class="scrl-\w+-s\d+"/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("chunkSize must be a positive integer", () => {
|
|
295
|
+
const segs = manySegs(2);
|
|
296
|
+
expect(() => composeScrollSvg(segs, { viewportW: 800, viewportH: 600, chunkSize: 0 })).toThrow(/positive integer/);
|
|
297
|
+
expect(() => composeScrollSvg(segs, { viewportW: 800, viewportH: 600, chunkSize: -1 })).toThrow(/positive integer/);
|
|
298
|
+
expect(() => composeScrollSvg(segs, { viewportW: 800, viewportH: 600, chunkSize: 1.5 })).toThrow(/positive integer/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("fixed overlay is emitted OUTSIDE chunk wrappers (after the scrolling composite)", () => {
|
|
302
|
+
const headerFixed = el({
|
|
303
|
+
tag: "header", x: 0, y: 0, width: 800, height: 60,
|
|
304
|
+
text: "BRAND",
|
|
305
|
+
styles: { position: "fixed" } as CapturedElement["styles"],
|
|
306
|
+
});
|
|
307
|
+
const segs: ScrollSegmentCapture[] = [
|
|
308
|
+
makeSeg(0, 0, 1000, [headerFixed, el({ tag: "section", x: 0, y: 100 })]),
|
|
309
|
+
makeSeg(600, 1000, 2000, [headerFixed, el({ tag: "section", x: 0, y: 100 })]),
|
|
310
|
+
];
|
|
311
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
312
|
+
// BRAND appears in the overlay; it must come AFTER the last
|
|
313
|
+
// will-change chunk wrapper.
|
|
314
|
+
const lastChunkIdx = svg.lastIndexOf('style="will-change: transform"');
|
|
315
|
+
const brandIdx = svg.indexOf("BRAND");
|
|
316
|
+
expect(lastChunkIdx).toBeGreaterThan(-1);
|
|
317
|
+
expect(brandIdx).toBeGreaterThan(lastChunkIdx);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── Fixed element hoisting (DM-643) ────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe("composeScrollSvg: position:fixed hoisting", () => {
|
|
324
|
+
function headerEl(): CapturedElement {
|
|
325
|
+
return el({
|
|
326
|
+
tag: "header", x: 0, y: 0, width: 800, height: 60,
|
|
327
|
+
text: "BRAND",
|
|
328
|
+
styles: { position: "fixed" } as CapturedElement["styles"],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
it("emits a fixed-position element exactly once even across many segments", () => {
|
|
333
|
+
// Three segment captures, each containing the same fixed header in addition
|
|
334
|
+
// to its own body content. Without hoisting, the SVG would contain three
|
|
335
|
+
// copies of the header — one per segment, each pinned to its segment's
|
|
336
|
+
// composite-y offset — and the consumer would see the header re-appear
|
|
337
|
+
// every viewport height during scroll.
|
|
338
|
+
const segs: ScrollSegmentCapture[] = [
|
|
339
|
+
makeSeg(0, 0, 1000, [headerEl(), el({ tag: "section", x: 0, y: 100, text: "alpha" })]),
|
|
340
|
+
makeSeg(600, 1000, 2000, [headerEl(), el({ tag: "section", x: 0, y: 100, text: "beta" })]),
|
|
341
|
+
makeSeg(1200, 2000, 3000, [headerEl(), el({ tag: "section", x: 0, y: 100, text: "gamma" })]),
|
|
342
|
+
];
|
|
343
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
344
|
+
// The number of "BRAND" substrings emitted per rendered header is
|
|
345
|
+
// platform-dependent: glyph-path mode (system fonts present, e.g. macOS)
|
|
346
|
+
// emits the text twice — once as `aria-label` and once as <title> — while
|
|
347
|
+
// the `<text>` fallback (no host system fonts, e.g. Linux CI) emits it
|
|
348
|
+
// once. So pin the invariant to "rendered exactly once after hoisting"
|
|
349
|
+
// rather than to a fixed literal count: derive the per-render hit count
|
|
350
|
+
// from a single-segment baseline and assert the three-segment compose
|
|
351
|
+
// matches it. Without the fix this would be 3× the baseline (one rendered
|
|
352
|
+
// header per segment).
|
|
353
|
+
const baselineSvg = composeScrollSvg(
|
|
354
|
+
[makeSeg(0, 0, 1000, [headerEl(), el({ tag: "section", x: 0, y: 100, text: "alpha" })])],
|
|
355
|
+
{ viewportW: 800, viewportH: 600 },
|
|
356
|
+
);
|
|
357
|
+
const perRenderHits = (baselineSvg.match(/BRAND/g) ?? []).length;
|
|
358
|
+
const brandHits = (svg.match(/BRAND/g) ?? []).length;
|
|
359
|
+
expect(perRenderHits, "single-segment baseline should render the header at least once").toBeGreaterThan(0);
|
|
360
|
+
expect(brandHits, "header should render exactly once (not once per segment) after hoisting").toBe(perRenderHits);
|
|
361
|
+
// Per-segment bodies still appear.
|
|
362
|
+
expect(svg).toContain("alpha");
|
|
363
|
+
expect(svg).toContain("beta");
|
|
364
|
+
expect(svg).toContain("gamma");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("renders the hoisted fixed element after the scrolling composite (sits above it)", () => {
|
|
368
|
+
// The fixed overlay should appear in the SVG source AFTER the scrolling
|
|
369
|
+
// `<g class="${animClass}">` block — that's the consumer's z-order.
|
|
370
|
+
const segs: ScrollSegmentCapture[] = [
|
|
371
|
+
makeSeg(0, 0, 1000, [headerEl(), el({ tag: "section", x: 0, y: 100 })]),
|
|
372
|
+
makeSeg(600, 1000, 2000, [headerEl(), el({ tag: "section", x: 0, y: 100 })]),
|
|
373
|
+
];
|
|
374
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
375
|
+
const scrollOpenIdx = svg.indexOf('class="scrl-');
|
|
376
|
+
const fixedIdx = svg.indexOf("BRAND");
|
|
377
|
+
expect(scrollOpenIdx).toBeGreaterThan(-1);
|
|
378
|
+
expect(fixedIdx).toBeGreaterThan(scrollOpenIdx);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("is a no-op when no segment contains a position:fixed element", () => {
|
|
382
|
+
const segs: ScrollSegmentCapture[] = [
|
|
383
|
+
makeSeg(0, 0, 1000, [el({ tag: "section", x: 0, y: 100 })]),
|
|
384
|
+
makeSeg(600, 1000, 2000, [el({ tag: "section", x: 0, y: 100 })]),
|
|
385
|
+
];
|
|
386
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
387
|
+
// No `fix-` id prefix (used only by the hoisted overlay).
|
|
388
|
+
expect(svg).not.toContain('id="fix-');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ── Sticky element hoisting (DM-647) ───────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
describe("composeScrollSvg: position:sticky hoisting", () => {
|
|
395
|
+
it("mid-scroll sticky: element renders inline for in-flow segs AND on overlay for stuck segs (with visibility class)", () => {
|
|
396
|
+
// Seg 0–1: nav is in flow (y decreases). Seg 2–3: stuck at y=0.
|
|
397
|
+
const stickyNav = (y: number): CapturedElement => el({
|
|
398
|
+
tag: "nav", x: 0, y, width: 800, height: 50, text: "STICKY-NAV",
|
|
399
|
+
styles: { position: "sticky" } as CapturedElement["styles"],
|
|
400
|
+
});
|
|
401
|
+
const body = (y: number): CapturedElement => el({
|
|
402
|
+
tag: "section", x: 0, y: 60, text: "main",
|
|
403
|
+
});
|
|
404
|
+
const segs: ScrollSegmentCapture[] = [
|
|
405
|
+
makeSeg(0, 0, 1000, [stickyNav(300), body(0)]),
|
|
406
|
+
makeSeg(600, 1000, 2000, [stickyNav(150), body(0)]),
|
|
407
|
+
makeSeg(1200, 2000, 3000, [stickyNav(0), body(0)]),
|
|
408
|
+
makeSeg(1800, 3000, 4000, [stickyNav(0), body(0)]),
|
|
409
|
+
];
|
|
410
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
411
|
+
// The nav text appears at MOST twice from segs 0–1 (inline inside the
|
|
412
|
+
// composite) plus twice from a single overlay render (aria-label +
|
|
413
|
+
// <title>) — total ≥ 6 substring hits.
|
|
414
|
+
// Critically, an overlay class for the sticky window has been
|
|
415
|
+
// emitted: a `scrl-…-kN` keyframe is present.
|
|
416
|
+
expect(svg).toMatch(/@keyframes scrl-\w+-k\d+ \{/);
|
|
417
|
+
expect(svg).toMatch(/\.scrl-\w+-k\d+ \{ animation:/);
|
|
418
|
+
// DM-641: no display-toggles slipped in via the sticky path.
|
|
419
|
+
expect(svg).not.toMatch(/display: none/);
|
|
420
|
+
expect(svg).not.toMatch(/display: inline/);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("mixed fixed + sticky in one fixture: both overlays emit, no regression on DM-643", () => {
|
|
424
|
+
const fixedHeader = (): CapturedElement => el({
|
|
425
|
+
tag: "header", x: 0, y: 0, width: 800, height: 60, text: "FIXED-HEADER",
|
|
426
|
+
styles: { position: "fixed" } as CapturedElement["styles"],
|
|
427
|
+
});
|
|
428
|
+
const stickyNav = (y: number): CapturedElement => el({
|
|
429
|
+
tag: "nav", x: 0, y, width: 800, height: 50, text: "STICKY-NAV",
|
|
430
|
+
styles: { position: "sticky" } as CapturedElement["styles"],
|
|
431
|
+
});
|
|
432
|
+
const segs: ScrollSegmentCapture[] = [
|
|
433
|
+
makeSeg(0, 0, 1000, [fixedHeader(), stickyNav(300), el({ tag: "main", x: 0, y: 100 })]),
|
|
434
|
+
makeSeg(600, 1000, 2000, [fixedHeader(), stickyNav(150), el({ tag: "main", x: 0, y: 100 })]),
|
|
435
|
+
makeSeg(1200, 2000, 3000, [fixedHeader(), stickyNav(60), el({ tag: "main", x: 0, y: 100 })]),
|
|
436
|
+
makeSeg(1800, 3000, 4000, [fixedHeader(), stickyNav(60), el({ tag: "main", x: 0, y: 100 })]),
|
|
437
|
+
];
|
|
438
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
439
|
+
// DM-643 fixed-header path: present once.
|
|
440
|
+
expect(svg).toContain("FIXED-HEADER");
|
|
441
|
+
// DM-647 sticky overlay: keyframe class emitted.
|
|
442
|
+
expect(svg).toMatch(/@keyframes scrl-\w+-k\d+ \{/);
|
|
443
|
+
// The fixed-header BRAND substring (`fix-` prefix) and the sticky-nav
|
|
444
|
+
// prefix `stk0-` both end up inside the overlay block AFTER the
|
|
445
|
+
// last chunk wrapper (DM-648 didn't move overlays into chunks).
|
|
446
|
+
const lastChunkIdx = svg.lastIndexOf('style="will-change: transform"');
|
|
447
|
+
expect(svg.lastIndexOf("FIXED-HEADER")).toBeGreaterThan(lastChunkIdx);
|
|
448
|
+
// The sticky nav appears INLINE in the in-flow segments (early in the
|
|
449
|
+
// SVG, inside chunks) AND on the overlay (after all chunks). The
|
|
450
|
+
// overlay occurrence is what we're checking for here.
|
|
451
|
+
expect(svg.lastIndexOf("STICKY-NAV")).toBeGreaterThan(lastChunkIdx);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("no sticky elements: no `scrl-…-k` keyframes, no `stk0-` markup", () => {
|
|
455
|
+
const segs: ScrollSegmentCapture[] = [
|
|
456
|
+
makeSeg(0, 0, 1000, [el({ tag: "main", x: 0, y: 0 })]),
|
|
457
|
+
makeSeg(600, 1000, 2000, [el({ tag: "main", x: 0, y: 0 })]),
|
|
458
|
+
];
|
|
459
|
+
const svg = composeScrollSvg(segs, { viewportW: 800, viewportH: 600 });
|
|
460
|
+
expect(svg).not.toMatch(/scrl-\w+-k\d+/);
|
|
461
|
+
expect(svg).not.toContain('id="stk0-');
|
|
184
462
|
});
|
|
185
463
|
});
|
|
186
464
|
|
|
@@ -197,3 +475,31 @@ describe("composeScrollSvg: background colour", () => {
|
|
|
197
475
|
expect(svg).toContain('fill="#ffffff"');
|
|
198
476
|
});
|
|
199
477
|
});
|
|
478
|
+
|
|
479
|
+
// ── DM-652: embedded-font render mode ──────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
describe("composeScrollSvg: renderText option", () => {
|
|
482
|
+
// No webfonts registered in this fixture, so all three modes fall
|
|
483
|
+
// through to glyph-path emission — none of them produce a `@font-face`
|
|
484
|
+
// block. These tests pin the SVG-shape invariants rather than the
|
|
485
|
+
// observable mode-switching (which needs a fixture with a registered
|
|
486
|
+
// webfont; covered indirectly via the real-world suite).
|
|
487
|
+
it("default (renderText omitted): SVG renders without crashing", () => {
|
|
488
|
+
const svg = composeScrollSvg([makeSeg(0, 0, 0)], { viewportW: 800, viewportH: 600 });
|
|
489
|
+
expect(svg).toMatch(/<svg [^>]*viewBox="0 0 800 600"/);
|
|
490
|
+
expect(svg).not.toContain("@font-face");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("renderText: 'paths' explicit: no @font-face block emitted", () => {
|
|
494
|
+
const svg = composeScrollSvg([makeSeg(0, 0, 0)], { viewportW: 800, viewportH: 600, renderText: "paths" });
|
|
495
|
+
expect(svg).not.toContain("@font-face");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("renderText: 'embedded-font' with no registered webfonts: still no @font-face (system fonts stay on paths fallback)", () => {
|
|
499
|
+
// No registerWebfont call → the embedded-font branch in renderTextAsPath
|
|
500
|
+
// can't find a webfont buffer, so the fixture text falls through to
|
|
501
|
+
// glyph-path rendering exactly as if renderText="paths" were set.
|
|
502
|
+
const svg = composeScrollSvg([makeSeg(0, 0, 0)], { viewportW: 800, viewportH: 600, renderText: "embedded-font" });
|
|
503
|
+
expect(svg).not.toContain("@font-face");
|
|
504
|
+
});
|
|
505
|
+
});
|