@1agh/maude 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dependencies.json +30 -2
  3. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  4. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  5. package/plugins/design/dev-server/api.ts +15 -1
  6. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  7. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  8. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  9. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  10. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  12. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  13. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  14. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  15. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  16. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  17. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  18. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  19. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  20. package/plugins/design/dev-server/client/app.jsx +1084 -417
  21. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  22. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  23. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  24. package/plugins/design/dev-server/exporters/html.ts +4 -1
  25. package/plugins/design/dev-server/exporters/index.ts +4 -1
  26. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  27. package/plugins/design/dev-server/exporters/png.ts +21 -2
  28. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  29. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  30. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  31. package/plugins/design/dev-server/http.ts +40 -9
  32. package/plugins/design/dev-server/input-router.tsx +9 -2
  33. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  34. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  35. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  36. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  37. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  39. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  40. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  41. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  42. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  43. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  44. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  45. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  46. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  47. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  48. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  49. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  50. package/plugins/design/templates/_shell.html +36 -9
@@ -194,6 +194,11 @@ const ANNOTATION_SVG_ELEMENTS = new Set([
194
194
  'ellipse',
195
195
  'line',
196
196
  'polyline',
197
+ // Phase 24 — `polygon` (diamond / triangle shape primitives + closed arrowhead
198
+ // outlines + diamond heads) and `circle` (circle arrowhead). Inert shape
199
+ // elements with no script capability — same safety class as the rest.
200
+ 'polygon',
201
+ 'circle',
197
202
  'text',
198
203
  ]);
199
204
 
