domotion-svg 0.1.1

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 (119) hide show
  1. package/FEATURES.md +102 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/dist/animator.d.ts +158 -0
  5. package/dist/animator.js +424 -0
  6. package/dist/animator.test.d.ts +5 -0
  7. package/dist/animator.test.js +169 -0
  8. package/dist/border-radius.test.d.ts +1 -0
  9. package/dist/border-radius.test.js +148 -0
  10. package/dist/capture.d.ts +193 -0
  11. package/dist/capture.js +786 -0
  12. package/dist/chrome.d.ts +45 -0
  13. package/dist/chrome.js +107 -0
  14. package/dist/cli.d.ts +16 -0
  15. package/dist/cli.js +512 -0
  16. package/dist/client/dom.d.ts +10 -0
  17. package/dist/client/dom.js +17 -0
  18. package/dist/conic-raster.d.ts +58 -0
  19. package/dist/conic-raster.js +292 -0
  20. package/dist/conic-raster.test.d.ts +1 -0
  21. package/dist/conic-raster.test.js +187 -0
  22. package/dist/coretext-extractor.test.d.ts +1 -0
  23. package/dist/coretext-extractor.test.js +94 -0
  24. package/dist/coretext-helper.d.ts +60 -0
  25. package/dist/coretext-helper.js +205 -0
  26. package/dist/cross-origin-font-face.test.d.ts +1 -0
  27. package/dist/cross-origin-font-face.test.js +107 -0
  28. package/dist/cursor-overlay.d.ts +123 -0
  29. package/dist/cursor-overlay.js +207 -0
  30. package/dist/cursor-overlay.test.d.ts +1 -0
  31. package/dist/cursor-overlay.test.js +88 -0
  32. package/dist/dark-mode-capture.test.d.ts +1 -0
  33. package/dist/dark-mode-capture.test.js +158 -0
  34. package/dist/dark-mode-form-controls.test.d.ts +1 -0
  35. package/dist/dark-mode-form-controls.test.js +218 -0
  36. package/dist/dom-to-svg.d.ts +1016 -0
  37. package/dist/dom-to-svg.js +7717 -0
  38. package/dist/embed-remote-images.test.d.ts +1 -0
  39. package/dist/embed-remote-images.test.js +424 -0
  40. package/dist/form-controls.d.ts +70 -0
  41. package/dist/form-controls.js +1151 -0
  42. package/dist/frame-merge.d.ts +95 -0
  43. package/dist/frame-merge.js +374 -0
  44. package/dist/frame-merge.test.d.ts +6 -0
  45. package/dist/frame-merge.test.js +144 -0
  46. package/dist/gradients.d.ts +184 -0
  47. package/dist/gradients.js +937 -0
  48. package/dist/gradients.test.d.ts +1 -0
  49. package/dist/gradients.test.js +150 -0
  50. package/dist/index.d.ts +12 -0
  51. package/dist/index.js +7 -0
  52. package/dist/jsx-runtime.d.ts +27 -0
  53. package/dist/jsx-runtime.js +96 -0
  54. package/dist/jsx-runtime.test.d.ts +1 -0
  55. package/dist/jsx-runtime.test.js +41 -0
  56. package/dist/kerfjs-imports.test.d.ts +1 -0
  57. package/dist/kerfjs-imports.test.js +36 -0
  58. package/dist/mask.test.d.ts +1 -0
  59. package/dist/mask.test.js +206 -0
  60. package/dist/optimize.d.ts +12 -0
  61. package/dist/optimize.js +32 -0
  62. package/dist/preserve-aspect-ratio.test.d.ts +1 -0
  63. package/dist/preserve-aspect-ratio.test.js +38 -0
  64. package/dist/resize-embedded-images.d.ts +33 -0
  65. package/dist/resize-embedded-images.js +164 -0
  66. package/dist/resize-embedded-images.test.d.ts +9 -0
  67. package/dist/resize-embedded-images.test.js +255 -0
  68. package/dist/stacking-context.test.d.ts +1 -0
  69. package/dist/stacking-context.test.js +927 -0
  70. package/dist/text-renderer.d.ts +42 -0
  71. package/dist/text-renderer.js +608 -0
  72. package/dist/text-renderer.test.d.ts +1 -0
  73. package/dist/text-renderer.test.js +150 -0
  74. package/dist/text-to-path.d.ts +265 -0
  75. package/dist/text-to-path.js +1800 -0
  76. package/dist/text-to-path.test.d.ts +1 -0
  77. package/dist/text-to-path.test.js +570 -0
  78. package/dist/utils/escapeHtml.d.ts +2 -0
  79. package/dist/utils/escapeHtml.js +15 -0
  80. package/dist/webfont-unicode-range.test.d.ts +1 -0
  81. package/dist/webfont-unicode-range.test.js +174 -0
  82. package/package.json +55 -0
  83. package/src/animator.test.ts +179 -0
  84. package/src/animator.ts +660 -0
  85. package/src/border-radius.test.ts +160 -0
  86. package/src/capture.ts +810 -0
  87. package/src/cli.ts +582 -0
  88. package/src/conic-raster.test.ts +213 -0
  89. package/src/conic-raster.ts +309 -0
  90. package/src/coretext-extractor.test.ts +130 -0
  91. package/src/coretext-helper.ts +256 -0
  92. package/src/cross-origin-font-face.test.ts +119 -0
  93. package/src/cursor-overlay.test.ts +95 -0
  94. package/src/cursor-overlay.ts +297 -0
  95. package/src/dark-mode-capture.test.ts +177 -0
  96. package/src/dark-mode-form-controls.test.ts +228 -0
  97. package/src/dom-to-svg.ts +8376 -0
  98. package/src/embed-remote-images.test.ts +461 -0
  99. package/src/form-controls.ts +1174 -0
  100. package/src/frame-merge.test.ts +157 -0
  101. package/src/frame-merge.ts +447 -0
  102. package/src/globals.d.ts +2 -0
  103. package/src/gradients.test.ts +175 -0
  104. package/src/gradients.ts +955 -0
  105. package/src/index.ts +12 -0
  106. package/src/kerf-jsx-augmentation.d.ts +36 -0
  107. package/src/kerfjs-imports.test.tsx +45 -0
  108. package/src/mask.test.ts +274 -0
  109. package/src/optimize.ts +34 -0
  110. package/src/preserve-aspect-ratio.test.ts +49 -0
  111. package/src/resize-embedded-images.test.ts +292 -0
  112. package/src/resize-embedded-images.ts +180 -0
  113. package/src/stacking-context.test.ts +967 -0
  114. package/src/text-renderer.test.ts +162 -0
  115. package/src/text-renderer.ts +623 -0
  116. package/src/text-to-path.test.ts +639 -0
  117. package/src/text-to-path.ts +1810 -0
  118. package/src/utils/escapeHtml.ts +16 -0
  119. package/src/webfont-unicode-range.test.ts +207 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Cursor / click overlay for animated SVGs (DM-277).
