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
@@ -105,13 +105,51 @@ export interface TextSegment {
105
105
  /** Pre-resolved srgb-form CSS color (or "transparent"). Renderer parses
106
106
  * via parseColor and emits as the rect fill. */
107
107
  backgroundColor?: string;
108
+ /** Raw CSS `background-image` value (gradient / url() / multiple
109
+ * layers) when the pseudo paints a gradient or image instead of (or in
110
+ * addition to) a flat color. DM-767: `.corner::after` accent stripes
111
+ * with `background: linear-gradient(...)` need this — without it the
112
+ * pseudoBox emit was skipped entirely. Renderer threads through
113
+ * `buildBackgroundLayerDef` the same way the regular-element
114
+ * background-image path does. */
115
+ backgroundImage?: string;
108
116
  /** Resolved px border-radius (CSS shorthand — single value, four-corner
109
117
  * symmetric). */
110
118
  borderRadius?: number;
111
- /** Uniform border (single side-width + color). Per-side variation isn't
112
- * supported in this pass; mixed-style borders fall through. */
119
+ /** Uniform border (single side-width + color). When set, the renderer
120
+ * emits `<rect stroke=... stroke-width=...>` around the box. */
113
121
  borderWidth?: number;
114
122
  borderColor?: string;
123
+ /** Per-side border widths (px). Always populated; zero when the side
124
+ * carries no border. Renderer reads these together with the per-side
125
+ * colors below when no `borderWidth` (uniform) is set. */
126
+ borL?: number;
127
+ borR?: number;
128
+ borT?: number;
129
+ borB?: number;
130
+ /** Per-side border colors. Set ONLY when the pseudo has a non-uniform
131
+ * border (e.g. `border-bottom: 1px solid …` only). For uniform borders
132
+ * the renderer uses `borderColor` + `borderWidth` and the per-side
133
+ * fields are undefined. Slashdot's `.carouselHeading::after` is the
134
+ * motivating fixture — a 1px translucent-white border-bottom on the
135
+ * italic "Most Discussed" heading. */
136
+ borderTopColor?: string;
137
+ borderRightColor?: string;
138
+ borderBottomColor?: string;
139
+ borderLeftColor?: string;
140
+ /** DM-783: pseudo's own `transform` (rotate/scale/translate/matrix/skew).
141
+ * Captured verbatim from `getComputedStyle(host, '::before').transform`,
142
+ * which Chrome returns in resolved `matrix(a,b,c,d,e,f)` form — pasteable
143
+ * directly into an SVG `<g transform="…">` wrapper. Renderer wraps the
144
+ * pseudoBox rect + glyph emit so rotate(45deg) on a `::before { border-
145
+ * right; border-bottom }` paints as a check-mark (the rotation pivots
146
+ * around the box center per `transformOrigin`). */
147
+ transform?: string;
148
+ /** Resolved px transform-origin (e.g. `"50px 50px"` for a 100×100 box's
149
+ * default `50% 50%`). Renderer pre-bakes a translate-transform-translate
150
+ * matrix so the rotation/scale pivots around the captured origin instead
151
+ * of (0, 0). When undefined, renderer defaults to the box center. */
152
+ transformOrigin?: string;
115
153
  };
116
154
  }
117
155
 
