@1agh/maude 1.4.6 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +534 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops-merge.ts +15 -0
  21. package/apps/studio/annotations/ops.ts +499 -0
  22. package/apps/studio/annotations/registry.ts +159 -0
  23. package/apps/studio/annotations/replica.ts +264 -0
  24. package/apps/studio/annotations/scene.ts +235 -0
  25. package/apps/studio/annotations/schema.ts +188 -0
  26. package/apps/studio/annotations/types.ts +77 -0
  27. package/apps/studio/annotations/ui/board.ts +126 -0
  28. package/apps/studio/annotations/ui/containment.ts +118 -0
  29. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  30. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  31. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  32. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  33. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  34. package/apps/studio/annotations/ui/render-model.ts +264 -0
  35. package/apps/studio/annotations/ui/scene.tsx +63 -0
  36. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  37. package/apps/studio/annotations/ui/text-session.ts +140 -0
  38. package/apps/studio/annotations/ui/text-style.ts +111 -0
  39. package/apps/studio/annotations/ui/world.ts +47 -0
  40. package/apps/studio/annotations/v1-adapter.ts +428 -0
  41. package/apps/studio/annotations-align.ts +21 -6
  42. package/apps/studio/annotations-bindings.ts +2 -0
  43. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  44. package/apps/studio/annotations-groups.ts +3 -0
  45. package/apps/studio/annotations-layer.tsx +923 -1925
  46. package/apps/studio/annotations-model.ts +65 -4
  47. package/apps/studio/annotations-sync.ts +4 -47
  48. package/apps/studio/api.ts +249 -69
  49. package/apps/studio/bin/_import-figma.mjs +22 -12
  50. package/apps/studio/bin/annotate.mjs +331 -838
  51. package/apps/studio/bin/annotate.sh +4 -4
  52. package/apps/studio/bin/perf.sh +21 -7
  53. package/apps/studio/bin/read-annotations.mjs +184 -666
  54. package/apps/studio/bin/read-annotations.sh +9 -5
  55. package/apps/studio/canvas-artifacts.ts +9 -0
  56. package/apps/studio/canvas-lib.tsx +13 -1
  57. package/apps/studio/canvas-shell.tsx +6 -0
  58. package/apps/studio/client/app.jsx +98 -35
  59. package/apps/studio/client/hmr.mjs +1 -1
  60. package/apps/studio/client/panels/git-grouping.js +2 -2
  61. package/apps/studio/client/tree-expansion.js +217 -0
  62. package/apps/studio/collab/index.ts +49 -5
  63. package/apps/studio/collab/persistence.ts +31 -10
  64. package/apps/studio/collab/registry.ts +64 -26
  65. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  66. package/apps/studio/cursors-overlay.tsx +158 -4
  67. package/apps/studio/dist/client.bundle.js +850 -850
  68. package/apps/studio/dist/comment-mount.js +2 -2
  69. package/apps/studio/figma/to-strokes.ts +31 -10
  70. package/apps/studio/git/endpoints.ts +1 -1
  71. package/apps/studio/git/service.ts +1 -1
  72. package/apps/studio/git/watch.ts +1 -1
  73. package/apps/studio/http.ts +90 -15
  74. package/apps/studio/server.ts +16 -0
  75. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  76. package/apps/studio/sync/agent.ts +8 -4
  77. package/apps/studio/sync/codec.ts +96 -40
  78. package/apps/studio/sync/file-membership.ts +12 -2
  79. package/apps/studio/sync/file-plane.ts +1 -1
  80. package/apps/studio/sync/index.ts +38 -16
  81. package/apps/studio/sync/journal-client.ts +5 -0
  82. package/apps/studio/sync/limits.ts +7 -2
  83. package/apps/studio/sync/migrate-seed.ts +8 -5
  84. package/apps/studio/sync/projection.ts +7 -2
  85. package/apps/studio/sync/remote-docs.ts +34 -0
  86. package/apps/studio/sync/writer-registry.ts +10 -0
  87. package/apps/studio/text-caret.ts +11 -2
  88. package/apps/studio/tree-state.ts +45 -0
  89. package/apps/studio/undo-stack.ts +2 -2
  90. package/apps/studio/use-annotation-resize.tsx +48 -23
  91. package/apps/studio/use-annotation-selection.tsx +9 -2
  92. package/apps/studio/use-collab.tsx +76 -0
  93. package/apps/studio/whats-new.json +27 -0
  94. package/cli/lib/design-link.mjs +5 -1
  95. package/cli/lib/gitignore-block.mjs +1 -1
  96. package/cli/lib/gitignore-drift.mjs +2 -1
  97. package/package.json +9 -8
  98. package/plugins/design/templates/brief-board.tsx.template +1 -1
  99. package/apps/studio/annotation-edit-base.ts +0 -36
  100. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -1,178 +1,107 @@
1
1
  #!/usr/bin/env bun
2
- // annotate.mjs — the AI annotation WRITE verb (FigJam v3).
2
+ // annotate.mjs — the AI annotation WRITE verb (DDR-242 AD9).
3
3
  //
4
- // `read-annotations` made the annotation layer machine-READABLE; this verb
5
- // closes the loop: an AI agent (or any tool) creates stickies, labelled
6
- // shapes, BOUND connectors, groups — or a whole auto-laid-out flow diagram —
7
- // through a typed ops vocabulary (never raw SVG). Everything renders through
8
- // the CANONICAL serializer (`annotations-model.ts` — the same code the canvas
9
- // uses) and passes the same allowlist sanitizer before a byte is written, so
10
- // the verb can never emit a shape the canvas wouldn't.
4
+ // An agent creates, updates, moves, re-parents, re-orders, groups and deletes
5
+ // whiteboard elements through a typed ops vocabulary — or lays out a whole
6
+ // flow diagram / board template. Every request becomes the SAME element ops
7
+ // the canvas sends (`put | patch | delete`, annotations/ops.ts), built by the
8
+ // registry-driven engine in annotations/ai-write.ts: field names, text slots,
9
+ // bindability and geometry all come from the element registry, so a new
10
+ // element type needs no change here.
11
11
  //
12
- // Runs under Bun (the .sh wrapper enforces it) because the model is TS.
12
+ // Write path: with a live dev-server (`<designRoot>/_server.json`, loopback
13
+ // only) the ops go through `POST /_api/annotations/ops` — merged under the one
14
+ // merge rule and broadcast to every open canvas. Without one, the resulting
15
+ // board is written directly (canonical, atomic tmp + rename). A board file that
16
+ // exists but can't be read is never written over.
13
17
  //
14
- // Write path: when `<designRoot>/_server.json` points at a live dev-server the
15
- // merged SVG goes through `PUT /_api/annotations` — the server sanitizes,
16
- // persists, and broadcasts through the collab bridge so every open canvas
17
- // updates in real time. With no server, the file is written directly (already
18
- // sanitized). Either way the result is LWW over the whole SVG — the same
19
- // trade-off the canvas itself has (documented; agents should read-then-write).
20
- //
21
- // Every created stroke is stamped `data-author="ai"` (provenance) and the
22
- // verb prints a ref → id map so a follow-up call can target what it made.
23
- // AI writes never enter any user's local undo stack (they arrive over the
24
- // sync channel, not through the canvas's commitStrokes).
18
+ // Created elements are stamped `author: {kind: 'ai'}`; AI writes never enter a
19
+ // user's undo stack (they arrive over the sync channel, DDR-100 §3). Updates
20
+ // are field patches that carry the values the agent read, so they merge with a
21
+ // concurrent human edit instead of replacing the board.
25
22
  //
