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
@@ -114,13 +114,51 @@ export interface TextSegment {
114
114
  /** Pre-resolved srgb-form CSS color (or "transparent"). Renderer parses
115
115
  * via parseColor and emits as the rect fill. */
116
116
  backgroundColor?: string;
117
+ /** Raw CSS `background-image` value (gradient / url() / multiple
118
+ * layers) when the pseudo paints a gradient or image instead of (or in
119
+ * addition to) a flat color. DM-767: `.corner::after` accent stripes
120
+ * with `background: linear-gradient(...)` need this — without it the
121
+ * pseudoBox emit was skipped entirely. Renderer threads through
122
+ * `buildBackgroundLayerDef` the same way the regular-element
123
+ * background-image path does. */
124
+ backgroundImage?: string;
117
125
  /** Resolved px border-radius (CSS shorthand — single value, four-corner
118
126
  * symmetric). */
119
127
  borderRadius?: number;
120
- /** Uniform border (single side-width + color). Per-side variation isn't
121
- * supported in this pass; mixed-style borders fall through. */
128
+ /** Uniform border (single side-width + color). When set, the renderer
129
+ * emits `<rect stroke=... stroke-width=...>` around the box. */
122
130
  borderWidth?: number;
123
131
  borderColor?: string;
132
+ /** Per-side border widths (px). Always populated; zero when the side
133
+ * carries no border. Renderer reads these together with the per-side
134
+ * colors below when no `borderWidth` (uniform) is set. */
135
+ borL?: number;
136
+ borR?: number;
137
+ borT?: number;
138
+ borB?: number;
139
+ /** Per-side border colors. Set ONLY when the pseudo has a non-uniform
140
+ * border (e.g. `border-bottom: 1px solid …` only). For uniform borders
141
+ * the renderer uses `borderColor` + `borderWidth` and the per-side
142
+ * fields are undefined. Slashdot's `.carouselHeading::after` is the
143
+ * motivating fixture — a 1px translucent-white border-bottom on the
144
+ * italic "Most Discussed" heading. */
145
+ borderTopColor?: string;
146
+ borderRightColor?: string;
147
+ borderBottomColor?: string;
148
+ borderLeftColor?: string;
149
+ /** DM-783: pseudo's own `transform` (rotate/scale/translate/matrix/skew).
150
+ * Captured verbatim from `getComputedStyle(host, '::before').transform`,
151
+ * which Chrome returns in resolved `matrix(a,b,c,d,e,f)` form — pasteable
152
+ * directly into an SVG `<g transform="…">` wrapper. Renderer wraps the
153
+ * pseudoBox rect + glyph emit so rotate(45deg) on a `::before { border-
154
+ * right; border-bottom }` paints as a check-mark (the rotation pivots
155
+ * around the box center per `transformOrigin`). */
156
+ transform?: string;
157
+ /** Resolved px transform-origin (e.g. `"50px 50px"` for a 100×100 box's
158
+ * default `50% 50%`). Renderer pre-bakes a translate-transform-translate
159
+ * matrix so the rotation/scale pivots around the captured origin instead
160
+ * of (0, 0). When undefined, renderer defaults to the box center. */
161
+ transformOrigin?: string;
124
162
  };
125
163
  }
