@1agh/maude 0.23.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 (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -135,6 +135,15 @@ export interface Api {
135
135
  loadCommentsForFile(file: string): Promise<Comment[]>;
136
136
  saveCommentsForFile(file: string, list: Comment[]): Promise<void>;
137
137
  loadAllComments(): Promise<Record<string, Comment[]>>;
138
+ /**
139
+ * Resolve a canvas URL slug back to its repo-relative `file` path by scanning
140
+ * the ACTUAL canvas files under each canvas group — independent of whether the
141
+ * canvas has any comments yet. The inverse of `fileSlug`. Returns null when no
142
+ * canvas matches. Load-bearing for collab: a peer that has not yet received any
143
+ * comment for a canvas must still resolve the file to MATERIALIZE the first
144
+ * hub-pushed comment to disk (the receiving-peer projection gap, DDR-064).
145
+ */
146
+ fileForSlug(slug: string): Promise<string | null>;
138
147
  commentsAdd(payload: Partial<Comment> & { file: string; text: string }): Promise<Comment | null>;
139
148
  commentsPatch(id: string, patch: Partial<Comment>): Promise<Comment | null>;
140
149
  commentsDelete(id: string): Promise<boolean>;
@@ -173,6 +182,82 @@ export interface ApiHooks {
173
182
  onAnnotationsChanged?: (file: string, svg: string) => void;
174
183
  }
175
184
 
185
+ /**
186
+ * Elements the annotation tool legitimately emits (`strokesToSvg` in
187
+ * annotations-layer.tsx). This is the WHOLE vocabulary — purely presentational.
188
+ */
189
+ const ANNOTATION_SVG_ELEMENTS = new Set([
190
+ 'svg',
191
+ 'g',
192
+ 'path',
193
+ 'rect',
194
+ 'ellipse',
195
+ 'line',
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',
202
+ 'text',
203
+ ]);
204
+
205
+ /**
206
+ * A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /
207
+ * synced / mirrored into the collab Y.Map. ALLOWLIST, not denylist (the F1
208
+ * confirming pass showed a denylist loses the race: `<svg:script>` via namespace,
209
+ * `javascript&#58;` via HTML entity, `<style>@import url()>`, CSS `url(javascript:)`
210
+ * all slipped a tag-name denylist). Two rules, both keyed to the fixed legit
211
+ * vocabulary so they're zero-regression on real annotations:
212
+ *
213
+ * 1. Element allowlist — drop the markup of any tag whose LOCAL name (namespace
214
+ * stripped, so `svg:script` → `script`) isn't in ANNOTATION_SVG_ELEMENTS.
215
+ * Dropped-tag text content survives as inert text (not in a script/style
216
+ * context), which is harmless.
217
+ * 2. Attribute denylist on survivors — the legit vocabulary uses none of
218
+ * `on*=`, `style=`, or any `href`, so stripping them closes inline handlers,
219
+ * CSS `url(javascript:)`, and entity-encoded `xlink:href` in one pass.
220
+ *
221
+ * The current consumer (`svgToStrokes` → DOMParser image/svg+xml → structured
222
+ * strokes → React) never raw-renders this string, so the live XSS risk is already
223
+ * nil; this keeps "inert" TRUE for any future raw-render consumer and for the
224
+ * synced file a peer / Claude-context ingests (defense-in-depth, DDR-054 §3).
225
+ */
226
+ export function sanitizeAnnotationSvg(svg: string): string {
227
+ return (
228
+ svg
229
+ // 1. Remove the CONTENT of executable / instruction-bearing elements (not
230
+ // just their tags) so an injected script body / `@import` / prompt-
231
+ // injection string can't survive as inert text a future raw-renderer or
232
+ // Claude-context read might act on. Namespace-tolerant (`svg:script`),
233
+ // non-greedy to the first matching dangerous close tag.
234
+ .replace(
235
+ /<\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\b[\s\S]*?<\s*\/\s*(?:[\w-]+:)?(?:script|style|foreignObject|title|desc)\s*>/gi,
236
+ ''
237
+ )
238
+ // 2. Element allowlist — drop the markup of any tag whose LOCAL name isn't
239
+ // in the fixed annotation vocabulary. `[^>]*` stops at the first `>`;
240
+ // annotation attrs never contain a literal `>`.
241
+ .replace(/<\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (match, rawName: string) => {
242
+ const local = String(rawName).split(':').pop()?.toLowerCase() ?? '';
243
+ return ANNOTATION_SVG_ELEMENTS.has(local) ? match : '';
244
+ })
245
+ // 3. Attribute denylist on the surviving allowlisted elements — the legit
246
+ // vocabulary uses no on*= / style= / *href=, so stripping them closes
247
+ // inline handlers, CSS url(javascript:), and entity-encoded hrefs.
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
+ )
258
+ );
259
+ }
260
+
176
261
  export function createApi(ctx: Context, hooks: ApiHooks): Api {
177
262
  const onCommentsChanged = hooks.onCommentsChanged;
178
263
  const onAnnotationsChanged = hooks.onAnnotationsChanged;
@@ -195,6 +280,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
195
280
  .toLowerCase();
196
281
  }
197
282
 
283
+ async function fileForSlug(slug: string): Promise<string | null> {
284
+ // Authoritative slug → canvas-file resolver: enumerate the real canvas
285
+ // files under each canvas group and match by the canonical slug. Unlike a
286
+ // comments-file scan, this resolves even when the canvas has NO comments yet
287
+ // — the fix for the receiving-peer projection gap where a fresh peer could
288
+ // not locate the file to write the first hub-pushed comment (DDR-064).
289
+ for (const g of cfg.canvasGroups) {
290
+ const groupAbs = path.join(paths.designRoot, g.path);
291
+ const groupRel = path.posix.join(paths.designRel, g.path);
292
+ let files: string[];
293
+ try {
294
+ files = await findHtmlFiles(groupAbs, groupRel);
295
+ } catch {
296
+ continue;
297
+ }
298
+ for (const rel of files) {
299
+ if (fileSlug(rel) === slug) return rel;
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+
198
305
  function commentsPath(file: string): string {
199
306
  return path.join(paths.commentsDir, `${fileSlug(file)}.json`);
200
307
  }
@@ -586,11 +693,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
586
693
  if (typeof svg !== 'string') return false;
587
694
  if (svg.length > 1024 * 1024) return false;
588
695
  // Cheap content gate — must look like an <svg> document. Avoids accidental
589
- // writes of arbitrary blobs through this endpoint. The client controls the
590
- // content fully, so we don't try to sanitize beyond a tag check.
696
+ // writes of arbitrary blobs through this endpoint.
591
697
  if (!/^\s*<svg[\s>]/i.test(svg)) return false;
592
- await Bun.write(annotationsPath(file), svg);
593
- onAnnotationsChanged?.(file, svg);
698
+ // A3 (DDR-060 F1 re-audit) — sanitize active content before persisting.
699
+ // This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab
700
+ // write") and accepts ANY `file`, so a hub-pushed canvas can write a
701
+ // sibling's `.annotations.svg`. The persisted SVG is currently consumed only
702
+ // via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React
703
+ // re-render), so a `<script>`/`on*` payload is parsed inertly and discarded
704
+ // — the stored-XSS chain is LATENT today, not live. We sanitize anyway so
705
+ // "inert" stays true for any future raw-render consumer and for the synced
706
+ // file a peer/Claude-context ingests. The legit annotation vocabulary
707
+ // (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
708
+ // polyline/text — so stripping executable constructs is zero-regression.
709
+ const clean = sanitizeAnnotationSvg(svg);
710
+ await Bun.write(annotationsPath(file), clean);
711
+ onAnnotationsChanged?.(file, clean);
594
712
  return true;
595
713
  }
596
714
 
@@ -946,6 +1064,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
946
1064
  loadCommentsForFile,
947
1065
  saveCommentsForFile,
948
1066
  loadAllComments,
1067
+ fileForSlug,
949
1068
  commentsAdd,
950
1069
  commentsPatch,
951
1070
  commentsDelete,
@@ -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
  }
@@ -13,9 +13,16 @@
13
13
  # preflight.sh --warn-only # same as --quiet (SessionStart hook)
14
14
  #
15
15
  # Resolution (DDR-045): never compute paths from `dirname $0` inside a
16
- # `bun --compile` standalone binary — that maps to /$bunfs/root. The CLI
17
- # entry stays Node, so we resolve via $CLAUDE_PLUGIN_ROOT first, with the
18
- # script-dir fallback only used when running uncompiled from the repo.
16
+ # `bun --compile` standalone binary — that maps to /$bunfs/root.
17
+ #
18
+ # Resolution (DDR-061): in a MARKETPLACE install the plugin is copied alone into
19
+ # `cache/<marketplace>/<plugin>/<version>/` — there is NO sibling `$PKG_ROOT/cli/`.
20
+ # So: use the sibling `cli/lib` when it exists (running uncompiled from the repo
21
+ # or an npm package, where cli/ is present); otherwise reach the check through
22
+ # the on-PATH `maude` binary (a declared plugin dependency), which resolves the
23
+ # manifest from its own package root. Fixing a straggler — the relative-path
24
+ # crash (`Cannot find module …/cache/maude/cli/lib/preflight.mjs`) that the cache
25
+ # steps already avoid by calling on-PATH `maude`.
19
26
  #
20
27
  # Exit: 0 if all hard deps pass; 1 otherwise.
21
28
 
@@ -23,10 +30,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23
30
  PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
24
31
  PKG_ROOT="$(cd "$PLUGIN_ROOT/../.." && pwd)"
25
32
 
26
- # The Node lib needs cwd to be the package root (it resolves dependencies.json
27
- # via `plugins/<plugin>/dependencies.json`). Subshell so we don't mutate the
28
- # caller's cwd.
29
- (
30
- cd "$PKG_ROOT" || exit 1
31
- exec node "$PKG_ROOT/cli/lib/preflight.mjs" --plugin design "$@"
32
- )
33
+ if [ -f "$PKG_ROOT/cli/lib/preflight.mjs" ]; then
34
+ # Sibling cli/ present (repo / npm package). The Node lib needs cwd = package
35
+ # root (it resolves `plugins/<plugin>/dependencies.json`). Subshell preserves
36
+ # the caller's cwd.
37
+ ( cd "$PKG_ROOT" || exit 1; exec node "$PKG_ROOT/cli/lib/preflight.mjs" --plugin design "$@" )
38
+ elif command -v maude >/dev/null 2>&1; then
39
+ exec maude preflight --plugin design "$@"
40
+ else
41
+ echo "preflight: no cli/lib beside the plugin and 'maude' not on PATH — install maude (npm i -g @1agh/maude)." >&2
42
+ exit 1
43
+ fi