@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,732 @@
1
+ #!/usr/bin/env bun
2
+ // annotate.mjs — the AI annotation WRITE verb (FigJam v3).
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.
11
+ //
12
+ // Runs under Bun (the .sh wrapper enforces it) because the model is TS.
13
+ //
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).
25
+ //
26
+ // 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
+
52
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
53
+ import { isAbsolute, join, resolve } from 'node:path';
54
+
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
+ } from '../annotations-model.ts';
71
+ import {
72
+ fileSlug,
73
+ loadArtboards,
74
+ parseAnnotations,
75
+ resolveDesignRoot,
76
+ } from './read-annotations.mjs';
77
+
78
+ // Mirrors MAX_ANNOTATIONS_BYTES (sync/codec.ts) — kept literal here so the bin
79
+ // stays import-light; the server enforces the same cap on the PUT path anyway.
80
+ const MAX_ANNOTATIONS_BYTES = 1024 * 1024;
81
+
82
+ const SVG_HEADER = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
83
+
84
+ // Flow-node visual defaults — the blue ink + paired tint read on light AND
85
+ // dark canvases (the verb cannot know the viewer's theme).
86
+ const NODE_INK = '#3b82f6';
87
+ const NODE_FILL = '#e0ebfd';
88
+ const TEXT_INK = '#1a1a1a';
89
+ const NODE_W = 180;
90
+ const NODE_H = 80;
91
+ const FLOW_GAP_X = 100;
92
+ const FLOW_GAP_Y = 60;
93
+
94
+ const HELP = `annotate.mjs — AI annotation WRITE verb (DDR-062 via \`maude design annotate\`)
95
+
96
+ Usage:
97
+ maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
98
+ [--near <artboardId>] [--canvas-state <path>]
99
+ [--root <repo>] [--dry-run]
100
+
101
+ Args:
102
+ <rel-path> Canvas path relative to the design root (e.g. "ui/Foo.tsx").
103
+ --ops <file|-> Ops JSON ({ ops: [...] }); "-" or omitted = stdin.
104
+ --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.
107
+ --canvas-state <p> Artboard rects JSON (same shape read-annotations takes).
108
+ --root <repo> Repo root. Default: $CLAUDE_PROJECT_DIR, then cwd.
109
+ --dry-run Print the merged SVG to stdout instead of writing.
110
+
111
+ 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 }.
114
+
115
+ The write is last-write-wins over the whole SVG — read before you write.`;
116
+
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ // Argv
119
+
120
+ function parseArgv(argv) {
121
+ const out = {
122
+ positional: [],
123
+ ops: null,
124
+ flow: null,
125
+ near: null,
126
+ canvasState: null,
127
+ root: null,
128
+ dryRun: false,
129
+ help: false,
130
+ };
131
+ const VALUE_FLAGS = {
132
+ '--ops': 'ops',
133
+ '--flow': 'flow',
134
+ '--near': 'near',
135
+ '--canvas-state': 'canvasState',
136
+ '--root': 'root',
137
+ };
138
+ for (let i = 0; i < argv.length; i += 1) {
139
+ const a = argv[i];
140
+ const eq = a.indexOf('=');
141
+ const flagKey = eq > 0 ? a.slice(0, eq) : a;
142
+ if (a === '--help' || a === '-h') {
143
+ out.help = true;
144
+ } else if (a === '--dry-run') {
145
+ out.dryRun = true;
146
+ } else if (flagKey in VALUE_FLAGS) {
147
+ if (eq > 0) {
148
+ out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
149
+ } else {
150
+ i += 1;
151
+ out[VALUE_FLAGS[flagKey]] = argv[i];
152
+ }
153
+ } else {
154
+ out.positional.push(a);
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ function fail(msg, code = 1) {
161
+ process.stderr.write(`annotate: ${msg}\n`);
162
+ process.exit(code);
163
+ }
164
+
165
+ function readInput(spec) {
166
+ if (!spec || spec === '-') {
167
+ try {
168
+ return readFileSync(0, 'utf8'); // stdin
169
+ } catch {
170
+ return '';
171
+ }
172
+ }
173
+ const p = isAbsolute(spec) ? spec : resolve(process.cwd(), spec);
174
+ if (!existsSync(p)) fail(`input not found: ${p}`, 2);
175
+ return readFileSync(p, 'utf8');
176
+ }
177
+
178
+ function escapeRe(s) {
179
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
+ }
181
+
182
+ /**
183
+ * Wave H F2 — the annotate egress allowlist. The dev-server is loopback-only
184
+ * (DDR-054); a PUT target that is not a loopback http(s) origin is refused so
185
+ * a poisoned `_server.json.url` can't exfiltrate the canvas SVG off-box.
186
+ */
187
+ function isLoopbackHttpUrl(base) {
188
+ let u;
189
+ try {
190
+ u = new URL(base);
191
+ } catch {
192
+ return false;
193
+ }
194
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
195
+ const h = u.hostname.toLowerCase();
196
+ return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]';
197
+ }
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // Existing-canvas context — host geometry for binds, placement origin.
201
+
202
+ /** Fabricate a bbox-shaped rect Stroke for an EXISTING annotation so the
203
+ * binding helpers (which only read strokeBBox) work against parsed data. */
204
+ function fakeHost(ann) {
205
+ return {
206
+ id: ann.id,
207
+ tool: 'rect',
208
+ color: '#000',
209
+ width: 1,
210
+ x: ann.x ?? 0,
211
+ y: ann.y ?? 0,
212
+ w: ann.w ?? 0,
213
+ h: ann.h ?? 0,
214
+ };
215
+ }
216
+
217
+ const BINDABLE_PARSED = new Set(['rect', 'ellipse', 'polygon', 'sticky', 'image']);
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ // Op application
221
+
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.
225
+ 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);
236
+ }
237
+ if (Number.isFinite(maxX)) origin = { x: maxX + 80, y: Math.max(0, minY) };
238
+ }
239
+ return {
240
+ origin,
241
+ cursor: { ...origin },
242
+ refs: new Map(), // '@ref' → minted id
243
+ newById: new Map(), // id → new Stroke
244
+ created: [], // Stroke[] in creation order
245
+ groupExisting: [], // { id, groupId } injections into existing elements
246
+ deletes: [], // existing ids to remove
247
+ existingById: new Map(existing.map((a) => [a.id, a])),
248
+ };
249
+ }
250
+
251
+ function resolveTarget(ctx, idOrRef) {
252
+ if (typeof idOrRef !== 'string' || !idOrRef) return null;
253
+ const id = idOrRef.startsWith('@') ? ctx.refs.get(idOrRef) : idOrRef;
254
+ if (!id) return null;
255
+ const created = ctx.newById.get(id);
256
+ if (created) return { id, stroke: created, isNew: true };
257
+ const existing = ctx.existingById.get(id);
258
+ if (existing && BINDABLE_PARSED.has(existing.tool)) {
259
+ return { id, stroke: fakeHost(existing), isNew: false };
260
+ }
261
+ return null;
262
+ }
263
+
264
+ function mint(ctx, ref) {
265
+ const id = rid();
266
+ if (typeof ref === 'string' && ref.startsWith('@')) ctx.refs.set(ref, id);
267
+ return id;
268
+ }
269
+
270
+ function autoPlace(ctx, w, op) {
271
+ const x = Number.isFinite(op.x) ? op.x : ctx.cursor.x;
272
+ const y = Number.isFinite(op.y) ? op.y : ctx.cursor.y;
273
+ if (!Number.isFinite(op.x)) ctx.cursor.x = x + w + 40;
274
+ return { x, y };
275
+ }
276
+
277
+ function pushCreated(ctx, stroke) {
278
+ ctx.created.push(stroke);
279
+ ctx.newById.set(stroke.id, stroke);
280
+ return stroke;
281
+ }
282
+
283
+ function createSticky(ctx, op) {
284
+ const w = Number.isFinite(op.w) ? op.w : STICKY_DEFAULT_W;
285
+ const h = Number.isFinite(op.h) ? op.h : STICKY_DEFAULT_H;
286
+ const { x, y } = autoPlace(ctx, w, op);
287
+ return pushCreated(ctx, {
288
+ id: mint(ctx, op.ref),
289
+ tool: 'sticky',
290
+ color: typeof op.color === 'string' ? op.color : DEFAULT_STICKY_COLOR,
291
+ x,
292
+ y,
293
+ w,
294
+ h,
295
+ text: typeof op.text === 'string' ? op.text : '',
296
+ fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
297
+ cornerRadius: STICKY_CORNER_RADIUS,
298
+ author: 'ai',
299
+ });
300
+ }
301
+
302
+ function createSection(ctx, op) {
303
+ const w = Number.isFinite(op.w) ? op.w : SECTION_DEFAULT_W;
304
+ const h = Number.isFinite(op.h) ? op.h : SECTION_DEFAULT_H;
305
+ const { x, y } = autoPlace(ctx, w, op);
306
+ return pushCreated(ctx, {
307
+ id: mint(ctx, op.ref),
308
+ tool: 'section',
309
+ x,
310
+ y,
311
+ w,
312
+ h,
313
+ label: typeof op.label === 'string' && op.label ? op.label : 'Section',
314
+ color: typeof op.color === 'string' ? op.color : DEFAULT_SECTION_COLOR,
315
+ author: 'ai',
316
+ });
317
+ }
318
+
319
+ function createText(ctx, op) {
320
+ const { x, y } = autoPlace(ctx, 160, op);
321
+ return pushCreated(ctx, {
322
+ id: mint(ctx, op.ref),
323
+ tool: 'text',
324
+ color: typeof op.color === 'string' ? op.color : TEXT_INK,
325
+ fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
326
+ text: typeof op.text === 'string' ? op.text : '',
327
+ x,
328
+ y,
329
+ author: 'ai',
330
+ });
331
+ }
332
+
333
+ function createShape(ctx, op) {
334
+ const w = Number.isFinite(op.w) ? op.w : NODE_W;
335
+ const h = Number.isFinite(op.h) ? op.h : NODE_H;
336
+ const { x, y } = autoPlace(ctx, w, op);
337
+ const ink = typeof op.color === 'string' ? op.color : NODE_INK;
338
+ const fill = op.fill === null ? null : typeof op.fill === 'string' ? op.fill : NODE_FILL;
339
+ const kind = typeof op.shape === 'string' ? op.shape : 'rounded';
340
+ const id = mint(ctx, op.ref);
341
+ let shape;
342
+ if (kind === 'ellipse' || kind === 'circle') {
343
+ shape = {
344
+ id,
345
+ tool: 'ellipse',
346
+ color: ink,
347
+ width: 3,
348
+ cx: x + w / 2,
349
+ cy: y + h / 2,
350
+ rx: w / 2,
351
+ ry: h / 2,
352
+ fill,
353
+ author: 'ai',
354
+ };
355
+ } else if (kind === 'diamond' || kind === 'triangle' || kind === 'triangle-down') {
356
+ shape = {
357
+ id,
358
+ tool: 'polygon',
359
+ shape: kind,
360
+ color: ink,
361
+ width: 3,
362
+ x,
363
+ y,
364
+ w,
365
+ h,
366
+ fill,
367
+ author: 'ai',
368
+ };
369
+ } else {
370
+ shape = {
371
+ id,
372
+ tool: 'rect',
373
+ color: ink,
374
+ width: 3,
375
+ x,
376
+ y,
377
+ w,
378
+ h,
379
+ fill,
380
+ cornerRadius: kind === 'rect' || kind === 'square' ? 0 : 8,
381
+ author: 'ai',
382
+ };
383
+ }
384
+ pushCreated(ctx, shape);
385
+ if (typeof op.label === 'string' && op.label) {
386
+ // Anchored label — renders centered in the host (the canvas convention).
387
+ // Wave G widened anchored text to every closed shape, polygons included.
388
+ pushCreated(ctx, {
389
+ id: rid(),
390
+ tool: 'text',
391
+ color: TEXT_INK,
392
+ fontSize: DEFAULT_FONT_SIZE,
393
+ text: op.label,
394
+ anchorId: id,
395
+ author: 'ai',
396
+ });
397
+ }
398
+ return shape;
399
+ }
400
+
401
+ function createConnect(ctx, op) {
402
+ const from = resolveTarget(ctx, op.from);
403
+ const to = resolveTarget(ctx, op.to);
404
+ if (!from || !to) {
405
+ fail(
406
+ `connect: unknown ${!from ? `"from" (${op.from})` : `"to" (${op.to})`} — targets must be existing bindable ids or @refs created earlier in the batch`,
407
+ 2
408
+ );
409
+ }
410
+ const fromCenter = centerOf(from.stroke);
411
+ const toCenter = centerOf(to.stroke);
412
+ const sb = facingAnchor(from.stroke, toCenter[0], toCenter[1]);
413
+ const eb = facingAnchor(to.stroke, fromCenter[0], fromCenter[1]);
414
+ if (!sb || !eb) fail('connect: could not derive anchors (zero-extent host?)', 2);
415
+ const p1 = anchorPoint(from.stroke, sb.nx, sb.ny);
416
+ const p2 = anchorPoint(to.stroke, eb.nx, eb.ny);
417
+ if (!p1 || !p2) fail('connect: could not derive endpoints', 2);
418
+ const arrow = pushCreated(ctx, {
419
+ id: mint(ctx, op.ref),
420
+ tool: 'arrow',
421
+ color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
422
+ width: 3,
423
+ x1: p1[0],
424
+ y1: p1[1],
425
+ x2: p2[0],
426
+ y2: p2[1],
427
+ startBind: { hostId: from.id, nx: sb.nx, ny: sb.ny },
428
+ endBind: { hostId: to.id, nx: eb.nx, ny: eb.ny },
429
+ author: 'ai',
430
+ });
431
+ if (typeof op.label === 'string' && op.label) {
432
+ pushCreated(ctx, {
433
+ id: rid(),
434
+ tool: 'text',
435
+ color: TEXT_INK,
436
+ fontSize: 12,
437
+ text: op.label,
438
+ x: (p1[0] + p2[0]) / 2 + 6,
439
+ y: (p1[1] + p2[1]) / 2 - 18,
440
+ author: 'ai',
441
+ });
442
+ }
443
+ return arrow;
444
+ }
445
+
446
+ function centerOf(stroke) {
447
+ if (stroke.tool === 'ellipse') return [stroke.cx, stroke.cy];
448
+ return [stroke.x + (stroke.w ?? 0) / 2, stroke.y + (stroke.h ?? 0) / 2];
449
+ }
450
+
451
+ function applyGroup(ctx, op) {
452
+ const ids = Array.isArray(op.ids) ? op.ids : [];
453
+ const resolved = [];
454
+ for (const raw of ids) {
455
+ const id = typeof raw === 'string' && raw.startsWith('@') ? ctx.refs.get(raw) : raw;
456
+ if (!id) fail(`group: unknown ref ${raw}`, 2);
457
+ resolved.push(id);
458
+ }
459
+ if (resolved.length < 2) fail('group: needs at least two ids', 2);
460
+ const groupId = gid();
461
+ for (const id of resolved) {
462
+ const created = ctx.newById.get(id);
463
+ if (created) {
464
+ created.groupIds = [...(created.groupIds ?? []), groupId];
465
+ } else if (ctx.existingById.has(id)) {
466
+ ctx.groupExisting.push({ id, groupId });
467
+ } else {
468
+ fail(`group: id not found on canvas: ${id}`, 2);
469
+ }
470
+ }
471
+ }
472
+
473
+ function applyOps(ctx, ops) {
474
+ for (const op of ops) {
475
+ if (!op || typeof op !== 'object') fail('ops: every entry must be an object', 2);
476
+ if (op.op === 'create') {
477
+ if (op.type === 'sticky') createSticky(ctx, op);
478
+ else if (op.type === 'section') createSection(ctx, op);
479
+ else if (op.type === 'text') createText(ctx, op);
480
+ else if (op.type === 'shape') createShape(ctx, op);
481
+ else if (op.type === 'arrow') {
482
+ if ([op.x1, op.y1, op.x2, op.y2].every(Number.isFinite)) {
483
+ pushCreated(ctx, {
484
+ id: mint(ctx, op.ref),
485
+ tool: 'arrow',
486
+ color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
487
+ width: 3,
488
+ x1: op.x1,
489
+ y1: op.y1,
490
+ x2: op.x2,
491
+ y2: op.y2,
492
+ author: 'ai',
493
+ });
494
+ } else {
495
+ createConnect(ctx, op); // from/to form
496
+ }
497
+ } else fail(`create: unknown type "${op.type}"`, 2);
498
+ } else if (op.op === 'connect') {
499
+ createConnect(ctx, op);
500
+ } else if (op.op === 'group') {
501
+ applyGroup(ctx, op);
502
+ } else if (op.op === 'delete') {
503
+ if (typeof op.id !== 'string' || !op.id) fail('delete: missing id', 2);
504
+ ctx.deletes.push(op.id);
505
+ } else {
506
+ fail(`ops: unknown op "${op.op}"`, 2);
507
+ }
508
+ }
509
+ }
510
+
511
+ // ─────────────────────────────────────────────────────────────────────────────
512
+ // Flow mode — layered left→right layout of nodes + bound connector edges.
513
+
514
+ function flowToOps(flow) {
515
+ const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
516
+ const edges = Array.isArray(flow.edges) ? flow.edges : [];
517
+ if (!nodes.length) fail('flow: needs at least one node', 2);
518
+ const ids = nodes.map((n) => n.id);
519
+ const idSet = new Set(ids);
520
+ for (const e of edges) {
521
+ if (!idSet.has(e.from) || !idSet.has(e.to)) {
522
+ fail(`flow: edge references unknown node (${e.from} → ${e.to})`, 2);
523
+ }
524
+ }
525
+ // Longest-path layering via Kahn; cycle leftovers keep their seeded layer.
526
+ const indeg = new Map(ids.map((id) => [id, 0]));
527
+ const adj = new Map(ids.map((id) => [id, []]));
528
+ for (const e of edges) {
529
+ adj.get(e.from).push(e.to);
530
+ indeg.set(e.to, indeg.get(e.to) + 1);
531
+ }
532
+ const layer = new Map(ids.map((id) => [id, 0]));
533
+ const queue = ids.filter((id) => indeg.get(id) === 0);
534
+ while (queue.length) {
535
+ const id = queue.shift();
536
+ for (const t of adj.get(id)) {
537
+ layer.set(t, Math.max(layer.get(t), layer.get(id) + 1));
538
+ indeg.set(t, indeg.get(t) - 1);
539
+ if (indeg.get(t) === 0) queue.push(t);
540
+ }
541
+ }
542
+ const byLayer = new Map();
543
+ for (const id of ids) {
544
+ const l = layer.get(id);
545
+ if (!byLayer.has(l)) byLayer.set(l, []);
546
+ byLayer.get(l).push(id);
547
+ }
548
+ const ops = [];
549
+ for (const [l, list] of [...byLayer.entries()].sort((a, b) => a[0] - b[0])) {
550
+ list.forEach((id, i) => {
551
+ const node = nodes.find((n) => n.id === id);
552
+ ops.push({
553
+ op: 'create',
554
+ type: 'shape',
555
+ shape: typeof node.shape === 'string' ? node.shape : 'rounded',
556
+ ref: `@${id}`,
557
+ label: typeof node.label === 'string' ? node.label : String(id),
558
+ // Relative to the placement origin — autoPlace sees explicit coords.
559
+ flowX: l * (NODE_W + FLOW_GAP_X),
560
+ flowY: i * (NODE_H + FLOW_GAP_Y),
561
+ });
562
+ });
563
+ }
564
+ for (const e of edges) {
565
+ ops.push({ op: 'connect', from: `@${e.from}`, to: `@${e.to}`, label: e.label });
566
+ }
567
+ return ops;
568
+ }
569
+
570
+ // ─────────────────────────────────────────────────────────────────────────────
571
+ // SVG assembly — deletes + group injections on the existing string, new
572
+ // strokes serialized through the canonical serializer, sanitize, cap.
573
+
574
+ function deleteElement(svg, id) {
575
+ const idEsc = escapeRe(id);
576
+ let out = svg.replace(
577
+ new RegExp(`<(g|text)\\b[^>]*data-id="${idEsc}"[^>]*>[\\s\\S]*?</\\1>`, 'g'),
578
+ ''
579
+ );
580
+ out = out.replace(
581
+ new RegExp(`<(?:path|rect|ellipse|polygon|image)\\b[^>]*data-id="${idEsc}"[^>]*/>`, 'g'),
582
+ ''
583
+ );
584
+ // Cascade — anchored text hosted by the deleted shape goes with it (the
585
+ // canvas deleteStrokes rule).
586
+ out = out.replace(
587
+ new RegExp(`<text\\b[^>]*data-anchor-id="${idEsc}"[^>]*>[\\s\\S]*?</text>`, 'g'),
588
+ ''
589
+ );
590
+ return out;
591
+ }
592
+
593
+ function injectGroupAttr(svg, id, groupId) {
594
+ const idEsc = escapeRe(id);
595
+ const re = new RegExp(`(<[a-zA-Z]+\\b[^>]*data-id="${idEsc}"[^>]*?)(/?>)`);
596
+ return svg.replace(re, (_whole, head, close) => {
597
+ if (/data-group-ids="/.test(head)) {
598
+ return (
599
+ head.replace(
600
+ /data-group-ids="([^"]*)"/,
601
+ (_m, cur) => `data-group-ids="${cur} ${groupId}"`
602
+ ) + close
603
+ );
604
+ }
605
+ return `${head} data-group-ids="${groupId}"${close}`;
606
+ });
607
+ }
608
+
609
+ // ─────────────────────────────────────────────────────────────────────────────
610
+ // Main
611
+
612
+ async function main() {
613
+ const args = parseArgv(process.argv.slice(2));
614
+ if (args.help) {
615
+ process.stdout.write(`${HELP}\n`);
616
+ return;
617
+ }
618
+ const relPath = args.positional[0];
619
+ if (!relPath) fail(`missing <rel-path>.\n\n${HELP}`, 2);
620
+ if (args.ops && args.flow) fail('--ops and --flow are mutually exclusive', 2);
621
+
622
+ const repoRoot = args.root
623
+ ? resolve(args.root)
624
+ : process.env.CLAUDE_PROJECT_DIR
625
+ ? resolve(process.env.CLAUDE_PROJECT_DIR)
626
+ : process.cwd();
627
+ const { designRel, designRoot } = resolveDesignRoot(repoRoot);
628
+ const slug = fileSlug(relPath, designRel);
629
+ const svgPath = join(designRoot, `${slug}.annotations.svg`);
630
+
631
+ let svg = '';
632
+ if (existsSync(svgPath)) {
633
+ try {
634
+ svg = readFileSync(svgPath, 'utf8');
635
+ } catch {
636
+ svg = '';
637
+ }
638
+ }
639
+ const existing = parseAnnotations(svg);
640
+
641
+ const artboards = args.canvasState
642
+ ? loadArtboards(
643
+ isAbsolute(args.canvasState) ? args.canvasState : resolve(process.cwd(), args.canvasState)
644
+ )
645
+ : [];
646
+
647
+ const ctx = buildContext(existing, artboards, args.near);
648
+
649
+ let ops;
650
+ if (args.flow != null) {
651
+ 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
+ );
655
+ } else {
656
+ const payload = parseJsonInput(readInput(args.ops), '--ops');
657
+ ops = Array.isArray(payload.ops) ? payload.ops : Array.isArray(payload) ? payload : null;
658
+ if (!ops) fail('ops: expected { ops: [...] } (or a bare array)', 2);
659
+ }
660
+
661
+ applyOps(ctx, ops);
662
+
663
+ // Assemble. Deletes + group injections operate on the existing string; new
664
+ // strokes append before </svg> through the canonical serializer.
665
+ let merged = svg && /<svg[\s>]/i.test(svg) ? svg : `${SVG_HEADER}</svg>`;
666
+ for (const id of ctx.deletes) merged = deleteElement(merged, id);
667
+ for (const inj of ctx.groupExisting) merged = injectGroupAttr(merged, inj.id, inj.groupId);
668
+ if (ctx.created.length) {
669
+ const body = ctx.created.map((s) => strokeToSvgEl(s)).join('');
670
+ const close = merged.lastIndexOf('</svg>');
671
+ if (close < 0) fail('existing annotation file is not a valid SVG', 1);
672
+ merged = merged.slice(0, close) + body + merged.slice(close);
673
+ }
674
+ merged = sanitizeAnnotationSvg(merged);
675
+ const bytes = Buffer.byteLength(merged, 'utf8');
676
+ if (bytes > MAX_ANNOTATIONS_BYTES) {
677
+ fail(`result exceeds the 1 MB annotation cap (${bytes} bytes) — nothing written`, 1);
678
+ }
679
+
680
+ if (args.dryRun) {
681
+ process.stdout.write(`${merged}\n`);
682
+ return;
683
+ }
684
+
685
+ // Prefer the live server (sanitize + persist + collab broadcast — open
686
+ // canvases update in real time); fall back to a direct file write.
687
+ let via = 'file';
688
+ const serverJsonPath = join(designRoot, '_server.json');
689
+ if (existsSync(serverJsonPath)) {
690
+ try {
691
+ const srv = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
692
+ const base = typeof srv.url === 'string' ? srv.url.replace(/\/+$/, '') : null;
693
+ // Security (Wave H F2): `_server.json` is local dev-server state, but the
694
+ // verb runs in an AGENT loop — only ever PUT to a loopback http(s) origin
695
+ // so a poisoned/foreign `url` can't turn this into an SSRF primitive that
696
+ // exfiltrates the whole canvas SVG to an arbitrary host. On anything else
697
+ // (remote host, file:, javascript:) we silently fall back to the file
698
+ // write — the local intent still succeeds, the egress is denied.
699
+ if (base && isLoopbackHttpUrl(base)) {
700
+ const res = await fetch(`${base}/_api/annotations`, {
701
+ method: 'PUT',
702
+ headers: { 'Content-Type': 'application/json' },
703
+ body: JSON.stringify({ file: `${designRel}/${relPath}`, svg: merged }),
704
+ signal: AbortSignal.timeout(3000),
705
+ });
706
+ if (res.ok) via = 'server';
707
+ }
708
+ } catch {
709
+ /* stale _server.json / server down — fall through to the file write */
710
+ }
711
+ }
712
+ if (via === 'file') {
713
+ writeFileSync(svgPath, merged, 'utf8');
714
+ }
715
+
716
+ const refs = {};
717
+ for (const [ref, id] of ctx.refs) refs[ref] = id;
718
+ process.stdout.write(
719
+ `${JSON.stringify({ ok: true, via, file: svgPath, bytes, created: ctx.created.length, deleted: ctx.deletes.length, refs })}\n`
720
+ );
721
+ }
722
+
723
+ function parseJsonInput(raw, what) {
724
+ if (!raw?.trim()) fail(`${what}: empty input`, 2);
725
+ try {
726
+ return JSON.parse(raw);
727
+ } catch (err) {
728
+ fail(`${what}: invalid JSON — ${err?.message ?? err}`, 2);
729
+ }
730
+ }
731
+
732
+ await main();