126
164
  export interface CapturedElement {
@@ -194,6 +232,14 @@ export interface CapturedElement {
194
232
  borderCollapse: string;
195
233
  overflowX: string;
196
234
  overflowY: string;
235
+ /**
236
+ * CSS `overflow-clip-margin` shorthand value as computed by Chrome
237
+ * (e.g. `"20px"`, `"content-box 12px"`, or the empty string when the
238
+ * default `0px` resolves and the element doesn't paint outside its
239
+ * reference box). Only takes effect when `overflow: clip` (DM-761) —
240
+ * `hidden` ignores it per CSS Overflow 3.
241
+ */
242
+ overflowClipMargin?: string;
197
243
  scrollbarGutter: string;
198
244
  /** el.scrollHeight / scrollWidth vs client* — used to decide whether to paint a scrollbar. */
199
245
  scrollWidth: number;
@@ -226,6 +272,36 @@ export interface CapturedElement {
226
272
  maskPosition: string;
227
273
  maskRepeat: string;
228
274
  maskComposite: string;
275
+ /**
276
+ * CSS `mask-clip` — the box (border-box / padding-box / content-box / etc.)
277
+ * the mask painted area is clipped to. Defaults to `border-box`. Captured
278
+ * separately from `mask-origin` because Chromium retains both verbatim
279
+ * (mask-clip controls visibility, mask-origin controls layer positioning).
280
+ */
281
+ maskClip?: string;
282
+ /**
283
+ * CSS `mask-border-source` / legacy `-webkit-mask-box-image` source.
284
+ * Chrome exposes this only via the legacy webkit name (DM-758). The
285
+ * renderer routes it through the mask-image pipeline ONLY for the
286
+ * "simple" 9-slice cases (`slice 0 fill / 0 / 0` and `slice 1 fill / 0
287
+ * / 0`) where the entire source is used as a stretched full-element
288
+ * mask. Real 9-slice tiling (non-zero `width` / `outset`, `round` /
289
+ * `space` repeat) needs its own implementation.
290
+ */
291
+ maskBorderSource?: string;
292
+ /** Resolved `-webkit-mask-box-image-slice` (e.g. `"1 fill"`, `"30 fill"`). */
293
+ maskBorderSlice?: string;
294
+ /** Resolved `-webkit-mask-box-image-width` (e.g. `"0"`, `"20px"`). */
295
+ maskBorderWidth?: string;
296
+ /** Resolved `-webkit-mask-box-image-outset` (e.g. `"0"`, `"15px"`). */
297
+ maskBorderOutset?: string;
298
+ /** Resolved `-webkit-mask-box-image-repeat` (`stretch` / `repeat` /
299
+ * `round` / `space`, optionally one per axis). DM-793. */
300
+ maskBorderRepeat?: string;
301
+ /** Intrinsic dimensions of the `mask-border-source` asset (px). Same
302
+ * probe pattern as `borderImageIntrinsicWidth`. DM-793. */
303
+ maskBorderIntrinsicWidth?: number;
304
+ maskBorderIntrinsicHeight?: number;
229
305
  listStyleType: string;
230
306
  listStyleImage: string;
231
307
  listStylePosition: string;
@@ -239,6 +315,14 @@ export interface CapturedElement {
239
315
  backgroundClip: string;
240
316
  backgroundOrigin: string;
241
317
  backgroundAttachment: string;
318
+ /**
319
+ * CSS `background-blend-mode` — per-layer blend mode (comma-separated to
320
+ * match the layer count). Captured verbatim from `getComputedStyle`. The
321
+ * renderer applies each layer's mode as `style="mix-blend-mode:<mode>"`
322
+ * on the layer's `<rect>`, wrapped in a `<g style="isolation:isolate">`
323
+ * so the blend doesn't escape the element's bg-layer stack.
324
+ */
325
+ backgroundBlendMode?: string;
242
326
  /**
243
327
  * CSS `-webkit-text-fill-color`. When `backgroundClip` is `text` the
244
328
  * common pattern is `webkit-text-fill-color: transparent` so the
@@ -248,6 +332,25 @@ export interface CapturedElement {
248
332
  * when the rendered text is actually transparent. DM-462.
249
333
  */
250
334
  webkitTextFillColor?: string;
335
+ /**
336
+ * DM-749: Stripe / Resend pattern — when an element has
337
+ * `webkit-text-fill-color: transparent` but its own background-image is
338
+ * `none`, the gradient lives on an ANCESTOR with `background-clip:
339
+ * text`. Chrome's paint propagates that gradient through descendant
340
+ * glyphs. Captured as the resolved `background-image` string of the
341
+ * nearest ancestor with `background-clip: text` (walked up to 8 levels).
342
+ */
343
+ inheritedTextFillGradient?: string;
344
+ /** `-webkit-text-stroke-width` (e.g. "2px"). DM-719. */
345
+ webkitTextStrokeWidth?: string;
346
+ /** `-webkit-text-stroke-color` (e.g. "rgb(220,38,38)"). DM-719. */
347
+ webkitTextStrokeColor?: string;
348
+ /** `paint-order` (e.g. "stroke fill"). Controls whether the text stroke
349
+ * paints before or after the fill — `stroke fill` puts the stroke
350
+ * UNDER the fill so the fill rests on top of half the stroke width,
351
+ * eliminating the chunky "fill-on-top-of-stroke" artifact at large
352
+ * stroke widths. DM-719. */
353
+ paintOrder?: string;
251
354
  paddingTop: string;
252
355
  paddingRight: string;
253
356
  paddingBottom: string;
@@ -457,6 +560,14 @@ export interface CapturedElement {
457
560
  transformCreatesSc?: boolean;
458
561
  /** CSS transform-style. `preserve-3d` (or anything != `flat`) creates a stacking context per CSS Transforms 2 §4 (DM-589). */
459
562
  transformStyle?: string;
563
+ /**
564
+ * DM-751: extracted Z translation from `matrix3d(...)` when the
565
+ * element's transform has a non-zero translateZ component. Used by the
566
+ * paint-order sort when the parent has `transform-style: preserve-3d`
567
+ * (CSS Transforms 2 §6 sorts children by Z in 3D space, not z-index).
568
+ * SVG can't render perspective, so this is paint-order only.
569
+ */
570
+ translateZ?: number;
460
571
  /** CSS writing-mode (`horizontal-tb` | `vertical-rl` | `vertical-lr` | `sideways-rl` | `sideways-lr`). */
461
572
  writingMode?: string;
462
573
  /** CSS text-orientation (`mixed` | `upright` | `sideways`). Used in vertical writing-modes. */
@@ -518,7 +629,45 @@ export interface CapturedElement {
518
629
  * `decoration_line_painter.cc`, only solid + double underlines honor
519
630
  * skip-ink; dashed / dotted / wavy ignore it. DM-446. */
520
631
  textDecorationSkipInk?: string;
632
+ /**
633
+ * `box-decoration-break` — `slice` (default) or `clone`. Controls how
634
+ * inline elements that wrap across multiple line boxes paint their
635
+ * background / border / padding / shadow: `slice` paints the box once
636
+ * across all fragments (first fragment gets the left side, last gets the
637
+ * right side); `clone` paints a complete box on every fragment. Captured
638
+ * so the renderer can split the per-fragment paint at line-box boundaries
639
+ * when `inlineFragments` is present.
640
+ */
641
+ boxDecorationBreak?: string;
521
642
  };
643
+ /**
644
+ * Per-line-fragment rects (viewport-relative px) for inline elements that
645
+ * wrap across multiple line boxes. Populated by capture when the element
646
+ * is `display: inline` AND has a non-transparent background or non-zero
647
+ * border AND `el.getClientRects().length > 1`. When present, the renderer
648
+ * paints the background + border per-fragment instead of once across the
649
+ * element's bbox — without this, an inline span like `<span class="hl">…
650
+ * wrapping text …</span>` paints a single rectangle covering the whole
651
+ * logical inline (typically the full container width) and the text floats
652
+ * outside / behind the painted background. Slice vs clone semantics are
653
+ * driven by `styles.boxDecorationBreak`. See `docs/01-fidelity.md`.
654
+ */
655
+ inlineFragments?: Array<{
656
+ x: number;
657
+ y: number;
658
+ width: number;
659
+ height: number;
660
+ }>;
661
+ /** DM-754: the fragmentation axis that produced the `inlineFragments`
662
+ * entries. `"inline"` — the element is `display: inline` and wrapped onto
663
+ * multiple line boxes (the original DM-721 case); slice mode suppresses
664
+ * the LEFT side on non-first fragments and the RIGHT side on non-last.
665
+ * `"block"` — the element is block-level inside a multi-column container
666
+ * ancestor (DM-754); slice mode suppresses TOP on non-first and BOTTOM on
667
+ * non-last. Both axes produce vertically-stacked frag rects in practice,
668
+ * so we can't distinguish them geometrically at render time. Defaults to
669
+ * `"inline"` when undefined (backwards-compatible with pre-DM-754 captures). */
670
+ fragmentAxis?: "inline" | "block";
522
671
  children: CapturedElement[];
523
672
  imageSrc?: string;
524
673
  /** Intrinsic pixel dimensions of <img>, used for object-fit: none. */
@@ -618,6 +767,18 @@ export interface CapturedElement {
618
767
  height: number;
619
768
  dataUri?: string;
620
769
  };
770
+ /**
771
+ * DM-680: per-axis cumulative ancestor scale, present ONLY when the element
772
+ * sits inside an anisotropically scaled subtree (e.g. `transform: scale(1.3,
773
+ * 0.8)`). The geometric mean is already folded into fontSize / fontAscent /
774
+ * fontDescent at capture time — these fields drive a per-axis correction
775
+ * `<g transform="scale(cx, cy)">` around the text emission so glyphs render
776
+ * with the same width / height stretch Chrome paints. Absent when the
777
+ * cumulative scale is isotropic (uniform scale, or no scale) — the
778
+ * geometric-mean handling already produces a faithful result there.
779
+ */
780
+ cumScaleX?: number;
781
+ cumScaleY?: number;
621
782
  /**
622
783
  * For <canvas> / <video> / <iframe> / <object> / <embed>: a viewport-relative
623
784
  * content-box rect (border-box minus border + padding) that
@@ -679,6 +840,18 @@ export interface CapturedElement {
679
840
  * See `docs/21-mask-fragment-references.md`.
680
841
  */
681
842
  maskDefs?: MaskFragmentDef[];
843
+ /**
844
+ * DM-826: Top-level (root only) collection of `<clipPath>` definitions
845
+ * referenced by fragment URLs (`clip-path: url("#id")`) anywhere in the
846
+ * captured tree. CAPTURE_SCRIPT resolves each fragment id via
847
+ * `document.getElementById` and serialises the `<clipPath>` element's
848
+ * `outerHTML` here. The renderer copies these into the output `<defs>`
849
+ * with id rewriting so a captured `<clipPath id="hex">` becomes a
850
+ * domotion-prefixed clip-path def referenced by elements that point at
851
+ * `#hex`. Same-document only — external `.svg#fragment` refs are
852
+ * deferred. See `docs/39-clip-path-fragment-references.md`.
853
+ */
854
+ clipPathDefs?: ClipPathFragmentDef[];
682
855
  /**
683
856
  * DM-494: Raster snapshots of elements referenced by `mask-image:
684
857
  * element(#id)`. Top-level (root only) — same-document only (cross-document
@@ -692,6 +865,38 @@ export interface CapturedElement {
692
865
  * `docs/22-mask-element-paint-references.md`.
693
866
  */
694
867
  maskRasters?: MaskRasterRef[];
868
+ /**
869
+ * DM-579 box-only pseudo-elements: empty-content `::before` / `::after`
870
+ * whose effective rect + per-side borders + background are captured for
871
+ * decorative-separator emission. The renderer in `element-tree-to-svg.ts`
872
+ * emits one `<rect>` per pseudoBox plus up to four `<line>`s for visible
873
+ * borders. Captured per element (not just root). Optional — only emitted
874
+ * when at least one such pseudo exists on this element.
875
+ */
876
+ pseudoBoxes?: PseudoBox[];
877
+ }
878
+ export interface PseudoBox {
879
+ x: number;
880
+ y: number;
881
+ width: number;
882
+ height: number;
883
+ backgroundColor?: string;
884
+ backgroundImage?: string;
885
+ borderTopWidth?: number;
886
+ borderTopColor?: string;
887
+ borderTopStyle?: string;
888
+ borderRightWidth?: number;
889
+ borderRightColor?: string;
890
+ borderRightStyle?: string;
891
+ borderBottomWidth?: number;
892
+ borderBottomColor?: string;
893
+ borderBottomStyle?: string;
894
+ borderLeftWidth?: number;
895
+ borderLeftColor?: string;
896
+ borderLeftStyle?: string;
897
+ borderRadius?: number;
898
+ transform?: string;
899
+ transformOrigin?: string;
695
900
  }
696
901
  export interface MaskFragmentDef {
697
902
  /** Original DOM id of the captured `<mask>` element. */
@@ -699,6 +904,12 @@ export interface MaskFragmentDef {
699
904
  /** Verbatim `outerHTML` of the captured `<mask>` element. */
700
905
  outerHTML: string;
701
906
  }
907
+ export interface ClipPathFragmentDef {
908
+ /** Original DOM id of the captured `<clipPath>` element. */
909
+ id: string;
910
+ /** Verbatim `outerHTML` of the captured `<clipPath>` element. */
911
+ outerHTML: string;
912
+ }
702
913
  export interface MaskRasterRef {
703
914
  /** DOM id referenced by `mask-image: element(#id)` — used by the renderer
704
915
  * to look up the raster from the layer reference. */
@@ -38,8 +38,8 @@ export async function runAnimate(args, help) {
38
38
  const configPath = resolve(positionals[0]);
39
39
  if (!existsSync(configPath))
40
40
  throw new Error(`animate: config not found: ${configPath}`);
41
- const cfg = JSON.parse(readFileSync(configPath, "utf8"));
42
- validateAnimateConfig(cfg);
41
+ const cfgRaw = JSON.parse(readFileSync(configPath, "utf8"));
42
+ const cfg = validateAnimateConfig(cfgRaw);
43
43
  const configDir = dirname(configPath);
44
44
  const log = makeLogger(values.quiet === true);
45
45
  log(`Launching Chromium…`);
@@ -208,36 +208,171 @@ async function runActions(page, actions) {
208
208
  throw new Error(`animate: unknown action type "${a.type}"`);
209
209
  }
210
210
  }
211
- function validateAnimateConfig(cfg) {
212
- if (typeof cfg.width !== "number" || typeof cfg.height !== "number") {
211
+ const COLOR_SCHEMES = new Set(["light", "dark", "no-preference"]);
212
+ const TRANSITION_TYPES = new Set(["crossfade", "push-left", "scroll", "cut"]);
213
+ const OVERLAY_SLIDE_FROMS = new Set(["top", "bottom", "left", "right"]);
214
+ function isObject(v) {
215
+ return typeof v === "object" && v !== null && !Array.isArray(v);
216
+ }
217
+ function validateAnimateConfig(raw) {
218
+ if (!isObject(raw))
219
+ throw new Error("animate: config must be an object");
220
+ if (typeof raw.width !== "number" || typeof raw.height !== "number") {
213
221
  throw new Error("animate: config requires numeric width and height");
214
222
  }
215
- if (!Array.isArray(cfg.frames) || cfg.frames.length === 0) {
223
+ if (raw.output != null && typeof raw.output !== "string") {
224
+ throw new Error("animate: config.output must be a string when present");
225
+ }
226
+ if (raw.optimize != null && typeof raw.optimize !== "boolean") {
227
+ throw new Error("animate: config.optimize must be a boolean when present");
228
+ }
229
+ if (raw.mobile != null && typeof raw.mobile !== "boolean") {
230
+ throw new Error("animate: config.mobile must be a boolean when present");
231
+ }
232
+ if (raw.colorScheme != null && (typeof raw.colorScheme !== "string" || !COLOR_SCHEMES.has(raw.colorScheme))) {
233
+ throw new Error(`animate: config.colorScheme must be one of ${[...COLOR_SCHEMES].join(", ")}`);
234
+ }
235
+ if (!Array.isArray(raw.frames) || raw.frames.length === 0) {
216
236
  throw new Error("animate: config.frames must be a non-empty array");
217
237
  }
218
- for (let i = 0; i < cfg.frames.length; i++) {
219
- const f = cfg.frames[i];
238
+ for (let i = 0; i < raw.frames.length; i++) {
239
+ const f = raw.frames[i];
240
+ if (!isObject(f))
241
+ throw new Error(`animate: frames[${i}] must be an object`);
220
242
  if (typeof f.input !== "string")
221
243
  throw new Error(`animate: frames[${i}].input must be a string`);
222
244
  if (typeof f.duration !== "number")
223
245
  throw new Error(`animate: frames[${i}].duration must be a number`);
246
+ if (f.transition != null) {
247
+ if (!isObject(f.transition))
248
+ throw new Error(`animate: frames[${i}].transition must be an object`);
249
+ if (typeof f.transition.type !== "string" || !TRANSITION_TYPES.has(f.transition.type)) {
250
+ throw new Error(`animate: frames[${i}].transition.type must be one of ${[...TRANSITION_TYPES].join(", ")}`);
251
+ }
252
+ if (typeof f.transition.duration !== "number") {
253
+ throw new Error(`animate: frames[${i}].transition.duration must be a number`);
254
+ }
255
+ }
256
+ if (f.scrollTo != null) {
257
+ if (!Array.isArray(f.scrollTo) || f.scrollTo.length !== 2 || typeof f.scrollTo[0] !== "number" || typeof f.scrollTo[1] !== "number") {
258
+ throw new Error(`animate: frames[${i}].scrollTo must be a [number, number] tuple`);
259
+ }
260
+ }
224
261
  if (f.scroll != null) {
262
+ if (!isObject(f.scroll))
263
+ throw new Error(`animate: frames[${i}].scroll must be an object`);
225
264
  if (typeof f.scroll.pattern !== "string" || f.scroll.pattern.trim() === "") {
226
265
  throw new Error(`animate: frames[${i}].scroll.pattern must be a non-empty string`);
227
266
  }
228
- // Parse the pattern eagerly so config errors surface before the run
229
- // starts (instead of mid-Playwright session). Throws on invalid
230
- // grammar with the original error including source position.
231
267
  try {
232
268
  parseScrollPattern(f.scroll.pattern);
233
269
  }
234
270
  catch (e) {
235
271
  throw new Error(`animate: frames[${i}].scroll.pattern is invalid: ${e instanceof Error ? e.message : String(e)}`);
236
272
  }
237
- if (f.scroll.speed != null && (!Number.isFinite(f.scroll.speed) || f.scroll.speed <= 0)) {
273
+ if (f.scroll.speed != null && (typeof f.scroll.speed !== "number" || !Number.isFinite(f.scroll.speed) || f.scroll.speed <= 0)) {
238
274
  throw new Error(`animate: frames[${i}].scroll.speed must be a positive number (px/s)`);
239
275
  }
240
276
  }
277
+ if (f.actions != null) {
278
+ if (!Array.isArray(f.actions))
279
+ throw new Error(`animate: frames[${i}].actions must be an array`);
280
+ f.actions.forEach((a, ai) => validateAction(a, i, ai));
281
+ }
282
+ if (f.animations != null) {
283
+ if (!Array.isArray(f.animations))
284
+ throw new Error(`animate: frames[${i}].animations must be an array`);
285
+ f.animations.forEach((a, ai) => validateFrameAnimation(a, i, ai));
286
+ }
287
+ if (f.overlays != null && !Array.isArray(f.overlays)) {
288
+ throw new Error(`animate: frames[${i}].overlays must be an array`);
289
+ }
290
+ // Overlay shape is validated lazily in `resolveSvgOverlays` via
291
+ // `validateOverlay` — each entry checked at use-site.
292
+ }
293
+ return raw;
294
+ }
295
+ function validateAction(a, frameIdx, ai) {
296
+ if (!isObject(a))
297
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] must be an object`);
298
+ switch (a.type) {
299
+ case "click":
300
+ case "hover":
301
+ if (typeof a.selector !== "string")
302
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (${a.type}) requires string selector`);
303
+ return;
304
+ case "fill":
305
+ if (typeof a.selector !== "string" || typeof a.value !== "string") {
306
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (fill) requires string selector and value`);
307
+ }
308
+ return;
309
+ case "press":
310
+ if (typeof a.key !== "string")
311
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (press) requires string key`);
312
+ return;
313
+ case "scroll":
314
+ if (a.x != null && typeof a.x !== "number")
315
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (scroll) x must be a number`);
316
+ if (a.y != null && typeof a.y !== "number")
317
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (scroll) y must be a number`);
318
+ return;
319
+ case "wait":
320
+ if (typeof a.ms !== "number")
321
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (wait) requires numeric ms`);
322
+ return;
323
+ default:
324
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}].type "${String(a.type)}" is not a recognised action`);
325
+ }
326
+ }
327
+ function validateFrameAnimation(a, frameIdx, ai) {
328
+ if (!isObject(a))
329
+ throw new Error(`animate: frames[${frameIdx}].animations[${ai}] must be an object`);
330
+ if (typeof a.selector !== "string")
331
+ throw new Error(`animate: frames[${frameIdx}].animations[${ai}].selector must be a string`);
332
+ if (typeof a.property !== "string")
333
+ throw new Error(`animate: frames[${frameIdx}].animations[${ai}].property must be a string`);
334
+ if (typeof a.from !== "string")
335
+ throw new Error(`animate: frames[${frameIdx}].animations[${ai}].from must be a string`);
336
+ if (typeof a.to !== "string")
337
+ throw new Error(`animate: frames[${frameIdx}].animations[${ai}].to must be a string`);
338
+ if (typeof a.duration !== "number")
339
+ throw new Error(`animate: frames[${frameIdx}].animations[${ai}].duration must be a number`);
340
+ }
341
+ function validateOverlay(ov, frameIdx, oi) {
342
+ if (!isObject(ov))
343
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] must be an object`);
344
+ switch (ov.kind) {
345
+ case "typing":
346
+ if (typeof ov.text !== "string" || typeof ov.x !== "number" || typeof ov.y !== "number") {
347
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] (typing) requires text/x/y`);
348
+ }
349
+ return ov;
350
+ case "tap":
351
+ if (typeof ov.x !== "number" || typeof ov.y !== "number") {
352
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] (tap) requires numeric x and y`);
353
+ }
354
+ return ov;
355
+ case "svg":
356
+ if (typeof ov.src !== "string" || typeof ov.x !== "number" || typeof ov.y !== "number" || typeof ov.width !== "number" || typeof ov.height !== "number") {
357
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] (svg) requires src/x/y/width/height`);
358
+ }
359
+ if (ov.enter != null)
360
+ validateOverlaySlide(ov.enter, frameIdx, oi, "enter");
361
+ if (ov.exit != null)
362
+ validateOverlaySlide(ov.exit, frameIdx, oi, "exit");
363
+ return ov;
364
+ default:
365
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].kind "${String(ov.kind)}" is not a recognised overlay`);
366
+ }
367
+ }
368
+ function validateOverlaySlide(s, frameIdx, oi, which) {
369
+ if (!isObject(s))
370
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].${which} must be an object`);
371
+ if (typeof s.from !== "string" || !OVERLAY_SLIDE_FROMS.has(s.from)) {
372
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].${which}.from must be one of ${[...OVERLAY_SLIDE_FROMS].join(", ")}`);
373
+ }
374
+ if (typeof s.duration !== "number") {
375
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].${which}.duration must be a number`);
241
376
  }
