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
@@ -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: translateY\(/);
89
- expect(svg).toMatch(/60\.000% \{ transform: translateY\(/);
90
- expect(svg).toMatch(/100\.000% \{ transform: translateY\(/);
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: translateX\(/);
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
- expect(svg).toContain(`<g transform="translate(0 0)">`);
128
- expect(svg).toContain(`<g transform="translate(0 800)">`);
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).toContain(`<g transform="translate(0 0)">`);
138
- expect(svg).toContain(`<g transform="translate(400 0)">`);
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: translateY\(-0\.000px\);/);
154
- expect(svg).toMatch(/80\.000% \{ transform: translateY\(-600\.000px\);/);
155
- expect(svg).toMatch(/100\.000% \{ transform: translateY\(-1200\.000px\);/);
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
- expect(svg).toContain(`<g transform="translate(0 0)">`);
180
- expect(svg).toContain(`<g transform="translate(0 1000)">`);
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: translateY\(-0\.000px\);/);
183
- expect(svg).toMatch(/100\.000% \{ transform: translateY\(-1000\.000px\);/);
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
+ });