@@ -240,7 +245,16 @@ export function sanitizeAnnotationSvg(svg: string): string {
240
245
  // 3. Attribute denylist on the surviving allowlisted elements — the legit
241
246
  // vocabulary uses no on*= / style= / *href=, so stripping them closes
242
247
  // inline handlers, CSS url(javascript:), and entity-encoded hrefs.
243
- .replace(/\s(?:on[a-z]+|style|(?:[\w-]+:)?href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
248
+ // The leading boundary is a LOOKBEHIND on whitespace / quote / slash
249
+ // (not a consumed `\s`) so a handler glued to the previous attribute's
250
+ // closing quote — `<circle r="2"onload="…"/>`, which HTML/SVG parsers
251
+ // accept as a distinct attribute — is also stripped (Phase 24 security
252
+ // review, DDR-067). The non-consuming lookbehind leaves the preceding
253
+ // quote intact so the legit attribute it belonged to survives.
254
+ .replace(
255
+ /(?<=[\s"'/])(?:on[a-z]+|style|(?:[\w-]+:)?href)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi,
256
+ ''
257
+ )
244
258
  );
245
259
  }
246
260
 
@@ -4,7 +4,7 @@
4
4
  // merge. Lives as a subprocess (not a direct import) so `bun build --compile`
5
5
  // of the dev-server binary doesn't pull in playwright + chromium-bidi deep deps.
6
6
 
7
- import { chromium } from 'playwright';
7
+ import { launchChromium } from './_pw-launch.mjs';
8
8
 
9
9
  const args = process.argv.slice(2);
10
10
  let url;
@@ -24,7 +24,7 @@ if (!url) {
24
24
  }
25
25
 
26
26
  const timeoutMs = timeoutSec * 1000;
27
- const browser = await chromium.launch();
27
+ const browser = await launchChromium();
28
28
  try {
29
29
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
30
30
  const page = await ctx.newPage();
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { mkdirSync, writeFileSync } from 'node:fs';
15
15
  import { dirname, join } from 'node:path';
16
- import { chromium } from 'playwright';
16
+ import { launchChromium } from './_pw-launch.mjs';
17
17
 
18
18
  const args = Object.fromEntries(
19
19
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -41,7 +41,7 @@ const widen = widenFlag !== undefined;
41
41
  const multi = multiFlag !== undefined;
42
42
  const timeoutMs = Number(timeout) * 1000;
43
43
 
44
- const browser = await chromium.launch();
44
+ const browser = await launchChromium();
45
45
  try {
46
46
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
47
47
  const page = await ctx.newPage();
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { mkdirSync, writeFileSync } from 'node:fs';
13
13
  import { dirname, join } from 'node:path';
14
- import { chromium } from 'playwright';
14
+ import { launchChromium } from './_pw-launch.mjs';
15
15
 
16
16
  const args = Object.fromEntries(
17
17
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -20,17 +20,26 @@ const args = Object.fromEntries(
20
20
  }, [])
21
21
  );
22
22
 
23
- const { url, selector, out, 'out-dir': outDir, multi: multiFlag, timeout = '12' } = args;
23
+ const {
24
+ url,
25
+ selector,
26
+ out,
27
+ 'out-dir': outDir,
28
+ 'widen-to-artboard': widenFlag,
29
+ multi: multiFlag,
30
+ timeout = '12',
31
+ } = args;
24
32
 
25
33
  if (!url) {
26
34
  console.error('usage: _pdf-playwright.mjs --url <url> --selector <css> --out <path>');
27
35
  process.exit(2);
28
36
  }
29
37
 
38
+ const widen = widenFlag !== undefined;
30
39
  const multi = multiFlag !== undefined;
31
40
  const timeoutMs = Number(timeout) * 1000;
32
41
 
33
- const browser = await chromium.launch();
42
+ const browser = await launchChromium();
34
43
  try {
35
44
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
36
45
  const page = await ctx.newPage();
@@ -63,12 +72,20 @@ try {
63
72
  });
64
73
 
65
74
  for (let i = 0; i < screens.length; i += 1) {
66
- const handle = screens[i];
67
- // Pin each artboard to (0,0) right before its capture so the world's
68
- // multi-artboard layout doesn't push the bbox off the viewport.
75
+ // Widen a descendant selector to its enclosing artboard when requested
76
+ // (artboard-via-descendant fallback); selection / artboard-by-id targets
77
+ // pass through unchanged.
78
+ const handle =
79
+ widen && !multi
80
+ ? await screens[i].evaluateHandle((el) => el.closest('[data-dc-screen]') ?? el)
81
+ : screens[i];
82
+ // Pin the captured element's own artboard to (0,0) right before its
83
+ // capture so the world's multi-artboard layout doesn't push the bbox off
84
+ // the viewport.
69
85
  await handle.evaluate((el) => {
70
- el.style.left = '0px';
71
- el.style.top = '0px';
86
+ const ab = el.closest('[data-dc-screen]') ?? el;
87
+ ab.style.left = '0px';
88
+ ab.style.top = '0px';
72
89
  });
73
90
  const rect = await handle.evaluate((el) => {
74
91
  const r = el.getBoundingClientRect();
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { mkdirSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
- import { chromium } from 'playwright';
10
+ import { launchChromium } from './_pw-launch.mjs';
11
11
 
12
12
  const args = Object.fromEntries(
13
13
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -37,7 +37,7 @@ const multi = multiFlag !== undefined;
37
37
  const timeoutMs = Number(timeout) * 1000;
38
38
  const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
39
39
 
40
- const browser = await chromium.launch();
40
+ const browser = await launchChromium();
41
41
  try {
42
42
  // 1440x900 matches the canvas viewport the design tool uses everywhere;
43
43
  // exporters resize per-target before each shot to fit the artboard exactly.
@@ -63,24 +63,24 @@ try {
63
63
  // `zoom` (not `transform: scale`) on `.dc-world`, which actually shrinks
64
64
  // layout — getBoundingClientRect returns 818×512 instead of 1440×900
65
65
  // unless we zero both `zoom` and `transform` here.
66
- await page.evaluate(
67
- (sel) => {
68
- const world = document.querySelector('.dc-world');
69
- if (world) {
70
- world.style.zoom = '1';
71
- world.style.transform = 'none';
72
- }
73
- // Each artboard carries `style="left: …; top: …;"` so the world plane
74
- // can position it as part of a multi-artboard layout. Pin the target
75
- // to (0,0) so the screenshot clip starts at the viewport origin.
76
- const ab = document.querySelector(sel);
77
- if (ab) {
78
- ab.style.left = '0px';
79
- ab.style.top = '0px';
80
- }
81
- },
82
- widen ? '[data-dc-screen]:first-of-type' : (selector ?? '[data-dc-screen]:first-of-type')
83
- );
66
+ await page.evaluate(() => {
67
+ const world = document.querySelector('.dc-world');
68
+ if (world) {
69
+ world.style.zoom = '1';
70
+ world.style.transform = 'none';
71
+ }
72
+ });
73
+ // Pin the captured element's OWN artboard to (0,0). Previously this reset
74
+ // `[data-dc-screen]:first-of-type` regardless of the target, so capturing
75
+ // artboard #2 (or any non-first selection's host) left the wrong artboard
76
+ // at the origin (item 5 root cause). Each artboard carries
77
+ // `style="left:…; top:…;"` for the world layout — zero the right one so the
78
+ // screenshot clip starts at the viewport origin.
79
+ await widenedHandle.evaluate((el) => {
80
+ const ab = el.closest('[data-dc-screen]') ?? el;
81
+ ab.style.left = '0px';
82
+ ab.style.top = '0px';
83
+ });
84
84
  const rect = await widenedHandle.evaluate((el) => {
85
85
  const r = el.getBoundingClientRect();
86
86
  return { x: r.left, y: r.top, width: r.width, height: r.height };
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { mkdirSync, writeFileSync } from 'node:fs';
13
13
  import { dirname } from 'node:path';
14
- import { chromium } from 'playwright';
14
+ import { launchChromium } from './_pw-launch.mjs';
15
15
 
16
16
  const args = Object.fromEntries(
17
17
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -34,7 +34,7 @@ const timeoutMs = Number(timeout) * 1000;
34
34
 
35
35
  mkdirSync(dirname(out), { recursive: true });
36
36
 
37
- const browser = await chromium.launch();
37
+ const browser = await launchChromium();
38
38
  try {
39
39
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
40
40
  const page = await ctx.newPage();
@@ -0,0 +1,61 @@
1
+ // _pw-launch.mjs — shared Chromium launcher for the export / screenshot shims.
2
+ //
3
+ // Every renderer shim (_png/_pdf/_svg/_html/_pptx/_enumerate-artboards/
4
+ // _screenshot-playwright.mjs) opens a Chromium instance with `chromium.launch()`.
5
+ // Playwright the npm package can be present while its browser BINARY is not — the
6
+ // package install and `npx playwright install chromium` are separate steps, and
7
+ // the dependency check `npx playwright --version` only proves the former. When
8
+ // the binary is missing, `launch()` rejects with a multi-line stack trace that
9
+ // `exporters/*.ts` rethrows and `/_api/export` surfaces verbatim as an opaque
10
+ // 500 ("nefunguji exporty"). This helper funnels that one failure mode through a
11
+ // single place: it prints one actionable line and exits with a distinct code, so
12
+ // the export endpoint's 500 body becomes the remediation instead of a stack. Any
13
+ // OTHER launch error is rethrown untouched — real diagnostics are preserved.
14
+ //
15
+ // See `.ai/logs/rca/issue-nefunguji-exporty.md`.
16
+
17
+ import { chromium } from 'playwright';
18
+
19
+ /** Exit code the shims surface for "Chromium browser binary not installed". */
20
+ export const NO_BROWSER_EXIT = 3;
21
+
22
+ /** One-line, copy-pasteable remediation shown in the export 500 body. */
23
+ export const INSTALL_HINT =
24
+ "Playwright's Chromium browser isn't installed. Run: npx playwright install chromium";
25
+
26
+ /**
27
+ * True when a `chromium.launch()` rejection is the "browser binary not
28
+ * downloaded" case (as opposed to a sandbox / port / crash failure). Pure +
29
+ * exported so it's unit-testable without spawning a browser. Matches the stable
30
+ * fragments Playwright emits across versions:
31
+ * "browserType.launch: Executable doesn't exist at <path>"
32
+ * "Please run the following command to download new browsers"
33
+ * "playwright install"
34
+ */
35
+ export function isMissingBrowserError(message) {
36
+ const m = String(message ?? '');
37
+ return (
38
+ /Executable doesn'?t exist/i.test(m) ||
39
+ /please run the following command to download/i.test(m) ||
40
+ /playwright install/i.test(m)
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Launch Chromium, mapping the missing-binary failure to a clean exit. Drop-in
46
+ * for `chromium.launch(opts)` — same signature, same resolved Browser.
47
+ */
48
+ export async function launchChromium(opts) {
49
+ try {
50
+ return await chromium.launch(opts);
51
+ } catch (err) {
52
+ const msg = err instanceof Error ? err.message : String(err);
53
+ if (isMissingBrowserError(msg)) {
54
+ // Single clean line — the export endpoint pipes shim stderr into the 500
55
+ // body, so this is what the user reads. No stack trace.
56
+ console.error(INSTALL_HINT);
57
+ process.exit(NO_BROWSER_EXIT);
58
+ }
59
+ throw err;
60
+ }
61
+ }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { mkdirSync } from 'node:fs';
13
13
  import { dirname } from 'node:path';
14
- import { chromium } from 'playwright';
14
+ import { launchChromium } from './_pw-launch.mjs';
15
15
 
16
16
  const args = Object.fromEntries(
17
17
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -31,7 +31,7 @@ if (!url || !out) {
31
31
  const timeoutMs = Number(timeout) * 1000;
32
32
  mkdirSync(dirname(out), { recursive: true });
33
33
 
34
- const browser = await chromium.launch();
34
+ const browser = await launchChromium();
35
35
  try {
36
36
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
37
37
  const page = await ctx.newPage();
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { mkdirSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
- import { chromium } from 'playwright';
21
+ import { launchChromium } from './_pw-launch.mjs';
22
22
 
23
23
  const args = Object.fromEntries(
24
24
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -49,7 +49,7 @@ const widen = widenFlag !== undefined;
49
49
  const multi = multiFlag !== undefined;
50
50
  const timeoutMs = Number(timeout) * 1000;
51
51
 
52
- const browser = await chromium.launch();
52
+ const browser = await launchChromium();
53
53
  try {
54
54
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
55
55
  const page = await ctx.newPage();
@@ -74,27 +74,142 @@ try {
74
74
 
75
75
  const written = [];
76
76
 
77
- const serializeOne = async (handle) => {
77
+ // Single in-page serializer used by BOTH branches (single + multi) so they
78
+ // can't drift again (the `formatXML` 500 bug came from a divergent copy).
79
+ // Note: dom-to-svg exports only elementToSVG / inlineResources /
80
+ // documentToSVG — there is NO `formatXML` pretty-printer; serialize straight
81
+ // to a string. dom-to-svg also does NOT paint the captured root's background
82
+ // fill, so the artboard's background was dropped (item 4) — we prepend a
83
+ // backdrop <rect> from the artboard's effective (visible) background color.
84
+ const serializeOne = async (handle, widenToArtboard) => {
78
85
  return await handle.evaluate(
79
86
  async (el, opts) => {
80
87
  const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
81
88
  // window.domToSvg is the IIFE-injected entry.
82
89
  const { elementToSVG, inlineResources } = /** @type any */ (window).domToSvg;
90
+
91
+ // Convert ANY computed color (modern Chromium serializes `oklch(...)`
92
+ // verbatim from getComputedStyle) to sRGB rgb()/rgba(). Affinity
93
+ // Designer / older Illustrator / Inkscape don't parse CSS Color 4
94
+ // (`oklch`, `color()`) and silently drop the fill — the artboard then
95
+ // shows as transparent (the exact symptom: bg vanished in Affinity).
96
+ // A 1×1 canvas normalizes any CSS color string to sRGB bytes. Returns
97
+ // null if the value can't be painted (keep the rect off rather than
98
+ // emit a wrong colour).
99
+ const _srgbCache = new Map();
100
+ const _srgbCanvas = document.createElement('canvas');
101
+ _srgbCanvas.width = 1;
102
+ _srgbCanvas.height = 1;
103
+ const _srgbCtx = _srgbCanvas.getContext('2d');
104
+ const toSrgb = (color) => {
105
+ if (_srgbCache.has(color)) return _srgbCache.get(color);
106
+ let result = null;
107
+ try {
108
+ if (_srgbCtx) {
109
+ _srgbCtx.clearRect(0, 0, 1, 1);
110
+ _srgbCtx.fillStyle = '#000';
111
+ _srgbCtx.fillStyle = color; // ignored (stays #000) if syntax unsupported
112
+ _srgbCtx.fillRect(0, 0, 1, 1);
113
+ const [r, g, b, a] = _srgbCtx.getImageData(0, 0, 1, 1).data;
114
+ if (a !== 0) {
115
+ result =
116
+ a === 255
117
+ ? `rgb(${r}, ${g}, ${b})`
118
+ : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
119
+ }
120
+ }
121
+ } catch {
122
+ result = null;
123
+ }
124
+ _srgbCache.set(color, result);
125
+ return result;
126
+ };
127
+
128
+ const isOpaque = (bg) =>
129
+ !!bg &&
130
+ bg !== 'transparent' &&
131
+ !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg) &&
132
+ !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0(\.0+)?\s*\)$/.test(bg);
133
+ // Effective background of the artboard region: the DS theme bg usually
134
+ // sits on an inner full-bleed wrapper (`.app` / `.mdcc`), occasionally
135
+ // on the artboard element itself, sometimes on body. Descend first to
136
+ // find a near-full-bleed opaque bg, then fall back to ancestors. An
137
+ // intentionally transparent artboard returns null → stays transparent.
138
+ const effectiveBg = (node) => {
139
+ const abRect = node.getBoundingClientRect();
140
+ const abArea = Math.max(1, abRect.width * abRect.height);
141
+ const queue = [node];
142
+ let guard = 0;
143
+ while (queue.length && guard < 80) {
144
+ const e = queue.shift();
145
+ guard += 1;
146
+ const bg = getComputedStyle(e).backgroundColor;
147
+ if (isOpaque(bg)) {
148
+ const r = e.getBoundingClientRect();
149
+ if (r.width * r.height >= abArea * 0.6) return bg;
150
+ }
151
+ for (const c of e.children) queue.push(c);
152
+ }
153
+ let cur = node.parentElement;
154
+ let up = 0;
155
+ while (cur && up < 12) {
156
+ const bg = getComputedStyle(cur).backgroundColor;
157
+ if (isOpaque(bg)) return bg;
158
+ cur = cur.parentElement;
159
+ up += 1;
160
+ }
161
+ return null;
162
+ };
163
+
164
+ const bg = effectiveBg(target);
165
+ // Normalize to sRGB so the fill survives in vector editors (item 4 /
166
+ // oklch follow-up). If conversion fails, skip the backdrop rather than
167
+ // emit an unparseable fill.
168
+ const fillColor = bg ? toSrgb(bg) : null;
83
169
  const svgDoc = elementToSVG(target);
170
+ const root = svgDoc.documentElement;
171
+ if (fillColor) {
172
+ const NS = 'http://www.w3.org/2000/svg';
173
+ const rect = svgDoc.createElementNS(NS, 'rect');
174
+ const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
175
+ if (vb.length === 4 && vb.every((n) => !Number.isNaN(n))) {
176
+ rect.setAttribute('x', String(vb[0]));
177
+ rect.setAttribute('y', String(vb[1]));
178
+ rect.setAttribute('width', String(vb[2]));
179
+ rect.setAttribute('height', String(vb[3]));
180
+ } else {
181
+ rect.setAttribute('x', '0');
182
+ rect.setAttribute('y', '0');
183
+ rect.setAttribute('width', '100%');
184
+ rect.setAttribute('height', '100%');
185
+ }
186
+ rect.setAttribute('fill', fillColor);
187
+ root.insertBefore(rect, root.firstChild);
188
+ }
84
189
  // base64-embeds fonts + images so the SVG is portable outside the
85
190
  // dev-server origin. Some external fetches fail silently — Affinity
86
191
  // tolerates missing resources better than missing primitives.
87
192
  try {
88
- await inlineResources(svgDoc.documentElement);
193
+ await inlineResources(root);
89
194
  } catch {
90
195
  /* best-effort */
91
196
  }
92
- return new XMLSerializer().serializeToString(svgDoc);
197
+ let out = new XMLSerializer().serializeToString(svgDoc);
198
+ // dom-to-svg copies computed colours through VERBATIM, and modern
199
+ // Chromium serializes DS tokens as `oklch(...)` — Affinity Designer /
200
+ // older Illustrator / Inkscape can't parse CSS Color 4 and drop every
201
+ // such fill/stroke (the artboard bg + many element colours vanish).
202
+ // Rewrite every CSS Color 4 colour function (oklch/oklab/lch/lab/color)
203
+ // anywhere in the serialized SVG to its sRGB rgb()/rgba() equivalent.
204
+ // Each token is a standalone colour (no nested parens), so a single
205
+ // `\([^)]*\)` match is safe; toSrgb returns null on an unconvertible
206
+ // value and we keep the original rather than corrupt it.
207
+ out = out.replace(/(?:oklch|oklab|lch|lab|color)\([^)]*\)/gi, (m) => toSrgb(m) || m);
208
+ return out;
93
209
  },
94
- { widenToArtboard: opts }
210
+ { widenToArtboard }
95
211
  );
96
212
  };
97
- const opts = { widenToArtboard: widen };
98
213
 
99
214
  if (multi) {
100
215
  if (!outDir) {
@@ -106,17 +221,8 @@ try {
106
221
  for (let i = 0; i < screens.length; i += 1) {
107
222
  const handle = screens[i];
108
223
  const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
109
- const svg = await handle.evaluate(async (el) => {
110
- // window.domToSvg is the IIFE-injected entry.
111
- const { elementToSVG, inlineResources, formatXML } = /** @type any */ (window).domToSvg;
112
- const svgDoc = elementToSVG(el);
113
- try {
114
- await inlineResources(svgDoc.documentElement);
115
- } catch {
116
- /* */
117
- }
118
- return formatXML(new XMLSerializer().serializeToString(svgDoc));
119
- });
224
+ // Each multi handle is already a `[data-dc-screen]` artboard — no widen.
225
+ const svg = await serializeOne(handle, false);
120
226
  const target = join(outDir, `${id}.svg`);
121
227
  writeFileSync(target, svg, 'utf8');
122
228
  written.push(target);
@@ -129,7 +235,7 @@ try {
129
235
  mkdirSync(dirname(out), { recursive: true });
130
236
  const handle = page.locator(selector ?? '[data-dc-screen]:first-of-type').first();
131
237
  await handle.waitFor({ state: 'visible', timeout: timeoutMs });
132
- const svg = await serializeOne(handle);
238
+ const svg = await serializeOne(handle, widen);
133
239
  writeFileSync(out, svg, 'utf8');
134
240
  written.push(out);
135
241
  }