@1agh/maude 0.40.0 → 0.42.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 (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -67,10 +67,13 @@ import {
67
67
  STICKY_DEFAULT_W,
68
68
  sanitizeAnnotationSvg,
69
69
  strokeToSvgEl,
70
+ svgToStrokes,
70
71
  } from '../annotations-model.ts';
71
72
  import {
72
73
  fileSlug,
74
+ findElementById,
73
75
  loadArtboards,
76
+ loadElements,
74
77
  parseAnnotations,
75
78
  resolveDesignRoot,
76
79
  } from './read-annotations.mjs';
@@ -95,22 +98,79 @@ const HELP = `annotate.mjs — AI annotation WRITE verb (DDR-062 via \`maude des
95
98
 
96
99
  Usage:
97
100
  maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
98
- [--near <artboardId>] [--canvas-state <path>]
101
+ [--board <file|->]
102
+ [--near <artboardId>] [--in <artboardId>]
103
+ [--pin <cdId|selector>] [--no-pointer]
104
+ [--canvas-state <path>] [--rects <path>]
99
105
  [--root <repo>] [--dry-run]
100
106
 
101
107
  Args:
102
108
  <rel-path> Canvas path relative to the design root (e.g. "ui/Foo.tsx").
103
109
  --ops <file|-> Ops JSON ({ ops: [...] }); "-" or omitted = stdin.
104
110
  --flow <file|-> Flow JSON ({ nodes, edges }) — auto-laid-out diagram of
105
- bound connectors. Mutually exclusive with --ops.
106
- --near <artboard> With --canvas-state: place the flow beside this artboard.
111
+ bound connectors. Mutually exclusive with --ops/--board.
112
+ --board <file|-> Board JSON ({ title?, layout?, groups?, nodes?, edges?,
113
+ connections? }) — a whole tidy TEMPLATE (retro, kanban,
114
+ social calendar, roadmap, brainstorm, checklist,
115
+ user-flow), the "generate a FigJam-style template"
116
+ surface (feature-whiteboard-ai-toolkit). Named presets
117
+ are NOT built in here — they're spec fixtures documented
118
+ in the \`whiteboard\` skill; this is the generic engine:
119
+ layout: "columns" (default; "grid"/"lanes" alias it) —
120
+ one titled section per groups[].title, its
121
+ groups[].cards (string[] or {text,color?}[]) stacked
122
+ inside as stickies. An empty cards[] still gets a
123
+ clean, evenly-spaced blank section (a board the team
124
+ fills in live).
125
+ layout: "radial" — a central shape/"title" (brainstorm
126
+ topic) with every group's cards ringed around it.
127
+ layout: "flow" — needs nodes[]/edges[] instead of
128
+ groups[]; delegates straight to --flow's auto-layout
129
+ (a user-flow / flowchart diagram of labelled shapes
130
+ wired by bound connectors).
131
+ connections?: [{from,to,label?}] adds bound arrows
132
+ between refs the expansion minted (@sec<i> per section,
133
+ @sec<i>card<j> per card, @center/@idea<i> for radial).
134
+ Mutually exclusive with --ops/--flow.
135
+ --near <artboard> Place beside this artboard (outside it, to the right).
136
+ --in <artboard> Place INSIDE this artboard (top-left + a 40px inset).
137
+ Requires --canvas-state or --rects. Unknown id = error.
138
+ --pin <cdId|sel> Place beside this ELEMENT (from a --rects manifest) —
139
+ "drop a note next to the CTA button". Unknown id/selector
140
+ = error (never a silent mis-place). A created sticky/text
141
+ also gets a pointer arrow to the element unless
142
+ --no-pointer or the op sets "pointer": false.
107
143
  --canvas-state <p> Artboard rects JSON (same shape read-annotations takes).
144
+ --rects <p> A \`maude design canvas-rects\` geometry manifest
145
+ ({ artboards, elements }) — feature-whiteboard-ai-toolkit.
146
+ Supplies --pin's element lookup, and --in/--near's
147
+ artboard lookup when --canvas-state isn't also given.
108
148
  --root <repo> Repo root. Default: $CLAUDE_PROJECT_DIR, then cwd.
109
149
  --dry-run Print the merged SVG to stdout instead of writing.
110
150
 
151
+ Per-op overrides: any "create" op may carry its own "in"/"near"/"pin" field
152
+ (and "pointer": false) to place just that op differently from the batch
153
+ default — the same resolution rules as the CLI flags above.
154
+
111
155
  Ops vocabulary: create (sticky | text | shape | arrow) · connect (bound arrow
112
- between hosts, by id or @ref) · group · delete. Created strokes carry
113
- data-author="ai" and fresh ids; the verb prints { ok, via, file, refs }.
156
+ between hosts, by id or @ref) · group · delete · move · set-text · set-color.
157
+ Created strokes carry data-author="ai" and fresh ids; the verb prints
158
+ { ok, via, file, refs }.
159
+
160
+ move/set-text/set-color (id-preserving, feature-whiteboard-ai-toolkit):
161
+ { "op": "move", "id": "<id|@ref>", "x": N, "y": N }
162
+ { "op": "set-text", "id": "<id|@ref>", "text": "…" } (or a section's label)
163
+ { "op": "set-color", "id": "<id|@ref>", "color": "#…" }
164
+ Every other attribute on the stroke (fontSize, bold/italic/dashed, rotation,
165
+ groupIds, cornerRadius, …) is preserved byte-for-byte — the target is parsed
166
+ through the CANONICAL parser and re-serialized through the CANONICAL
167
+ serializer, not reconstructed from defaults. Works on a stroke created earlier
168
+ in the SAME batch (by @ref) or an existing one from the file. Not every tool
169
+ supports every op (arrows/pen have no single position; anchored text has no
170
+ independent position; shapes have no single color/text field) — unsupported
171
+ combinations fail loud (exit 2) rather than silently no-op or mis-write.
172
+ DDR-100 deliberately omitted "update" for LWW honesty — these stay id-
173
+ preserving but are still whole-file last-write-wins like every other op.
114
174
 
115
175
  The write is last-write-wins over the whole SVG — read before you write.`;
116
176
 
@@ -122,8 +182,13 @@ function parseArgv(argv) {
122
182
  positional: [],
123
183
  ops: null,
124
184
  flow: null,
185
+ board: null,
125
186
  near: null,
187
+ in: null,
188
+ pin: null,
189
+ pointer: true,
126
190
  canvasState: null,
191
+ rects: null,
127
192
  root: null,
128
193
  dryRun: false,
129
194
  help: false,
@@ -131,8 +196,12 @@ function parseArgv(argv) {
131
196
  const VALUE_FLAGS = {
132
197
  '--ops': 'ops',
133
198
  '--flow': 'flow',
199
+ '--board': 'board',
134
200
  '--near': 'near',
201
+ '--in': 'in',
202
+ '--pin': 'pin',
135
203
  '--canvas-state': 'canvasState',
204
+ '--rects': 'rects',
136
205
  '--root': 'root',
137
206
  };
138
207
  for (let i = 0; i < argv.length; i += 1) {
@@ -143,6 +212,8 @@ function parseArgv(argv) {
143
212
  out.help = true;
144
213
  } else if (a === '--dry-run') {
145
214
  out.dryRun = true;
215
+ } else if (a === '--no-pointer') {
216
+ out.pointer = false;
146
217
  } else if (flagKey in VALUE_FLAGS) {
147
218
  if (eq > 0) {
148
219
  out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
@@ -219,35 +290,81 @@ const BINDABLE_PARSED = new Set(['rect', 'ellipse', 'polygon', 'sticky', 'image'
219
290
  // ─────────────────────────────────────────────────────────────────────────────
220
291
  // Op application
221
292
 
222
- function buildContext(existing, artboards, near) {
223
- // Placement origin: beside --near's artboard when given, else right of the
224
- // existing annotation extent, else a sane top-left.
293
+ function buildContext(existing, artboards, elements, placement) {
294
+ // Placement origin, priority: --pin (beside the element) > --in (inside the
295
+ // artboard) > --near (beside the artboard, existing behavior) > right of the
296
+ // existing annotation extent > a sane top-left. Unknown --in/--pin targets
297
+ // are a hard error (never a silent mis-place) — --near stays lenient
298
+ // (pre-existing behavior: an unmatched id silently falls through).
225
299
  let origin = { x: 100, y: 100 };
226
- const nearBoard = near ? artboards.find((r) => r.id === near) : null;
227
- if (nearBoard) {
228
- origin = { x: nearBoard.x + nearBoard.w + 80, y: nearBoard.y };
229
- } else if (existing.length) {
230
- let maxX = Number.NEGATIVE_INFINITY;
231
- let minY = Number.POSITIVE_INFINITY;
232
- for (const a of existing) {
233
- if (a.x == null) continue;
234
- maxX = Math.max(maxX, a.x + (a.w || 0));
235
- minY = Math.min(minY, a.y ?? 0);
300
+ let pinnedEl = null;
301
+ if (placement?.in) {
302
+ const board = artboards.find((r) => r.id === placement.in);
303
+ if (!board) fail(`--in: unknown artboard "${placement.in}"`, 2);
304
+ origin = { x: board.x + 40, y: board.y + 40 };
305
+ } else if (placement?.pin) {
306
+ const el = findElementById(elements, placement.pin);
307
+ if (!el) fail(`--pin: element "${placement.pin}" not found in the --rects manifest`, 2);
308
+ pinnedEl = el;
309
+ origin = { x: el.x + el.w + 40, y: el.y };
310
+ } else {
311
+ const nearBoard = placement?.near ? artboards.find((r) => r.id === placement.near) : null;
312
+ if (nearBoard) {
313
+ origin = { x: nearBoard.x + nearBoard.w + 80, y: nearBoard.y };
314
+ } else if (existing.length) {
315
+ let maxX = Number.NEGATIVE_INFINITY;
316
+ let minY = Number.POSITIVE_INFINITY;
317
+ for (const a of existing) {
318
+ if (a.x == null) continue;
319
+ maxX = Math.max(maxX, a.x + (a.w || 0));
320
+ minY = Math.min(minY, a.y ?? 0);
321
+ }
322
+ if (Number.isFinite(maxX)) origin = { x: maxX + 80, y: Math.max(0, minY) };
236
323
  }
237
- if (Number.isFinite(maxX)) origin = { x: maxX + 80, y: Math.max(0, minY) };
238
324
  }
239
325
  return {
240
326
  origin,
241
327
  cursor: { ...origin },
328
+ pinnedEl, // element the GLOBAL --pin resolved, or null
329
+ pointer: placement?.pointer !== false, // --no-pointer disables pointer arrows entirely
330
+ artboards, // for per-op "in"/"near" overrides
331
+ elements, // for per-op "pin" overrides
242
332
  refs: new Map(), // '@ref' → minted id
243
333
  newById: new Map(), // id → new Stroke
244
334
  created: [], // Stroke[] in creation order
245
335
  groupExisting: [], // { id, groupId } injections into existing elements
246
336
  deletes: [], // existing ids to remove
247
337
  existingById: new Map(existing.map((a) => [a.id, a])),
338
+ replaces: new Map(), // id -> patched full Stroke (move/set-text/set-color)
339
+ rawSvg: '', // set by main() before applyOps — the pre-batch SVG, for ensureFullStrokes
340
+ fullStrokes: null, // lazy id -> full Stroke cache, populated on first move/set-text/set-color
248
341
  };
249
342
  }
250
343
 
344
+ /**
345
+ * Per-op "in"/"near"/"pin" placement override — the same resolution rules as
346
+ * the CLI flags (buildContext, above), scoped to ONE op. Returns null when the
347
+ * op carries none of the three (the caller then falls back to ctx.cursor).
348
+ */
349
+ function resolveOpPlacement(ctx, op) {
350
+ if (op.pin) {
351
+ const el = findElementById(ctx.elements, op.pin);
352
+ if (!el) fail(`op.pin: element "${op.pin}" not found in the --rects manifest`, 2);
353
+ return { x: el.x + el.w + 40, y: el.y, pinnedEl: el };
354
+ }
355
+ if (op.in) {
356
+ const board = ctx.artboards.find((r) => r.id === op.in);
357
+ if (!board) fail(`op.in: unknown artboard "${op.in}"`, 2);
358
+ return { x: board.x + 40, y: board.y + 40, pinnedEl: null };
359
+ }
360
+ if (op.near) {
361
+ const board = ctx.artboards.find((r) => r.id === op.near);
362
+ if (!board) fail(`op.near: unknown artboard "${op.near}"`, 2);
363
+ return { x: board.x + board.w + 80, y: board.y, pinnedEl: null };
364
+ }
365
+ return null;
366
+ }
367
+
251
368
  function resolveTarget(ctx, idOrRef) {
252
369
  if (typeof idOrRef !== 'string' || !idOrRef) return null;
253
370
  const id = idOrRef.startsWith('@') ? ctx.refs.get(idOrRef) : idOrRef;
@@ -267,11 +384,24 @@ function mint(ctx, ref) {
267
384
  return id;
268
385
  }
269
386
 
387
+ /**
388
+ * Resolves an op's placement, returning { x, y, pinnedEl }. An op-level
389
+ * in/near/pin override takes priority over the batch's ctx.cursor; explicit
390
+ * op.x/op.y always win over either. `pinnedEl` (an op-level pin, or the
391
+ * GLOBAL --pin when the op has no override of its own) is what createSticky/
392
+ * createText use to attach a pointer arrow.
393
+ */
270
394
  function autoPlace(ctx, w, op) {
395
+ const override = op.in || op.near || op.pin ? resolveOpPlacement(ctx, op) : null;
396
+ if (override) {
397
+ const x = Number.isFinite(op.x) ? op.x : override.x;
398
+ const y = Number.isFinite(op.y) ? op.y : override.y;
399
+ return { x, y, pinnedEl: override.pinnedEl };
400
+ }
271
401
  const x = Number.isFinite(op.x) ? op.x : ctx.cursor.x;
272
402
  const y = Number.isFinite(op.y) ? op.y : ctx.cursor.y;
273
403
  if (!Number.isFinite(op.x)) ctx.cursor.x = x + w + 40;
274
- return { x, y };
404
+ return { x, y, pinnedEl: ctx.pinnedEl };
275
405
  }
276
406
 
277
407
  function pushCreated(ctx, stroke) {
@@ -280,11 +410,41 @@ function pushCreated(ctx, stroke) {
280
410
  return stroke;
281
411
  }
282
412
 
413
+ /**
414
+ * A visual pointer from a note to a DOM element (--pin). Not a magnetic BIND
415
+ * (annotate.mjs:18 — binds only host on annotation strokes, DDR-100) — a DOM
416
+ * element isn't part of this SVG, so the arrow is a one-time snapshot
417
+ * computed via the SAME facing-anchor math createConnect uses, against a
418
+ * fabricated rect host built from the element's manifest rect.
419
+ */
420
+ function pointerArrowTo(ctx, fromStroke, el) {
421
+ const elHost = { id: `_el_${el.cdId ?? 'x'}`, tool: 'rect', x: el.x, y: el.y, w: el.w, h: el.h };
422
+ const fromCenter = centerOf(fromStroke);
423
+ const toCenter = [el.x + el.w / 2, el.y + el.h / 2];
424
+ const sb = facingAnchor(fromStroke, toCenter[0], toCenter[1]);
425
+ const eb = facingAnchor(elHost, fromCenter[0], fromCenter[1]);
426
+ if (!sb || !eb) return null;
427
+ const p1 = anchorPoint(fromStroke, sb.nx, sb.ny);
428
+ const p2 = anchorPoint(elHost, eb.nx, eb.ny);
429
+ if (!p1 || !p2) return null;
430
+ return pushCreated(ctx, {
431
+ id: rid(),
432
+ tool: 'arrow',
433
+ color: DEFAULT_COLOR,
434
+ width: 3,
435
+ x1: p1[0],
436
+ y1: p1[1],
437
+ x2: p2[0],
438
+ y2: p2[1],
439
+ author: 'ai',
440
+ });
441
+ }
442
+
283
443
  function createSticky(ctx, op) {
284
444
  const w = Number.isFinite(op.w) ? op.w : STICKY_DEFAULT_W;
285
445
  const h = Number.isFinite(op.h) ? op.h : STICKY_DEFAULT_H;
286
- const { x, y } = autoPlace(ctx, w, op);
287
- return pushCreated(ctx, {
446
+ const { x, y, pinnedEl } = autoPlace(ctx, w, op);
447
+ const stroke = pushCreated(ctx, {
288
448
  id: mint(ctx, op.ref),
289
449
  tool: 'sticky',
290
450
  color: typeof op.color === 'string' ? op.color : DEFAULT_STICKY_COLOR,
@@ -297,6 +457,8 @@ function createSticky(ctx, op) {
297
457
  cornerRadius: STICKY_CORNER_RADIUS,
298
458
  author: 'ai',
299
459
  });
460
+ if (pinnedEl && ctx.pointer && op.pointer !== false) pointerArrowTo(ctx, stroke, pinnedEl);
461
+ return stroke;
300
462
  }
301
463
 
302
464
  function createSection(ctx, op) {
@@ -317,8 +479,8 @@ function createSection(ctx, op) {
317
479
  }
318
480
 
319
481
  function createText(ctx, op) {
320
- const { x, y } = autoPlace(ctx, 160, op);
321
- return pushCreated(ctx, {
482
+ const { x, y, pinnedEl } = autoPlace(ctx, 160, op);
483
+ const stroke = pushCreated(ctx, {
322
484
  id: mint(ctx, op.ref),
323
485
  tool: 'text',
324
486
  color: typeof op.color === 'string' ? op.color : TEXT_INK,
@@ -328,6 +490,8 @@ function createText(ctx, op) {
328
490
  y,
329
491
  author: 'ai',
330
492
  });
493
+ if (pinnedEl && ctx.pointer && op.pointer !== false) pointerArrowTo(ctx, stroke, pinnedEl);
494
+ return stroke;
331
495
  }
332
496
 
333
497
  function createShape(ctx, op) {
@@ -470,7 +634,143 @@ function applyGroup(ctx, op) {
470
634
  }
471
635
  }
472
636
 
473
- function applyOps(ctx, ops) {
637
+ // ─────────────────────────────────────────────────────────────────────────────
638
+ // move / set-text / set-color — id-preserving mutation (feature-whiteboard-
639
+ // ai-toolkit). DDR-100 deliberately omitted "update" for LWW honesty; this is
640
+ // the additive answer: parse the FULL existing stroke via the CANONICAL
641
+ // parser (svgToStrokes — the same code the canvas itself uses), patch just
642
+ // the requested field, and re-serialize through the CANONICAL serializer —
643
+ // every other attribute (fontSize, bold/italic/dashed, rotation, groupIds,
644
+ // cornerRadius, …) survives untouched. `svgToStrokes` needs a DOMParser,
645
+ // which Bun doesn't ship — happy-dom is loaded ONLY when a move/set-text/
646
+ // set-color op actually appears in the batch, so the common create-only path
647
+ // pays zero cost for it.
648
+ //
649
+ // Security note (feature-whiteboard-ai-toolkit security review): this reads
650
+ // an on-disk .annotations.svg a peer/hub can write (DDR-054) or that landed
651
+ // via any git commit — content `sanitizeAnnotationSvg` was designed to gate
652
+ // only on WRITE. We now sanitize on READ too before it ever reaches the DOM
653
+ // parser. `GlobalRegistrator.register()` is still what we use (happy-dom's
654
+ // internals need its full global scaffolding — e.g. querySelector's error
655
+ // path reaches through `this.window`, not just a bare `DOMParser` reference,
656
+ // so patching `DOMParser` alone leaves the parse broken) — but register()
657
+ // itself records every property it overwrote, so we call `unregister()`
658
+ // (its documented inverse) IMMEDIATELY after the parse, before control ever
659
+ // returns to the batch's later loopback-gated PUT. That closes the window
660
+ // this feature's own security review flagged: fetch/Response/Request/URL
661
+ // never stay monkey-patched past this one parse.
662
+ async function ensureFullStrokes(ctx) {
663
+ if (ctx.fullStrokes) return ctx.fullStrokes;
664
+ let GlobalRegistrator;
665
+ try {
666
+ ({ GlobalRegistrator } = await import('@happy-dom/global-registrator'));
667
+ } catch {
668
+ // Packaging note (feature-whiteboard-ai-toolkit review): apps/studio's
669
+ // package.json is a nested workspace manifest the npm tarball doesn't
670
+ // ship, and unlike every other bin script here this is the first one
671
+ // that needs a real third-party package at runtime — an npm-installed
672
+ // maude may have no node_modules for it. Fail loud with the documented
673
+ // escape hatch rather than an opaque "Cannot find package" stack trace.
674
+ fail(
675
+ 'move/set-text/set-color need the happy-dom package, which is not installed — ' +
676
+ "reinstall maude, or use delete + create instead (the vocabulary's existing fallback for full replacement)",
677
+ 1
678
+ );
679
+ }
680
+ GlobalRegistrator.register({ settings: { disableJavaScriptEvaluation: true } });
681
+ let strokes;
682
+ try {
683
+ strokes = svgToStrokes(sanitizeAnnotationSvg(ctx.rawSvg));
684
+ } finally {
685
+ await GlobalRegistrator.unregister();
686
+ }
687
+ ctx.fullStrokes = new Map(strokes.map((s) => [s.id, s]));
688
+ return ctx.fullStrokes;
689
+ }
690
+
691
+ /**
692
+ * Resolves an id or "@ref" to the mutable stroke — a just-created stroke in
693
+ * THIS batch (mutated in place, not yet serialized), an EARLIER patch this
694
+ * same batch already queued (checked before the cached original — otherwise
695
+ * a second move/set-text/set-color on the same id would clobber the first
696
+ * patch instead of building on it), or an existing one from the pre-batch SVG.
697
+ */
698
+ async function resolveMutable(ctx, idOrRef, verb) {
699
+ const id =
700
+ typeof idOrRef === 'string' && idOrRef.startsWith('@') ? ctx.refs.get(idOrRef) : idOrRef;
701
+ if (!id) fail(`${verb}: unknown ref "${idOrRef}"`, 2);
702
+ const created = ctx.newById.get(id);
703
+ if (created) return { id, stroke: created, isNew: true };
704
+ if (ctx.replaces.has(id)) return { id, stroke: ctx.replaces.get(id), isNew: false };
705
+ const byId = await ensureFullStrokes(ctx);
706
+ const stroke = byId.get(id);
707
+ if (!stroke) fail(`${verb}: unknown id "${id}"`, 2);
708
+ return { id, stroke, isNew: false };
709
+ }
710
+
711
+ function commitMutation(ctx, target, patched) {
712
+ if (target.isNew) Object.assign(target.stroke, patched);
713
+ else ctx.replaces.set(target.id, { ...target.stroke, ...patched });
714
+ }
715
+
716
+ async function applyMove(ctx, op) {
717
+ if (typeof op.id !== 'string' || !op.id) fail('move: missing id', 2);
718
+ if (!Number.isFinite(op.x) || !Number.isFinite(op.y)) fail('move: x/y must be finite numbers', 2);
719
+ const target = await resolveMutable(ctx, op.id, 'move');
720
+ const { stroke } = target;
721
+ if (stroke.tool === 'ellipse') {
722
+ commitMutation(ctx, target, { cx: op.x + stroke.rx, cy: op.y + stroke.ry });
723
+ return;
724
+ }
725
+ if (stroke.tool === 'arrow' || stroke.tool === 'pen') {
726
+ fail(`move: "${stroke.tool}" has no single position (multi-point) — use delete + create`, 2);
727
+ }
728
+ if (stroke.tool === 'text' && stroke.anchorId) {
729
+ fail('move: anchored text derives its position from its host — move the host instead', 2);
730
+ }
731
+ if (stroke.x == null || stroke.y == null) {
732
+ fail(`move: tool "${stroke.tool}" has no movable x/y`, 2);
733
+ }
734
+ commitMutation(ctx, target, { x: op.x, y: op.y });
735
+ }
736
+
737
+ async function applySetText(ctx, op) {
738
+ if (typeof op.id !== 'string' || !op.id) fail('set-text: missing id', 2);
739
+ if (typeof op.text !== 'string') fail('set-text: text must be a string', 2);
740
+ const target = await resolveMutable(ctx, op.id, 'set-text');
741
+ const { stroke } = target;
742
+ const field = 'label' in stroke ? 'label' : 'text' in stroke ? 'text' : null;
743
+ if (!field) fail(`set-text: tool "${stroke.tool}" has no text/label field`, 2);
744
+ commitMutation(ctx, target, { [field]: op.text });
745
+ }
746
+
747
+ async function applySetColor(ctx, op) {
748
+ if (typeof op.id !== 'string' || !op.id) fail('set-color: missing id', 2);
749
+ if (typeof op.color !== 'string' || !op.color)
750
+ fail('set-color: color must be a non-empty string', 2);
751
+ const target = await resolveMutable(ctx, op.id, 'set-color');
752
+ const { stroke } = target;
753
+ if (!('color' in stroke)) {
754
+ fail(`set-color: tool "${stroke.tool}" has no single color field — use delete + create`, 2);
755
+ }
756
+ commitMutation(ctx, target, { color: op.color });
757
+ }
758
+
759
+ /** Splice a re-serialized stroke into the SAME position the original element
760
+ * occupied — preserves document order/z, unlike delete-then-append. Mirrors
761
+ * deleteElement's g-wrapped-vs-flat matching. */
762
+ function replaceElement(svg, id, replacement) {
763
+ const idEsc = escapeRe(id);
764
+ const gOrTextRe = new RegExp(`<(g|text)\\b[^>]*data-id="${idEsc}"[^>]*>[\\s\\S]*?</\\1>`);
765
+ if (gOrTextRe.test(svg)) return svg.replace(gOrTextRe, replacement);
766
+ const flatRe = new RegExp(
767
+ `<(?:path|rect|ellipse|polygon|image)\\b[^>]*data-id="${idEsc}"[^>]*/>`
768
+ );
769
+ if (flatRe.test(svg)) return svg.replace(flatRe, replacement);
770
+ return null;
771
+ }
772
+
773
+ async function applyOps(ctx, ops) {
474
774
  for (const op of ops) {
475
775
  if (!op || typeof op !== 'object') fail('ops: every entry must be an object', 2);
476
776
  if (op.op === 'create') {
@@ -502,6 +802,12 @@ function applyOps(ctx, ops) {
502
802
  } else if (op.op === 'delete') {
503
803
  if (typeof op.id !== 'string' || !op.id) fail('delete: missing id', 2);
504
804
  ctx.deletes.push(op.id);
805
+ } else if (op.op === 'move') {
806
+ await applyMove(ctx, op);
807
+ } else if (op.op === 'set-text') {
808
+ await applySetText(ctx, op);
809
+ } else if (op.op === 'set-color') {
810
+ await applySetColor(ctx, op);
505
811
  } else {
506
812
  fail(`ops: unknown op "${op.op}"`, 2);
507
813
  }
@@ -515,6 +821,12 @@ function flowToOps(flow) {
515
821
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
516
822
  const edges = Array.isArray(flow.edges) ? flow.edges : [];
517
823
  if (!nodes.length) fail('flow: needs at least one node', 2);
824
+ if (nodes.length > FLOW_MAX_NODES) {
825
+ fail(`flow: nodes[] has ${nodes.length}, max ${FLOW_MAX_NODES}`, 2);
826
+ }
827
+ if (edges.length > FLOW_MAX_EDGES) {
828
+ fail(`flow: edges[] has ${edges.length}, max ${FLOW_MAX_EDGES}`, 2);
829
+ }
518
830
  const ids = nodes.map((n) => n.id);
519
831
  const idSet = new Set(ids);
520
832
  for (const e of edges) {
@@ -567,6 +879,187 @@ function flowToOps(flow) {
567
879
  return ops;
568
880
  }
569
881
 
882
+ // ─────────────────────────────────────────────────────────────────────────────
883
+ // Board mode (feature-whiteboard-ai-toolkit) — a typed template spec expands
884
+ // into ops the same way --flow does. Named presets (retro / kanban / social-
885
+ // calendar / roadmap / brainstorm / checklist / user-flow) are NOT hardcoded
886
+ // here — they're spec fixtures documented in the `whiteboard` skill; this is
887
+ // just the generic layout engine they share.
888
+
889
+ const BOARD_COL_W = 280;
890
+ const BOARD_COL_GAP = 40;
891
+ const BOARD_CARD_W = 240;
892
+ const BOARD_CARD_H = 90;
893
+ const BOARD_CARD_GAP = 20;
894
+ const BOARD_HEADER_H = 60;
895
+ const BOARD_EMPTY_SECTION_H = 500;
896
+ const BOARD_RADIAL_RADIUS = 320;
897
+ const BOARD_RADIAL_CENTER_W = 200;
898
+ const BOARD_RADIAL_CENTER_H = 100;
899
+ const BOARD_RADIAL_CARD_W = 200;
900
+ const BOARD_RADIAL_CARD_H = 80;
901
+
902
+ // Security (feature-whiteboard-ai-toolkit review): --board is arbitrary JSON
903
+ // an agent composes; MAX_ANNOTATIONS_BYTES only rejects AFTER the full spec
904
+ // is expanded + serialized, so an unbounded groups/cards array could burn
905
+ // real CPU/memory before that cap ever fires. Cap generously (well above any
906
+ // real retro/kanban/roadmap board) up front instead.
907
+ const BOARD_MAX_GROUPS = 20;
908
+ const BOARD_MAX_CARDS_PER_GROUP = 50;
909
+ const BOARD_MAX_TOTAL_CARDS = 300;
910
+ const BOARD_MAX_CONNECTIONS = 400;
911
+ // Shared by --flow (top-level) and --board's layout:"flow" (which delegates
912
+ // straight to flowToOps) — checked once, in flowToOps itself, so neither
913
+ // caller can bypass it. A relationship array (edges) is just as capable of
914
+ // manufacturing unbounded connect ops as an entity array (nodes/cards) is —
915
+ // createConnect mints a fresh arrow (+ optional label text) stroke per call
916
+ // regardless of how few distinct nodes are involved.
917
+ const FLOW_MAX_NODES = 200;
918
+ const FLOW_MAX_EDGES = 400;
919
+
920
+ function cardText(card) {
921
+ return typeof card === 'string' ? card : typeof card?.text === 'string' ? card.text : '';
922
+ }
923
+
924
+ function cardColor(card) {
925
+ return typeof card === 'object' && card && typeof card.color === 'string'
926
+ ? card.color
927
+ : undefined;
928
+ }
929
+
930
+ /**
931
+ * "columns" (default; "grid"/"lanes" are v1 aliases of the same engine) — one
932
+ * titled section per group, its cards stacked inside it. Deterministic and
933
+ * non-overlapping: each section's own height grows with its own card count,
934
+ * so an empty column next to a seeded one (a half-filled retro board) never
935
+ * collides with its neighbor.
936
+ */
937
+ function boardColumns(groups) {
938
+ const ops = [];
939
+ groups.forEach((g, i) => {
940
+ const cards = Array.isArray(g.cards) ? g.cards : [];
941
+ const h = cards.length
942
+ ? BOARD_HEADER_H + cards.length * (BOARD_CARD_H + BOARD_CARD_GAP)
943
+ : BOARD_EMPTY_SECTION_H;
944
+ const colX = i * (BOARD_COL_W + BOARD_COL_GAP);
945
+ ops.push({
946
+ op: 'create',
947
+ type: 'section',
948
+ ref: `@sec${i}`,
949
+ label: typeof g.title === 'string' && g.title ? g.title : `Group ${i + 1}`,
950
+ color: typeof g.color === 'string' ? g.color : undefined,
951
+ boardX: colX,
952
+ boardY: 0,
953
+ w: BOARD_COL_W,
954
+ h,
955
+ });
956
+ cards.forEach((c, j) => {
957
+ ops.push({
958
+ op: 'create',
959
+ type: 'sticky',
960
+ ref: `@sec${i}card${j}`,
961
+ text: cardText(c),
962
+ color: cardColor(c),
963
+ boardX: colX + (BOARD_COL_W - BOARD_CARD_W) / 2,
964
+ boardY: BOARD_HEADER_H + j * (BOARD_CARD_H + BOARD_CARD_GAP),
965
+ w: BOARD_CARD_W,
966
+ h: BOARD_CARD_H,
967
+ });
968
+ });
969
+ });
970
+ return ops;
971
+ }
972
+
973
+ /**
974
+ * "radial" — a central topic shape with idea cards arranged in a ring
975
+ * (brainstorm). Cards flatten across every group's cards in order — a
976
+ * brainstorm spec doesn't need multiple groups, but tolerates them.
977
+ */
978
+ function boardRadial(spec, groups) {
979
+ const cards = groups.flatMap((g) => (Array.isArray(g.cards) ? g.cards : []));
980
+ const pad = BOARD_RADIAL_RADIUS + Math.max(BOARD_RADIAL_CARD_W, BOARD_RADIAL_CARD_H);
981
+ const ops = [
982
+ {
983
+ op: 'create',
984
+ type: 'shape',
985
+ shape: 'ellipse',
986
+ ref: '@center',
987
+ label: typeof spec.title === 'string' && spec.title ? spec.title : 'Topic',
988
+ boardX: pad - BOARD_RADIAL_CENTER_W / 2,
989
+ boardY: pad - BOARD_RADIAL_CENTER_H / 2,
990
+ w: BOARD_RADIAL_CENTER_W,
991
+ h: BOARD_RADIAL_CENTER_H,
992
+ },
993
+ ];
994
+ const n = cards.length;
995
+ cards.forEach((c, i) => {
996
+ const angle = (2 * Math.PI * i) / Math.max(1, n) - Math.PI / 2;
997
+ const cx = pad + BOARD_RADIAL_RADIUS * Math.cos(angle);
998
+ const cy = pad + BOARD_RADIAL_RADIUS * Math.sin(angle);
999
+ ops.push({
1000
+ op: 'create',
1001
+ type: 'sticky',
1002
+ ref: `@idea${i}`,
1003
+ text: cardText(c),
1004
+ color: cardColor(c),
1005
+ boardX: cx - BOARD_RADIAL_CARD_W / 2,
1006
+ boardY: cy - BOARD_RADIAL_CARD_H / 2,
1007
+ w: BOARD_RADIAL_CARD_W,
1008
+ h: BOARD_RADIAL_CARD_H,
1009
+ });
1010
+ });
1011
+ return ops;
1012
+ }
1013
+
1014
+ /**
1015
+ * Expand a typed board spec into ops (create + connect), positioned relative
1016
+ * to the placement origin via `boardX`/`boardY` — the same convention
1017
+ * `flowToOps` uses for `flowX`/`flowY` (applyOriginOffset, below, applies
1018
+ * either). `layout: "flow"` delegates straight to `flowToOps` so a user-flow
1019
+ * diagram shares ONE auto-layout implementation with plain `--flow`.
1020
+ */
1021
+ function boardToOps(spec) {
1022
+ const layout = typeof spec.layout === 'string' ? spec.layout : 'columns';
1023
+ if (layout === 'flow') {
1024
+ if (!Array.isArray(spec.nodes) || !spec.nodes.length) {
1025
+ fail('board: layout "flow" needs a nodes[] array', 2);
1026
+ }
1027
+ return flowToOps({ nodes: spec.nodes, edges: spec.edges });
1028
+ }
1029
+ const groups = Array.isArray(spec.groups) ? spec.groups : [];
1030
+ if (!groups.length) fail('board: needs a non-empty groups[] array (or layout: "flow")', 2);
1031
+ if (groups.length > BOARD_MAX_GROUPS) {
1032
+ fail(`board: groups[] has ${groups.length}, max ${BOARD_MAX_GROUPS}`, 2);
1033
+ }
1034
+ let totalCards = 0;
1035
+ for (const g of groups) {
1036
+ const n = Array.isArray(g?.cards) ? g.cards.length : 0;
1037
+ if (n > BOARD_MAX_CARDS_PER_GROUP) {
1038
+ fail(`board: group "${g?.title ?? '?'}" has ${n} cards, max ${BOARD_MAX_CARDS_PER_GROUP}`, 2);
1039
+ }
1040
+ totalCards += n;
1041
+ }
1042
+ if (totalCards > BOARD_MAX_TOTAL_CARDS) {
1043
+ fail(`board: ${totalCards} total cards across groups, max ${BOARD_MAX_TOTAL_CARDS}`, 2);
1044
+ }
1045
+ const ops = layout === 'radial' ? boardRadial(spec, groups) : boardColumns(groups);
1046
+
1047
+ // Optional cross-references — "from"/"to" name a ref this expansion minted
1048
+ // (section refs are `@sec<i>`, 0-indexed by group order; card refs are
1049
+ // `@sec<i>card<j>`; the radial center is `@center`, ideas are `@idea<i>`).
1050
+ const connections = Array.isArray(spec.connections) ? spec.connections : [];
1051
+ if (connections.length > BOARD_MAX_CONNECTIONS) {
1052
+ fail(`board: connections[] has ${connections.length}, max ${BOARD_MAX_CONNECTIONS}`, 2);
1053
+ }
1054
+ for (const c of connections) {
1055
+ if (!c || typeof c.from !== 'string' || typeof c.to !== 'string') {
1056
+ fail('board: each connections[] entry needs string "from"/"to"', 2);
1057
+ }
1058
+ ops.push({ op: 'connect', from: c.from, to: c.to, label: c.label });
1059
+ }
1060
+ return ops;
1061
+ }
1062
+
570
1063
  // ─────────────────────────────────────────────────────────────────────────────
571
1064
  // SVG assembly — deletes + group injections on the existing string, new
572
1065
  // strokes serialized through the canonical serializer, sanitize, cap.
@@ -606,6 +1099,20 @@ function injectGroupAttr(svg, id, groupId) {
606
1099
  });
607
1100
  }
608
1101
 
1102
+ /**
1103
+ * Shared by --flow and --board: both expansions emit ops with a relative
1104
+ * offset field (flowX/flowY, or boardX/boardY) instead of absolute x/y, so
1105
+ * ONE placement resolution (--near/--in/--pin/existing-extent, buildContext)
1106
+ * positions the whole diagram/board as a unit.
1107
+ */
1108
+ function applyOriginOffset(ops, origin) {
1109
+ return ops.map((op) => {
1110
+ if ('flowX' in op) return { ...op, x: origin.x + op.flowX, y: origin.y + op.flowY };
1111
+ if ('boardX' in op) return { ...op, x: origin.x + op.boardX, y: origin.y + op.boardY };
1112
+ return op;
1113
+ });
1114
+ }
1115
+
609
1116
  // ─────────────────────────────────────────────────────────────────────────────
610
1117
  // Main
611
1118
 
@@ -617,7 +1124,9 @@ async function main() {
617
1124
  }
618
1125
  const relPath = args.positional[0];
619
1126
  if (!relPath) fail(`missing <rel-path>.\n\n${HELP}`, 2);
620
- if (args.ops && args.flow) fail('--ops and --flow are mutually exclusive', 2);
1127
+ if ([args.ops, args.flow, args.board].filter((v) => v != null).length > 1) {
1128
+ fail('--ops, --flow, and --board are mutually exclusive', 2);
1129
+ }
621
1130
 
622
1131
  const repoRoot = args.root
623
1132
  ? resolve(args.root)
@@ -636,35 +1145,68 @@ async function main() {
636
1145
  svg = '';
637
1146
  }
638
1147
  }
1148
+ // Security (feature-whiteboard-ai-toolkit review): MAX_ANNOTATIONS_BYTES was
1149
+ // only ever checked on the MERGED OUTPUT. A file already at/near the cap
1150
+ // (peer-written per DDR-054, or git-committed by anyone) would still be
1151
+ // read in full and DOM-parsed on every move/set-text/set-color — the exact
1152
+ // "pay the cost before the check" pattern this feature's own board-size
1153
+ // caps exist to avoid, just via the read path instead of the write path.
1154
+ if (Buffer.byteLength(svg, 'utf8') > MAX_ANNOTATIONS_BYTES) {
1155
+ fail(`annotations file exceeds ${MAX_ANNOTATIONS_BYTES} bytes on disk — refusing to read`, 2);
1156
+ }
639
1157
  const existing = parseAnnotations(svg);
640
1158
 
641
- const artboards = args.canvasState
1159
+ let artboards = args.canvasState
642
1160
  ? loadArtboards(
643
1161
  isAbsolute(args.canvasState) ? args.canvasState : resolve(process.cwd(), args.canvasState)
644
1162
  )
645
1163
  : [];
646
1164
 
647
- const ctx = buildContext(existing, artboards, args.near);
1165
+ // feature-whiteboard-ai-toolkit --rects supplies element lookups for
1166
+ // --pin, and (absent a separate --canvas-state) artboard lookups for
1167
+ // --in/--near too: loadArtboards already understands a canvas-rects
1168
+ // manifest's { artboards, elements } shape.
1169
+ let elements = [];
1170
+ if (args.rects) {
1171
+ const rectsPath = isAbsolute(args.rects) ? args.rects : resolve(process.cwd(), args.rects);
1172
+ elements = loadElements(rectsPath);
1173
+ if (!artboards.length) artboards = loadArtboards(rectsPath);
1174
+ }
1175
+
1176
+ const ctx = buildContext(existing, artboards, elements, {
1177
+ near: args.near,
1178
+ in: args.in,
1179
+ pin: args.pin,
1180
+ pointer: args.pointer,
1181
+ });
1182
+ ctx.rawSvg = svg;
648
1183
 
649
1184
  let ops;
650
1185
  if (args.flow != null) {
651
1186
  const flow = parseJsonInput(readInput(args.flow), '--flow');
652
- ops = flowToOps(flow).map((op) =>
653
- 'flowX' in op ? { ...op, x: ctx.origin.x + op.flowX, y: ctx.origin.y + op.flowY } : op
654
- );
1187
+ ops = applyOriginOffset(flowToOps(flow), ctx.origin);
1188
+ } else if (args.board != null) {
1189
+ const spec = parseJsonInput(readInput(args.board), '--board');
1190
+ ops = applyOriginOffset(boardToOps(spec), ctx.origin);
655
1191
  } else {
656
1192
  const payload = parseJsonInput(readInput(args.ops), '--ops');
657
1193
  ops = Array.isArray(payload.ops) ? payload.ops : Array.isArray(payload) ? payload : null;
658
1194
  if (!ops) fail('ops: expected { ops: [...] } (or a bare array)', 2);
659
1195
  }
660
1196
 
661
- applyOps(ctx, ops);
1197
+ await applyOps(ctx, ops);
662
1198
 
663
- // Assemble. Deletes + group injections operate on the existing string; new
664
- // strokes append before </svg> through the canonical serializer.
1199
+ // Assemble. Deletes + group injections + move/set-text/set-color replaces
1200
+ // operate on the existing string; new strokes append before </svg> through
1201
+ // the canonical serializer.
665
1202
  let merged = svg && /<svg[\s>]/i.test(svg) ? svg : `${SVG_HEADER}</svg>`;
666
1203
  for (const id of ctx.deletes) merged = deleteElement(merged, id);
667
1204
  for (const inj of ctx.groupExisting) merged = injectGroupAttr(merged, inj.id, inj.groupId);
1205
+ for (const [id, stroke] of ctx.replaces) {
1206
+ const next = replaceElement(merged, id, strokeToSvgEl(stroke));
1207
+ if (next === null) fail(`internal: replaced stroke "${id}" vanished before assembly`, 1);
1208
+ merged = next;
1209
+ }
668
1210
  if (ctx.created.length) {
669
1211
  const body = ctx.created.map((s) => strokeToSvgEl(s)).join('');
670
1212
  const close = merged.lastIndexOf('</svg>');