3
+ *
4
+ * Paints a macOS-style cursor moving along a user-authored timeline and
5
+ * QuickTime-style click pulses at click events. Single pointer at a time;
6
+ * multi-touch is out of scope for v1.
7
+ *
8
+ * The overlay is opt-in via `AnimationConfig.cursorOverlay`. The emitted
9
+ * markup goes inside the viewport-clipped group, after the frame groups,
10
+ * so it paints above the frame content and is clipped to the viewport.
11
+ *
12
+ * Selector resolution: events that target a captured element via `selector`
13
+ * are resolved by an optional `resolveSelector(sel, frameIndex)` callback
14
+ * the caller supplies. The frame index is computed from the event's `t`
15
+ * (each frame's start/end time is derived from the animation timing).
16
+ *
17
+ * See docs/13-cursor-overlay.md for the design.
18
+ */
19
+ const DEFAULT_STYLE = {
20
+ pointer: "mouse",
21
+ cursorFill: "rgb(255, 255, 255)",
22
+ cursorStroke: "rgb(0, 0, 0)",
23
+ pulseStroke: "rgba(255, 255, 255, 0.95)",
24
+ pulseStrokeOuter: "rgba(0, 0, 0, 0.4)",
25
+ pulseDurationMs: 500,
26
+ pulseRadius: 32,
27
+ cursorScale: 1,
28
+ };
29
+ /**
30
+ * Resolve a script into absolute-coord position keyframes + click pulses.
31
+ * Caller passes a `resolveSelector(sel, frameIndex)` if the script uses
32
+ * selectors; otherwise pass `null` and selector events become no-ops with
33
+ * a console warning.
34
+ */
35
+ export function resolveCursorScript(overlay, totalDurationMs, frameStartTimes, resolveSelector) {
36
+ const baseStyle = { ...DEFAULT_STYLE, ...overlay.style };
37
+ const events = [...overlay.events].sort((a, b) => a.t - b.t);
38
+ const positions = [];
39
+ const clicks = [];
40
+ let curX = 0;
41
+ let curY = 0;
42
+ let visible = false;
43
+ const pushKey = (t, x, y, vis) => {
44
+ positions.push({ t, x, y, visible: vis });
45
+ curX = x;
46
+ curY = y;
47
+ visible = vis;
48
+ };
49
+ const frameForT = (t) => {
50
+ let idx = 0;
51
+ for (let i = 0; i < frameStartTimes.length; i++) {
52
+ if (t >= frameStartTimes[i])
53
+ idx = i;
54
+ else
55
+ break;
56
+ }
57
+ return idx;
58
+ };
59
+ for (const ev of events) {
60
+ if (ev.type === "show") {
61
+ pushKey(ev.t, ev.x, ev.y, true);
62
+ }
63
+ else if (ev.type === "hide") {
64
+ pushKey(ev.t, curX, curY, false);
65
+ }
66
+ else if (ev.type === "move") {
67
+ const dur = ev.duration ?? 0;
68
+ const target = resolveMoveTarget(ev, curX, curY, frameForT(ev.t), resolveSelector);
69
+ if (target == null)
70
+ continue;
71
+ // First time we ever position the cursor — also turn it visible.
72
+ const becomeVisible = !visible;
73
+ if (dur > 0) {
74
+ if (becomeVisible) {
75
+ // Hold the cursor at its previous spot until the move begins so we
76
+ // don't see it pop in mid-frame; the start keyframe carries `false`
77
+ // visibility, the end keyframe carries `true` and the cursor fades
78
+ // in at the move's start.
79
+ pushKey(ev.t, curX, curY, true);
80
+ }
81
+ else {
82
+ pushKey(ev.t, curX, curY, true);
83
+ }
84
+ pushKey(ev.t + dur, target.x, target.y, true);
85
+ }
86
+ else {
87
+ pushKey(ev.t, target.x, target.y, true);
88
+ }
89
+ }
90
+ else if (ev.type === "click") {
91
+ const button = ev.button ?? "primary";
92
+ const style = { ...baseStyle, ...(ev.style ?? {}) };
93
+ clicks.push({ t: ev.t, x: curX, y: curY, button, style });
94
+ }
95
+ }
96
+ // Always anchor an initial keyframe at t=0 with visibility `false` so the
97
+ // animation doesn't accidentally show the cursor at (0, 0) before the
98
+ // first `show` / `move`.
99
+ if (positions.length === 0 || positions[0].t > 0) {
100
+ positions.unshift({ t: 0, x: positions[0]?.x ?? 0, y: positions[0]?.y ?? 0, visible: false });
101
+ }
102
+ // Anchor a final keyframe at totalDurationMs so animateTransform interpolates
103
+ // through the whole loop.
104
+ if (positions[positions.length - 1].t < totalDurationMs) {
105
+ const last = positions[positions.length - 1];
106
+ positions.push({ t: totalDurationMs, x: last.x, y: last.y, visible: last.visible });
107
+ }
108
+ return { positions, clicks, style: baseStyle };
109
+ }
110
+ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
111
+ if (ev.to != null)
112
+ return { x: ev.to.x, y: ev.to.y };
113
+ if (ev.by != null)
114
+ return { x: curX + ev.by.dx, y: curY + ev.by.dy };
115
+ if (ev.selector != null) {
116
+ if (resolveSelector == null) {
117
+ // eslint-disable-next-line no-console
118
+ console.warn(`cursor-overlay: selector "${ev.selector}" used but no resolveSelector provided; skipping`);
119
+ return null;
120
+ }
121
+ const rect = resolveSelector(ev.selector, frameIndex);
122
+ if (rect == null) {
123
+ // eslint-disable-next-line no-console
124
+ console.warn(`cursor-overlay: selector "${ev.selector}" matched no element in frame ${frameIndex}; skipping`);
125
+ return null;
126
+ }
127
+ const cx = rect.x + rect.w / 2 + (ev.offset?.dx ?? 0);
128
+ const cy = rect.y + rect.h / 2 + (ev.offset?.dy ?? 0);
129
+ return { x: cx, y: cy };
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * Emit the `<g class="cursor-overlay">` markup for an already-resolved
135
+ * timeline. Returns "" when the timeline has no positions or every keyframe
136
+ * is invisible.
137
+ */
138
+ export function cursorOverlayMarkup(positions, clicks, style, totalDurationMs) {
139
+ if (positions.length === 0 || totalDurationMs <= 0)
140
+ return "";
141
+ const totalSec = totalDurationMs / 1000;
142
+ // animateTransform with values + keyTimes drives the cursor's translate.
143
+ const valueStrs = [];
144
+ const keyTimes = [];
145
+ for (const p of positions) {
146
+ valueStrs.push(`${num(p.x)},${num(p.y)}`);
147
+ keyTimes.push((p.t / totalDurationMs).toFixed(4));
148
+ }
149
+ // SMIL animateTransform requires keyTimes to start at 0 and end at 1; the
150
+ // resolveCursorScript anchor at t=0 and t=totalDurationMs guarantees this.
151
+ // Visibility timeline: opacity flips between 0 and 1 at keyframes whose
152
+ // `visible` differs from the previous one. Use `discrete` calc-mode for
153
+ // step-wise transitions (no interpolation between 0 and 1).
154
+ const visValues = [];
155
+ for (const p of positions)
156
+ visValues.push(p.visible ? "1" : "0");
157
+ const cursorPath = macosCursorPath(style.cursorScale);
158
+ // Pulse SVG fragments — one per click, with timing keyed off `t`.
159
+ const pulseMarkup = clicks.map((c, i) => buildPulseFragment(c, i, totalDurationMs)).join("\n");
160
+ return ` <g class="cursor-overlay" pointer-events="none">
161
+ <g class="cursor-arrow" opacity="0">
162
+ <animateTransform attributeName="transform" type="translate" values="${valueStrs.join("; ")}" keyTimes="${keyTimes.join("; ")}" dur="${totalSec}s" repeatCount="indefinite" fill="freeze" />
163
+ <animate attributeName="opacity" values="${visValues.join(";")}" keyTimes="${keyTimes.join(";")}" dur="${totalSec}s" repeatCount="indefinite" calcMode="discrete" fill="freeze" />
164
+ ${cursorPath}
165
+ </g>
166
+ ${pulseMarkup}
167
+ </g>`;
168
+ }
169
+ /** Build the SVG fragment for a single click pulse. */
170
+ function buildPulseFragment(c, idx, totalDurationMs) {
171
+ const beginSec = (c.t / 1000).toFixed(3);
172
+ const durSec = (c.style.pulseDurationMs / 1000).toFixed(3);
173
+ const r0 = 4;
174
+ const r1 = c.style.pulseRadius;
175
+ const innerR = r1 * 0.55;
176
+ // Right-half-disc fill for secondary clicks.
177
+ let secondaryHalf = "";
178
+ if (c.button === "secondary") {
179
+ const halfPath = `M ${num(c.x)} ${num(c.y - innerR)} A ${num(innerR)} ${num(innerR)} 0 0 1 ${num(c.x)} ${num(c.y + innerR)} Z`;
180
+ secondaryHalf = `
181
+ <path d="${halfPath}" fill="rgba(0,0,0,0.2)" opacity="0">
182
+ <animate attributeName="opacity" values="0; 1; 0" keyTimes="0; 0.2; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
183
+ </path>`;
184
+ }
185
+ return ` <g class="cursor-click cursor-click-${idx}">
186
+ <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStrokeOuter}" stroke-width="2" opacity="0">
187
+ <animate attributeName="r" values="${r0}; ${r1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
188
+ <animate attributeName="opacity" values="0; 0.9; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
189
+ </circle>
190
+ <circle cx="${num(c.x)}" cy="${num(c.y)}" r="${r0}" fill="none" stroke="${c.style.pulseStroke}" stroke-width="1" opacity="0">
191
+ <animate attributeName="r" values="${r0}; ${r1 - 1}" keyTimes="0; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
192
+ <animate attributeName="opacity" values="0; 0.95; 0" keyTimes="0; 0.15; 1" dur="${durSec}s" begin="${beginSec}s" fill="freeze" />
193
+ </circle>${secondaryHalf}
194
+ </g>`;
195
+ }
196
+ /** macOS-style cursor arrow path. The hot point (0, 0) sits at the tip. */
197
+ function macosCursorPath(scale) {
198
+ // Path approximates the macOS pointer: tip at (0, 0), tail extends down and
199
+ // right with a small notch. Stroked white with a thin black outline so it
200
+ // reads on any background.
201
+ const d = "M 0 0 L 0 16 L 4 12 L 7 18 L 9 17 L 6 11 L 12 11 Z";
202
+ const transform = scale !== 1 ? ` transform="scale(${num(scale)})"` : "";
203
+ return `<path d="${d}" fill="rgb(255,255,255)" stroke="rgb(0,0,0)" stroke-width="1" stroke-linejoin="round"${transform} />`;
204
+ }
205
+ function num(n) {
206
+ return Number(n.toFixed(2)).toString();
207
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cursorOverlayMarkup, resolveCursorScript } from "./cursor-overlay.js";
3
+ const TOTAL = 4000;
4
+ const FRAME_STARTS = [0, 1500, 3000];
5
+ describe("resolveCursorScript: position keyframes", () => {
6
+ it("anchors a hidden keyframe at t=0 even when the script starts later", () => {
7
+ const overlay = {
8
+ events: [{ type: "show", t: 500, x: 100, y: 200 }],
9
+ };
10
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
11
+ expect(r.positions[0]).toMatchObject({ t: 0, visible: false });
12
+ expect(r.positions.find((p) => p.t === 500)).toMatchObject({ x: 100, y: 200, visible: true });
13
+ expect(r.positions[r.positions.length - 1].t).toBe(TOTAL);
14
+ });
15
+ it("resolves `to` (absolute), `by` (relative), and `selector` move targets", () => {
16
+ const overlay = {
17
+ events: [
18
+ { type: "show", t: 0, x: 10, y: 20 },
19
+ { type: "move", t: 100, to: { x: 50, y: 60 } },
20
+ { type: "move", t: 200, by: { dx: 10, dy: -5 } },
21
+ { type: "move", t: 300, selector: ".btn" },
22
+ ],
23
+ };
24
+ const resolveSelector = () => ({ x: 200, y: 300, w: 80, h: 24 });
25
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, resolveSelector);
26
+ const at100 = r.positions.find((p) => p.t === 100);
27
+ const at200 = r.positions.find((p) => p.t === 200);
28
+ const at300 = r.positions.find((p) => p.t === 300);
29
+ expect(at100).toMatchObject({ x: 50, y: 60 });
30
+ expect(at200).toMatchObject({ x: 60, y: 55 });
31
+ // Center of (200, 300, 80, 24) is (240, 312).
32
+ expect(at300).toMatchObject({ x: 240, y: 312 });
33
+ });
34
+ it("emits two keyframes for a duration move (start + end) so SMIL interpolates", () => {
35
+ const overlay = {
36
+ events: [
37
+ { type: "show", t: 0, x: 0, y: 0 },
38
+ { type: "move", t: 1000, to: { x: 100, y: 100 }, duration: 500 },
39
+ ],
40
+ };
41
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
42
+ const at1000 = r.positions.find((p) => p.t === 1000);
43
+ const at1500 = r.positions.find((p) => p.t === 1500);
44
+ expect(at1000).toMatchObject({ x: 0, y: 0, visible: true });
45
+ expect(at1500).toMatchObject({ x: 100, y: 100, visible: true });
46
+ });
47
+ it("captures clicks with the cursor's current position at the click's t", () => {
48
+ const overlay = {
49
+ events: [
50
+ { type: "show", t: 0, x: 30, y: 40 },
51
+ { type: "click", t: 100, button: "primary" },
52
+ { type: "move", t: 200, to: { x: 80, y: 80 } },
53
+ { type: "click", t: 300, button: "secondary" },
54
+ ],
55
+ };
56
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
57
+ expect(r.clicks).toHaveLength(2);
58
+ expect(r.clicks[0]).toMatchObject({ t: 100, x: 30, y: 40, button: "primary" });
59
+ expect(r.clicks[1]).toMatchObject({ t: 300, x: 80, y: 80, button: "secondary" });
60
+ });
61
+ });
62
+ describe("cursorOverlayMarkup: SVG emission", () => {
63
+ it("includes a cursor-arrow group and a click pulse per click", () => {
64
+ const overlay = {
65
+ events: [
66
+ { type: "show", t: 0, x: 50, y: 50 },
67
+ { type: "click", t: 200, button: "primary" },
68
+ { type: "click", t: 600, button: "secondary" },
69
+ ],
70
+ };
71
+ const r = resolveCursorScript(overlay, TOTAL, FRAME_STARTS, null);
72
+ const svg = cursorOverlayMarkup(r.positions, r.clicks, r.style, TOTAL);
73
+ expect(svg).toContain('class="cursor-overlay"');
74
+ expect(svg).toContain('class="cursor-arrow"');
75
+ expect(svg).toContain('class="cursor-click cursor-click-0"');
76
+ expect(svg).toContain('class="cursor-click cursor-click-1"');
77
+ // Secondary click adds the right-half-disc fill.
78
+ expect(svg).toContain('rgba(0,0,0,0.2)');
79
+ });
80
+ it("emits empty string when there are no events", () => {
81
+ const r = resolveCursorScript({ events: [] }, TOTAL, FRAME_STARTS, null);
82
+ const svg = cursorOverlayMarkup(r.positions, r.clicks, r.style, TOTAL);
83
+ // Even with no events, resolveCursorScript anchors t=0 and t=total
84
+ // (both invisible). The markup is non-empty but contains no clicks.
85
+ expect(svg).toContain('class="cursor-overlay"');
86
+ expect(svg).not.toContain('class="cursor-click');
87
+ });
88
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { rootSvgColorSchemeAttr, transparentRootBgRect, wrapSvg } from "./dom-to-svg.js";
3
+ // DM-552: capture-side propagation of `rootColorScheme` and `rootBgComputed`
4
+ // stamped on the captured tree's root element by CAPTURE_SCRIPT, plus the
5
+ // renderer-side helper that emits `color-scheme="dark"` on the wrapping
6
+ // `<svg>` element. End-to-end CAPTURE_SCRIPT integration is covered by
7
+ // the real-world suite + the dark-mode form-control fixture (DM-553); this
8
+ // file unit-tests the renderer-side surface in isolation.
9
+ function makeRoot(overrides = {}) {
10
+ return {
11
+ tag: "body",
12
+ text: "",
13
+ x: 0, y: 0, width: 100, height: 100,
14
+ children: [],
15
+ styles: {
16
+ backgroundColor: "rgb(255,255,255)",
17
+ borderColor: "rgb(0,0,0)",
18
+ borderWidth: "0",
19
+ borderRadius: "0",
20
+ borderTopLeftRadius: "0",
21
+ borderTopRightRadius: "0",
22
+ borderBottomRightRadius: "0",
23
+ borderBottomLeftRadius: "0",
24
+ borderTopWidth: "0",
25
+ borderRightWidth: "0",
26
+ borderBottomWidth: "0",
27
+ borderLeftWidth: "0",
28
+ borderTopColor: "rgb(0,0,0)",
29
+ borderRightColor: "rgb(0,0,0)",
30
+ borderBottomColor: "rgb(0,0,0)",
31
+ borderLeftColor: "rgb(0,0,0)",
32
+ borderTopStyle: "none",
33
+ borderRightStyle: "none",
34
+ borderBottomStyle: "none",
35
+ borderLeftStyle: "none",
36
+ color: "rgb(0,0,0)",
37
+ fontSize: "16px",
38
+ fontFamily: "sans-serif",
39
+ fontWeight: "400",
40
+ fontStyle: "normal",
41
+ lineHeight: "20px",
42
+ letterSpacing: "normal",
43
+ textAlign: "left",
44
+ textTransform: "none",
45
+ textDecoration: "none",
46
+ textDecorationLine: "none",
47
+ textDecorationStyle: "solid",
48
+ textDecorationColor: "rgb(0,0,0)",
49
+ textDecorationThickness: "auto",
50
+ textIndent: "0",
51
+ textShadow: "none",
52
+ textWrap: "wrap",
53
+ whiteSpace: "normal",
54
+ wordBreak: "normal",
55
+ overflowWrap: "normal",
56
+ verticalAlign: "baseline",
57
+ backgroundImage: "none",
58
+ backgroundSize: "auto",
59
+ backgroundPosition: "0% 0%",
60
+ backgroundRepeat: "repeat",
61
+ backgroundAttachment: "scroll",
62
+ backgroundClip: "border-box",
63
+ backgroundOrigin: "padding-box",
64
+ opacity: "1",
65
+ position: "static",
66
+ top: "auto",
67
+ left: "auto",
68
+ right: "auto",
69
+ bottom: "auto",
70
+ display: "block",
71
+ flexDirection: "row",
72
+ visibility: "visible",
73
+ zIndex: "auto",
74
+ transform: "none",
75
+ transformOrigin: "50% 50%",
76
+ writingMode: "horizontal-tb",
77
+ textOrientation: "mixed",
78
+ cursor: "auto",
79
+ pointerEvents: "auto",
80
+ ...overrides,
81
+ },
82
+ };
83
+ }
84
+ describe("rootSvgColorSchemeAttr (DM-552)", () => {
85
+ it("returns empty string when the captured tree is empty", () => {
86
+ expect(rootSvgColorSchemeAttr([])).toBe("");
87
+ });
88
+ it("returns empty string when rootColorScheme is undefined (default scheme is attribute-free — today's SVG byte-identical)", () => {
89
+ expect(rootSvgColorSchemeAttr([makeRoot()])).toBe("");
90
+ });
91
+ it("returns empty string when rootColorScheme is explicitly 'light'", () => {
92
+ expect(rootSvgColorSchemeAttr([makeRoot({ rootColorScheme: "light" })])).toBe("");
93
+ });
94
+ it("returns ` color-scheme=\"dark\"` (with leading space, ready to concat) when rootColorScheme is 'dark'", () => {
95
+ expect(rootSvgColorSchemeAttr([makeRoot({ rootColorScheme: "dark" })])).toBe(` color-scheme="dark"`);
96
+ });
97
+ it("only inspects elements[0] — siblings with mismatched scheme are ignored (only the root carries the page-level signal)", () => {
98
+ const root = makeRoot({ rootColorScheme: "light" });
99
+ const sibling = makeRoot({ rootColorScheme: "dark" });
100
+ expect(rootSvgColorSchemeAttr([root, sibling])).toBe("");
101
+ });
102
+ });
103
+ describe("wrapSvg with tree option (DM-552)", () => {
104
+ it("emits a vanilla <svg> when no tree is passed (back-compat)", () => {
105
+ const out = wrapSvg("<g/>", 100, 50);
106
+ expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><g/></svg>`);
107
+ });
108
+ it("emits a vanilla <svg> when the tree's root has no rootColorScheme", () => {
109
+ const out = wrapSvg("<g/>", 100, 50, { tree: [makeRoot()] });
110
+ expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><g/></svg>`);
111
+ });
112
+ it("includes color-scheme=\"dark\" on the root <svg> when tree's root reports dark scheme", () => {
113
+ const out = wrapSvg("<g/>", 100, 50, { tree: [makeRoot({ rootColorScheme: "dark" })] });
114
+ expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50" color-scheme="dark"><g/></svg>`);
115
+ });
116
+ it("does NOT add color-scheme=\"light\" — the absence of the attribute IS the light default (today's output preserved verbatim)", () => {
117
+ const out = wrapSvg("<g/>", 100, 50, { tree: [makeRoot({ rootColorScheme: "light" })] });
118
+ expect(out).not.toContain("color-scheme");
119
+ });
120
+ });
121
+ describe("transparentRootBgRect (DM-554)", () => {
122
+ it("returns empty string for an empty tree", () => {
123
+ expect(transparentRootBgRect([], 100, 50)).toBe("");
124
+ });
125
+ it("returns empty string when rootBgComputed is missing (pre-DM-552 capture)", () => {
126
+ expect(transparentRootBgRect([makeRoot()], 100, 50)).toBe("");
127
+ });
128
+ it("returns empty string when rootBgComputed is explicitly transparent (intentional transparent SVG)", () => {
129
+ expect(transparentRootBgRect([makeRoot({ rootBgComputed: "rgba(0, 0, 0, 0)" })], 100, 50)).toBe("");
130
+ expect(transparentRootBgRect([makeRoot({ rootBgComputed: "transparent" })], 100, 50)).toBe("");
131
+ });
132
+ it("emits a body-bg <rect> with the resolved color when rootBgComputed is set", () => {
133
+ const out = transparentRootBgRect([makeRoot({ rootBgComputed: "rgb(28, 28, 28)" })], 1280, 800);
134
+ expect(out).toBe(`<rect width="1280" height="800" fill="rgb(28, 28, 28)" />`);
135
+ });
136
+ it("uses the same color whether the scheme is light or dark — the rect mirrors Chromium's resolved bg, not a hardcoded scheme palette", () => {
137
+ const lightOut = transparentRootBgRect([makeRoot({ rootBgComputed: "rgb(255, 255, 255)", rootColorScheme: "light" })], 100, 50);
138
+ const darkOut = transparentRootBgRect([makeRoot({ rootBgComputed: "rgb(28, 28, 28)", rootColorScheme: "dark" })], 100, 50);
139
+ expect(lightOut).toBe(`<rect width="100" height="50" fill="rgb(255, 255, 255)" />`);
140
+ expect(darkOut).toBe(`<rect width="100" height="50" fill="rgb(28, 28, 28)" />`);
141
+ });
142
+ });
143
+ describe("wrapSvg with body-bg rect injection (DM-554)", () => {
144
+ it("emits the body-bg rect BEFORE the inner content so the inner paints on top", () => {
145
+ const out = wrapSvg("<g class='content'/>", 100, 50, { tree: [makeRoot({ rootBgComputed: "rgb(28, 28, 28)" })] });
146
+ expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><rect width="100" height="50" fill="rgb(28, 28, 28)" /><g class='content'/></svg>`);
147
+ });
148
+ it("combines color-scheme attr and body-bg rect when the tree has both signals", () => {
149
+ const tree = [makeRoot({ rootColorScheme: "dark", rootBgComputed: "rgb(28, 28, 28)" })];
150
+ const out = wrapSvg("<g/>", 100, 50, { tree });
151
+ expect(out).toContain(`color-scheme="dark"`);
152
+ expect(out).toContain(`<rect width="100" height="50" fill="rgb(28, 28, 28)" />`);
153
+ });
154
+ it("stays byte-identical to today's output when no tree is passed (back-compat)", () => {
155
+ const out = wrapSvg("<g/>", 100, 50);
156
+ expect(out).toBe(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" width="100" height="50"><g/></svg>`);
157
+ });
158
+ });
@@ -0,0 +1 @@
1
+ export {};