26
23
  // Reached via `maude design annotate` (DDR-062), never a raw bin path.
27
- //
28
- // Usage:
29
- // maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
30
- // [--near <artboardId>] [--canvas-state <path>]
31
- // [--root <repo>] [--dry-run]
32
- //
33
- // Ops JSON (--ops / stdin):
34
- // { "ops": [
35
- // { "op": "create", "type": "sticky", "ref": "@a", "text": "…",
36
- // "x"?, "y"?, "w"?, "h"?, "color"? },
37
- // { "op": "create", "type": "text", "text": "…", "x"?, "y"?, "fontSize"? },
38
- // { "op": "create", "type": "shape", "shape": "rect|rounded|ellipse|diamond|triangle|triangle-down",
39
- // "ref"?, "label"?, "x"?, "y"?, "w"?, "h"?, "color"?, "fill"? },
40
- // { "op": "create", "type": "arrow", "x1", "y1", "x2", "y2" },
41
- // { "op": "connect", "from": "<id|@ref>", "to": "<id|@ref>", "label"? },
42
- // { "op": "group", "ids": ["<id|@ref>", …] },
43
- // { "op": "delete", "id": "<id>" }
44
- // ] }
45
- //
46
- // Flow JSON (--flow): { "nodes": [{ "id", "label", "shape"? }],
47
- // "edges": [{ "from", "to", "label"? }] }
48
- // Nodes are auto-laid-out left→right by dependency layer and connected with
49
- // BOUND arrows, so `read-annotations --graph` reads the diagram back as the
50
- // same graph.
51
24
 
52
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { existsSync, readFileSync } from 'node:fs';
53
26
  import { isAbsolute, join, resolve } from 'node:path';
54
27
 
55
- import { anchorPoint, facingAnchor } from '../annotations-bindings.ts';
56
- import {
57
- DEFAULT_COLOR,
58
- DEFAULT_FONT_SIZE,
59
- DEFAULT_SECTION_COLOR,
60
- DEFAULT_STICKY_COLOR,
61
- gid,
62
- rid,
63
- SECTION_DEFAULT_H,
64
- SECTION_DEFAULT_W,
65
- STICKY_CORNER_RADIUS,
66
- STICKY_DEFAULT_H,
67
- STICKY_DEFAULT_W,
68
- sanitizeAnnotationSvg,
69
- strokeToSvgEl,
70
- svgToStrokes,
71
- } from '../annotations-model.ts';
28
+ import { AiBatch, AiOpError, CREATE_SIZE, describeTypes } from '../annotations/ai-write.ts';
29
+ import { readBoardFile, writeBoardFileAtomic } from '../annotations/board-io.ts';
30
+ import { MAX_BOARD_BYTES } from '../annotations/constants.ts';
31
+ import { serializeBoard } from '../annotations/schema.ts';
72
32
  import {
73
33
  fileSlug,
74
34
  findElementById,
75
35
  loadArtboards,
76
36
  loadElements,
77
- parseAnnotations,
78
37
  resolveDesignRoot,
79
38
  } from './read-annotations.mjs';
80
39
 
81
- // Mirrors MAX_ANNOTATIONS_BYTES (sync/codec.ts) — kept literal here so the bin
82
- // stays import-light; the server enforces the same cap on the PUT path anyway.
83
- const MAX_ANNOTATIONS_BYTES = 1024 * 1024;
84
-
85
- const SVG_HEADER = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
86
-
87
- // Flow-node visual defaults — the blue ink + paired tint read on light AND
88
- // dark canvases (the verb cannot know the viewer's theme).
40
+ // Flow-node look — the blue ink + paired tint read on light AND dark canvases
41
+ // (the verb cannot know the viewer's theme).
89
42
  const NODE_INK = '#3b82f6';
90
43
  const NODE_FILL = '#e0ebfd';
91
- const TEXT_INK = '#1a1a1a';
92
44
  const NODE_W = 180;
93
45
  const NODE_H = 80;
94
46
  const FLOW_GAP_X = 100;
95
47
  const FLOW_GAP_Y = 60;
96
48
 
97
- const HELP = `annotate.mjs — AI annotation WRITE verb (DDR-062 via \`maude design annotate\`)
49
+ const HELP = `annotate — the AI whiteboard write verb (DDR-242, via \`maude design annotate\`)
98
50
 
99
51
  Usage:
100
- maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
101
- [--board <file|->]
52
+ maude design annotate <rel-path> [--ops <file|-> | --flow <file|-> | --board <file|->]
102
53
  [--near <artboardId>] [--in <artboardId>]
103
54
  [--pin <cdId|selector>] [--no-pointer]
104
55
  [--canvas-state <path>] [--rects <path>]
105
56
  [--root <repo>] [--dry-run]
106
57
 
107
- Args:
108
- <rel-path> Canvas path relative to the design root (e.g. "ui/Foo.tsx").
109
- --ops <file|-> Ops JSON ({ ops: [...] }); "-" or omitted = stdin.
110
- --flow <file|-> Flow JSON ({ nodes, edges }) — auto-laid-out diagram of
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.
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.
148
- --root <repo> Repo root. Default: $CLAUDE_PROJECT_DIR, then cwd.
149
- --dry-run Print the merged SVG to stdout instead of writing.
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
-
155
- Ops vocabulary: create (sticky | text | shape | arrow) · connect (bound arrow
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.
174
-
175
- The write is last-write-wins over the whole SVG — read before you write.`;
58
+ Ops JSON ({ "ops": [ … ] } or a bare array; "-" or omitted = stdin). Coordinates
59
+ are WORLD coordinates — the same ones read-annotations prints. Targets are ids
60
+ or "@refs" minted earlier in the batch.
61
+
62
+ { "op": "create", "type": <type>, "ref"?: "@a", "x"?, "y"?, "w"?, "h"?,
63
+ "parent"?: <section|@ref|null>, …fields }
64
+ Without x/y the element is auto-placed (see placement below). Without
65
+ "parent" it joins the section its centre lands in; null = top level.
66
+ "text" always means the type's text (sticky/text body, shape label,
67
+ section title). Shapes: "shape": rounded|rect|ellipse|diamond|triangle|
68
+ triangle-down (default rounded, blue node look). Arrows: "from"/"to"
69
+ (bound, follow their hosts) or x1/y1/x2/y2 (free).
70
+ { "op": "connect", "from": <id>, "to": <id>, "label"? } bound arrow
71
+ { "op": "update", "id": <id>, …fields } patch fields; null resets
72
+ { "op": "move", "id": <id>, "x", "y" } | { …, "dx", "dy" } then joins the section
73
+ it lands in ("keepParent": true to stay); a bound arrow end follows its host
74
+ { "op": "reparent", "id": <id>, "parent": <section|null> } keeps its world position
75
+ { "op": "reorder", "id": <id>, "to": front|back|forward|backward }
76
+ | { …, "before"|"after": <sibling id> }
77
+ { "op": "group", "ids": [<id>, …] }
78
+ { "op": "delete", "id": <id> } a section's children move up; bound arrows keep their end
79
+ { "op": "set-text", "id", "text" } · { "op": "set-color", "id", "color" } (= update)
80
+
81
+ Types and their fields (from the element registry):
82
+ ${describeTypes()}
83
+
84
+ --flow <file|-> { nodes: [{ id, label, shape? }], edges: [{ from, to, label? }] } —
85
+ auto-laid-out left→right, wired by BOUND arrows.
86
+ --board <file|-> { title?, layout?, groups?, nodes?, edges?, connections? } — a whole
87
+ template: layout "columns" (default; "grid"/"lanes" alias it) = one
88
+ titled section per group with its cards (string or {text,color?})
89
+ inside; "radial" = a centre topic with cards ringed around it;
90
+ "flow" = nodes/edges as in --flow. connections: [{from,to,label?}]
91
+ between minted refs (@sec<i>, @sec<i>card<j>, @center, @idea<i>).
92
+
93
+ Placement (creates without x/y, and whole --flow/--board layouts):
94
+ --near <artboard> beside the artboard (right of it)
95
+ --in <artboard> inside the artboard (top-left + 40px). Unknown id = error.
96
+ --pin <cdId|sel> beside a DOM element from a --rects manifest; a created
97
+ sticky/text also gets a pointer arrow to it (--no-pointer or
98
+ "pointer": false to skip). Unknown element = error.
99
+ Any create op may carry its own "in"/"near"/"pin" to override for that op.
100
+ --canvas-state <p> artboard rects; --rects <p> a \`maude design canvas-rects\` manifest.
101
+
102
+ Output: { ok, via: "server"|"file", file, created, updated, deleted, refs }.
103
+ --dry-run prints { dryRun: true, ops } (the element ops) and writes nothing.
104
+ Invalid requests fail loud (exit 2) and write nothing.`;
176
105
 
