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
@@ -23,6 +23,14 @@
23
23
 
24
24
  import type { ScrollSegmentCapture } from "./executor.js";
25
25
  import { elementTreeToSvg } from "../render/element-tree-to-svg.js";
26
+ import {
27
+ clearEmbeddedFonts,
28
+ getEmbeddedFontFaceCss,
29
+ setRenderTextMode,
30
+ type RenderTextMode,
31
+ } from "../render/text-to-path.js";
32
+ import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
33
+ import { extractStickyWindows, type StickyOverlay } from "./hoist-sticky.js";
26
34
 
27
35
  export interface ScrollComposerOptions {
28
36
  /** Visible viewport width (output SVG width). */
@@ -40,6 +48,31 @@ export interface ScrollComposerOptions {
40
48
  * to source-resolution data URIs. Default 2 (matches `elementTreeToSvg`).
41
49
  */
42
50
  hiDPIFactor?: number;
51
+ /**
52
+ * DM-648: number of consecutive segments to group inside one
53
+ * `<g style="will-change: transform">` chunk wrapper. Each chunk gets its
54
+ * own GPU backing store so tall composites don't blow Chromium's
55
+ * per-layer raster budget (the 1280 × 6015 px apple-desktop-scroll capture
56
+ * at hi-DPI 2 was ~30 MB of raster on a single layer). Default `2` —
57
+ * meaning every two segments share one layer; a 16-segment scroll yields
58
+ * 8 layers. `1` puts each segment on its own layer (more layers, smaller
59
+ * each); higher values trade layer count for layer size. Must be ≥ 1.
60
+ * See `docs/36-scroll-composite-layer-chunking.md`.
61
+ */
62
+ chunkSize?: number;
63
+ /**
64
+ * DM-652: text rendering mode. Default `"embedded-font"` emits `<text>`
65
+ * elements with a single `@font-face` per used webfont — much faster
66
+ * in WebKit (≈1.84× perf gain + 4.5× smaller file on text-heavy
67
+ * fixtures per DM-651) and visually equivalent in Chromium. `"paths"`
68
+ * is the legacy Chromium-faithful mode that emits `<use href="#gN">`
69
+ * references to glyph path defs, useful when the consumer needs
70
+ * per-pixel parity with the Chromium capture (e.g. visual-regression
71
+ * diffing against the live page). System-font runs and bidi /
72
+ * fallback-chain runs always stay in `"paths"` mode regardless of
73
+ * this flag (MVP scope).
74
+ */
75
+ renderText?: RenderTextMode;
43
76
  }
44
77
 
45
78
  const DEFAULT_BG = "#0d1117";
@@ -62,6 +95,23 @@ export function composeScrollSvg(
62
95
  const VH = opts.viewportH;
63
96
  const bg = opts.bgColor ?? DEFAULT_BG;
64
97
  const hiDPIFactor = opts.hiDPIFactor ?? 2;
98
+ const chunkSize = opts.chunkSize ?? 2;
99
+ if (chunkSize < 1 || !Number.isInteger(chunkSize)) {
100
+ throw new Error(`composeScrollSvg: chunkSize must be a positive integer, got ${chunkSize}`);
101
+ }
102
+
103
+ // DM-652: arm the text-render lifecycle. Default is "embedded-font" —
104
+ // the per-segment text renderer emits `<text>` runs against a single
105
+ // @font-face per used webfont, ~2× faster in WebKit and ~5× smaller
106
+ // SVG payload than the legacy glyph-path output, visually equivalent
107
+ // in Chromium. Callers that need per-pixel Chromium parity (e.g.
108
+ // visual-regression diffing) opt back into "paths" explicitly. State
109
+ // is module-global so every segment's `elementTreeToSvg` call shares
110
+ // the same `@font-face` registry; we collect everything into one
111
+ // top-level <style> block at the bottom of this function.
112
+ const renderTextMode: RenderTextMode = opts.renderText ?? "embedded-font";
113
+ clearEmbeddedFonts();
114
+ setRenderTextMode(renderTextMode);
65
115
 
66
116
  // ── Total scene duration ──
67
117
  // The last segment's endMs is the cycle length. For a single-segment input,
@@ -80,22 +130,175 @@ export function composeScrollSvg(
80
130
  const compositeH = axis === "y" ? (maxPos - minPos) + VH : VH;
81
131
  const compositeW = axis === "x" ? (maxPos - minPos) + W : W;
82
132
 
133
+ const animClass = `scrl-${Math.random().toString(36).slice(2, 8)}`;
134
+
135
+ // ── Split each capture into "scrolling" + "fixed" subtrees ──
136
+ // DM-643: `position: fixed` elements (site headers, cookie banners, etc.)
137
+ // appear at the same viewport coordinates in every capture. If we leave
138
+ // them inside the per-segment subtrees, the composer offsets each copy by
139
+ // that segment's scrollY and the scrolling composite carries them past the
140
+ // viewport — so the consumer sees the header "scroll" once per segment.
141
+ // Strip the fixed subtrees from every segment and hoist them onto a single
142
+ // viewport-level overlay below.
143
+ const fixedStripped: typeof segments[number]["tree"][] = [];
144
+ const perSegFixed: typeof segments[number]["tree"][] = [];
145
+ for (const seg of segments) {
146
+ const { stripped, fixed } = extractFixedSubtrees(seg.tree);
147
+ fixedStripped.push(stripped);
148
+ perSegFixed.push(fixed);
149
+ }
150
+ const fixedOverlay = dedupeFixedAcrossSegments(perSegFixed);
151
+
152
+ // ── Sticky overlays (DM-647) ──
153
+ // For each `position: sticky` element, find the runs of consecutive
154
+ // segments where it stays at the same viewport-y (stuck windows) and
155
+ // hoist each run onto the same overlay layer as the fixed overlay. The
156
+ // element stays inline in segments where it's still scrolling.
157
+ const { stripped: strippedTrees, overlays: stickyOverlays } = extractStickyWindows(fixedStripped);
158
+
159
+ // ── Per-segment visibility windows (DM-642) ──
160
+ // Each segment K is anchored at scroll-y = offsetK. Reading the keyframe
161
+ // schedule, find the % of cycle at which the composite's scroll-y reaches
162
+ // (offsetK - VH) [segment K enters viewport from below] and (offsetK + VH)
163
+ // [segment K leaves viewport off the top]. Outside that window the segment
164
+ // is `visibility: hidden` so the browser skips painting it. With ~8 segments
165
+ // for an apple.com-style page only ~2 segments are visible at any moment;
166
+ // hiding the other ~6 cuts per-frame paint cost dramatically.
167
+ //
168
+ // DM-641: we use `visibility` (not `display`) for the same reason the
169
+ // animator does — an element whose 0% keyframe is `display: none`
170
+ // never enters Chromium's render tree, so the animation engine never
171
+ // ticks the keyframe that would bring it back in.
172
+ // The full-cycle anchors come from `dedupedStops` below; build a temporary
173
+ // helper here that mirrors the same data shape.
174
+ const segOffsets: number[] = segments.map((s) =>
175
+ (axis === "y" ? s.scrollY : s.scrollX) - minPos,
176
+ );
177
+ const cycleStops: Array<{ pct: number; offset: number }> = [];
178
+ cycleStops.push({ pct: 0, offset: segOffsets[0] });
179
+ for (let i = 0; i < segments.length; i++) {
180
+ const endPct = (segments[i].segmentEndMs / totalMs) * 100;
181
+ cycleStops.push({ pct: endPct, offset: segOffsets[i] });
182
+ }
183
+ function pctAtScrollY(targetY: number, mode: "first" | "last"): number {
184
+ // Linear interpolation across cycleStops to find when scroll-y crosses
185
+ // targetY. With monotonic ascending scroll-y, "first" returns the first
186
+ // crossing (entering); "last" returns the last crossing (leaving).
187
+ // Out-of-range clamps to 0 / 100.
188
+ let hit = mode === "first" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
189
+ for (let s = 0; s + 1 < cycleStops.length; s++) {
190
+ const a = cycleStops[s], b = cycleStops[s + 1];
191
+ const lo = Math.min(a.offset, b.offset);
192
+ const hi = Math.max(a.offset, b.offset);
193
+ if (targetY < lo - 0.001 || targetY > hi + 0.001) continue;
194
+ const span = b.offset - a.offset;
195
+ const t = span === 0 ? 0 : (targetY - a.offset) / span;
196
+ const pct = a.pct + (b.pct - a.pct) * t;
197
+ if (mode === "first" && pct < hit) hit = pct;
198
+ if (mode === "last" && pct > hit) hit = pct;
199
+ }
200
+ if (!isFinite(hit)) return mode === "first" ? 0 : 100;
201
+ return Math.max(0, Math.min(100, hit));
202
+ }
203
+
83
204
  // ── Render each capture's content at its position offset ──
84
205
  const captureGroups: string[] = [];
206
+ const segmentCullCss: string[] = [];
85
207
  for (let i = 0; i < segments.length; i++) {
86
208
  const seg = segments[i];
87
- const offset = (axis === "y" ? seg.scrollY : seg.scrollX) - minPos;
88
- const inner = elementTreeToSvg(seg.tree, W, VH, `seg${i}-`, true, hiDPIFactor);
209
+ const offset = segOffsets[i];
210
+ const inner = elementTreeToSvg(strippedTrees[i], W, VH, `seg${i}-`, true, hiDPIFactor);
89
211
  const tx = axis === "x" ? offset : 0;
90
212
  const ty = axis === "y" ? offset : 0;
91
- captureGroups.push(
92
- ` <g transform="translate(${tx} ${ty})">` +
213
+ // Visibility window: visible while scroll-y is in the rasterisation
214
+ // window for this segment. The strict mathematical bound is
215
+ // `offset ± VH` (segment content can intersect viewport), but using
216
+ // exactly that triggers a Chromium compositor pop: the segment is
217
+ // `visibility: hidden` until the moment its content first enters the
218
+ // viewport, then `visible` snaps it on — but the GPU has zero time to
219
+ // rasterise the segment before its first frame of "should be visible"
220
+ // content is displayed, so for one or two frames the segment paints
221
+ // empty while raster catches up. Users see "content pops in at the
222
+ // viewport bottom" (DM-668 — reproducible on the NYT-desktop-scroll
223
+ // fixture at the s5→s6 boundary, ~26.3% of cycle).
224
+ //
225
+ // Fix: make each segment visible one extra `dim` (viewport-height /
226
+ // -width) earlier on enter and later on leave. Browsers get a full
227
+ // viewport-height of scroll time to rasterise before the segment's
228
+ // content actually needs to paint, and the segment stays rasterised
229
+ // a viewport longer after it leaves so a rapid scroll reversal also
230
+ // hits a warm GPU layer. Peak segments-visible-at-once roughly
231
+ // doubles (2 → ~4 worst case), which doesn't materially affect file
232
+ // size (the wrappers are hidden, not omitted) but does increase
233
+ // resident raster memory by ~one extra viewport per layer chunk —
234
+ // acceptable on every device we've profiled.
235
+ const dim = axis === "y" ? VH : W;
236
+ const rasterBuffer = dim;
237
+ const enterPct = pctAtScrollY(offset - dim - rasterBuffer, "first");
238
+ const leavePct = pctAtScrollY(offset + dim + rasterBuffer, "last");
239
+ const fullyVisible = enterPct <= 0 && leavePct >= 100;
240
+ if (fullyVisible) {
241
+ captureGroups.push(
242
+ ` <g transform="translate(${tx} ${ty})">` +
243
+ `<svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
244
+ inner +
245
+ `</svg>` +
246
+ `</g>`,
247
+ );
248
+ } else {
249
+ const cls = `${animClass}-s${i}`;
250
+ // step-end so the segment snaps in/out at the boundary, no fractional
251
+ // opacity that would force the browser to keep compositing it.
252
+ segmentCullCss.push(
253
+ ` @keyframes ${cls} { 0% { visibility: hidden } ${Math.max(0, enterPct - 0.001).toFixed(3)}% { visibility: hidden } ${enterPct.toFixed(3)}% { visibility: visible } ${leavePct.toFixed(3)}% { visibility: visible } ${Math.min(100, leavePct + 0.001).toFixed(3)}% { visibility: hidden } 100% { visibility: hidden } }
254
+ .${cls} { animation: ${cls} ${totalSec.toFixed(3)}s infinite; animation-timing-function: step-end; }`,
255
+ );
256
+ captureGroups.push(
257
+ ` <g class="${cls}" transform="translate(${tx} ${ty})">` +
258
+ `<svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
259
+ inner +
260
+ `</svg>` +
261
+ `</g>`,
262
+ );
263
+ }
264
+ }
265
+ // ── Sticky overlay markup + visibility keyframes ──
266
+ // Each overlay is one stuck window for one sticky element. Wrap the
267
+ // rendered subtree in a <g class="…"> whose visibility keyframe shows it
268
+ // only during [firstSegmentStart, lastSegmentEnd] of the cycle and hides
269
+ // it otherwise. DM-641 convention: visibility, never display.
270
+ const stickyMarkup: string[] = [];
271
+ const stickyCullCss: string[] = [];
272
+ for (let i = 0; i < stickyOverlays.length; i++) {
273
+ const o: StickyOverlay = stickyOverlays[i];
274
+ const visStartPct = (segments[o.firstSegmentIdx].segmentStartMs / totalMs) * 100;
275
+ const visEndPct = (segments[o.lastSegmentIdx].segmentEndMs / totalMs) * 100;
276
+ const alwaysVisible = visStartPct <= 0 && visEndPct >= 100;
277
+ const inner = elementTreeToSvg([o.subtree], W, VH, `stk${i}-`, true, hiDPIFactor);
278
+ if (alwaysVisible) {
279
+ stickyMarkup.push(
280
+ `\n <g><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">${inner}</svg></g>`,
281
+ );
282
+ } else {
283
+ const cls = `${animClass}-k${i}`;
284
+ stickyCullCss.push(
285
+ ` @keyframes ${cls} { 0% { visibility: hidden } ${Math.max(0, visStartPct - 0.001).toFixed(3)}% { visibility: hidden } ${visStartPct.toFixed(3)}% { visibility: visible } ${visEndPct.toFixed(3)}% { visibility: visible } ${Math.min(100, visEndPct + 0.001).toFixed(3)}% { visibility: hidden } 100% { visibility: hidden } }
286
+ .${cls} { animation: ${cls} ${totalSec.toFixed(3)}s infinite; animation-timing-function: step-end; }`,
287
+ );
288
+ stickyMarkup.push(
289
+ `\n <g class="${cls}"><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">${inner}</svg></g>`,
290
+ );
291
+ }
292
+ }
293
+
294
+ const fixedMarkup = fixedOverlay.length === 0
295
+ ? ""
296
+ : `\n <g>` +
93
297
  `<svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
94
- inner +
298
+ elementTreeToSvg(fixedOverlay, W, VH, "fix-", true, hiDPIFactor) +
95
299
  `</svg>` +
96
- `</g>`,
97
- );
98
- }
300
+ `</g>`;
301
+ const overlayMarkup = fixedMarkup + stickyMarkup.join("");
99
302
 
100
303
  // ── Keyframes — one stop per segment ──
101
304
  // Each segment anchors a (time-percent, position-offset) pair. Linear
@@ -110,21 +313,51 @@ export function composeScrollSvg(
110
313
  }
111
314
  // Dedupe trivially equal stops to keep CSS small.
112
315
  const dedupedStops = stops.filter((s, i, arr) => i === 0 || !(s.pct === arr[i - 1].pct && s.offset === arr[i - 1].offset));
113
- const animClass = `scrl-${Math.random().toString(36).slice(2, 8)}`;
316
+ const translateFn = axis === "x" ? "X" : "Y";
114
317
  const keyframes = dedupedStops
115
- .map((s) => ` ${s.pct.toFixed(3)}% { transform: translate${axis === "x" ? "X" : "Y"}(-${s.offset.toFixed(3)}px); }`)
318
+ .map((s) =>
319
+ // DM-642: use translate3d to coax the browser into promoting the
320
+ // animated <g> onto its own compositing layer so per-frame motion is
321
+ // a GPU paint rather than a CPU re-rasterisation of every embedded
322
+ // image/path under the composite.
323
+ ` ${s.pct.toFixed(3)}% { transform: translate3d(${translateFn === "X" ? `-${s.offset.toFixed(3)}px, 0` : `0, -${s.offset.toFixed(3)}px`}, 0); }`,
324
+ )
116
325
  .join("\n");
117
326
 
327
+ // ── DM-648: chunked compositing layers ──
328
+ // Group consecutive segment wrappers into chunks of `chunkSize`. Each
329
+ // chunk wrapper carries `style="will-change: transform"` so Chromium gives
330
+ // it its own GPU backing store, letting tall composites stay under the
331
+ // per-layer raster cap. The chunk wrapper itself has NO `transform` (would
332
+ // double-translate); the inner per-segment `translate(0 Y)` still places
333
+ // content. The DM-642 cull classes on each inner segment are untouched.
334
+ const chunks: string[] = [];
335
+ for (let i = 0; i < captureGroups.length; i += chunkSize) {
336
+ const slice = captureGroups.slice(i, i + chunkSize);
337
+ chunks.push(`<g style="will-change: transform">\n${slice.join("\n")}\n </g>`);
338
+ }
339
+
340
+ // DM-652: collect every `@font-face` rule the embedded-font path
341
+ // registered during segment + overlay rendering above, into a single
342
+ // top-level <style> block. Each font appears once (registry is keyed
343
+ // per (family, weight, italic)) — segments referencing the same font
344
+ // collapse onto one rule. Restore the default render mode now that
345
+ // all per-segment rendering has finished.
346
+ const fontFaceCss = getEmbeddedFontFaceCss();
347
+ setRenderTextMode("paths");
348
+
118
349
  // ── Compose final SVG ──
119
350
  return `<?xml version="1.0" encoding="UTF-8"?>
120
351
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}">
121
352
  <defs>
122
353
  <clipPath id="${animClass}-clip"><rect width="${W}" height="${VH}"/></clipPath>
123
354
  <style>
124
- .${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; }
355
+ ${fontFaceCss !== "" ? fontFaceCss + "\n" : ""} .${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; will-change: transform; }
125
356
  @keyframes ${animClass} {
126
357
  ${keyframes}
127
358
  }
359
+ ${segmentCullCss.join("\n")}
360
+ ${stickyCullCss.join("\n")}
128
361
  </style>
129
362
  </defs>
130
363
  <rect width="${W}" height="${VH}" fill="${bg}"/>
@@ -132,9 +365,9 @@ ${keyframes}
132
365
  <g class="${animClass}">
133
366
  <svg x="0" y="0" width="${compositeW}" height="${compositeH}" viewBox="0 0 ${compositeW} ${compositeH}">
134
367
  <rect width="${compositeW}" height="${compositeH}" fill="${bg}"/>
135
- ${captureGroups.join("\n")}
368
+ ${chunks.join("\n ")}
136
369
  </svg>
137
370
  </g>
138
- </g>
371
+ </g>${overlayMarkup}
139
372
  </svg>`;
140
373
  }
@@ -174,6 +174,33 @@ describe("resolveScrollAction", () => {
174
174
  expect(r.scrollDurationMs).toBe(3000);
175
175
  });
176
176
 
177
+ it("explicit @<speed> overrides the executor's default speed", async () => {
178
+ const { query, snap } = fakePageQuery({ maxScrollY: 4000 });
179
+ const action: ScrollAction = {
180
+ kind: "scroll",
181
+ target: { kind: "delta", signedLength: { sign: 1, value: 720, unit: "px" } },
182
+ speedPxPerSec: 600,
183
+ };
184
+ // 720 / 600 * 1000 = 1200. Default speed of 1500 is ignored.
185
+ const r = await resolveScrollAction(action, query, snap, 1500);
186
+ expect(r.scrollDurationMs).toBe(1200);
187
+ });
188
+
189
+ it("@<speed> applies to the actual scroll magnitude, not the requested delta", async () => {
190
+ // Scroll request of 5000 px but page maxScrollY=4000 — actual magnitude
191
+ // is 4000 px after clamping. Speed math uses the clamped magnitude so
192
+ // the action lands at the boundary in the right amount of time.
193
+ const { query, snap } = fakePageQuery({ maxScrollY: 4000, scrollY: 0 });
194
+ const action: ScrollAction = {
195
+ kind: "scroll",
196
+ target: { kind: "delta", signedLength: { sign: 1, value: 5000, unit: "px" } },
197
+ speedPxPerSec: 1000,
198
+ };
199
+ const r = await resolveScrollAction(action, query, snap, 1500);
200
+ expect(r.destY).toBe(4000);
201
+ expect(r.scrollDurationMs).toBe(4000);
202
+ });
203
+
177
204
  it("`down:-100px` reverses direction (same as up:100px)", async () => {
178
205
  const { query, snap } = fakePageQuery({ maxScrollY: 4000, scrollY: 500 });
179
206
  const action: ScrollAction = {
@@ -14,7 +14,9 @@
14
14
  * a single `PageQuery` interface lets a test inject a fake page-state
15
15
  * provider in lieu of the real `Page`.
16
16
  *
17
- * Grammar reference: `dm604-scroll-grammar.txt` (signed-off draft on DM-604).
17
+ * Canonical grammar reference: `docs/37-scroll-pattern-grammar.md`. Keep
18
+ * this executor's axis/direction resolution, speed-derived duration math,
19
+ * and `until`-clause semantics in sync with what doc 37 describes.
18
20
  */
19
21
 
20
22
  import type { Page } from "@playwright/test";
@@ -215,7 +217,14 @@ export async function resolveScrollAction(
215
217
  const destX = axis === "x" ? clamp(snapshot.scrollX + delta, 0, snapshot.maxScrollX) : snapshot.scrollX;
216
218
  const destY = axis === "y" ? clamp(snapshot.scrollY + delta, 0, snapshot.maxScrollY) : snapshot.scrollY;
217
219
  const magnitude = Math.abs(axis === "x" ? destX - snapshot.scrollX : destY - snapshot.scrollY);
218
- const scrollDurationMs = action.durationMs ?? Math.round((magnitude / defaultSpeed) * 1000);
220
+ // Duration resolution priority:
221
+ // 1. Explicit `/<duration>` on the action wins (the user pinned the time).
222
+ // 2. Else, action's `@<speed>` suffix (the user pinned constant speed for
223
+ // this action only — magnitude / actionSpeed yields the duration).
224
+ // 3. Else, executor's inherited `defaultSpeed` (whole-pattern fallback).
225
+ // Cases 2 and 3 are mutually exclusive at parse time; safe to read both.
226
+ const effectiveSpeed = action.speedPxPerSec ?? defaultSpeed;
227
+ const scrollDurationMs = action.durationMs ?? Math.round((magnitude / effectiveSpeed) * 1000);
219
228
  return { axis, destX, destY, scrollDurationMs };
220
229
  }
221
230
 
@@ -527,8 +536,8 @@ function realPageQuery(page: Page, selector: string | null): PageQuery {
527
536
  }));
528
537
  }
529
538
  return page.evaluate((sel) => {
530
- const el = document.querySelector(sel) as HTMLElement | null;
531
- if (el == null) return { maxScrollX: 0, maxScrollY: 0, scrollX: 0, scrollY: 0 };
539
+ const el = document.querySelector(sel);
540
+ if (!(el instanceof HTMLElement)) return { maxScrollX: 0, maxScrollY: 0, scrollX: 0, scrollY: 0 };
532
541
  return {
533
542
  maxScrollX: Math.max(0, el.scrollWidth - el.clientWidth),
534
543
  maxScrollY: Math.max(0, el.scrollHeight - el.clientHeight),
@@ -553,8 +562,8 @@ async function scrollTo(page: Page, selector: string | null, destX: number, dest
553
562
  await page.evaluate(({ x, y }) => window.scrollTo(x, y), { x: destX, y: destY });
554
563
  } else {
555
564
  await page.evaluate(({ sel, x, y }) => {
556
- const el = document.querySelector(sel) as HTMLElement | null;
557
- if (el != null) { el.scrollLeft = x; el.scrollTop = y; }
565
+ const el = document.querySelector(sel);
566
+ if (el instanceof HTMLElement) { el.scrollLeft = x; el.scrollTop = y; }
558
567
  }, { sel: selector, x: destX, y: destY });
559
568
  }
560
569
  // Wait the action's nominal duration plus a small settle for layout.
@@ -570,13 +579,13 @@ async function runPrescroll(page: Page, selector: string | null): Promise<void>
570
579
  await page.waitForTimeout(PRESCROLL_TOP_WAIT_MS);
571
580
  } else {
572
581
  await page.evaluate((sel) => {
573
- const el = document.querySelector(sel) as HTMLElement | null;
574
- if (el != null) el.scrollTop = el.scrollHeight;
582
+ const el = document.querySelector(sel);
583
+ if (el instanceof HTMLElement) el.scrollTop = el.scrollHeight;
575
584
  }, selector);
576
585
  await page.waitForTimeout(PRESCROLL_BOTTOM_WAIT_MS);
577
586
  await page.evaluate((sel) => {
578
- const el = document.querySelector(sel) as HTMLElement | null;
579
- if (el != null) el.scrollTop = 0;
587
+ const el = document.querySelector(sel);
588
+ if (el instanceof HTMLElement) el.scrollTop = 0;
580
589
  }, selector);
581
590
  await page.waitForTimeout(PRESCROLL_TOP_WAIT_MS);
582
591
  }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { CapturedElement } from "../capture/types.js";
4
+ import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
5
+
6
+ function el(
7
+ partial: Partial<CapturedElement> & { tag: string; x: number; y: number },
8
+ position: string = "static",
9
+ ): CapturedElement {
10
+ return {
11
+ text: "",
12
+ width: partial.width ?? 100,
13
+ height: partial.height ?? 20,
14
+ children: partial.children ?? [],
15
+ ...partial,
16
+ styles: {
17
+ position,
18
+ ...(partial.styles ?? {}),
19
+ } as CapturedElement["styles"],
20
+ };
21
+ }
22
+
23
+ describe("extractFixedSubtrees", () => {
24
+ it("returns input unchanged when no fixed elements exist", () => {
25
+ const tree: CapturedElement[] = [
26
+ el({ tag: "div", x: 0, y: 0 }, "static"),
27
+ el({ tag: "p", x: 0, y: 30 }, "relative"),
28
+ ];
29
+ const r = extractFixedSubtrees(tree);
30
+ expect(r.fixed).toEqual([]);
31
+ expect(r.stripped).toBe(tree);
32
+ });
33
+
34
+ it("hoists a top-level position:fixed subtree", () => {
35
+ const header = el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "fixed");
36
+ const main = el({ tag: "main", x: 0, y: 60 }, "static");
37
+ const r = extractFixedSubtrees([header, main]);
38
+ expect(r.fixed).toEqual([header]);
39
+ expect(r.stripped).toHaveLength(1);
40
+ expect(r.stripped[0].tag).toBe("main");
41
+ });
42
+
43
+ it("hoists a fixed subtree nested inside a normal-positioned ancestor", () => {
44
+ const fixedNav = el({ tag: "nav", x: 0, y: 0, width: 800, height: 50 }, "fixed");
45
+ const root = el({
46
+ tag: "body",
47
+ x: 0, y: 0, width: 800, height: 600,
48
+ children: [fixedNav, el({ tag: "section", x: 0, y: 100 }, "static")],
49
+ }, "static");
50
+ const r = extractFixedSubtrees([root]);
51
+ expect(r.fixed).toEqual([fixedNav]);
52
+ // body shallow-copied with the nav stripped out
53
+ expect(r.stripped).toHaveLength(1);
54
+ expect(r.stripped[0]).not.toBe(root);
55
+ expect(r.stripped[0].children).toHaveLength(1);
56
+ expect(r.stripped[0].children![0].tag).toBe("section");
57
+ });
58
+
59
+ it("treats a fixed subtree atomically — inner fixed descendants ride along", () => {
60
+ // Real-world: a fixed header with a fixed dropdown inside it. The
61
+ // dropdown is part of the header's stacking context; we shouldn't try
62
+ // to extract it separately.
63
+ const innerFixed = el({ tag: "div", x: 10, y: 10 }, "fixed");
64
+ const outerFixed = el({
65
+ tag: "header",
66
+ x: 0, y: 0, width: 800, height: 60,
67
+ children: [innerFixed],
68
+ }, "fixed");
69
+ const r = extractFixedSubtrees([outerFixed]);
70
+ expect(r.fixed).toEqual([outerFixed]);
71
+ expect(r.fixed[0].children).toContain(innerFixed);
72
+ });
73
+
74
+ it("does not mutate the input tree when stripping", () => {
75
+ const fixedChild = el({ tag: "div", x: 0, y: 0 }, "fixed");
76
+ const original = el({
77
+ tag: "body",
78
+ x: 0, y: 0,
79
+ children: [fixedChild, el({ tag: "p", x: 0, y: 20 }, "static")],
80
+ }, "static");
81
+ const originalChildren = original.children;
82
+ extractFixedSubtrees([original]);
83
+ // The original wasn't mutated — its children array reference and contents are intact.
84
+ expect(original.children).toBe(originalChildren);
85
+ expect(original.children).toHaveLength(2);
86
+ });
87
+ });
88
+
89
+ describe("dedupeFixedAcrossSegments", () => {
90
+ it("returns first occurrence per (tag, position, size) key", () => {
91
+ const segA: CapturedElement[] = [
92
+ el({ tag: "header", x: 0, y: 0, width: 800, height: 60, text: "v1" }, "fixed"),
93
+ ];
94
+ const segB: CapturedElement[] = [
95
+ el({ tag: "header", x: 0, y: 0, width: 800, height: 60, text: "v2" }, "fixed"),
96
+ ];
97
+ const r = dedupeFixedAcrossSegments([segA, segB]);
98
+ expect(r).toHaveLength(1);
99
+ expect(r[0].text).toBe("v1");
100
+ });
101
+
102
+ it("keeps distinct fixed elements that differ in size or position", () => {
103
+ const segA: CapturedElement[] = [
104
+ el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "fixed"),
105
+ ];
106
+ const segB: CapturedElement[] = [
107
+ el({ tag: "header", x: 0, y: 0, width: 800, height: 60 }, "fixed"),
108
+ el({ tag: "div", x: 0, y: 540, width: 800, height: 60 }, "fixed"),
109
+ ];
110
+ const r = dedupeFixedAcrossSegments([segA, segB]);
111
+ expect(r).toHaveLength(2);
112
+ });
113
+
114
+ it("returns empty when no segment contributed fixed elements", () => {
115
+ expect(dedupeFixedAcrossSegments([[], [], []])).toEqual([]);
116
+ });
117
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Hoist `position: fixed` elements out of scroll-segment trees so the scroll
3
+ * composer doesn't render them once per segment.
4
+ *
5
+ * DM-643: each segment's capture sees the live page at a different scrollY but
6
+ * a `position: fixed` element (e.g. a sticky-feeling site header) always
7
+ * occupies the same viewport coordinates. The scroll composer stacks segments
8
+ * vertically inside a translating composite, so the header from segment 0
9
+ * ends up at composite-y = 0+headerY, the header from segment 1 at composite-y
10
+ * = segment1.scrollY + headerY, and so on — producing the "fixed header
11
+ * repeats every viewport-height" artifact described in the ticket.
12
+ *
13
+ * The fix is to recognise `position: fixed` during composition: strip those
14
+ * subtrees from every per-segment capture and emit them once as a static
15
+ * overlay above the scrolling composite (where they belong visually). They
16
+ * stay in their captured viewport coordinates and never animate.
17
+ */
18
+
19
+ import type { CapturedElement } from "../capture/types.js";
20
+
21
+ /**
22
+ * Walk the tree, remove every subtree rooted at a `position: fixed` element
23
+ * (matching what Chromium reports in `getComputedStyle`), and return the list
24
+ * of removed subtrees. The input tree is shallow-copied at every level
25
+ * touched by the strip so the caller's references aren't mutated.
26
+ *
27
+ * A subtree rooted at a fixed element is hoisted whole: descendants follow
28
+ * their fixed ancestor. Inner fixed descendants of an already-hoisted subtree
29
+ * are NOT separately extracted — they live inside their ancestor's hoisted
30
+ * copy, which is the correct paint result (Chromium would paint them in the
31
+ * same stacking context anyway).
32
+ */
33
+ export function extractFixedSubtrees(tree: CapturedElement[]): {
34
+ stripped: CapturedElement[];
35
+ fixed: CapturedElement[];
36
+ } {
37
+ const fixed: CapturedElement[] = [];
38
+ const stripped = stripFromList(tree, fixed);
39
+ return { stripped, fixed };
40
+ }
41
+
42
+ function stripFromList(
43
+ list: CapturedElement[],
44
+ fixedOut: CapturedElement[],
45
+ ): CapturedElement[] {
46
+ const kept: CapturedElement[] = [];
47
+ for (const node of list) {
48
+ if (node.styles?.position === "fixed") {
49
+ fixedOut.push(node);
50
+ continue;
51
+ }
52
+ if (node.children != null && node.children.length > 0) {
53
+ const newChildren = stripFromList(node.children, fixedOut);
54
+ if (newChildren !== node.children) {
55
+ // At least one descendant was stripped — emit a shallow copy with the
56
+ // pruned child list so the caller's tree isn't mutated.
57
+ kept.push({ ...node, children: newChildren });
58
+ continue;
59
+ }
60
+ }
61
+ kept.push(node);
62
+ }
63
+ // If nothing was removed at this level, return the original list (lets
64
+ // ancestor levels avoid copying too).
65
+ if (kept.length === list.length && fixedOut.length === 0) return list;
66
+ return kept;
67
+ }
68
+
69
+ /**
70
+ * Cross-segment dedupe: capture 0's fixed elements are typically present in
71
+ * every segment too (the live page kept them fixed throughout the scroll).
72
+ * Returns the union of fixed elements seen across all segments, keyed by a
73
+ * coarse identity (tag + viewport position + size) so the same logical
74
+ * element captured at slightly different times (e.g. with a small text-style
75
+ * tweak) doesn't get emitted twice.
76
+ *
77
+ * v1 keeps the FIRST occurrence's subtree. Future work could pick the
78
+ * occurrence whose neighbours match the largest fraction of segments, or
79
+ * crossfade subtle variants — but for fixed site chrome the first occurrence
80
+ * is already what consumers see at scrollY=0.
81
+ */
82
+ export function dedupeFixedAcrossSegments(perSegment: CapturedElement[][]): CapturedElement[] {
83
+ const seen = new Map<string, CapturedElement>();
84
+ for (const seg of perSegment) {
85
+ for (const el of seg) {
86
+ const key = fixedKey(el);
87
+ if (!seen.has(key)) seen.set(key, el);
88
+ }
89
+ }
90
+ return [...seen.values()];
91
+ }
92
+
93
+ function fixedKey(el: CapturedElement): string {
94
+ return `${el.tag}|${Math.round(el.x)},${Math.round(el.y)}|${Math.round(el.width)}x${Math.round(el.height)}`;
95
+ }