242
377
  }
243
378
  /**
@@ -250,9 +385,10 @@ function resolveSvgOverlays(rawOverlays, configDir, frameIdx) {
250
385
  return undefined;
251
386
  const out = [];
252
387
  let svgIdx = 0;
253
- for (const ov of rawOverlays) {
254
- if (ov != null && typeof ov === "object" && ov.kind === "svg") {
255
- const raw = ov;
388
+ for (let oi = 0; oi < rawOverlays.length; oi++) {
389
+ const validated = validateOverlay(rawOverlays[oi], frameIdx, oi);
390
+ if (validated.kind === "svg" && "src" in rawOverlays[oi]) {
391
+ const raw = rawOverlays[oi];
256
392
  const srcPath = resolve(configDir, raw.src);
257
393
  if (!existsSync(srcPath))
258
394
  throw new Error(`animate: svg overlay file not found: ${srcPath}`);
@@ -268,7 +404,7 @@ function resolveSvgOverlays(rawOverlays, configDir, frameIdx) {
268
404
  });
269
405
  }
270
406
  else {
271
- out.push(ov);
407
+ out.push(validated);
272
408
  }
273
409
  }
274
410
  return out;
package/dist/mask.test.js CHANGED
@@ -27,17 +27,22 @@ describe("buildMaskDef — single-layer gradient masks (DM-395)", () => {
27
27
  it("radial-gradient mask centers correctly when sized + positioned", () => {
28
28
  // mask-image: radial-gradient(circle, black 40%, transparent 40%);
29
29
  // mask-size: 80px; mask-position: 25% 25%
30
- // Element at (680, 240); mask should be at gx=680+25, gy=240+10, 80x80.
30
+ // Element at (680, 240, 180x120). DM-679: single-length mask-size is
31
+ // `width=80, height=auto`. For gradient layers (no intrinsic size)
32
+ // `auto` resolves to the container's corresponding axis per CSS
33
+ // Backgrounds 3 §3.7 + CSS Images 3 §6.2 — so the gradient box is
34
+ // 80×120 (height = container 120), not 80×80. Position 25% 25% then
35
+ // gives gx=680 + 0.25*(180-80)=705 and gy=240 + 0.25*(120-120)=240.
31
36
  const r = buildMaskDef("m", "radial-gradient(circle, black 40%, transparent 40%)", 680, 240, 180, 120, "match-source", "80px", "25% 25%", "no-repeat", "add");
32
37
  expect(r.def).toContain('x="705"');
33
- expect(r.def).toContain('y="250"');
38
+ expect(r.def).toContain('y="240"');
34
39
  expect(r.def).toContain('width="80"');
35
- expect(r.def).toContain('height="80"');
36
- // Center of the 80x80 mask box: cx=745, cy=290.
40
+ expect(r.def).toContain('height="120"');
41
+ // Center of the 80x120 mask box: cx=745, cy=300.
37
42
  expect(r.def).toMatch(/cx="745"/);
38
- expect(r.def).toMatch(/cy="290"/);
39
- // farthest-corner radius = sqrt(40^2 + 40^2) ≈ 56.5685.
40
- expect(r.def).toMatch(/r="56\.5685"/);
43
+ expect(r.def).toMatch(/cy="300"/);
44
+ // farthest-corner radius = sqrt(40^2 + 60^2) ≈ 72.111.
45
+ expect(r.def).toMatch(/r="72\.111"/);
41
46
  });
42
47
  it("mask-mode: alpha emits mask-type='alpha'", () => {
43
48
  const r = buildMaskDef("m", "linear-gradient(45deg, black, transparent)", 0, 0, 180, 120, "alpha", "auto", "0% 0%", "repeat", "add");
@@ -43,6 +43,15 @@ export declare function parseCornerRadii(styles: {
43
43
  * that the inner corner is the outer corner pulled in by the adjacent border
44
44
  * widths (top + left for TL, top + right for TR, etc.). */
