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.
- package/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
package/src/capture/types.ts
CHANGED
|
@@ -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).
|
|
112
|
-
*
|
|
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. */
|
package/src/cli/animate.ts
CHANGED
|
@@ -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
|
|
133
|
-
validateAnimateConfig(
|
|
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
|
-
|
|
308
|
-
|
|
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 (
|
|
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 <
|
|
315
|
-
const f =
|
|
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 (
|
|
347
|
-
|
|
348
|
-
|
|
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(
|
|
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)
|
|
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="
|
|
45
|
+
expect(r.def).toContain('y="240"');
|
|
41
46
|
expect(r.def).toContain('width="80"');
|
|
42
|
-
expect(r.def).toContain('height="
|
|
43
|
-
// Center of the
|
|
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="
|
|
46
|
-
// farthest-corner radius = sqrt(40^2 +
|
|
47
|
-
expect(r.def).toMatch(/r="
|
|
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'", () => {
|