domotion-svg 0.27.2 → 0.28.0

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.
@@ -1371,9 +1371,10 @@ async function buildCapturedFrame(fc, i, ctx) {
1371
1371
  // can diff it against the next frame's. `null` for scroll-block frames
1372
1372
  // (no single tree) — magic-move then falls back to crossfade.
1373
1373
  let frameTree = null;
1374
- // DM-1556: a `typeResample` frame nests a per-keystroke animated SVG with its
1375
- // own internal timeline; set so the animator re-anchors it to this frame's
1376
- // master-loop offset (same as a `cast` / animated-`template` frame).
1374
+ // Timed nested frames (`typeResample`, `jsReveal`, `states`, and `scroll`)
1375
+ // carry their own animated SVG. Record its period so the animator re-anchors
1376
+ // that document-global timeline to this frame's master-loop offset (the same
1377
+ // contract used by a `cast` / animated-`template` frame).
1377
1378
  let embeddedAnimationPeriodMs;
1378
1379
  // DM-1767 (docs/104): overlays a `states` run's individual states carried,
1379
1380
  // already anchor-resolved per state and re-based onto this frame's timeline.
@@ -1438,9 +1439,8 @@ async function buildCapturedFrame(fc, i, ctx) {
1438
1439
  // page, cull each segment's tree (DM-603), compose into one
1439
1440
  // animated SVG, and use as this frame's svgContent. The composed
1440
1441
  // SVG carries its own internal keyframes loop (animation-duration =
1441
- // pattern's total scroll time) caller is expected to size the
1442
- // frame's `duration` to match so the outer scene cycle aligns with
1443
- // the inner scroll loop.
1442
+ // pattern's total scroll time). Record that period below so the animator
1443
+ // re-anchors the nested loop to this frame's master-timeline window.
1444
1444
  log(` scroll pattern: ${fc.scroll.pattern}`);
1445
1445
  const scrollPattern = parseScrollPattern(fc.scroll.pattern);
1446
1446
  const scrollClip = fc.scroll.clip ?? [0, 0, cfg.width, cfg.height];
@@ -1476,6 +1476,10 @@ async function buildCapturedFrame(fc, i, ctx) {
1476
1476
  restoreGeneration(outerGeneration);
1477
1477
  }
1478
1478
  composed = namespaceEmbeddedAnimatedSvg(composed, `sf${i}_`);
1479
+ embeddedAnimationPeriodMs = Math.max(segments[segments.length - 1]?.segmentEndMs ?? 0, 1);
1480
+ if (fc.duration < embeddedAnimationPeriodMs) {
1481
+ log(` note: frame duration ${fc.duration}ms < scroll play time ${embeddedAnimationPeriodMs}ms — the scroll will be cut off; size duration to ≈ ${embeddedAnimationPeriodMs}ms`);
1482
+ }
1479
1483
  // The composer emits a full `<?xml ...><svg>...</svg>` document. The
1480
1484
  // outer animator wraps `svgContent` in a `<g class="f f-N">`, which
1481
1485
  // happily contains a nested `<svg>` element — strip just the XML
@@ -7,6 +7,7 @@ import { type SessionGenericFamilyOverrides } from "./font-resolution.js";
7
7
  export { parseGradientStops, buildRadialGradientDef, parseBgPositionPx } from "./gradient-defs.js";
8
8
  export { buildMaskDef, maskPaintAreas, positionFragmentMaskDef, rewriteFragmentMaskDef } from "./mask.js";
9
9
  export { resolveMaskContainCoverRect, resolveMaskPosition, resolveMaskPositionAxis } from "./mask-position.js";
10
+ import { type CornerRadii } from "./borders.js";
10
11
  import type { CapturedElement, CapturedTreeInput } from "../capture/types.js";
11
12
  export { _dataUriCache, _resizedDataUriCache, embedResizedDataUri, embedRemoteImages, resolveSvgSource, type EmbedRemoteImagesOptions } from "../capture/embed.js";
12
13
  export { getLastCaptureWarnings, logCaptureWarnings } from "../capture/warnings.js";
@@ -98,6 +99,20 @@ interface BoxReflectionSpec {
98
99
  /** Parse Chromium's computed `-webkit-box-reflect` serialization. */
99
100
  export declare function parseBoxReflection(value: string | undefined, width: number, height: number): BoxReflectionSpec | null;
100
101
  export declare function boxReflectionTransform(spec: BoxReflectionSpec, x: number, y: number, width: number, height: number): string;
102
+ export interface ChildOverflowClipGeometry {
103
+ x: number;
104
+ y: number;
105
+ width: number;
106
+ height: number;
107
+ corners: CornerRadii;
108
+ }
109
+ /**
110
+ * Resolve the fixed page-space clip applied to an element's descendants.
111
+ * Keeping this geometry separate from emission lets compound renderers retain
112
+ * an ancestor clip after detaching a descendant, without duplicating the CSS
113
+ * padding-edge, containment, radius, and overflow-clip-margin rules.
114
+ */
115
+ export declare function childOverflowClipGeometry(el: CapturedElement, corners?: CornerRadii): ChildOverflowClipGeometry | null;
101
116
  /**
102
117
  * DM-950: render a CapturedElement tree into a **complete `<svg>`
103
118
  * document** — the obvious entry point for "I have a tree, give me a
@@ -4506,6 +4506,63 @@ function openChildOverflowClip(state, el, indent, corners) {
4506
4506
  state.svgParts.push(`${indent}<g clip-path="url(#${id})">`);
4507
4507
  return id;
4508
4508
  }
4509
+ /**
4510
+ * Resolve the fixed page-space clip applied to an element's descendants.
4511
+ * Keeping this geometry separate from emission lets compound renderers retain
4512
+ * an ancestor clip after detaching a descendant, without duplicating the CSS
4513
+ * padding-edge, containment, radius, and overflow-clip-margin rules.
4514
+ */
4515
+ export function childOverflowClipGeometry(el, corners = parseCornerRadii(el.styles, el.width, el.height)) {
4516
+ const ox = el.styles.overflowX;
4517
+ const oy = el.styles.overflowY;
4518
+ const containVal = el.styles.contain;
4519
+ const containClips = containVal != null && containVal !== "" && containVal !== "none"
4520
+ && /\b(?:paint|strict|content)\b/i.test(containVal);
4521
+ const clipsOverflow = (ox != null && ox !== "visible") || (oy != null && oy !== "visible") || containClips;
4522
+ // Body overflow propagates to the viewport instead of clipping body itself.
4523
+ if (!clipsOverflow || el.tag === "body" || el.children.length === 0)
4524
+ return null;
4525
+ // CSS overflow clips descendants to the padding edge, whose inner corner
4526
+ // radii are the captured outer radii inset by the adjacent border widths.
4527
+ const top = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
4528
+ const right = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
4529
+ const bottom = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
4530
+ const left = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
4531
+ let clipCorners = insetCornerRadii(corners, top, right, bottom, left);
4532
+ let x = el.x + left;
4533
+ let y = el.y + top;
4534
+ let width = Math.max(0, el.width - left - right);
4535
+ let height = Math.max(0, el.height - top - bottom);
4536
+ // An active overflow-clip-margin replaces the ordinary padding-box contour.
4537
+ const marginGeometry = resolvedOverflowClipMarginGeometry(el, corners);
4538
+ if (marginGeometry != null) {
4539
+ x = marginGeometry.x;
4540
+ y = marginGeometry.y;
4541
+ width = marginGeometry.width;
4542
+ height = marginGeometry.height;
4543
+ clipCorners = marginGeometry.corners;
4544
+ }
4545
+ if (el.fieldsetLegendNotch != null) {
4546
+ // A rendered legend belongs to the fieldset border, not its scrollport.
4547
+ const protrude = y - el.fieldsetLegendNotch.y;
4548
+ if (protrude > 0) {
4549
+ y -= protrude;
4550
+ height += protrude;
4551
+ }
4552
+ }
4553
+ const unbounded = 100000;
4554
+ // SVG has one clip shape, so model a visible axis by extending it beyond any
4555
+ // plausible paint area while retaining the other axis's authored clip.
4556
+ if (!containClips && ox === "visible" && oy === "clip") {
4557
+ x = el.x - unbounded;
4558
+ width = el.width + unbounded * 2;
4559
+ }
4560
+ if (!containClips && oy === "visible" && ox === "clip") {
4561
+ y = el.y - unbounded;
4562
+ height = el.height + unbounded * 2;
4563
+ }
4564
+ return { x, y, width, height, corners: clipCorners };
4565
+ }
4509
4566
  /**
4510
4567
  * Mint (once) the `<clipPath>` that clips `el`'s children to its overflow
4511
4568
  * region, and return its id — or null when the element doesn't clip.
@@ -4521,110 +4578,11 @@ function ensureChildOverflowClipId(state, el, corners) {
4521
4578
  const memoized = overflowClipPathIds.get(el);
4522
4579
  if (memoized != null)
4523
4580
  return memoized;
4524
- // Overflow clipping: when a parent has overflow != visible (hidden/scroll/
4525
- // auto/clip on either axis), its children must be clipped to its box.
4526
- // We wrap just the child recursion in a <g clip-path="..."> so the element's
4527
- // own bg/border/text render unclipped.
4528
- //
4529
- // CSS spec (DM-363): overflow clips to the **padding edge**, not the
4530
- // border-box edge. If we clip to the border-box, child fills extending to
4531
- // the bottom of the box paint OVER the bottom border stroke and the
4532
- // border disappears from the rendered output (e.g. 13-pos-sticky:
4533
- // Section B's `.filler` rect was hiding the scroller's `border-bottom`).
4534
- // Inset the clip rect by the per-side border widths so the border stroke
4535
- // remains visible above the clipped children.
4536
- const ox = el.styles.overflowX;
4537
- const oy = el.styles.overflowY;
4538
- // DM-522: `contain: paint | strict | content` clips descendants to the
4539
- // principal (padding) box per the CSS Containment spec — same effective
4540
- // clip as overflow:hidden, so route it through the same machinery. Without
4541
- // this, a `contain:paint` ancestor lets descendants overflow visually
4542
- // (regression observable on `13-deep-stacking-context-creators`'s
4543
- // contain:paint stage: the blue inner z:9999 box paints past the dashed
4544
- // ancestor instead of being trapped). The `containClips` test deliberately
4545
- // excludes `contain: layout` / `size` / `inline-size` since those don't
4546
- // imply paint clipping.
4547
- const containVal = el.styles.contain;
4548
- const containClips = containVal != null && containVal !== "" && containVal !== "none"
4549
- && /\b(?:paint|strict|content)\b/i.test(containVal);
4550
- const clipsOverflow = (ox != null && ox !== "visible") || (oy != null && oy !== "visible") || containClips;
4551
- // DM-650: same body-overflow-propagation rule as the earlier clip-path
4552
- // emission — when body has non-visible overflow it propagates to the
4553
- // viewport rather than clipping body itself; skip the children-overflow
4554
- // clip too so descendants positioned outside body's bbox (e.g. NYT
4555
- // desktop's content wrapper, which extends below body's height: 100vh
4556
- // box) stay visible after the document scroll moves body off-viewport.
4557
- const isBodyOverflowPropagatedHere = el.tag === "body";
4581
+ const geometry = childOverflowClipGeometry(el, corners);
4558
4582
  let overflowClipId = null;
4559
- if (clipsOverflow && !isBodyOverflowPropagatedHere && el.children.length > 0) {
4583
+ if (geometry != null) {
4560
4584
  overflowClipId = paintCtx.nextClipId("ov");
4561
- const cbt = parseFloat(el.styles.borderTopWidth ?? "0") || 0;
4562
- const cbr = parseFloat(el.styles.borderRightWidth ?? "0") || 0;
4563
- const cbb = parseFloat(el.styles.borderBottomWidth ?? "0") || 0;
4564
- const cbl = parseFloat(el.styles.borderLeftWidth ?? "0") || 0;
4565
- // DM-698: overflow clips to the inner border-radius (per CSS Backgrounds 3
4566
- // — the rounded clip on the padding box uses radii inset by each side's
4567
- // border width, clamped to zero). Previously we passed the OUTER `corners`
4568
- // which made the clip too generous near each corner, exposing a sliver of
4569
- // the parent's background between the border and the clipped child.
4570
- // (e.g. `18-deep-radius-overflow` `.card` border-radius:32 / border:4 +
4571
- // child `position:absolute inset:0`: 4 px gradient sliver visible inside
4572
- // each rounded corner.)
4573
- let overflowCorners = insetCornerRadii(corners, cbt, cbr, cbb, cbl);
4574
- // Default clip = padding box (border-inset). DM-2419: for an active
4575
- // overflow-clip-margin, Blink instead starts from a PIXEL-SNAPPED inner
4576
- // border, applies the physical reference-box/margin outsets, and grows or
4577
- // contracts every corner with FloatRoundedRect's coverage correction.
4578
- // Reference-box-only zero values remain active; a negative CSS length is
4579
- // invalid and never reaches this branch.
4580
- let ocX = el.x + cbl;
4581
- let ocY = el.y + cbt;
4582
- let ocW = Math.max(0, el.width - cbl - cbr);
4583
- let ocH = Math.max(0, el.height - cbt - cbb);
4584
- const ocmGeometry = resolvedOverflowClipMarginGeometry(el, corners);
4585
- if (ocmGeometry != null) {
4586
- ocX = ocmGeometry.x;
4587
- ocY = ocmGeometry.y;
4588
- ocW = ocmGeometry.width;
4589
- ocH = ocmGeometry.height;
4590
- overflowCorners = ocmGeometry.corners;
4591
- }
4592
- // DM-1264: a <fieldset>'s rendered <legend> is part of the block-start BORDER,
4593
- // not the scrollport — per Blink `fieldset_layout_algorithm.cc`: "the rendered
4594
- // legend shouldn't be part of the scrollport; the legend is essentially a part
4595
- // of the block-start border ... scrollbars are handled by the anonymous child
4596
- // box." So a `fieldset { overflow: auto }` (resize needs overflow != visible)
4597
- // must NOT clip the legend, which straddles the border line and protrudes above
4598
- // the padding box. Raise the clip's top edge to clear the legend (the block-
4599
- // start border strip holds nothing else that could leak out).
4600
- if (el.fieldsetLegendNotch != null) {
4601
- const protrude = ocY - el.fieldsetLegendNotch.y;
4602
- if (protrude > 0) {
4603
- ocY -= protrude;
4604
- ocH += protrude;
4605
- }
4606
- }
4607
- // DM-787: CSS Overflow 3 allows mixing `overflow-x: clip; overflow-y:
4608
- // visible` (only `clip` permits this — `hidden + visible` coerces to
4609
- // `auto + hidden`). Chrome clips only the clipped axis; content can
4610
- // still escape on the visible axis. The SVG clipPath is a single rect,
4611
- // so to NOT clip on an axis we extend that axis past any plausible
4612
- // paint area with `±UNBOUNDED`. Such a one-axis combination is also an
4613
- // explicit negative activation control for overflow-clip-margin.
4614
- const UNBOUNDED = 100000;
4615
- // Paint containment supplies its own both-axis overflow clip edge, so an
4616
- // authored visible axis does not punch through that containment clip.
4617
- const xVisible = !containClips && ox === "visible" && oy === "clip";
4618
- const yVisible = !containClips && oy === "visible" && ox === "clip";
4619
- if (xVisible) {
4620
- ocX = el.x - UNBOUNDED;
4621
- ocW = el.width + UNBOUNDED * 2;
4622
- }
4623
- if (yVisible) {
4624
- ocY = el.y - UNBOUNDED;
4625
- ocH = el.height + UNBOUNDED * 2;
4626
- }
4627
- defsParts.push(`<clipPath id="${overflowClipId}">${roundedRectSvg(ocX, ocY, ocW, ocH, overflowCorners, "")}</clipPath>`);
4585
+ defsParts.push(`<clipPath id="${overflowClipId}">${roundedRectSvg(geometry.x, geometry.y, geometry.width, geometry.height, geometry.corners, "")}</clipPath>`);
4628
4586
  // DM-673: stash the clip-path id so hoisted descendants of this
4629
4587
  // overflow scroller can re-wrap their emission in the same clip.
4630
4588
  overflowClipPathIds.set(el, overflowClipId);
@@ -250,10 +250,11 @@ export declare function passes(cmp: CompareResult): boolean;
250
250
  * 3712 px with `regionCount === 0`, including a component larger than the
251
251
  * unchanged 256 px single-region cap. The same Chromium build's pinned Linux
252
252
  * x64 run measures 2065 px total in the two-pane fixture (48 sparse regions,
253
- * 135 px largest, zero high-severity regions). The 2304 aggregate cap admits
254
- * both measured scattered text-edge floors while remaining below the known
255
- * structural break; the independent 256 px component cap still rejects that
256
- * break by itself.
253
+ * 135 px largest, zero high-severity regions). GitHub's macOS 26.6.2 runner
254
+ * image moved the clean ceiling to 2835 px total / 171 px largest, with five
255
+ * edge components crossing the default high-severity classification. The 3072
256
+ * aggregate cap admits those measured scattered text-edge floors while the
257
+ * independent 256 px component cap still rejects the known structural break.
257
258
  *
258
259
  * Unlike the visual gate's per-platform hinting floor ("Per-platform coverage
259
260
  * floor" in docs/12-diff-scoring.md), this bar needs no per-platform relief:
@@ -266,10 +267,12 @@ export interface StrictCaps {
266
267
  export declare function strictCapsFor(_platform: NodeJS.Platform | string): StrictCaps;
267
268
  /** The host's caps. Never null: the bar is calibrated on every platform. */
268
269
  export declare const STRICT_CAPS: StrictCaps;
269
- /** The no-motion pass criterion: `passes()` PLUS "nothing block-sized moved or
270
- * swapped paint order". Lifts the high-severity-fraction gate that keeps
271
- * low-severity components out of `regionCount`, then bounds what is left by
272
- * area (see `strictCapsFor` for the measured sizing).
270
+ /** The no-motion pass criterion: "nothing block-sized moved or swapped paint
271
+ * order". Lifts the high-severity-fraction gate that splits components between
272
+ * `regionCount` and `shiftyRegionCount`, then bounds ALL of them by area (see
273
+ * `strictCapsFor` for the measured sizing). Requiring `passes()` as well would
274
+ * put that raster-sensitive severity split back into the strict gate even
275
+ * though the strict aggregates already contain both buckets.
273
276
  *
274
277
  * Use this ONLY where both images are known to depict the same content at the
275
278
  * same positions — e.g. the frame-sequence compressor's flipbook-parity
@@ -570,14 +570,16 @@ export function passes(cmp) {
570
570
  return cmp.regionCount === 0;
571
571
  }
572
572
  export function strictCapsFor(_platform) {
573
- return { maxRegionArea: 256, totalRegionArea: 2304 };
573
+ return { maxRegionArea: 256, totalRegionArea: 3072 };
574
574
  }
575
575
  /** The host's caps. Never null: the bar is calibrated on every platform. */
576
576
  export const STRICT_CAPS = strictCapsFor(process.platform);
577
- /** The no-motion pass criterion: `passes()` PLUS "nothing block-sized moved or
578
- * swapped paint order". Lifts the high-severity-fraction gate that keeps
579
- * low-severity components out of `regionCount`, then bounds what is left by
580
- * area (see `strictCapsFor` for the measured sizing).
577
+ /** The no-motion pass criterion: "nothing block-sized moved or swapped paint
578
+ * order". Lifts the high-severity-fraction gate that splits components between
579
+ * `regionCount` and `shiftyRegionCount`, then bounds ALL of them by area (see
580
+ * `strictCapsFor` for the measured sizing). Requiring `passes()` as well would
581
+ * put that raster-sensitive severity split back into the strict gate even
582
+ * though the strict aggregates already contain both buckets.
581
583
  *
582
584
  * Use this ONLY where both images are known to depict the same content at the
583
585
  * same positions — e.g. the frame-sequence compressor's flipbook-parity
@@ -591,10 +593,8 @@ export const STRICT_CAPS = strictCapsFor(process.platform);
591
593
  * Pass an explicit set to score a result against different numbers; passing
592
594
  * `null` deliberately degrades the bar to plain `passes()`. */
593
595
  export function passesStrict(cmp, caps = STRICT_CAPS) {
594
- if (!passes(cmp))
595
- return false;
596
596
  if (caps == null)
597
- return true;
597
+ return passes(cmp);
598
598
  return cmp.strictMaxRegionArea <= caps.maxRegionArea
599
599
  && cmp.strictRegionArea <= caps.totalRegionArea;
600
600
  }
@@ -21,7 +21,8 @@
21
21
  * shrinking output size on mostly-static-content pages.
22
22
  */
23
23
  import { capturedScrollOwnerBindingSha256, validateCapturedFrameScrollState, } from "../capture/frame-scroll-state.js";
24
- import { elementTreeToSvgInner } from "../render/element-tree-to-svg.js";
24
+ import { childOverflowClipGeometry, elementTreeToSvgInner, } from "../render/element-tree-to-svg.js";
25
+ import { roundedRectSvg } from "../render/borders.js";
25
26
  import { rootSvgA11y } from "../render/format.js";
26
27
  import { isTransparentBackground } from "../utils/transparent-background.js";
27
28
  import { resetGeneration, getEmbeddedFontFaceCss, getGlyphDefs, withRenderTextMode, } from "../render/text-to-path.js";
@@ -29,6 +30,7 @@ import { beginCharacterFallbackDocument, endCharacterFallbackDocument } from "..
29
30
  import { hoistDuplicateImagePayloads } from "../post-processing/hoist-image-payloads.js";
30
31
  import { extractFixedSubtrees, dedupeFixedAcrossSegments } from "./hoist-fixed.js";
31
32
  import { extractStickyWindows } from "./hoist-sticky.js";
33
+ import { mapTreePruning } from "../tree-ops/prune-tree.js";
32
34
  /**
33
35
  * Map a parsed scroll-action easing (DM-1076) to a CSS `<timing-function>`, or
34
36
  * `null` when it's absent or `linear` — the composite's animation-level default
@@ -81,6 +83,63 @@ function scrollOwner(state, ownerId) {
81
83
  }
82
84
  return undefined;
83
85
  }
86
+ function findCapturedScrollOwnerPath(tree, ownerId, ancestors = []) {
87
+ for (const element of tree) {
88
+ const path = [...ancestors, element];
89
+ if (element.scrollbars?.owner?.ownerId === ownerId)
90
+ return path;
91
+ const descendant = findCapturedScrollOwnerPath(element.children ?? [], ownerId, path);
92
+ if (descendant != null)
93
+ return descendant;
94
+ }
95
+ return undefined;
96
+ }
97
+ /**
98
+ * An element scroll owner moves its own clipped contents, not the page around
99
+ * it. Keep the first capture's surrounding page as a static underlay and feed
100
+ * only the authenticated owner subtree through the offset-stacking composer.
101
+ * The existing stack translation then cancels the owner's per-segment offset,
102
+ * leaving its border box in place while its captured children move inside it.
103
+ */
104
+ function isolateElementScrollOwner(segments) {
105
+ const first = segments[0];
106
+ if (first?.frameScrollState == null || first.scrollOwnerId == null)
107
+ return null;
108
+ const firstOwnerRecord = scrollOwner(first.frameScrollState, first.scrollOwnerId);
109
+ if (firstOwnerRecord?.kind !== "element")
110
+ return null;
111
+ const firstOwnerPath = findCapturedScrollOwnerPath(first.tree, first.scrollOwnerId);
112
+ const firstOwner = firstOwnerPath?.at(-1);
113
+ // An explicitly owner-only capture (`--selector` / frame `selector`) keeps
114
+ // the established list-strip contract; there is no surrounding page to pin.
115
+ if (firstOwnerPath == null || firstOwner == null || first.tree.includes(firstOwner))
116
+ return null;
117
+ const staticUnderlay = mapTreePruning(first.tree, (element) => element.scrollbars?.owner?.ownerId === first.scrollOwnerId);
118
+ const isolated = segments.map((segment, index) => {
119
+ if (segment.frameScrollState == null || segment.scrollOwnerId == null) {
120
+ throw new Error(`composeScrollSvg: inner-scroll segment ${index} omitted its owner authority`);
121
+ }
122
+ const ownerRecord = scrollOwner(segment.frameScrollState, segment.scrollOwnerId);
123
+ const ownerElement = findCapturedScrollOwnerPath(segment.tree, segment.scrollOwnerId)?.at(-1);
124
+ if (ownerRecord?.kind !== "element" || ownerElement == null) {
125
+ throw new Error(`composeScrollSvg: inner-scroll segment ${index} changed or omitted its element owner`);
126
+ }
127
+ const sessionGenericFamilies = segment.tree.find((root) => root.sessionGenericFamilies != null)
128
+ ?.sessionGenericFamilies;
129
+ return {
130
+ ...segment,
131
+ tree: [{
132
+ ...ownerElement,
133
+ ...(sessionGenericFamilies == null ? {} : { sessionGenericFamilies }),
134
+ }],
135
+ };
136
+ });
137
+ return {
138
+ segments: isolated,
139
+ staticUnderlay,
140
+ ownerClips: firstOwnerPath.map((element) => childOverflowClipGeometry(element)).filter((clip) => clip != null),
141
+ };
142
+ }
84
143
  /**
85
144
  * Fail closed before composition when frame/allowlist/offset authority is
86
145
  * omitted, stale, or assigned to a sibling Chromium frame.
@@ -244,6 +303,8 @@ export function composeScrollSvg(segments, opts) {
244
303
  if (chunkSize < 1 || !Number.isInteger(chunkSize)) {
245
304
  throw new Error(`composeScrollSvg: chunkSize must be a positive integer, got ${chunkSize}`);
246
305
  }
306
+ const elementScroll = isolateElementScrollOwner(segments);
307
+ const composedSegments = elementScroll?.segments ?? segments;
247
308
  // DM-652: arm the text-render lifecycle. Default is "embedded-font" —
248
309
  // the per-segment text renderer emits `<text>` runs against a single
249
310
  // @font-face per used webfont, ~2× faster in WebKit and ~5× smaller
@@ -265,7 +326,11 @@ export function composeScrollSvg(segments, opts) {
265
326
  // `elementTreeToSvgInner` scopes nest inside this one as no-ops.
266
327
  beginCharacterFallbackDocument();
267
328
  try {
268
- return withRenderTextMode(renderTextMode, () => composeScrollSvgBody(segments, opts, { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize }));
329
+ return withRenderTextMode(renderTextMode, () => composeScrollSvgBody(composedSegments, opts, {
330
+ axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize,
331
+ staticUnderlay: elementScroll?.staticUnderlay,
332
+ elementOwnerClips: elementScroll?.ownerClips,
333
+ }));
269
334
  }
270
335
  finally {
271
336
  endCharacterFallbackDocument();
@@ -320,13 +385,16 @@ function buildScrollVisibility(segments, segOffsets, totalMs) {
320
385
  * reads (axis / viewport / background / hiDPI / chunk size).
321
386
  */
322
387
  function composeScrollSvgBody(segments, opts, ctx) {
323
- const { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize } = ctx;
388
+ const { axis, W, VH, bg, paintBg, hiDPIFactor, chunkSize, staticUnderlay, elementOwnerClips, } = ctx;
324
389
  // ── Total scene duration ──
325
390
  // The last segment's endMs is the cycle length. For a single-segment input,
326
391
  // the scene is effectively static — emit a 1 s loop so the SVG renders
327
392
  // sensibly without a degenerate 0 s animation.
328
393
  const totalMs = Math.max(segments[segments.length - 1].segmentEndMs, 1);
329
394
  const totalSec = totalMs / 1000;
395
+ const paintSegments = segments
396
+ .map((segment, index) => ({ segment, index }))
397
+ .filter(({ segment }) => segment.timelineOnly !== true);
330
398
  // ── Compute composite dimensions ──
331
399
  // The composite spans the full scroll range. For axis=y, that's from min
332
400
  // scrollY (typically 0) to max scrollY + viewportH (covers the LAST
@@ -351,7 +419,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
351
419
  // viewport-level overlay below.
352
420
  const fixedStripped = [];
353
421
  const perSegFixed = [];
354
- for (const seg of segments) {
422
+ for (const { segment: seg } of paintSegments) {
355
423
  const { stripped, fixed } = extractFixedSubtrees(seg.tree);
356
424
  fixedStripped.push(stripped);
357
425
  perSegFixed.push(fixed);
@@ -383,10 +451,10 @@ function composeScrollSvgBody(segments, opts, ctx) {
383
451
  // ── Render each capture's content at its position offset ──
384
452
  const captureGroups = [];
385
453
  const segmentCullCss = [];
386
- for (let i = 0; i < segments.length; i++) {
387
- const seg = segments[i];
388
- const offset = segOffsets[i];
389
- const inner = elementTreeToSvgInner(strippedTrees[i], W, VH, `seg${i}-`, false, hiDPIFactor, false);
454
+ for (let paintIndex = 0; paintIndex < paintSegments.length; paintIndex++) {
455
+ const { segment: seg, index: segmentIndex } = paintSegments[paintIndex];
456
+ const offset = segOffsets[segmentIndex];
457
+ const inner = elementTreeToSvgInner(strippedTrees[paintIndex], W, VH, `seg${segmentIndex}-`, false, hiDPIFactor, false);
390
458
  const tx = axis === "x" ? offset : 0;
391
459
  const ty = axis === "y" ? offset : 0;
392
460
  // Visibility window: visible while scroll-y is in the rasterisation
@@ -424,7 +492,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
424
492
  `</g>`);
425
493
  }
426
494
  else {
427
- const cls = `${animClass}-s${i}`;
495
+ const cls = `${animClass}-s${segmentIndex}`;
428
496
  // step-end so the segment snaps in/out at the boundary, no fractional
429
497
  // opacity that would force the browser to keep compositing it.
430
498
  segmentCullCss.push(buildVisibilityKeyframes(cls, enterPct, leavePct, totalSec));
@@ -436,7 +504,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
436
504
  }
437
505
  }
438
506
  // ── Sticky overlay markup + visibility keyframes (DM-647) ──
439
- const { markup: stickyMarkup, cullCss: stickyCullCss } = buildStickyOverlays(stickyOverlays, segments, totalMs, animClass, W, VH, hiDPIFactor);
507
+ const { markup: stickyMarkup, cullCss: stickyCullCss } = buildStickyOverlays(stickyOverlays, paintSegments.map(({ segment }) => segment), totalMs, animClass, W, VH, hiDPIFactor);
440
508
  const fixedMarkup = fixedOverlay.length === 0
441
509
  ? ""
442
510
  : `\n <g>` +
@@ -491,6 +559,15 @@ function composeScrollSvgBody(segments, opts, ctx) {
491
559
  const slice = captureGroups.slice(i, i + chunkSize);
492
560
  chunks.push(`<g style="will-change: transform">\n${slice.join("\n")}\n </g>`);
493
561
  }
562
+ // Render the pinned page context before taking the generation-global font /
563
+ // glyph snapshots. Its text can add PUA glyphs to the same subset registries
564
+ // as the moving owner captures; collecting first would leave those late
565
+ // glyph references absent from the emitted embedded fonts.
566
+ const staticMarkup = staticUnderlay == null
567
+ ? ""
568
+ : `\n <g data-scroll-static-context="true"><svg x="0" y="0" width="${W}" height="${VH}" viewBox="0 0 ${W} ${VH}">` +
569
+ elementTreeToSvgInner(staticUnderlay, W, VH, "static-", false, hiDPIFactor, false) +
570
+ `</svg></g>`;
494
571
  // DM-652: collect every `@font-face` rule the embedded-font path
495
572
  // registered during segment + overlay rendering above, into a single
496
573
  // top-level <style> block. Each font appears once (registry is keyed
@@ -505,6 +582,9 @@ function composeScrollSvgBody(segments, opts, ctx) {
505
582
  // an animate frame (Blink resolves local SVG IRIs through the shared TreeScope,
506
583
  // not through the nearest nested <svg> element).
507
584
  const glyphDefs = getGlyphDefs();
585
+ const ownerClipDefs = (elementOwnerClips ?? []).map((clip, index) => ` <clipPath id="${animClass}-owner-clip-${index}">${roundedRectSvg(clip.x, clip.y, clip.width, clip.height, clip.corners, "")}</clipPath>`).join("\n");
586
+ const ownerClipOpen = (elementOwnerClips ?? []).map((_clip, index) => ` <g clip-path="url(#${animClass}-owner-clip-${index})">`).join("\n");
587
+ const ownerClipClose = (elementOwnerClips ?? []).map(() => " </g>").join("\n");
508
588
  // ── Compose final SVG ──
509
589
  // Share each raster payload across the segments that show it (the `<image>`
510
590
  // emit is per-element, and a scroll composite repeats a sticky header /
@@ -514,7 +594,7 @@ function composeScrollSvgBody(segments, opts, ctx) {
514
594
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${VH}" width="${W}" height="${VH}"${a11y.roleAttr}>${a11y.markup}
515
595
  <defs>
516
596
  <clipPath id="${animClass}-clip"><rect width="${W}" height="${VH}"/></clipPath>
517
- ${glyphDefs !== "" ? ` ${glyphDefs}\n` : ""} <style>
597
+ ${ownerClipDefs === "" ? "" : ownerClipDefs + "\n"}${glyphDefs !== "" ? ` ${glyphDefs}\n` : ""} <style>
518
598
  ${fontFaceCss !== "" ? fontFaceCss + "\n" : ""} .${animClass} { animation: ${animClass} ${totalSec.toFixed(3)}s linear infinite; will-change: transform; }
519
599
  @keyframes ${animClass} {
520
600
  ${keyframes}
@@ -527,11 +607,14 @@ ${stickyCullCss.join("\n")}
527
607
  </style>
528
608
  </defs>
529
609
  ${paintBg ? ` <rect width="${W}" height="${VH}" fill="${bg}"/>\n` : ""} <g clip-path="url(#${animClass}-clip)">
610
+ ${staticMarkup}
611
+ ${ownerClipOpen === "" ? "" : ownerClipOpen + "\n"}
530
612
  <g class="${animClass}">
531
613
  <svg x="0" y="0" width="${compositeW}" height="${compositeH}" viewBox="0 0 ${compositeW} ${compositeH}">
532
614
  ${paintBg ? ` <rect width="${compositeW}" height="${compositeH}" fill="${bg}"/>\n` : ""} ${chunks.join("\n ")}
533
615
  </svg>
534
616
  </g>
617
+ ${ownerClipClose === "" ? "" : ownerClipClose + "\n"}
535
618
  </g>${overlayMarkup}
536
619
  </svg>`);
537
620
  }
@@ -77,6 +77,12 @@ export interface ScrollSegmentCapture {
77
77
  tree: CapturedElement[];
78
78
  /** Diff from the previous segment's capture. Null for the very first. */
79
79
  diffFromPrev: TreeDiff | null;
80
+ /**
81
+ * This capture contributes a same-offset timeline stop but no additional
82
+ * paint subtree. Used for pauses whose settled DOM is unchanged, so the
83
+ * composer holds position without duplicating identical scene content.
84
+ */
85
+ timelineOnly?: true;
80
86
  /** Exact Chromium FrameId/scroll-owner authority sampled with this tree. */
81
87
  frameScrollState?: CapturedFrameScrollState;
82
88
  /** Scroll owner whose raw offset drives this segment's composition anchor. */
@@ -170,8 +176,8 @@ export declare class ScrollExecutionError extends Error {
170
176
  * 3. Walk the pattern AST. For each scroll action: snapshot page state,
171
177
  * resolve destination, scroll there, wait the action's duration plus a
172
178
  * small settle, then capture and diff against previous.
173
- * 4. Pause actions just wait; they don't add a capture unless the DOM is
174
- * observed to have changed (lazy-load fired during the pause).
179
+ * 4. Pause actions wait, then add either a changed-DOM capture or a
180
+ * non-painting same-offset timeline stop when the DOM stayed unchanged.
175
181
  * 5. `until` loops re-resolve conditions each iteration. The grammar's
176
182
  * "clamp on overshoot" rule is honored: the last iteration of a
177
183
  * position-bounded loop has its scroll magnitude shrunk so the
@@ -177,8 +177,8 @@ export class ScrollExecutionError extends Error {
177
177
  * 3. Walk the pattern AST. For each scroll action: snapshot page state,
178
178
  * resolve destination, scroll there, wait the action's duration plus a
179
179
  * small settle, then capture and diff against previous.
180
- * 4. Pause actions just wait; they don't add a capture unless the DOM is
181
- * observed to have changed (lazy-load fired during the pause).
180
+ * 4. Pause actions wait, then add either a changed-DOM capture or a
181
+ * non-painting same-offset timeline stop when the DOM stayed unchanged.
182
182
  * 5. `until` loops re-resolve conditions each iteration. The grammar's
183
183
  * "clamp on overshoot" rule is honored: the last iteration of a
184
184
  * position-bounded loop has its scroll magnitude shrunk so the
@@ -267,6 +267,21 @@ export async function executeScrollPattern(page, pattern, opts) {
267
267
  prevTree = nextTree;
268
268
  log(` captured frame ${captures.length} (DOM changed during pause)`);
269
269
  }
270
+ else {
271
+ captures.push({
272
+ scrollX: nextCapture.scrollX, scrollY: nextCapture.scrollY,
273
+ segmentStartMs: sceneTime - op.durationMs,
274
+ segmentEndMs: sceneTime,
275
+ tree: nextTree,
276
+ diffFromPrev: diff,
277
+ timelineOnly: true,
278
+ frameScrollState: nextCapture.frameScrollState,
279
+ captureWarnings: nextCapture.captureWarnings,
280
+ scrollOwnerId: nextCapture.scrollOwnerId,
281
+ scrollOwnerBindingSha256: capturedScrollOwnerBindingSha256(nextCapture.frameScrollState, nextCapture.scrollOwnerId, nextCapture.scrollX, nextCapture.scrollY),
282
+ });
283
+ log(` recorded hold through ${sceneTime} ms (DOM unchanged during pause)`);
284
+ }
270
285
  return;
271
286
  }
272
287
  // op.kind === "scroll"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domotion-svg",
3
- "version": "0.27.2",
3
+ "version": "0.28.0",
4
4
  "description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
5
5
  "license": "MIT",
6
6
  "author": "Brian Westphal",