177
106
  // ─────────────────────────────────────────────────────────────────────────────
178
107
  // Argv
@@ -208,22 +137,16 @@ function parseArgv(argv) {
208
137
  const a = argv[i];
209
138
  const eq = a.indexOf('=');
210
139
  const flagKey = eq > 0 ? a.slice(0, eq) : a;
211
- if (a === '--help' || a === '-h') {
212
- out.help = true;
213
- } else if (a === '--dry-run') {
214
- out.dryRun = true;
215
- } else if (a === '--no-pointer') {
216
- out.pointer = false;
217
- } else if (flagKey in VALUE_FLAGS) {
218
- if (eq > 0) {
219
- out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
220
- } else {
140
+ if (a === '--help' || a === '-h') out.help = true;
141
+ else if (a === '--dry-run') out.dryRun = true;
142
+ else if (a === '--no-pointer') out.pointer = false;
143
+ else if (flagKey in VALUE_FLAGS) {
144
+ if (eq > 0) out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
145
+ else {
221
146
  i += 1;
222
147
  out[VALUE_FLAGS[flagKey]] = argv[i];
223
148
  }
224
- } else {
225
- out.positional.push(a);
226
- }
149
+ } else out.positional.push(a);
227
150
  }
228
151
  return out;
229
152
  }
@@ -246,14 +169,19 @@ function readInput(spec) {
246
169
  return readFileSync(p, 'utf8');
247
170
  }
248
171
 
