@1agh/maude 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -3,7 +3,7 @@
3
3
 
4
4
  import crypto from 'node:crypto';
5
5
  import type { Dirent } from 'node:fs';
6
- import { mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
6
+ import { lstat, mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
9
9
  import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
@@ -12,15 +12,24 @@ import {
12
12
  assembleCompSource,
13
13
  CanvasEditError,
14
14
  type ClipInfo,
15
+ deleteArtboard,
16
+ deleteElement,
17
+ duplicateElement,
18
+ type EditScope,
15
19
  editArrayElementString,
16
20
  editAttribute,
17
21
  enumerateClips,
22
+ type InsertKind,
23
+ insertArtboard,
18
24
  insertClip,
25
+ insertElement,
19
26
  type MovePosition,
20
27
  moveElement,
21
28
  removeAttribute,
22
29
  removeClip,
23
30
  reorderClip,
31
+ resizeArtboard,
32
+ resolveEditScope,
24
33
  retimeSequence,
25
34
  retimeSequenceByClip,
26
35
  editText as runEditText,
@@ -171,16 +180,6 @@ export interface GitCommitter {
171
180
  commits: number;
172
181
  }
173
182
 
174
- // Phase 6.5 T10 — export history. Five-deep ring buffer of recent exports
175
- // surfaced by the dialog's "Recent" tab + ⌘⇧E re-run.
176
- export interface ExportHistoryEntry {
177
- format: string;
178
- scope: string;
179
- options?: Record<string, unknown>;
180
- filename: string;
181
- at: string;
182
- }
183
-
184
183
  export type CreateCanvasResult =
185
184
  | { ok: true; file: string; rel: string; slug: string }
186
185
  | { ok: false; status: number; error: string };
@@ -254,6 +253,8 @@ export interface Api {
254
253
  saveAnnotations(file: string, svg: string): Promise<boolean>;
255
254
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
256
255
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
256
+ /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
257
+ listAssets(): Promise<{ ok: true; assets: AssetListing[] }>;
257
258
  /** DDR-148 — streaming variant for the HTTP route (100 MB video without a
258
259
  * full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
259
260
  saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
@@ -283,6 +284,8 @@ export interface Api {
283
284
  id?: unknown;
284
285
  property?: unknown;
285
286
  value?: unknown;
287
+ reset?: unknown;
288
+ idIndex?: unknown;
286
289
  }): Promise<EditOpResult>;
287
290
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
288
291
  editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
@@ -383,6 +386,63 @@ export interface Api {
383
386
  }
384
387
  | { ok: false; status: number; error: string }
385
388
  >;
389
+ // Stage I (feature-element-editing-robustness) — general element structural
390
+ // edits. Each logs a whole-file undo seq (reverted via reorderRevert).
391
+ /** Delete an element by data-cd-id (reused-component instance via idIndex). */
392
+ deleteElementOp(input: {
393
+ canvas?: unknown;
394
+ id?: unknown;
395
+ idIndex?: unknown;
396
+ }): Promise<
397
+ { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
398
+ >;
399
+ /** Insert a synthesized div/text/image relative to a reference element. */
400
+ insertElementOp(input: {
401
+ canvas?: unknown;
402
+ refId?: unknown;
403
+ position?: unknown;
404
+ kind?: unknown;
405
+ src?: unknown;
406
+ refIndex?: unknown;
407
+ }): Promise<
408
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
409
+ >;
410
+ /** Insert a new empty artboard from a screen-size preset. */
411
+ insertArtboardOp(input: {
412
+ canvas?: unknown;
413
+ id?: unknown;
414
+ label?: unknown;
415
+ width?: unknown;
416
+ height?: unknown;
417
+ }): Promise<
418
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
419
+ >;
420
+ /** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
421
+ resizeArtboardOp(input: {
422
+ canvas?: unknown;
423
+ artboardId?: unknown;
424
+ width?: unknown;
425
+ height?: unknown;
426
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
427
+ /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
428
+ duplicateElementOp(input: {
429
+ canvas?: unknown;
430
+ id?: unknown;
431
+ idIndex?: unknown;
432
+ }): Promise<
433
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
434
+ >;
435
+ /** Delete an artboard by its `id` prop (whole-file undo seq). */
436
+ deleteArtboardOp(input: {
437
+ canvas?: unknown;
438
+ artboardId?: unknown;
439
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
440
+ /** Edit-scope verdict (local vs shared component instance) for the INV-3 badge. */
441
+ editScopeOp(input: {
442
+ canvas?: unknown;
443
+ id?: unknown;
444
+ rendered?: unknown;
445
+ }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>;
386
446
  // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
387
447
  // file content swap from the in-memory revert log — immune to the positional
388
448
  // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
@@ -395,9 +455,6 @@ export interface Api {
395
455
  // Aggregate data
396
456
  buildIndexData(): Promise<unknown>;
397
457
  buildSystemData(dsName?: string | null): Promise<unknown>;
398
- // Export history (Phase 6.5 T10)
399
- loadExportHistory(): Promise<ExportHistoryEntry[]>;
400
- appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
401
458
  }
402
459
 
403
460
  export interface ApiHooks {
@@ -497,6 +554,17 @@ export interface SaveAssetResult {
497
554
  path?: string;
498
555
  }
499
556
 
557
+ /** Stage F1 — one media asset in the AssetPicker listing. */
558
+ export interface AssetListing {
559
+ /** designRoot-relative path (`assets/<sha8>.<ext>`) — what a src/href uses. */
560
+ path: string;
561
+ name: string;
562
+ ext: string;
563
+ kind: 'image' | 'video';
564
+ size: number;
565
+ mtimeMs: number;
566
+ }
567
+
500
568
  /** DDR-148 — media category, decides which per-file cap applies. */
501
569
  export type AssetCategory = 'image' | 'video' | 'audio';
502
570
 
@@ -630,6 +698,51 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
630
698
  return seq;
631
699
  }
632
700
 
701
+ // ── Structural-write throttle + source-size ceiling (G3 security, DDR-152) ──
702
+ // The new structural verbs (delete / insert-element / insert-artboard /
703
+ // resize-artboard) are the first that let an UNTRUSTED active canvas both
704
+ // *remove* and *grow* its own source: the shell relays a canvas's `dgn:*`
705
+ // request after gating only on `e.source === activeWin` — there is no user
706
+ // gesture on the wire, so a hostile on-load script can drive these in a loop.
707
+ // Two bounds close the disk-fill / silent-shred DoS the adversarial review
708
+ // flagged (mirrors the ASSET_SESSION_BUDGET the /_api/asset lane already has,
709
+ // DDR-088 — this is the OTHER untrusted-origin disk-write surface):
710
+ // (a) a per-api token bucket caps the sustained rate. A human does a few
711
+ // structural edits/sec; a scripted loop can't beat the refill, which
712
+ // keeps every whole-file _history snapshot + RAM undo entry rate-bound.
713
+ // (b) a growth op is refused once the source already exceeds
714
+ // MAX_CANVAS_SOURCE, so inserts can't inflate the .tsx (and the snapshot
715
+ // + undo copies it holds) without bound. Deletes/shrinks always pass.
716
+ // Bucket state is per-createApi (each test/instance starts full); env-tunable.
717
+ const STRUCTURAL_BURST = (() => {
718
+ const env = Number(process.env.MAUDE_STRUCTURAL_BURST);
719
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 40;
720
+ })();
721
+ const STRUCTURAL_REFILL_PER_SEC = 8;
722
+ let structuralTokens = STRUCTURAL_BURST;
723
+ let structuralLastRefill = Date.now();
724
+ /** Consume one token; false when the caller is over the sustained rate. */
725
+ function takeStructuralToken(): boolean {
726
+ const now = Date.now();
727
+ structuralTokens = Math.min(
728
+ STRUCTURAL_BURST,
729
+ structuralTokens + ((now - structuralLastRefill) / 1000) * STRUCTURAL_REFILL_PER_SEC
730
+ );
731
+ structuralLastRefill = now;
732
+ if (structuralTokens < 1) return false;
733
+ structuralTokens -= 1;
734
+ return true;
735
+ }
736
+ const RATE_LIMITED = {
737
+ ok: false as const,
738
+ status: 429,
739
+ error: 'too many structural edits — slow down',
740
+ };
741
+ const MAX_CANVAS_SOURCE = (() => {
742
+ const env = Number(process.env.MAUDE_MAX_CANVAS_SOURCE);
743
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 512 * 1024;
744
+ })();
745
+
633
746
  function fileSlug(file: string): string {
634
747
  return canvasSlugFromRel(file, paths.designRel);
635
748
  }
@@ -1361,6 +1474,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1361
1474
  return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
1362
1475
  }
1363
1476
 
1477
+ // Stage F1 (feature-element-editing-robustness) — list the versioned content-
1478
+ // addressed media under <designRoot>/assets/ for the AssetPicker (Replace
1479
+ // image / insert image). Read-only; returns image + video assets only (never
1480
+ // arbitrary files), newest first, capped. MAIN-ORIGIN ONLY at the route layer.
1481
+ async function listAssets(): Promise<{ ok: true; assets: AssetListing[] }> {
1482
+ const assetsDir = path.join(paths.designRoot, 'assets');
1483
+ const IMG = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg']);
1484
+ const VID = new Set(['.mp4', '.webm', '.mov', '.m4v', '.ogg']);
1485
+ let entries: string[] = [];
1486
+ try {
1487
+ entries = await readdir(assetsDir);
1488
+ } catch {
1489
+ return { ok: true, assets: [] }; // no assets dir yet
1490
+ }
1491
+ const out: AssetListing[] = [];
1492
+ for (const name of entries) {
1493
+ const ext = path.extname(name).toLowerCase();
1494
+ const kind = IMG.has(ext) ? 'image' : VID.has(ext) ? 'video' : null;
1495
+ if (!kind) continue;
1496
+ let size = 0;
1497
+ let mtimeMs = 0;
1498
+ try {
1499
+ // lstat (not stat): a symlink planted under assets/ resolves to isFile()
1500
+ // === false here → skipped. Defends the listing against a symlink that
1501
+ // would otherwise be served by the /assets/<file> static route.
1502
+ const st = await lstat(path.join(assetsDir, name));
1503
+ if (!st.isFile()) continue;
1504
+ size = st.size;
1505
+ mtimeMs = st.mtimeMs;
1506
+ } catch {
1507
+ continue;
1508
+ }
1509
+ out.push({ path: `assets/${name}`, name, ext: ext.slice(1), kind, size, mtimeMs });
1510
+ }
1511
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
1512
+ return { ok: true, assets: out.slice(0, 500) };
1513
+ }
1514
+
1364
1515
  // Phase 31 follow-up — persist an image pasted straight from the clipboard into
1365
1516
  // the ACP composer (a screenshot has no path yet), so the chip can point Claude
1366
1517
  // at a real file to Read. Sibling of saveAsset, but:
@@ -1808,11 +1959,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1808
1959
  property?: unknown;
1809
1960
  value?: unknown;
1810
1961
  reset?: unknown;
1962
+ idIndex?: unknown;
1811
1963
  }): Promise<EditOpResult> {
1812
1964
  const r = resolveCanvasAbs(input.canvas);
1813
1965
  if (!r.ok) return r;
1814
1966
  const id = typeof input.id === 'string' ? input.id.trim() : '';
1815
1967
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1968
+ // Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
1969
+ // move/resize (reposition/resize-request); routes the write to the dragged
1970
+ // component instance's own `<Component/>` usage so it stays LOCAL. Absent for
1971
+ // knob / paste-style edits (those stay global on the shared inner element).
1972
+ const idIndex =
1973
+ typeof input.idIndex === 'number' && Number.isInteger(input.idIndex) && input.idIndex >= 0
1974
+ ? input.idIndex
1975
+ : undefined;
1816
1976
  const property = typeof input.property === 'string' ? input.property.trim() : '';
1817
1977
  // CSS property names are ASCII letters + hyphens only (optionally a leading
1818
1978
  // `-` for vendor prefixes) — reject anything that could smuggle a second key
@@ -1827,7 +1987,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1827
1987
  if (input.reset === true) {
1828
1988
  return suppressedEdit(
1829
1989
  r.abs,
1830
- () => removeAttribute(r.abs, id, `style.${camel}`),
1990
+ () => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
1831
1991
  'reset failed'
1832
1992
  );
1833
1993
  }
@@ -1840,7 +2000,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1840
2000
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
1841
2001
  return suppressedEdit(
1842
2002
  r.abs,
1843
- () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value)),
2003
+ () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
1844
2004
  'edit failed'
1845
2005
  );
1846
2006
  }
@@ -2140,6 +2300,326 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2140
2300
  }
2141
2301
  }
2142
2302
 
2303
+ // Stage I (feature-element-editing-robustness) — general element structural
2304
+ // edits (delete / insert / new-artboard) + free-hand artboard resize (D4). All
2305
+ // MAIN-ORIGIN ONLY at the route layer; each logs a whole-file {before, after}
2306
+ // under a seq (logUndo) so Cmd+Z reverts through /_api/reorder-revert (a
2307
+ // structural edit renumbers positional data-cd-ids → an inverse descriptor
2308
+ // goes stale). Same agent-rim suppression + pre-op snapshot as reorder.
2309
+
2310
+ /** Delete the element with `data-cd-id === id` (reused-component instance via idIndex). */
2311
+ async function deleteElementOp(input: {
2312
+ canvas?: unknown;
2313
+ id?: unknown;
2314
+ idIndex?: unknown;
2315
+ }): Promise<
2316
+ { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
2317
+ > {
2318
+ const r = resolveCanvasAbs(input.canvas);
2319
+ if (!r.ok) return r;
2320
+ if (!takeStructuralToken()) return RATE_LIMITED;
2321
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2322
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2323
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
2324
+ const rel = path.relative(paths.designRoot, r.abs);
2325
+ ctx.bus.emit('activity:suppress', rel);
2326
+ try {
2327
+ const before = await Bun.file(r.abs).text();
2328
+ const res = await deleteElement(r.abs, id, idIndex);
2329
+ const after = await Bun.file(r.abs).text();
2330
+ if (after === before) {
2331
+ ctx.bus.emit('activity:unsuppress', rel);
2332
+ return { ok: true, deletedId: res.deletedId };
2333
+ }
2334
+ try {
2335
+ await history.writeSnapshot(rel, before, 'pre-delete-element');
2336
+ } catch {
2337
+ /* snapshot best-effort */
2338
+ }
2339
+ return { ok: true, deletedId: res.deletedId, seq: logUndo(r.abs, before, after) };
2340
+ } catch (err) {
2341
+ ctx.bus.emit('activity:unsuppress', rel);
2342
+ return {
2343
+ ok: false,
2344
+ status: err instanceof CanvasEditError ? 422 : 500,
2345
+ error: err instanceof Error ? err.message : 'delete failed',
2346
+ };
2347
+ }
2348
+ }
2349
+
2350
+ /** Insert a synthesized element (div/text/image) relative to `refId`. */
2351
+ async function insertElementOp(input: {
2352
+ canvas?: unknown;
2353
+ refId?: unknown;
2354
+ position?: unknown;
2355
+ kind?: unknown;
2356
+ src?: unknown;
2357
+ refIndex?: unknown;
2358
+ }): Promise<
2359
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
2360
+ > {
2361
+ const r = resolveCanvasAbs(input.canvas);
2362
+ if (!r.ok) return r;
2363
+ if (!takeStructuralToken()) return RATE_LIMITED;
2364
+ const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
2365
+ if (!CD_ID_RE.test(refId)) {
2366
+ return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
2367
+ }
2368
+ const position = input.position;
2369
+ if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
2370
+ return { ok: false, status: 400, error: 'invalid position' };
2371
+ }
2372
+ const kind = input.kind;
2373
+ if (kind !== 'div' && kind !== 'text' && kind !== 'image') {
2374
+ return { ok: false, status: 400, error: 'invalid kind (div|text|image)' };
2375
+ }
2376
+ const src = typeof input.src === 'string' ? input.src : undefined;
2377
+ const refIndex = Number.isInteger(input.refIndex) ? (input.refIndex as number) : undefined;
2378
+ const rel = path.relative(paths.designRoot, r.abs);
2379
+ ctx.bus.emit('activity:suppress', rel);
2380
+ try {
2381
+ const before = await Bun.file(r.abs).text();
2382
+ if (before.length > MAX_CANVAS_SOURCE) {
2383
+ ctx.bus.emit('activity:unsuppress', rel);
2384
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2385
+ }
2386
+ const res = await insertElement(r.abs, refId, position as MovePosition, kind as InsertKind, {
2387
+ src,
2388
+ occurrence: refIndex,
2389
+ });
2390
+ const after = await Bun.file(r.abs).text();
2391
+ if (after === before) {
2392
+ ctx.bus.emit('activity:unsuppress', rel);
2393
+ return { ok: true, newId: res.newId };
2394
+ }
2395
+ try {
2396
+ await history.writeSnapshot(rel, before, 'pre-insert-element');
2397
+ } catch {
2398
+ /* snapshot best-effort */
2399
+ }
2400
+ return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
2401
+ } catch (err) {
2402
+ ctx.bus.emit('activity:unsuppress', rel);
2403
+ return {
2404
+ ok: false,
2405
+ status: err instanceof CanvasEditError ? 422 : 500,
2406
+ error: err instanceof Error ? err.message : 'insert failed',
2407
+ };
2408
+ }
2409
+ }
2410
+
2411
+ /** Insert a new empty artboard (id/label/width/height) after the last one. */
2412
+ async function insertArtboardOp(input: {
2413
+ canvas?: unknown;
2414
+ id?: unknown;
2415
+ label?: unknown;
2416
+ width?: unknown;
2417
+ height?: unknown;
2418
+ }): Promise<
2419
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
2420
+ > {
2421
+ const r = resolveCanvasAbs(input.canvas);
2422
+ if (!r.ok) return r;
2423
+ if (!takeStructuralToken()) return RATE_LIMITED;
2424
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2425
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(id)) {
2426
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2427
+ }
2428
+ const label = typeof input.label === 'string' ? input.label.slice(0, 120) : id;
2429
+ const width = Number.isFinite(Number(input.width))
2430
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.width))))
2431
+ : 0;
2432
+ const height = Number.isFinite(Number(input.height))
2433
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.height))))
2434
+ : 0;
2435
+ if (!width || !height) return { ok: false, status: 400, error: 'width and height required' };
2436
+ const rel = path.relative(paths.designRoot, r.abs);
2437
+ ctx.bus.emit('activity:suppress', rel);
2438
+ try {
2439
+ const before = await Bun.file(r.abs).text();
2440
+ if (before.length > MAX_CANVAS_SOURCE) {
2441
+ ctx.bus.emit('activity:unsuppress', rel);
2442
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2443
+ }
2444
+ const res = await insertArtboard(r.abs, { id, label, width, height });
2445
+ const after = await Bun.file(r.abs).text();
2446
+ if (after === before) {
2447
+ ctx.bus.emit('activity:unsuppress', rel);
2448
+ return { ok: true, artboardId: res.artboardId };
2449
+ }
2450
+ try {
2451
+ await history.writeSnapshot(rel, before, 'pre-insert-artboard');
2452
+ } catch {
2453
+ /* snapshot best-effort */
2454
+ }
2455
+ return { ok: true, artboardId: res.artboardId, seq: logUndo(r.abs, before, after) };
2456
+ } catch (err) {
2457
+ ctx.bus.emit('activity:unsuppress', rel);
2458
+ return {
2459
+ ok: false,
2460
+ status: err instanceof CanvasEditError ? 422 : 500,
2461
+ error: err instanceof Error ? err.message : 'insert-artboard failed',
2462
+ };
2463
+ }
2464
+ }
2465
+
2466
+ /** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
2467
+ async function resizeArtboardOp(input: {
2468
+ canvas?: unknown;
2469
+ artboardId?: unknown;
2470
+ width?: unknown;
2471
+ height?: unknown;
2472
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2473
+ const r = resolveCanvasAbs(input.canvas);
2474
+ if (!r.ok) return r;
2475
+ if (!takeStructuralToken()) return RATE_LIMITED;
2476
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2477
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2478
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2479
+ }
2480
+ const dim = (v: unknown): number | undefined =>
2481
+ Number.isFinite(Number(v)) ? Math.max(64, Math.min(8192, Math.round(Number(v)))) : undefined;
2482
+ const width = dim(input.width);
2483
+ const height = dim(input.height);
2484
+ if (width == null && height == null) {
2485
+ return { ok: false, status: 400, error: 'width or height required' };
2486
+ }
2487
+ const rel = path.relative(paths.designRoot, r.abs);
2488
+ ctx.bus.emit('activity:suppress', rel);
2489
+ try {
2490
+ const before = await Bun.file(r.abs).text();
2491
+ await resizeArtboard(r.abs, artboardId, width, height);
2492
+ const after = await Bun.file(r.abs).text();
2493
+ if (after === before) {
2494
+ ctx.bus.emit('activity:unsuppress', rel);
2495
+ return { ok: true };
2496
+ }
2497
+ try {
2498
+ await history.writeSnapshot(rel, before, 'pre-resize-artboard');
2499
+ } catch {
2500
+ /* snapshot best-effort */
2501
+ }
2502
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2503
+ } catch (err) {
2504
+ ctx.bus.emit('activity:unsuppress', rel);
2505
+ return {
2506
+ ok: false,
2507
+ status: err instanceof CanvasEditError ? 422 : 500,
2508
+ error: err instanceof Error ? err.message : 'resize-artboard failed',
2509
+ };
2510
+ }
2511
+ }
2512
+
2513
+ /** Duplicate an element (Cmd+D) — insert a copy as the next sibling. */
2514
+ async function duplicateElementOp(input: {
2515
+ canvas?: unknown;
2516
+ id?: unknown;
2517
+ idIndex?: unknown;
2518
+ }): Promise<
2519
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
2520
+ > {
2521
+ const r = resolveCanvasAbs(input.canvas);
2522
+ if (!r.ok) return r;
2523
+ if (!takeStructuralToken()) return RATE_LIMITED;
2524
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2525
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2526
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
2527
+ const rel = path.relative(paths.designRoot, r.abs);
2528
+ ctx.bus.emit('activity:suppress', rel);
2529
+ try {
2530
+ const before = await Bun.file(r.abs).text();
2531
+ if (before.length > MAX_CANVAS_SOURCE) {
2532
+ ctx.bus.emit('activity:unsuppress', rel);
2533
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2534
+ }
2535
+ const res = await duplicateElement(r.abs, id, idIndex);
2536
+ const after = await Bun.file(r.abs).text();
2537
+ if (after === before) {
2538
+ ctx.bus.emit('activity:unsuppress', rel);
2539
+ return { ok: true, newId: res.newId };
2540
+ }
2541
+ try {
2542
+ await history.writeSnapshot(rel, before, 'pre-duplicate-element');
2543
+ } catch {
2544
+ /* snapshot best-effort */
2545
+ }
2546
+ return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
2547
+ } catch (err) {
2548
+ ctx.bus.emit('activity:unsuppress', rel);
2549
+ return {
2550
+ ok: false,
2551
+ status: err instanceof CanvasEditError ? 422 : 500,
2552
+ error: err instanceof Error ? err.message : 'duplicate failed',
2553
+ };
2554
+ }
2555
+ }
2556
+
2557
+ /** Delete an artboard by its `id` prop (Backspace / context-menu on a frame). */
2558
+ async function deleteArtboardOp(input: {
2559
+ canvas?: unknown;
2560
+ artboardId?: unknown;
2561
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2562
+ const r = resolveCanvasAbs(input.canvas);
2563
+ if (!r.ok) return r;
2564
+ if (!takeStructuralToken()) return RATE_LIMITED;
2565
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2566
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2567
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2568
+ }
2569
+ const rel = path.relative(paths.designRoot, r.abs);
2570
+ ctx.bus.emit('activity:suppress', rel);
2571
+ try {
2572
+ const before = await Bun.file(r.abs).text();
2573
+ await deleteArtboard(r.abs, artboardId);
2574
+ const after = await Bun.file(r.abs).text();
2575
+ if (after === before) {
2576
+ ctx.bus.emit('activity:unsuppress', rel);
2577
+ return { ok: true };
2578
+ }
2579
+ try {
2580
+ await history.writeSnapshot(rel, before, 'pre-delete-artboard');
2581
+ } catch {
2582
+ /* snapshot best-effort */
2583
+ }
2584
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2585
+ } catch (err) {
2586
+ ctx.bus.emit('activity:unsuppress', rel);
2587
+ return {
2588
+ ok: false,
2589
+ status: err instanceof CanvasEditError ? 422 : 500,
2590
+ error: err instanceof Error ? err.message : 'delete-artboard failed',
2591
+ };
2592
+ }
2593
+ }
2594
+
2595
+ /**
2596
+ * Edit-scope verdict for the INV-3 predictability badge (Stage H). READ-only —
2597
+ * parses the canvas + returns whether an edit to `id` is local or shared. No
2598
+ * write, no undo, no rate-cap (a mere parse the shell runs on selection). The
2599
+ * client supplies `rendered` = the DOM occurrence count of the cd-id so the
2600
+ * `.map()` case is honest.
2601
+ */
2602
+ async function editScopeOp(input: {
2603
+ canvas?: unknown;
2604
+ id?: unknown;
2605
+ rendered?: unknown;
2606
+ }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }> {
2607
+ const r = resolveCanvasAbs(input.canvas);
2608
+ if (!r.ok) return r;
2609
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2610
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2611
+ const rendered = Number.isFinite(Number(input.rendered))
2612
+ ? Math.max(1, Math.round(Number(input.rendered)))
2613
+ : 1;
2614
+ try {
2615
+ const source = await Bun.file(r.abs).text();
2616
+ const scope = resolveEditScope(r.abs, source, id, rendered);
2617
+ return { ok: true, ...scope };
2618
+ } catch (err) {
2619
+ return { ok: false, status: 500, error: err instanceof Error ? err.message : 'scope failed' };
2620
+ }
2621
+ }
2622
+
2143
2623
  async function toggleHideOp(input: {
2144
2624
  canvas?: unknown;
2145
2625
  stableId?: unknown;
@@ -2513,32 +2993,6 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2513
2993
  };
2514
2994
  }