45
45
  export declare function insetCornerRadii(c: CornerRadii, top: number, right: number, bottom: number, left: number): CornerRadii;
46
+ /** Grow each corner radius outward by `spread` for an OUTSET box-shadow shape.
47
+ * Per CSS Backgrounds 3 §6.4 and Chromium's `FloatRoundedRect::Outset`, a
48
+ * corner whose source radius is zero STAYS sharp through any spread — only
49
+ * pre-curved corners grow. A naive `corner + spread` produces visibly
50
+ * rounded shadow corners on a sharp-cornered box (e.g. concentric outlines
51
+ * built from `box-shadow: 0 0 0 Npx`). Use this for outset shadow shapes;
52
+ * the dual inset case is already covered by `insetCornerRadii` shrinking to
53
+ * zero when the border eats past the radius. */
54
+ export declare function outsetCornerRadiiForShadow(c: CornerRadii, spread: number): CornerRadii;
46
55
  /** Emit an SVG path `d` attribute for a rounded rectangle with per-corner radii.
47
56
  * Path goes clockwise from the top-left, using elliptical arc commands at each
48
57
  * corner. Zero-radius corners collapse to a sharp 90° join. */
@@ -82,19 +91,6 @@ export declare function dashArrayForStyle(style: string, width: number): string;
82
91
  * them onto the nested `<svg>` is correct in both cases.
83
92
  */
84
93
  export declare function injectSvgSize(svgHtml: string, w: number, h: number): string;
85
- /**
86
- * Render a CSS border-image 9-slice around the element's border box.
87
- *
88
- * Supports:
89
- * - border-image-source: url(...) (gradient sources out of scope)
90
- * - border-image-slice: t r b l (percent + length, optional 'fill')
91
- * - border-image-width: per-side (falls back to element border-width)
92
- * - border-image-outset: per-side
93
- * - border-image-repeat: stretch / repeat / round / space
94
- *
95
- * Returns { svg, usedIds }. usedIds indicates how many clipIdx values were
96
- * consumed so the caller can keep its own counter in sync.
97
- */
98
94
  export declare function renderBorderImage(el: CapturedElement, indent: string, idPrefix: string, defsParts: string[], clipIdx: number): {
99
95
  svg: string;
100
96
  usedIds: number;