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