2515
2995
 
2516
- // ---------- Export history (Phase 6.5 T10) ----------
2517
- //
2518
- // 5-deep ring buffer persisted at `<designRoot>/_export-history.json`.
2519
- // Reads tolerate missing / malformed files (returns []). Writes truncate
2520
- // to most-recent-first.
2521
-
2522
- const HISTORY_PATH = path.join(paths.designRoot, '_export-history.json');
2523
- const HISTORY_DEPTH = 5;
2524
-
2525
- async function loadExportHistory(): Promise<ExportHistoryEntry[]> {
2526
- try {
2527
- const raw = await Bun.file(HISTORY_PATH).text();
2528
- const arr = JSON.parse(raw);
2529
- if (!Array.isArray(arr)) return [];
2530
- return arr.slice(0, HISTORY_DEPTH);
2531
- } catch {
2532
- return [];
2533
- }
2534
- }
2535
-
2536
- async function appendExportHistory(entry: ExportHistoryEntry): Promise<void> {
2537
- const prev = await loadExportHistory();
2538
- const next = [entry, ...prev].slice(0, HISTORY_DEPTH);
2539
- await Bun.write(HISTORY_PATH, JSON.stringify(next, null, 2));
2540
- }
2541
-
2542
2996
  function tokenKind(name: string, value: string): string {
2543
2997
  const n = name.toLowerCase();
2544
2998
  const v = String(value).trim();
@@ -2753,6 +3207,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2753
3207
  loadAnnotations,
2754
3208
  saveAnnotations,
2755
3209
  saveAsset,
3210
+ listAssets,
2756
3211
  saveAssetFromStream,
2757
3212
  saveChatAttachment,
2758
3213
  resolveChatAttachment,
@@ -2769,10 +3224,15 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2769
3224
  compClips,
2770
3225
  reorder,
2771
3226
  retimeSequenceOp,
3227
+ deleteElementOp,
3228
+ insertElementOp,
3229
+ insertArtboardOp,
3230
+ resizeArtboardOp,
3231
+ deleteArtboardOp,
3232
+ duplicateElementOp,
3233
+ editScopeOp,
2772
3234
  reorderRevert,
2773
3235
  buildIndexData,
2774
3236
  buildSystemData,
2775
- loadExportHistory,
2776
- appendExportHistory,
2777
3237
  };
2778
3238
  }
@@ -51,7 +51,7 @@ function shouldIgnoreTarget(t: EventTarget | null): boolean {
51
51
  // Skip floating chrome and existing overlays (their click handlers run).
52
52
  if (
53
53
  el.closest(
54
- '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
54
+ '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-el-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
55
55
  )
56
56
  ) {
57
57
  return true;