@@ -186,6 +224,14 @@ export interface CapturedElement {
186
224
  borderCollapse: string;
187
225
  overflowX: string;
188
226
  overflowY: string;
227
+ /**
228
+ * CSS `overflow-clip-margin` shorthand value as computed by Chrome
229
+ * (e.g. `"20px"`, `"content-box 12px"`, or the empty string when the
230
+ * default `0px` resolves and the element doesn't paint outside its
231
+ * reference box). Only takes effect when `overflow: clip` (DM-761) —
232
+ * `hidden` ignores it per CSS Overflow 3.
233
+ */
234
+ overflowClipMargin?: string;
189
235
  scrollbarGutter: string;
190
236
  /** el.scrollHeight / scrollWidth vs client* — used to decide whether to paint a scrollbar. */
191
237
  scrollWidth: number;
@@ -218,6 +264,36 @@ export interface CapturedElement {
218
264
  maskPosition: string;
219
265
  maskRepeat: string;
220
266
  maskComposite: string;
267
+ /**
268
+ * CSS `mask-clip` — the box (border-box / padding-box / content-box / etc.)
269
+ * the mask painted area is clipped to. Defaults to `border-box`. Captured
270
+ * separately from `mask-origin` because Chromium retains both verbatim
271
+ * (mask-clip controls visibility, mask-origin controls layer positioning).
272
+ */
273
+ maskClip?: string;
274
+ /**
275
+ * CSS `mask-border-source` / legacy `-webkit-mask-box-image` source.
276
+ * Chrome exposes this only via the legacy webkit name (DM-758). The
277
+ * renderer routes it through the mask-image pipeline ONLY for the
278
+ * "simple" 9-slice cases (`slice 0 fill / 0 / 0` and `slice 1 fill / 0
279
+ * / 0`) where the entire source is used as a stretched full-element
280
+ * mask. Real 9-slice tiling (non-zero `width` / `outset`, `round` /
281
+ * `space` repeat) needs its own implementation.
282
+ */
283
+ maskBorderSource?: string;
284
+ /** Resolved `-webkit-mask-box-image-slice` (e.g. `"1 fill"`, `"30 fill"`). */
285
+ maskBorderSlice?: string;
286
+ /** Resolved `-webkit-mask-box-image-width` (e.g. `"0"`, `"20px"`). */
287
+ maskBorderWidth?: string;
288
+ /** Resolved `-webkit-mask-box-image-outset` (e.g. `"0"`, `"15px"`). */
289
+ maskBorderOutset?: string;
290
+ /** Resolved `-webkit-mask-box-image-repeat` (`stretch` / `repeat` /
291
+ * `round` / `space`, optionally one per axis). DM-793. */
292
+ maskBorderRepeat?: string;
293
+ /** Intrinsic dimensions of the `mask-border-source` asset (px). Same
294
+ * probe pattern as `borderImageIntrinsicWidth`. DM-793. */
295
+ maskBorderIntrinsicWidth?: number;
296
+ maskBorderIntrinsicHeight?: number;
221
297
  listStyleType: string;
222
298
  listStyleImage: string;
223
299
  listStylePosition: string;
@@ -231,6 +307,14 @@ export interface CapturedElement {
231
307
  backgroundClip: string;
232
308
  backgroundOrigin: string;
233
309
  backgroundAttachment: string;
310
+ /**
311
+ * CSS `background-blend-mode` — per-layer blend mode (comma-separated to
312
+ * match the layer count). Captured verbatim from `getComputedStyle`. The
313
+ * renderer applies each layer's mode as `style="mix-blend-mode:<mode>"`
314
+ * on the layer's `<rect>`, wrapped in a `<g style="isolation:isolate">`
315
+ * so the blend doesn't escape the element's bg-layer stack.
316
+ */
317
+ backgroundBlendMode?: string;
234
318
  /**
235
319
  * CSS `-webkit-text-fill-color`. When `backgroundClip` is `text` the
236
320
  * common pattern is `webkit-text-fill-color: transparent` so the
@@ -240,6 +324,25 @@ export interface CapturedElement {
240
324
  * when the rendered text is actually transparent. DM-462.
241
325
  */
242
326
  webkitTextFillColor?: string;
327
+ /**
328
+ * DM-749: Stripe / Resend pattern — when an element has
329
+ * `webkit-text-fill-color: transparent` but its own background-image is
330
+ * `none`, the gradient lives on an ANCESTOR with `background-clip:
331
+ * text`. Chrome's paint propagates that gradient through descendant
332
+ * glyphs. Captured as the resolved `background-image` string of the
333
+ * nearest ancestor with `background-clip: text` (walked up to 8 levels).
334
+ */
335
+ inheritedTextFillGradient?: string;
336
+ /** `-webkit-text-stroke-width` (e.g. "2px"). DM-719. */
337
+ webkitTextStrokeWidth?: string;
338
+ /** `-webkit-text-stroke-color` (e.g. "rgb(220,38,38)"). DM-719. */
339
+ webkitTextStrokeColor?: string;
340
+ /** `paint-order` (e.g. "stroke fill"). Controls whether the text stroke
341
+ * paints before or after the fill — `stroke fill` puts the stroke
342
+ * UNDER the fill so the fill rests on top of half the stroke width,
343
+ * eliminating the chunky "fill-on-top-of-stroke" artifact at large
344
+ * stroke widths. DM-719. */
345
+ paintOrder?: string;
243
346
  paddingTop: string;
244
347
  paddingRight: string;
245
348
  paddingBottom: string;
@@ -446,6 +549,14 @@ export interface CapturedElement {
446
549
  transformCreatesSc?: boolean;
447
550
  /** CSS transform-style. `preserve-3d` (or anything != `flat`) creates a stacking context per CSS Transforms 2 §4 (DM-589). */
448
551
  transformStyle?: string;
552
+ /**
553
+ * DM-751: extracted Z translation from `matrix3d(...)` when the
554
+ * element's transform has a non-zero translateZ component. Used by the
555
+ * paint-order sort when the parent has `transform-style: preserve-3d`
556
+ * (CSS Transforms 2 §6 sorts children by Z in 3D space, not z-index).
557
+ * SVG can't render perspective, so this is paint-order only.
558
+ */
559
+ translateZ?: number;
449
560
  /** CSS writing-mode (`horizontal-tb` | `vertical-rl` | `vertical-lr` | `sideways-rl` | `sideways-lr`). */
450
561
  writingMode?: string;
451
562
  /** CSS text-orientation (`mixed` | `upright` | `sideways`). Used in vertical writing-modes. */
@@ -507,7 +618,40 @@ export interface CapturedElement {
507
618
  * `decoration_line_painter.cc`, only solid + double underlines honor
508
619
  * skip-ink; dashed / dotted / wavy ignore it. DM-446. */
509
620
  textDecorationSkipInk?: string;
621
+ /**
622
+ * `box-decoration-break` — `slice` (default) or `clone`. Controls how
623
+ * inline elements that wrap across multiple line boxes paint their
624
+ * background / border / padding / shadow: `slice` paints the box once
625
+ * across all fragments (first fragment gets the left side, last gets the
626
+ * right side); `clone` paints a complete box on every fragment. Captured
627
+ * so the renderer can split the per-fragment paint at line-box boundaries
628
+ * when `inlineFragments` is present.
629
+ */
630
+ boxDecorationBreak?: string;
510
631
  };
632
+ /**
633
+ * Per-line-fragment rects (viewport-relative px) for inline elements that
634
+ * wrap across multiple line boxes. Populated by capture when the element
635
+ * is `display: inline` AND has a non-transparent background or non-zero
636
+ * border AND `el.getClientRects().length > 1`. When present, the renderer
637
+ * paints the background + border per-fragment instead of once across the
638
+ * element's bbox — without this, an inline span like `<span class="hl">…
639
+ * wrapping text …</span>` paints a single rectangle covering the whole
640
+ * logical inline (typically the full container width) and the text floats
641
+ * outside / behind the painted background. Slice vs clone semantics are
642
+ * driven by `styles.boxDecorationBreak`. See `docs/01-fidelity.md`.
643
+ */
644
+ inlineFragments?: Array<{ x: number; y: number; width: number; height: number }>;
645
+ /** DM-754: the fragmentation axis that produced the `inlineFragments`
646
+ * entries. `"inline"` — the element is `display: inline` and wrapped onto
647
+ * multiple line boxes (the original DM-721 case); slice mode suppresses
648
+ * the LEFT side on non-first fragments and the RIGHT side on non-last.
649
+ * `"block"` — the element is block-level inside a multi-column container
650
+ * ancestor (DM-754); slice mode suppresses TOP on non-first and BOTTOM on
651
+ * non-last. Both axes produce vertically-stacked frag rects in practice,
652
+ * so we can't distinguish them geometrically at render time. Defaults to
653
+ * `"inline"` when undefined (backwards-compatible with pre-DM-754 captures). */
654
+ fragmentAxis?: "inline" | "block";
511
655
  children: CapturedElement[];
512
656
  imageSrc?: string;
513
657
  /** Intrinsic pixel dimensions of <img>, used for object-fit: none. */
@@ -589,6 +733,18 @@ export interface CapturedElement {
589
733
  * content region. Detection happens in CAPTURE_SCRIPT.
590
734
  */
591
735
  elementRaster?: { x: number; y: number; width: number; height: number; dataUri?: string };
736
+ /**
737
+ * DM-680: per-axis cumulative ancestor scale, present ONLY when the element
738
+ * sits inside an anisotropically scaled subtree (e.g. `transform: scale(1.3,
739
+ * 0.8)`). The geometric mean is already folded into fontSize / fontAscent /
740
+ * fontDescent at capture time — these fields drive a per-axis correction
741
+ * `<g transform="scale(cx, cy)">` around the text emission so glyphs render
742
+ * with the same width / height stretch Chrome paints. Absent when the
743
+ * cumulative scale is isotropic (uniform scale, or no scale) — the
744
+ * geometric-mean handling already produces a faithful result there.
745
+ */
746
+ cumScaleX?: number;
747
+ cumScaleY?: number;
592
748
  /**
593
749
  * For <canvas> / <video> / <iframe> / <object> / <embed>: a viewport-relative
594
750
  * content-box rect (border-box minus border + padding) that
@@ -636,6 +792,18 @@ export interface CapturedElement {
636
792
  * See `docs/21-mask-fragment-references.md`.
637
793
  */
638
794
  maskDefs?: MaskFragmentDef[];
795
+ /**
796
+ * DM-826: Top-level (root only) collection of `<clipPath>` definitions
797
+ * referenced by fragment URLs (`clip-path: url("#id")`) anywhere in the
798
+ * captured tree. CAPTURE_SCRIPT resolves each fragment id via
799
+ * `document.getElementById` and serialises the `<clipPath>` element's
800
+ * `outerHTML` here. The renderer copies these into the output `<defs>`
801
+ * with id rewriting so a captured `<clipPath id="hex">` becomes a
802
+ * domotion-prefixed clip-path def referenced by elements that point at
803
+ * `#hex`. Same-document only — external `.svg#fragment` refs are
804
+ * deferred. See `docs/39-clip-path-fragment-references.md`.
805
+ */
806
+ clipPathDefs?: ClipPathFragmentDef[];
639
807
  /**
640
808
  * DM-494: Raster snapshots of elements referenced by `mask-image:
641
809
  * element(#id)`. Top-level (root only) — same-document only (cross-document
@@ -649,6 +817,31 @@ export interface CapturedElement {
649
817
  * `docs/22-mask-element-paint-references.md`.
650
818
  */
651
819
  maskRasters?: MaskRasterRef[];
820
+ /**
821
+ * DM-579 box-only pseudo-elements: empty-content `::before` / `::after`
822
+ * whose effective rect + per-side borders + background are captured for
823
+ * decorative-separator emission. The renderer in `element-tree-to-svg.ts`
824
+ * emits one `<rect>` per pseudoBox plus up to four `<line>`s for visible
825
+ * borders. Captured per element (not just root). Optional — only emitted
826
+ * when at least one such pseudo exists on this element.
827
+ */
828
+ pseudoBoxes?: PseudoBox[];
829
+ }
830
+
831
+ export interface PseudoBox {
832
+ x: number;
833
+ y: number;
834
+ width: number;
835
+ height: number;
836
+ backgroundColor?: string;
837
+ backgroundImage?: string;
838
+ borderTopWidth?: number; borderTopColor?: string; borderTopStyle?: string;
839
+ borderRightWidth?: number; borderRightColor?: string; borderRightStyle?: string;
840
+ borderBottomWidth?: number; borderBottomColor?: string; borderBottomStyle?: string;
841
+ borderLeftWidth?: number; borderLeftColor?: string; borderLeftStyle?: string;
842
+ borderRadius?: number;
843
+ transform?: string;
844
+ transformOrigin?: string;
652
845
  }
653
846
 
654
847
  export interface MaskFragmentDef {
@@ -658,6 +851,13 @@ export interface MaskFragmentDef {
658
851
  outerHTML: string;
659
852
  }
660
853
 
854
+ export interface ClipPathFragmentDef {
855
+ /** Original DOM id of the captured `<clipPath>` element. */
856
+ id: string;
857
+ /** Verbatim `outerHTML` of the captured `<clipPath>` element. */
858
+ outerHTML: string;
859
+ }
860
+
661
861
  export interface MaskRasterRef {
662
862
  /** DOM id referenced by `mask-image: element(#id)` — used by the renderer
663
863
  * to look up the raster from the layer reference. */
@@ -129,8 +129,8 @@ export async function runAnimate(args: string[], help: string): Promise<void> {
129
129
  const configPath = resolve(positionals[0]);
130
130
  if (!existsSync(configPath)) throw new Error(`animate: config not found: ${configPath}`);
131
131
 
132
- const cfg = JSON.parse(readFileSync(configPath, "utf8")) as AnimateConfig;
133
- validateAnimateConfig(cfg);
132
+ const cfgRaw: unknown = JSON.parse(readFileSync(configPath, "utf8"));
133
+ const cfg = validateAnimateConfig(cfgRaw);
134
134
  const configDir = dirname(configPath);
135
135
 
136
136
  const log = makeLogger(values.quiet === true);
@@ -304,33 +304,152 @@ async function runActions(page: Page, actions: AnimateAction[]): Promise<void> {
304
304
  }
305
305
  }
306
306
 
307
- function validateAnimateConfig(cfg: AnimateConfig): void {
308
- if (typeof cfg.width !== "number" || typeof cfg.height !== "number") {
307
+ const COLOR_SCHEMES = new Set(["light", "dark", "no-preference"] as const);
308
+ const TRANSITION_TYPES = new Set(["crossfade", "push-left", "scroll", "cut"] as const);
309
+ const OVERLAY_SLIDE_FROMS = new Set(["top", "bottom", "left", "right"] as const);
310
+
311
+ function isObject(v: unknown): v is Record<string, unknown> {
312
+ return typeof v === "object" && v !== null && !Array.isArray(v);
313
+ }
314
+
315
+ function validateAnimateConfig(raw: unknown): AnimateConfig {
316
+ if (!isObject(raw)) throw new Error("animate: config must be an object");
317
+ if (typeof raw.width !== "number" || typeof raw.height !== "number") {
309
318
  throw new Error("animate: config requires numeric width and height");
310
319
  }
311
- if (!Array.isArray(cfg.frames) || cfg.frames.length === 0) {
320
+ if (raw.output != null && typeof raw.output !== "string") {
321
+ throw new Error("animate: config.output must be a string when present");
322
+ }
323
+ if (raw.optimize != null && typeof raw.optimize !== "boolean") {
324
+ throw new Error("animate: config.optimize must be a boolean when present");
325
+ }
326
+ if (raw.mobile != null && typeof raw.mobile !== "boolean") {
327
+ throw new Error("animate: config.mobile must be a boolean when present");
328
+ }
329
+ if (raw.colorScheme != null && (typeof raw.colorScheme !== "string" || !COLOR_SCHEMES.has(raw.colorScheme as never))) {
330
+ throw new Error(`animate: config.colorScheme must be one of ${[...COLOR_SCHEMES].join(", ")}`);
331
+ }
332
+ if (!Array.isArray(raw.frames) || raw.frames.length === 0) {
312
333
  throw new Error("animate: config.frames must be a non-empty array");
313
334
  }
314
- for (let i = 0; i < cfg.frames.length; i++) {
315
- const f = cfg.frames[i];
335
+ for (let i = 0; i < raw.frames.length; i++) {
336
+ const f = raw.frames[i];
337
+ if (!isObject(f)) throw new Error(`animate: frames[${i}] must be an object`);
316
338
  if (typeof f.input !== "string") throw new Error(`animate: frames[${i}].input must be a string`);
317
339
  if (typeof f.duration !== "number") throw new Error(`animate: frames[${i}].duration must be a number`);
340
+ if (f.transition != null) {
341
+ if (!isObject(f.transition)) throw new Error(`animate: frames[${i}].transition must be an object`);
342
+ if (typeof f.transition.type !== "string" || !TRANSITION_TYPES.has(f.transition.type as never)) {
343
+ throw new Error(`animate: frames[${i}].transition.type must be one of ${[...TRANSITION_TYPES].join(", ")}`);
344
+ }
345
+ if (typeof f.transition.duration !== "number") {
346
+ throw new Error(`animate: frames[${i}].transition.duration must be a number`);
347
+ }
348
+ }
349
+ if (f.scrollTo != null) {
350
+ if (!Array.isArray(f.scrollTo) || f.scrollTo.length !== 2 || typeof f.scrollTo[0] !== "number" || typeof f.scrollTo[1] !== "number") {
351
+ throw new Error(`animate: frames[${i}].scrollTo must be a [number, number] tuple`);
352
+ }
353
+ }
318
354
  if (f.scroll != null) {
355
+ if (!isObject(f.scroll)) throw new Error(`animate: frames[${i}].scroll must be an object`);
319
356
  if (typeof f.scroll.pattern !== "string" || f.scroll.pattern.trim() === "") {
320
357
  throw new Error(`animate: frames[${i}].scroll.pattern must be a non-empty string`);
321
358
  }
322
- // Parse the pattern eagerly so config errors surface before the run
323
- // starts (instead of mid-Playwright session). Throws on invalid
324
- // grammar with the original error including source position.
325
359
  try {
326
360
  parseScrollPattern(f.scroll.pattern);
327
361
  } catch (e) {
328
362
  throw new Error(`animate: frames[${i}].scroll.pattern is invalid: ${e instanceof Error ? e.message : String(e)}`);
329
363
  }
330
- if (f.scroll.speed != null && (!Number.isFinite(f.scroll.speed) || f.scroll.speed <= 0)) {
364
+ if (f.scroll.speed != null && (typeof f.scroll.speed !== "number" || !Number.isFinite(f.scroll.speed) || f.scroll.speed <= 0)) {
331
365
  throw new Error(`animate: frames[${i}].scroll.speed must be a positive number (px/s)`);
332
366
  }
333
367
  }
368
+ if (f.actions != null) {
369
+ if (!Array.isArray(f.actions)) throw new Error(`animate: frames[${i}].actions must be an array`);
370
+ f.actions.forEach((a, ai) => validateAction(a, i, ai));
371
+ }
372
+ if (f.animations != null) {
373
+ if (!Array.isArray(f.animations)) throw new Error(`animate: frames[${i}].animations must be an array`);
374
+ f.animations.forEach((a, ai) => validateFrameAnimation(a, i, ai));
375
+ }
376
+ if (f.overlays != null && !Array.isArray(f.overlays)) {
377
+ throw new Error(`animate: frames[${i}].overlays must be an array`);
378
+ }
379
+ // Overlay shape is validated lazily in `resolveSvgOverlays` via
380
+ // `validateOverlay` — each entry checked at use-site.
381
+ }
382
+ return raw as unknown as AnimateConfig;
383
+ }
384
+
385
+ function validateAction(a: unknown, frameIdx: number, ai: number): void {
386
+ if (!isObject(a)) throw new Error(`animate: frames[${frameIdx}].actions[${ai}] must be an object`);
387
+ switch (a.type) {
388
+ case "click":
389
+ case "hover":
390
+ if (typeof a.selector !== "string") throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (${a.type}) requires string selector`);
391
+ return;
392
+ case "fill":
393
+ if (typeof a.selector !== "string" || typeof a.value !== "string") {
394
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (fill) requires string selector and value`);
395
+ }
396
+ return;
397
+ case "press":
398
+ if (typeof a.key !== "string") throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (press) requires string key`);
399
+ return;
400
+ case "scroll":
401
+ if (a.x != null && typeof a.x !== "number") throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (scroll) x must be a number`);
402
+ if (a.y != null && typeof a.y !== "number") throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (scroll) y must be a number`);
403
+ return;
404
+ case "wait":
405
+ if (typeof a.ms !== "number") throw new Error(`animate: frames[${frameIdx}].actions[${ai}] (wait) requires numeric ms`);
406
+ return;
407
+ default:
408
+ throw new Error(`animate: frames[${frameIdx}].actions[${ai}].type "${String(a.type)}" is not a recognised action`);
409
+ }
410
+ }
411
+
412
+ function validateFrameAnimation(a: unknown, frameIdx: number, ai: number): void {
413
+ if (!isObject(a)) throw new Error(`animate: frames[${frameIdx}].animations[${ai}] must be an object`);
414
+ if (typeof a.selector !== "string") throw new Error(`animate: frames[${frameIdx}].animations[${ai}].selector must be a string`);
415
+ if (typeof a.property !== "string") throw new Error(`animate: frames[${frameIdx}].animations[${ai}].property must be a string`);
416
+ if (typeof a.from !== "string") throw new Error(`animate: frames[${frameIdx}].animations[${ai}].from must be a string`);
417
+ if (typeof a.to !== "string") throw new Error(`animate: frames[${frameIdx}].animations[${ai}].to must be a string`);
418
+ if (typeof a.duration !== "number") throw new Error(`animate: frames[${frameIdx}].animations[${ai}].duration must be a number`);
419
+ }
420
+
421
+ function validateOverlay(ov: unknown, frameIdx: number, oi: number): AnimationOverlay {
422
+ if (!isObject(ov)) throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] must be an object`);
423
+ switch (ov.kind) {
424
+ case "typing":
425
+ if (typeof ov.text !== "string" || typeof ov.x !== "number" || typeof ov.y !== "number") {
426
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] (typing) requires text/x/y`);
427
+ }
428
+ return ov as unknown as AnimationOverlay;
429
+ case "tap":
430
+ if (typeof ov.x !== "number" || typeof ov.y !== "number") {
431
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] (tap) requires numeric x and y`);
432
+ }
433
+ return ov as unknown as AnimationOverlay;
434
+ case "svg":
435
+ if (typeof ov.src !== "string" || typeof ov.x !== "number" || typeof ov.y !== "number" || typeof ov.width !== "number" || typeof ov.height !== "number") {
436
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}] (svg) requires src/x/y/width/height`);
437
+ }
438
+ if (ov.enter != null) validateOverlaySlide(ov.enter, frameIdx, oi, "enter");
439
+ if (ov.exit != null) validateOverlaySlide(ov.exit, frameIdx, oi, "exit");
440
+ return ov as unknown as AnimationOverlay;
441
+ default:
442
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].kind "${String(ov.kind)}" is not a recognised overlay`);
443
+ }
444
+ }
445
+
446
+ function validateOverlaySlide(s: unknown, frameIdx: number, oi: number, which: "enter" | "exit"): void {
447
+ if (!isObject(s)) throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].${which} must be an object`);
448
+ if (typeof s.from !== "string" || !OVERLAY_SLIDE_FROMS.has(s.from as never)) {
449
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].${which}.from must be one of ${[...OVERLAY_SLIDE_FROMS].join(", ")}`);
450
+ }
451
+ if (typeof s.duration !== "number") {
452
+ throw new Error(`animate: frames[${frameIdx}].overlays[${oi}].${which}.duration must be a number`);
334
453
  }
335
454
  }
336
455
 
@@ -343,9 +462,10 @@ function resolveSvgOverlays(rawOverlays: unknown[] | undefined, configDir: strin
343
462
  if (rawOverlays == null) return undefined;
344
463
  const out: AnimationOverlay[] = [];
345
464
  let svgIdx = 0;
346
- for (const ov of rawOverlays) {
347
- if (ov != null && typeof ov === "object" && (ov as { kind?: string }).kind === "svg") {
348
- const raw = ov as { kind: "svg"; src: string; x: number; y: number; width: number; height: number; enter?: SvgOverlay["enter"]; exit?: SvgOverlay["exit"] };
465
+ for (let oi = 0; oi < rawOverlays.length; oi++) {
466
+ const validated = validateOverlay(rawOverlays[oi], frameIdx, oi);
467
+ if (validated.kind === "svg" && "src" in (rawOverlays[oi] as Record<string, unknown>)) {
468
+ const raw = rawOverlays[oi] as { src: string; x: number; y: number; width: number; height: number; enter?: SvgOverlay["enter"]; exit?: SvgOverlay["exit"] };
349
469
  const srcPath = resolve(configDir, raw.src);
350
470
  if (!existsSync(srcPath)) throw new Error(`animate: svg overlay file not found: ${srcPath}`);
351
471
  const fileText = readFileSync(srcPath, "utf8");
@@ -359,7 +479,7 @@ function resolveSvgOverlays(rawOverlays: unknown[] | undefined, configDir: strin
359
479
  enter: raw.enter, exit: raw.exit,
360
480
  });
361
481
  } else {
362
- out.push(ov as AnimationOverlay);
482
+ out.push(validated);
363
483
  }
364
484
  }
365
485
  return out;
package/src/mask.test.ts CHANGED
@@ -33,18 +33,23 @@ describe("buildMaskDef — single-layer gradient masks (DM-395)", () => {
33
33
  it("radial-gradient mask centers correctly when sized + positioned", () => {
34
34
  // mask-image: radial-gradient(circle, black 40%, transparent 40%);
35
35
  // mask-size: 80px; mask-position: 25% 25%
36
- // Element at (680, 240); mask should be at gx=680+25, gy=240+10, 80x80.
36
+ // Element at (680, 240, 180x120). DM-679: single-length mask-size is
37
+ // `width=80, height=auto`. For gradient layers (no intrinsic size)
38
+ // `auto` resolves to the container's corresponding axis per CSS
39
+ // Backgrounds 3 §3.7 + CSS Images 3 §6.2 — so the gradient box is
40
+ // 80×120 (height = container 120), not 80×80. Position 25% 25% then
41
+ // gives gx=680 + 0.25*(180-80)=705 and gy=240 + 0.25*(120-120)=240.
37
42
  const r = buildMaskDef("m", "radial-gradient(circle, black 40%, transparent 40%)",
38
43
  680, 240, 180, 120, "match-source", "80px", "25% 25%", "no-repeat", "add");
39
44
  expect(r.def).toContain('x="705"');
40
- expect(r.def).toContain('y="250"');
45
+ expect(r.def).toContain('y="240"');
41
46
  expect(r.def).toContain('width="80"');
42
- expect(r.def).toContain('height="80"');
43
- // Center of the 80x80 mask box: cx=745, cy=290.
47
+ expect(r.def).toContain('height="120"');
48
+ // Center of the 80x120 mask box: cx=745, cy=300.
44
49
  expect(r.def).toMatch(/cx="745"/);
45
- expect(r.def).toMatch(/cy="290"/);
46
- // farthest-corner radius = sqrt(40^2 + 40^2) ≈ 56.5685.
47
- expect(r.def).toMatch(/r="56\.5685"/);
50
+ expect(r.def).toMatch(/cy="300"/);
51
+ // farthest-corner radius = sqrt(40^2 + 60^2) ≈ 72.111.
52
+ expect(r.def).toMatch(/r="72\.111"/);
48
53
  });
49
54
 
50
55
  it("mask-mode: alpha emits mask-type='alpha'", () => {