249
- function escapeRe(s) {
250
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
172
+ function parseJsonInput(raw, what) {
173
+ if (!raw?.trim()) fail(`${what}: empty input`, 2);
174
+ try {
175
+ return JSON.parse(raw);
176
+ } catch (err) {
177
+ fail(`${what}: invalid JSON — ${err?.message ?? err}`, 2);
178
+ }
251
179
  }
252
180
 
253
181
  /**
254
182
  * Wave H F2 — the annotate egress allowlist. The dev-server is loopback-only
255
- * (DDR-054); a PUT target that is not a loopback http(s) origin is refused so
256
- * a poisoned `_server.json.url` can't exfiltrate the canvas SVG off-box.
183
+ * (DDR-054); a POST target that is not a loopback http(s) origin is refused so
184
+ * a poisoned `_server.json.url` can't exfiltrate the board off-box.
257
185
  */
258
186
  function isLoopbackHttpUrl(base) {
259
187
  let u;
@@ -268,56 +196,35 @@ function isLoopbackHttpUrl(base) {
268
196
  }
269
197
 
270
198
  // ─────────────────────────────────────────────────────────────────────────────
271
- // Existing-canvas context — host geometry for binds, placement origin.
272
-
273
- /** Fabricate a bbox-shaped rect Stroke for an EXISTING annotation so the
274
- * binding helpers (which only read strokeBBox) work against parsed data. */
275
- function fakeHost(ann) {
276
- return {
277
- id: ann.id,
278
- tool: 'rect',
279
- color: '#000',
280
- width: 1,
281
- x: ann.x ?? 0,
282
- y: ann.y ?? 0,
283
- w: ann.w ?? 0,
284
- h: ann.h ?? 0,
285
- };
286
- }
287
-
288
- const BINDABLE_PARSED = new Set(['rect', 'ellipse', 'polygon', 'sticky', 'image']);
289
-
290
- // ─────────────────────────────────────────────────────────────────────────────
291
- // Op application
199
+ // Placement — where a create without x/y lands.
292
200
 
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).
201
+ function buildPlacement(batch, artboards, elements, placement) {
202
+ // Priority: --pin (beside the element) > --in (inside the artboard) > --near
203
+ // (beside the artboard) > right of the existing board > a sane top-left.
204
+ // Unknown --in/--pin targets are a hard error (never a silent mis-place);
205
+ // --near stays lenient (an unmatched id falls through).
299
206
  let origin = { x: 100, y: 100 };
300
207
  let pinnedEl = null;
301
- if (placement?.in) {
208
+ if (placement.in) {
302
209
  const board = artboards.find((r) => r.id === placement.in);
303
210
  if (!board) fail(`--in: unknown artboard "${placement.in}"`, 2);
304
211
  origin = { x: board.x + 40, y: board.y + 40 };
305
- } else if (placement?.pin) {
212
+ } else if (placement.pin) {
306
213
  const el = findElementById(elements, placement.pin);
307
214
  if (!el) fail(`--pin: element "${placement.pin}" not found in the --rects manifest`, 2);
308
215
  pinnedEl = el;
309
216
  origin = { x: el.x + el.w + 40, y: el.y };
310
217
  } 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) {
218
+ const nearBoard = placement.near ? artboards.find((r) => r.id === placement.near) : null;
219
+ if (nearBoard) origin = { x: nearBoard.x + nearBoard.w + 80, y: nearBoard.y };
220
+ else {
315
221
  let maxX = Number.NEGATIVE_INFINITY;
316
222
  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);
223
+ for (const el of batch.scene.childrenOf(null)) {
224
+ const b = batch.worldBox(el.id);
225
+ if (!b) continue;
226
+ maxX = Math.max(maxX, b.x + b.w);
227
+ minY = Math.min(minY, b.y);
321
228
  }
322
229
  if (Number.isFinite(maxX)) origin = { x: maxX + 80, y: Math.max(0, minY) };
323
230
  }
@@ -325,491 +232,112 @@ function buildContext(existing, artboards, elements, placement) {
325
232
  return {
326
233
  origin,
327
234
  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
332
- refs: new Map(), // '@ref' → minted id
333
- newById: new Map(), // id → new Stroke
334
- created: [], // Stroke[] in creation order
335
- groupExisting: [], // { id, groupId } injections into existing elements
336
- deletes: [], // existing ids to remove
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
235
+ pinnedEl,
236
+ pointer: placement.pointer !== false,
237
+ artboards,
238
+ elements,
341
239
  };
342
240
  }
343
241
 
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) {
242
+ /** A per-op "in"/"near"/"pin" override — the same rules as the flags, for one op. */
243
+ function opPlacement(pl, op) {
350
244
  if (op.pin) {
351
- const el = findElementById(ctx.elements, op.pin);
245
+ const el = findElementById(pl.elements, op.pin);
352
246
  if (!el) fail(`op.pin: element "${op.pin}" not found in the --rects manifest`, 2);
353
247
  return { x: el.x + el.w + 40, y: el.y, pinnedEl: el };
354
248
  }
355
249
  if (op.in) {
356
- const board = ctx.artboards.find((r) => r.id === op.in);
250
+ const board = pl.artboards.find((r) => r.id === op.in);
357
251
  if (!board) fail(`op.in: unknown artboard "${op.in}"`, 2);
358
252
  return { x: board.x + 40, y: board.y + 40, pinnedEl: null };
359
253
  }
360
254
  if (op.near) {
361
- const board = ctx.artboards.find((r) => r.id === op.near);
255
+ const board = pl.artboards.find((r) => r.id === op.near);
362
256
  if (!board) fail(`op.near: unknown artboard "${op.near}"`, 2);
363
257
  return { x: board.x + board.w + 80, y: board.y, pinnedEl: null };
364
258
  }
365
259
  return null;
366
260
  }
367
261
 
368
- function resolveTarget(ctx, idOrRef) {
369
- if (typeof idOrRef !== 'string' || !idOrRef) return null;
370
- const id = idOrRef.startsWith('@') ? ctx.refs.get(idOrRef) : idOrRef;
371
- if (!id) return null;
372
- const created = ctx.newById.get(id);
373
- if (created) return { id, stroke: created, isNew: true };
374
- const existing = ctx.existingById.get(id);
375
- if (existing && BINDABLE_PARSED.has(existing.tool)) {
376
- return { id, stroke: fakeHost(existing), isNew: false };
377
- }
378
- return null;
379
- }
380
-
381
- function mint(ctx, ref) {
382
- const id = rid();
383
- if (typeof ref === 'string' && ref.startsWith('@')) ctx.refs.set(ref, id);
384
- return id;
385
- }
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
- */
394
- function autoPlace(ctx, w, op) {
395
- const override = op.in || op.near || op.pin ? resolveOpPlacement(ctx, op) : null;
262
+ /** Resolve an op's x/y: explicit wins, then a per-op override, then the batch cursor. */
263
+ function place(pl, op, w) {
264
+ const override = op.in || op.near || op.pin ? opPlacement(pl, op) : null;
396
265
  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
- }
401
- const x = Number.isFinite(op.x) ? op.x : ctx.cursor.x;
402
- const y = Number.isFinite(op.y) ? op.y : ctx.cursor.y;
403
- if (!Number.isFinite(op.x)) ctx.cursor.x = x + w + 40;
404
- return { x, y, pinnedEl: ctx.pinnedEl };
405
- }
406
-
407
- function pushCreated(ctx, stroke) {
408
- ctx.created.push(stroke);
409
- ctx.newById.set(stroke.id, stroke);
410
- return stroke;
411
- }
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
-
443
- function createSticky(ctx, op) {
444
- const w = Number.isFinite(op.w) ? op.w : STICKY_DEFAULT_W;
445
- const h = Number.isFinite(op.h) ? op.h : STICKY_DEFAULT_H;
446
- const { x, y, pinnedEl } = autoPlace(ctx, w, op);
447
- const stroke = pushCreated(ctx, {
448
- id: mint(ctx, op.ref),
449
- tool: 'sticky',
450
- color: typeof op.color === 'string' ? op.color : DEFAULT_STICKY_COLOR,
451
- x,
452
- y,
453
- w,
454
- h,
455
- text: typeof op.text === 'string' ? op.text : '',
456
- fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
457
- cornerRadius: STICKY_CORNER_RADIUS,
458
- author: 'ai',
459
- });
460
- if (pinnedEl && ctx.pointer && op.pointer !== false) pointerArrowTo(ctx, stroke, pinnedEl);
461
- return stroke;
462
- }
463
-
464
- function createSection(ctx, op) {
465
- const w = Number.isFinite(op.w) ? op.w : SECTION_DEFAULT_W;
466
- const h = Number.isFinite(op.h) ? op.h : SECTION_DEFAULT_H;
467
- const { x, y } = autoPlace(ctx, w, op);
468
- return pushCreated(ctx, {
469
- id: mint(ctx, op.ref),
470
- tool: 'section',
471
- x,
472
- y,
473
- w,
474
- h,
475
- label: typeof op.label === 'string' && op.label ? op.label : 'Section',
476
- color: typeof op.color === 'string' ? op.color : DEFAULT_SECTION_COLOR,
477
- author: 'ai',
478
- });
479
- }
480
-
481
- function createText(ctx, op) {
482
- const { x, y, pinnedEl } = autoPlace(ctx, 160, op);
483
- const stroke = pushCreated(ctx, {
484
- id: mint(ctx, op.ref),
485
- tool: 'text',
486
- color: typeof op.color === 'string' ? op.color : TEXT_INK,
487
- fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
488
- text: typeof op.text === 'string' ? op.text : '',
489
- x,
490
- y,
491
- author: 'ai',
492
- });
493
- if (pinnedEl && ctx.pointer && op.pointer !== false) pointerArrowTo(ctx, stroke, pinnedEl);
494
- return stroke;
495
- }
496
-
497
- function createShape(ctx, op) {
498
- const w = Number.isFinite(op.w) ? op.w : NODE_W;
499
- const h = Number.isFinite(op.h) ? op.h : NODE_H;
500
- const { x, y } = autoPlace(ctx, w, op);
501
- const ink = typeof op.color === 'string' ? op.color : NODE_INK;
502
- const fill = op.fill === null ? null : typeof op.fill === 'string' ? op.fill : NODE_FILL;
503
- const kind = typeof op.shape === 'string' ? op.shape : 'rounded';
504
- const id = mint(ctx, op.ref);
505
- let shape;
506
- if (kind === 'ellipse' || kind === 'circle') {
507
- shape = {
508
- id,
509
- tool: 'ellipse',
510
- color: ink,
511
- width: 3,
512
- cx: x + w / 2,
513
- cy: y + h / 2,
514
- rx: w / 2,
515
- ry: h / 2,
516
- fill,
517
- author: 'ai',
518
- };
519
- } else if (kind === 'diamond' || kind === 'triangle' || kind === 'triangle-down') {
520
- shape = {
521
- id,
522
- tool: 'polygon',
523
- shape: kind,
524
- color: ink,
525
- width: 3,
526
- x,
527
- y,
528
- w,
529
- h,
530
- fill,
531
- author: 'ai',
266
+ return {
267
+ x: Number.isFinite(op.x) ? op.x : override.x,
268
+ y: Number.isFinite(op.y) ? op.y : override.y,
269
+ pinnedEl: override.pinnedEl,
532
270
  };
533
- } else {
534
- shape = {
535
- id,
536
- tool: 'rect',
537
- color: ink,
538
- width: 3,
539
- x,
540
- y,
541
- w,
542
- h,
543
- fill,
544
- cornerRadius: kind === 'rect' || kind === 'square' ? 0 : 8,
545
- author: 'ai',
546
- };
547
- }
548
- pushCreated(ctx, shape);
549
- if (typeof op.label === 'string' && op.label) {
550
- // Anchored label — renders centered in the host (the canvas convention).
551
- // Wave G widened anchored text to every closed shape, polygons included.
552
- pushCreated(ctx, {
553
- id: rid(),
554
- tool: 'text',
555
- color: TEXT_INK,
556
- fontSize: DEFAULT_FONT_SIZE,
557
- text: op.label,
558
- anchorId: id,
559
- author: 'ai',
560
- });
561
- }
562
- return shape;
563
- }
564
-
565
- function createConnect(ctx, op) {
566
- const from = resolveTarget(ctx, op.from);
567
- const to = resolveTarget(ctx, op.to);
568
- if (!from || !to) {
569
- fail(
570
- `connect: unknown ${!from ? `"from" (${op.from})` : `"to" (${op.to})`} — targets must be existing bindable ids or @refs created earlier in the batch`,
571
- 2
572
- );
573
- }
574
- const fromCenter = centerOf(from.stroke);
575
- const toCenter = centerOf(to.stroke);
576
- const sb = facingAnchor(from.stroke, toCenter[0], toCenter[1]);
577
- const eb = facingAnchor(to.stroke, fromCenter[0], fromCenter[1]);
578
- if (!sb || !eb) fail('connect: could not derive anchors (zero-extent host?)', 2);
579
- const p1 = anchorPoint(from.stroke, sb.nx, sb.ny);
580
- const p2 = anchorPoint(to.stroke, eb.nx, eb.ny);
581
- if (!p1 || !p2) fail('connect: could not derive endpoints', 2);
582
- const arrow = pushCreated(ctx, {
583
- id: mint(ctx, op.ref),
584
- tool: 'arrow',
585
- color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
586
- width: 3,
587
- x1: p1[0],
588
- y1: p1[1],
589
- x2: p2[0],
590
- y2: p2[1],
591
- startBind: { hostId: from.id, nx: sb.nx, ny: sb.ny },
592
- endBind: { hostId: to.id, nx: eb.nx, ny: eb.ny },
593
- author: 'ai',
594
- });
595
- if (typeof op.label === 'string' && op.label) {
596
- pushCreated(ctx, {
597
- id: rid(),
598
- tool: 'text',
599
- color: TEXT_INK,
600
- fontSize: 12,
601
- text: op.label,
602
- x: (p1[0] + p2[0]) / 2 + 6,
603
- y: (p1[1] + p2[1]) / 2 - 18,
604
- author: 'ai',
605
- });
606
- }
607
- return arrow;
608
- }
609
-
610
- function centerOf(stroke) {
611
- if (stroke.tool === 'ellipse') return [stroke.cx, stroke.cy];
612
- return [stroke.x + (stroke.w ?? 0) / 2, stroke.y + (stroke.h ?? 0) / 2];
613
- }
614
-
615
- function applyGroup(ctx, op) {
616
- const ids = Array.isArray(op.ids) ? op.ids : [];
617
- const resolved = [];
618
- for (const raw of ids) {
619
- const id = typeof raw === 'string' && raw.startsWith('@') ? ctx.refs.get(raw) : raw;
620
- if (!id) fail(`group: unknown ref ${raw}`, 2);
621
- resolved.push(id);
622
- }
623
- if (resolved.length < 2) fail('group: needs at least two ids', 2);
624
- const groupId = gid();
625
- for (const id of resolved) {
626
- const created = ctx.newById.get(id);
627
- if (created) {
628
- created.groupIds = [...(created.groupIds ?? []), groupId];
629
- } else if (ctx.existingById.has(id)) {
630
- ctx.groupExisting.push({ id, groupId });
631
- } else {
632
- fail(`group: id not found on canvas: ${id}`, 2);
633
- }
634
271
  }
272
+ const x = Number.isFinite(op.x) ? op.x : pl.cursor.x;
273
+ const y = Number.isFinite(op.y) ? op.y : pl.cursor.y;
274
+ if (!Number.isFinite(op.x)) pl.cursor.x = x + w + 40;
275
+ return { x, y, pinnedEl: pl.pinnedEl };
635
276
  }
636
277
 
637
278
  // ─────────────────────────────────────────────────────────────────────────────
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();
279
+ // Ops → the engine.
280
+
281
+ function createOp(batch, pl, op) {
282
+ const type = op.type;
283
+ // Placement keys are resolved here, never passed on as element fields.
284
+ const { in: _in, near: _near, pin: _pin, pointer: _pointer, ...fields } = op;
285
+ if (type === 'shape') {
286
+ fields.shape ??= 'rounded';
287
+ if (fields.color === undefined) fields.color = NODE_INK;
288
+ if (fields.fill === undefined) fields.fill = NODE_FILL;
289
+ fields.width ??= 3;
290
+ }
291
+ if (type === 'arrow' || type === 'pen') return batch.create(type, fields);
292
+ const w = Number.isFinite(op.w) ? op.w : (CREATE_SIZE[type]?.w ?? 160);
293
+ const { x, y, pinnedEl } = place(pl, op, w);
294
+ const id = batch.create(type, { ...fields, x, y });
295
+ if (pinnedEl && pl.pointer && op.pointer !== false && (type === 'sticky' || type === 'text')) {
296
+ batch.pointer(id, { x: pinnedEl.x, y: pinnedEl.y, w: pinnedEl.w, h: pinnedEl.h });
686
297
  }
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;
298
+ return id;
771
299
  }
772
300
 
773
- async function applyOps(ctx, ops) {
301
+ function runOps(batch, pl, ops) {
774
302
  for (const op of ops) {
775
- if (!op || typeof op !== 'object') fail('ops: every entry must be an object', 2);
776
- if (op.op === 'create') {
777
- if (op.type === 'sticky') createSticky(ctx, op);
778
- else if (op.type === 'section') createSection(ctx, op);
779
- else if (op.type === 'text') createText(ctx, op);
780
- else if (op.type === 'shape') createShape(ctx, op);
781
- else if (op.type === 'arrow') {
782
- if ([op.x1, op.y1, op.x2, op.y2].every(Number.isFinite)) {
783
- pushCreated(ctx, {
784
- id: mint(ctx, op.ref),
785
- tool: 'arrow',
786
- color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
787
- width: 3,
788
- x1: op.x1,
789
- y1: op.y1,
790
- x2: op.x2,
791
- y2: op.y2,
792
- author: 'ai',
793
- });
794
- } else {
795
- createConnect(ctx, op); // from/to form
303
+ if (!op || typeof op !== 'object') throw new AiOpError('ops: every entry must be an object');
304
+ switch (op.op) {
305
+ case 'create':
306
+ createOp(batch, pl, op);
307
+ break;
308
+ case 'connect':
309
+ batch.connect(op);
310
+ break;
311
+ case 'update':
312
+ batch.update(op);
313
+ break;
314
+ case 'set-text':
315
+ if (typeof op.text !== 'string') throw new AiOpError('set-text: text must be a string');
316
+ batch.update({ id: op.id, text: op.text });
317
+ break;
318
+ case 'set-color':
319
+ if (typeof op.color !== 'string' || !op.color) {
320
+ throw new AiOpError('set-color: color must be a non-empty string');
796
321
  }
797
- } else fail(`create: unknown type "${op.type}"`, 2);
798
- } else if (op.op === 'connect') {
799
- createConnect(ctx, op);
800
- } else if (op.op === 'group') {
801
- applyGroup(ctx, op);
802
- } else if (op.op === 'delete') {
803
- if (typeof op.id !== 'string' || !op.id) fail('delete: missing id', 2);
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);
811
- } else {
812
- fail(`ops: unknown op "${op.op}"`, 2);
322
+ batch.update({ id: op.id, color: op.color });
323
+ break;
324
+ case 'move':
325
+ batch.move(op);
326
+ break;
327
+ case 'reparent':
328
+ batch.reparent(op);
329
+ break;
330
+ case 'reorder':
331
+ batch.reorder(op);
332
+ break;
333
+ case 'group':
334
+ batch.group(op);
335
+ break;
336
+ case 'delete':
337
+ batch.delete(op);
338
+ break;
339
+ default:
340
+ throw new AiOpError(`ops: unknown op "${op.op}"`);
813
341
  }
814
342
  }
815
343
  }
@@ -817,6 +345,12 @@ async function applyOps(ctx, ops) {
817
345
  // ─────────────────────────────────────────────────────────────────────────────
818
346
  // Flow mode — layered left→right layout of nodes + bound connector edges.
819
347
 
348
+ // Shared by --flow and --board layout:"flow" (which delegates to flowToOps) —
349
+ // checked once, in flowToOps, so neither caller can bypass it. A relationship
350
+ // array (edges) manufactures ops just as surely as an entity array does.
351
+ const FLOW_MAX_NODES = 200;
352
+ const FLOW_MAX_EDGES = 400;
353
+
820
354
  function flowToOps(flow) {
821
355
  const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
822
356
  const edges = Array.isArray(flow.edges) ? flow.edges : [];
@@ -866,8 +400,10 @@ function flowToOps(flow) {
866
400
  type: 'shape',
867
401
  shape: typeof node.shape === 'string' ? node.shape : 'rounded',
868
402
  ref: `@${id}`,
869
- label: typeof node.label === 'string' ? node.label : String(id),
870
- // Relative to the placement origin — autoPlace sees explicit coords.
403
+ text: typeof node.label === 'string' ? node.label : String(id),
404
+ w: NODE_W,
405
+ h: NODE_H,
406
+ // Relative to the placement origin (applyOriginOffset).
871
407
  flowX: l * (NODE_W + FLOW_GAP_X),
872
408
  flowY: i * (NODE_H + FLOW_GAP_Y),
873
409
  });
@@ -880,11 +416,10 @@ function flowToOps(flow) {
880
416
  }
881
417
 
882
418
  // ─────────────────────────────────────────────────────────────────────────────
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.
419
+ // Board mode — a typed template spec expands into ops the same way --flow
420
+ // does. Named presets (retro / kanban / social calendar / roadmap / brainstorm
421
+ // / checklist / user flow) are spec fixtures in the `whiteboard` skill, not
422
+ // code: this is only the generic layout engine they share.
888
423
 
889
424
  const BOARD_COL_W = 280;
890
425
  const BOARD_COL_GAP = 40;
@@ -899,23 +434,12 @@ const BOARD_RADIAL_CENTER_H = 100;
899
434
  const BOARD_RADIAL_CARD_W = 200;
900
435
  const BOARD_RADIAL_CARD_H = 80;
901
436
 
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.
437
+ // --board is arbitrary agent-composed JSON: cap it BEFORE expansion, so an
438
+ // unbounded groups/cards array can't burn CPU/memory before the board byte cap.
907
439
  const BOARD_MAX_GROUPS = 20;
908
440
  const BOARD_MAX_CARDS_PER_GROUP = 50;
909
441
  const BOARD_MAX_TOTAL_CARDS = 300;
910
442
  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
443
 
920
444
  function cardText(card) {
921
445
  return typeof card === 'string' ? card : typeof card?.text === 'string' ? card.text : '';
@@ -928,11 +452,9 @@ function cardColor(card) {
928
452
  }
929
453
 
930
454
  /**
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.
455
+ * "columns" — one titled section per group, its cards stacked INSIDE it (they
456
+ * are the section's children). Each section's height grows with its own card
457
+ * count, so a half-filled board never collides with its neighbour.
936
458
  */
937
459
  function boardColumns(groups) {
938
460
  const ops = [];
@@ -946,12 +468,13 @@ function boardColumns(groups) {
946
468
  op: 'create',
947
469
  type: 'section',
948
470
  ref: `@sec${i}`,
949
- label: typeof g.title === 'string' && g.title ? g.title : `Group ${i + 1}`,
471
+ text: typeof g.title === 'string' && g.title ? g.title : `Group ${i + 1}`,
950
472
  color: typeof g.color === 'string' ? g.color : undefined,
951
473
  boardX: colX,
952
474
  boardY: 0,
953
475
  w: BOARD_COL_W,
954
476
  h,
477
+ parent: null,
955
478
  });
956
479
  cards.forEach((c, j) => {
957
480
  ops.push({
@@ -964,17 +487,14 @@ function boardColumns(groups) {
964
487
  boardY: BOARD_HEADER_H + j * (BOARD_CARD_H + BOARD_CARD_GAP),
965
488
  w: BOARD_CARD_W,
966
489
  h: BOARD_CARD_H,
490
+ parent: `@sec${i}`,
967
491
  });
968
492
  });
969
493
  });
970
494
  return ops;
971
495
  }
972
496
 
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
- */
497
+ /** "radial" — a central topic shape with idea cards in a ring (brainstorm). */
978
498
  function boardRadial(spec, groups) {
979
499
  const cards = groups.flatMap((g) => (Array.isArray(g.cards) ? g.cards : []));
980
500
  const pad = BOARD_RADIAL_RADIUS + Math.max(BOARD_RADIAL_CARD_W, BOARD_RADIAL_CARD_H);
@@ -984,7 +504,7 @@ function boardRadial(spec, groups) {
984
504
  type: 'shape',
985
505
  shape: 'ellipse',
986
506
  ref: '@center',
987
- label: typeof spec.title === 'string' && spec.title ? spec.title : 'Topic',
507
+ text: typeof spec.title === 'string' && spec.title ? spec.title : 'Topic',
988
508
  boardX: pad - BOARD_RADIAL_CENTER_W / 2,
989
509
  boardY: pad - BOARD_RADIAL_CENTER_H / 2,
990
510
  w: BOARD_RADIAL_CENTER_W,
@@ -1011,13 +531,6 @@ function boardRadial(spec, groups) {
1011
531
  return ops;
1012
532
  }
1013
533
 
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
534
  function boardToOps(spec) {
1022
535
  const layout = typeof spec.layout === 'string' ? spec.layout : 'columns';
1023
536
  if (layout === 'flow') {
@@ -1043,10 +556,6 @@ function boardToOps(spec) {
1043
556
  fail(`board: ${totalCards} total cards across groups, max ${BOARD_MAX_TOTAL_CARDS}`, 2);
1044
557
  }
1045
558
  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
559
  const connections = Array.isArray(spec.connections) ? spec.connections : [];
1051
560
  if (connections.length > BOARD_MAX_CONNECTIONS) {
1052
561
  fail(`board: connections[] has ${connections.length}, max ${BOARD_MAX_CONNECTIONS}`, 2);
@@ -1060,59 +569,80 @@ function boardToOps(spec) {
1060
569
  return ops;
1061
570
  }
1062
571
 
1063
- // ─────────────────────────────────────────────────────────────────────────────
1064
- // SVG assembly — deletes + group injections on the existing string, new
1065
- // strokes serialized through the canonical serializer, sanitize, cap.
1066
-
1067
- function deleteElement(svg, id) {
1068
- const idEsc = escapeRe(id);
1069
- let out = svg.replace(
1070
- new RegExp(`<(g|text)\\b[^>]*data-id="${idEsc}"[^>]*>[\\s\\S]*?</\\1>`, 'g'),
1071
- ''
1072
- );
1073
- out = out.replace(
1074
- new RegExp(`<(?:path|rect|ellipse|polygon|image)\\b[^>]*data-id="${idEsc}"[^>]*/>`, 'g'),
1075
- ''
1076
- );
1077
- // Cascade — anchored text hosted by the deleted shape goes with it (the
1078
- // canvas deleteStrokes rule).
1079
- out = out.replace(
1080
- new RegExp(`<text\\b[^>]*data-anchor-id="${idEsc}"[^>]*>[\\s\\S]*?</text>`, 'g'),
1081
- ''
1082
- );
1083
- return out;
1084
- }
1085
-
1086
- function injectGroupAttr(svg, id, groupId) {
1087
- const idEsc = escapeRe(id);
1088
- const re = new RegExp(`(<[a-zA-Z]+\\b[^>]*data-id="${idEsc}"[^>]*?)(/?>)`);
1089
- return svg.replace(re, (_whole, head, close) => {
1090
- if (/data-group-ids="/.test(head)) {
1091
- return (
1092
- head.replace(
1093
- /data-group-ids="([^"]*)"/,
1094
- (_m, cur) => `data-group-ids="${cur} ${groupId}"`
1095
- ) + close
1096
- );
1097
- }
1098
- return `${head} data-group-ids="${groupId}"${close}`;
1099
- });
1100
- }
1101
-
1102
572
  /**
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.
573
+ * --flow and --board emit a relative offset (flowX/flowY, boardX/boardY)
574
+ * instead of absolute x/y, so ONE placement resolution positions the whole
575
+ * diagram/board as a unit.
1107
576
  */
1108
577
  function applyOriginOffset(ops, origin) {
1109
578
  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;
579
+ const { flowX, flowY, boardX, boardY, ...rest } = op;
580
+ if (flowX !== undefined) return { ...rest, x: origin.x + flowX, y: origin.y + flowY };
581
+ if (boardX !== undefined) return { ...rest, x: origin.x + boardX, y: origin.y + boardY };
582
+ return rest;
1113
583
  });
1114
584
  }
1115
585
 
586
+ // ─────────────────────────────────────────────────────────────────────────────
587
+ // Write — the live server's op endpoint, else a direct canonical file write.
588
+
589
+ /**
590
+ * POST the ops to a live, loopback dev-server. Returns the parsed response,
591
+ * `null` when no server answers or it predates the op route (then the caller
592
+ * writes the file), and fails on any other refusal or a timeout — a file write
593
+ * would bypass the server and its live room.
594
+ */
595
+ async function postOps(designRoot, file, ops) {
596
+ const serverJsonPath = join(designRoot, '_server.json');
597
+ if (!existsSync(serverJsonPath)) return null;
598
+ let base = null;
599
+ try {
600
+ const srv = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
601
+ base = typeof srv.url === 'string' ? srv.url.replace(/\/+$/, '') : null;
602
+ } catch {
603
+ return null;
604
+ }
605
+ // Security (Wave H F2): only ever POST to a loopback origin — a poisoned
606
+ // `url` can't turn the verb into an exfiltration primitive. Anything else
607
+ // silently falls back to the file write (the local intent still succeeds).
608
+ if (!base || !isLoopbackHttpUrl(base)) return null;
609
+ let res;
610
+ try {
611
+ res = await fetch(`${base}/_api/annotations/ops`, {
612
+ method: 'POST',
613
+ headers: { 'Content-Type': 'application/json' },
614
+ body: JSON.stringify({
615
+ file,
616
+ actionId: `ai-annotate-${Math.random().toString(36).slice(2, 12)}`,
617
+ ops,
618
+ }),
619
+ signal: AbortSignal.timeout(10_000),
620
+ });
621
+ } catch (err) {
622
+ // No server answering (a stale _server.json) → the file write is safe.
623
+ // A server that IS there but too slow must not be bypassed: writing the
624
+ // file would reseed its live room from a stale snapshot and erase what
625
+ // collaborators did meanwhile (security review A2).
626
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
627
+ fail('the dev-server did not answer in time — nothing written; try again', 1);
628
+ }
629
+ return null;
630
+ }
631
+ // Only a server too old to have the op route falls back to the file.
632
+ if (res.status === 404 || res.status === 405) return null;
633
+ // Inside a cloud workspace (MAUDE_WORKSPACE_MODE=1) the studio treats a
634
+ // loopback request without the proxy's role header as read-only; the
635
+ // workspace's own file write IS the agent's channel there (the workspace
636
+ // agent syncs it). Anywhere else a read-only refusal stands — a local write
637
+ // the hub refuses to sync is a silent fork.
638
+ if (res.status === 403 && process.env.MAUDE_WORKSPACE_MODE === '1') return null;
639
+ if (!res.ok) {
640
+ const body = await res.json().catch(() => ({}));
641
+ fail(`the server refused the batch: ${body?.error ?? res.status} — nothing written`, 1);
642
+ }
643
+ return res.json().catch(() => ({ ok: true, rejected: [] }));
644
+ }
645
+
1116
646
  // ─────────────────────────────────────────────────────────────────────────────
1117
647
  // Main
1118
648
 
@@ -1135,37 +665,27 @@ async function main() {
1135
665
  : process.cwd();
1136
666
  const { designRel, designRoot } = resolveDesignRoot(repoRoot);
1137
667
  const slug = fileSlug(relPath, designRel);
1138
- const svgPath = join(designRoot, `${slug}.annotations.svg`);
668
+ // The canvas path the server keys its board by — relative to the design
669
+ // root, whether the caller wrote `ui/X.tsx` or `.design/ui/X.tsx` (the slug
670
+ // above tolerates both; the server must get the same file, or every op is
671
+ // refused as `gone` against a board that doesn't exist).
672
+ const designPrefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
673
+ let canvasRel = String(relPath).replace(/^\/+/, '');
674
+ if (canvasRel.startsWith(designPrefix)) canvasRel = canvasRel.slice(designPrefix.length);
1139
675
 
1140
- let svg = '';
1141
- if (existsSync(svgPath)) {
1142
- try {
1143
- svg = readFileSync(svgPath, 'utf8');
1144
- } catch {
1145
- svg = '';
1146
- }
676
+ const boardFile = readBoardFile(designRoot, slug);
677
+ if (boardFile.tooLarge) {
678
+ fail(`${slug}.annotations.json exceeds ${MAX_BOARD_BYTES} bytes — refusing to read it`, 2);
1147
679
  }
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);
680
+ if (boardFile.unreadable) {
681
+ fail(`${slug}.annotations.json is not a valid board — refusing to write over it`, 2);
1156
682
  }
1157
- const existing = parseAnnotations(svg);
1158
683
 
1159
684
  let artboards = args.canvasState
1160
685
  ? loadArtboards(
1161
686
  isAbsolute(args.canvasState) ? args.canvasState : resolve(process.cwd(), args.canvasState)
1162
687
  )
1163
688
  : [];
1164
-
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
689
  let elements = [];
1170
690
  if (args.rects) {
1171
691
  const rectsPath = isAbsolute(args.rects) ? args.rects : resolve(process.cwd(), args.rects);
@@ -1173,104 +693,77 @@ async function main() {
1173
693
  if (!artboards.length) artboards = loadArtboards(rectsPath);
1174
694
  }
1175
695
 
1176
- const ctx = buildContext(existing, artboards, elements, {
696
+ const batch = new AiBatch(boardFile.elements);
697
+ const pl = buildPlacement(batch, artboards, elements, {
1177
698
  near: args.near,
1178
699
  in: args.in,
1179
700
  pin: args.pin,
1180
701
  pointer: args.pointer,
1181
702
  });
1182
- ctx.rawSvg = svg;
1183
703
 
1184
704
  let ops;
1185
705
  if (args.flow != null) {
1186
- const flow = parseJsonInput(readInput(args.flow), '--flow');
1187
- ops = applyOriginOffset(flowToOps(flow), ctx.origin);
706
+ ops = applyOriginOffset(flowToOps(parseJsonInput(readInput(args.flow), '--flow')), pl.origin);
1188
707
  } else if (args.board != null) {
1189
- const spec = parseJsonInput(readInput(args.board), '--board');
1190
- ops = applyOriginOffset(boardToOps(spec), ctx.origin);
708
+ ops = applyOriginOffset(
709
+ boardToOps(parseJsonInput(readInput(args.board), '--board')),
710
+ pl.origin
711
+ );
1191
712
  } else {
1192
713
  const payload = parseJsonInput(readInput(args.ops), '--ops');
1193
- ops = Array.isArray(payload.ops) ? payload.ops : Array.isArray(payload) ? payload : null;
714
+ ops = Array.isArray(payload?.ops) ? payload.ops : Array.isArray(payload) ? payload : null;
1194
715
  if (!ops) fail('ops: expected { ops: [...] } (or a bare array)', 2);
1195
716
  }
1196
717
 
1197
- await applyOps(ctx, ops);
1198
-
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.
1202
- let merged = svg && /<svg[\s>]/i.test(svg) ? svg : `${SVG_HEADER}</svg>`;
1203
- for (const id of ctx.deletes) merged = deleteElement(merged, id);
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
- }
1210
- if (ctx.created.length) {
1211
- const body = ctx.created.map((s) => strokeToSvgEl(s)).join('');
1212
- const close = merged.lastIndexOf('</svg>');
1213
- if (close < 0) fail('existing annotation file is not a valid SVG', 1);
1214
- merged = merged.slice(0, close) + body + merged.slice(close);
1215
- }
1216
- merged = sanitizeAnnotationSvg(merged);
1217
- const bytes = Buffer.byteLength(merged, 'utf8');
1218
- if (bytes > MAX_ANNOTATIONS_BYTES) {
1219
- fail(`result exceeds the 1 MB annotation cap (${bytes} bytes) — nothing written`, 1);
718
+ try {
719
+ runOps(batch, pl, ops);
720
+ } catch (err) {
721
+ if (err instanceof AiOpError) fail(err.message, 2);
722
+ throw err;
1220
723
  }
1221
724
 
725
+ const text = serializeBoard(batch.elements);
726
+ if (text.length > MAX_BOARD_BYTES) {
727
+ fail(
728
+ `result exceeds the ${MAX_BOARD_BYTES}-byte board cap (${text.length}) — nothing written`,
729
+ 1
730
+ );
731
+ }
1222
732
  if (args.dryRun) {
1223
- process.stdout.write(`${merged}\n`);
733
+ process.stdout.write(`${JSON.stringify({ dryRun: true, ops: batch.ops })}\n`);
1224
734
  return;
1225
735
  }
1226
736
 
1227
- // Prefer the live server (sanitize + persist + collab broadcast — open
1228
- // canvases update in real time); fall back to a direct file write.
737
+ const file = join(designRoot, `${slug}.annotations.json`);
1229
738
  let via = 'file';
1230
- const serverJsonPath = join(designRoot, '_server.json');
1231
- if (existsSync(serverJsonPath)) {
1232
- try {
1233
- const srv = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
1234
- const base = typeof srv.url === 'string' ? srv.url.replace(/\/+$/, '') : null;
1235
- // Security (Wave H F2): `_server.json` is local dev-server state, but the
1236
- // verb runs in an AGENT loop — only ever PUT to a loopback http(s) origin
1237
- // so a poisoned/foreign `url` can't turn this into an SSRF primitive that
1238
- // exfiltrates the whole canvas SVG to an arbitrary host. On anything else
1239
- // (remote host, file:, javascript:) we silently fall back to the file
1240
- // write — the local intent still succeeds, the egress is denied.
1241
- if (base && isLoopbackHttpUrl(base)) {
1242
- const res = await fetch(`${base}/_api/annotations`, {
1243
- method: 'PUT',
1244
- headers: { 'Content-Type': 'application/json' },
1245
- // `base` — the SVG this edit was merged onto, so a peer's strokes
1246
- // that landed meanwhile are merged, not replaced (DDR-241).
1247
- body: JSON.stringify({ file: `${designRel}/${relPath}`, svg: merged, base: svg }),
1248
- signal: AbortSignal.timeout(3000),
1249
- });
1250
- if (res.ok) via = 'server';
739
+ let rejected = [];
740
+ if (batch.ops.length) {
741
+ const res = await postOps(designRoot, `${designRel}/${canvasRel}`, batch.ops);
742
+ if (res) {
743
+ via = 'server';
744
+ rejected = Array.isArray(res.rejected) ? res.rejected : [];
745
+ } else {
746
+ try {
747
+ writeBoardFileAtomic(designRoot, slug, text);
748
+ } catch (err) {
749
+ fail(err instanceof Error ? err.message : String(err), 2);
1251
750
  }
1252
- } catch {
1253
- /* stale _server.json / server down — fall through to the file write */
1254
751
  }
1255
752
  }
1256
- if (via === 'file') {
1257
- writeFileSync(svgPath, merged, 'utf8');
1258
- }
1259
753
 
1260
- const refs = {};
1261
- for (const [ref, id] of ctx.refs) refs[ref] = id;
1262
- process.stdout.write(
1263
- `${JSON.stringify({ ok: true, via, file: svgPath, bytes, created: ctx.created.length, deleted: ctx.deletes.length, refs })}\n`
1264
- );
1265
- }
1266
-
1267
- function parseJsonInput(raw, what) {
1268
- if (!raw?.trim()) fail(`${what}: empty input`, 2);
1269
- try {
1270
- return JSON.parse(raw);
1271
- } catch (err) {
1272
- fail(`${what}: invalid JSON — ${err?.message ?? err}`, 2);
1273
- }
754
+ const refs = Object.fromEntries(batch.refs);
755
+ const out = {
756
+ ok: rejected.length === 0,
757
+ via,
758
+ file,
759
+ created: batch.created.length,
760
+ updated: batch.updated.size,
761
+ deleted: batch.deleted.length,
762
+ refs,
763
+ ...(rejected.length ? { rejected } : {}),
764
+ };
765
+ process.stdout.write(`${JSON.stringify(out)}\n`);
766
+ if (rejected.length) process.exitCode = 1;
1274
767
  }
1275
768
 
1276
769
  await main();