@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.
Files changed (73) 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 -3
  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/annotate.mjs +576 -34
  9. package/apps/studio/bin/canvas-rects.sh +152 -0
  10. package/apps/studio/bin/fetch-asset.sh +34 -0
  11. package/apps/studio/bin/read-annotations.mjs +138 -7
  12. package/apps/studio/bin/smoke.sh +42 -6
  13. package/apps/studio/build.ts +21 -0
  14. package/apps/studio/canvas-comment-mount.tsx +138 -4
  15. package/apps/studio/canvas-edit.ts +744 -11
  16. package/apps/studio/canvas-lib.tsx +219 -2
  17. package/apps/studio/canvas-shell.tsx +487 -20
  18. package/apps/studio/client/app.jsx +1451 -22
  19. package/apps/studio/client/comments-overlay.css +130 -126
  20. package/apps/studio/client/github.js +8 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +48 -0
  22. package/apps/studio/comments-overlay.tsx +148 -41
  23. package/apps/studio/context-menu.tsx +15 -5
  24. package/apps/studio/contextual-toolbar.tsx +262 -4
  25. package/apps/studio/cursors-overlay.tsx +4 -4
  26. package/apps/studio/dist/client.bundle.js +20 -20
  27. package/apps/studio/dist/comment-mount.js +59 -1
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/dom-selection.ts +127 -1
  30. package/apps/studio/drag-state.ts +24 -0
  31. package/apps/studio/equal-spacing-detector.ts +205 -0
  32. package/apps/studio/export-dialog.tsx +1 -1
  33. package/apps/studio/history.ts +47 -1
  34. package/apps/studio/http.ts +223 -0
  35. package/apps/studio/input-router.tsx +12 -0
  36. package/apps/studio/marquee-overlay.tsx +1 -1
  37. package/apps/studio/measure-overlay.tsx +241 -0
  38. package/apps/studio/participants-chrome.tsx +3 -3
  39. package/apps/studio/sizing-mode.ts +117 -0
  40. package/apps/studio/spacing-handles.ts +166 -0
  41. package/apps/studio/test/annotate-write.test.ts +890 -0
  42. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  43. package/apps/studio/test/canvas-edit.test.ts +50 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
  45. package/apps/studio/test/canvas-rects.test.ts +198 -0
  46. package/apps/studio/test/comments-overlay.test.ts +117 -0
  47. package/apps/studio/test/dom-selection.test.ts +130 -0
  48. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  50. package/apps/studio/test/element-resize.test.ts +136 -0
  51. package/apps/studio/test/element-structural-api.test.ts +360 -0
  52. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  53. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  54. package/apps/studio/test/history-rollback.test.ts +26 -0
  55. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  56. package/apps/studio/test/read-annotations.test.ts +154 -0
  57. package/apps/studio/test/sizing-mode.test.ts +102 -0
  58. package/apps/studio/test/spacing-handles.test.ts +138 -0
  59. package/apps/studio/test/specimen-select.test.ts +88 -0
  60. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  61. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  62. package/apps/studio/tool-palette.tsx +122 -2
  63. package/apps/studio/undo-hud.tsx +2 -2
  64. package/apps/studio/use-element-resize.tsx +732 -0
  65. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  66. package/apps/studio/use-selection-set.tsx +14 -0
  67. package/apps/studio/use-spacing-handles.tsx +388 -0
  68. package/apps/studio/whats-new.json +28 -0
  69. package/cli/commands/design.mjs +6 -1
  70. package/cli/commands/design.test.mjs +49 -1
  71. package/cli/lib/fetch-asset.test.mjs +213 -0
  72. package/package.json +8 -8
  73. 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,
@@ -254,6 +263,8 @@ export interface Api {
254
263
  saveAnnotations(file: string, svg: string): Promise<boolean>;
255
264
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
256
265
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
266
+ /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
267
+ listAssets(): Promise<{ ok: true; assets: AssetListing[] }>;
257
268
  /** DDR-148 — streaming variant for the HTTP route (100 MB video without a
258
269
  * full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
259
270
  saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
@@ -283,6 +294,8 @@ export interface Api {
283
294
  id?: unknown;
284
295
  property?: unknown;
285
296
  value?: unknown;
297
+ reset?: unknown;
298
+ idIndex?: unknown;
286
299
  }): Promise<EditOpResult>;
287
300
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
288
301
  editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
@@ -383,6 +396,63 @@ export interface Api {
383
396
  }
384
397
  | { ok: false; status: number; error: string }
385
398
  >;
399
+ // Stage I (feature-element-editing-robustness) — general element structural
400
+ // edits. Each logs a whole-file undo seq (reverted via reorderRevert).
401
+ /** Delete an element by data-cd-id (reused-component instance via idIndex). */
402
+ deleteElementOp(input: {
403
+ canvas?: unknown;
404
+ id?: unknown;
405
+ idIndex?: unknown;
406
+ }): Promise<
407
+ { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
408
+ >;
409
+ /** Insert a synthesized div/text/image relative to a reference element. */
410
+ insertElementOp(input: {
411
+ canvas?: unknown;
412
+ refId?: unknown;
413
+ position?: unknown;
414
+ kind?: unknown;
415
+ src?: unknown;
416
+ refIndex?: unknown;
417
+ }): Promise<
418
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
419
+ >;
420
+ /** Insert a new empty artboard from a screen-size preset. */
421
+ insertArtboardOp(input: {
422
+ canvas?: unknown;
423
+ id?: unknown;
424
+ label?: unknown;
425
+ width?: unknown;
426
+ height?: unknown;
427
+ }): Promise<
428
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
429
+ >;
430
+ /** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
431
+ resizeArtboardOp(input: {
432
+ canvas?: unknown;
433
+ artboardId?: unknown;
434
+ width?: unknown;
435
+ height?: unknown;
436
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
437
+ /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
438
+ duplicateElementOp(input: {
439
+ canvas?: unknown;
440
+ id?: unknown;
441
+ idIndex?: unknown;
442
+ }): Promise<
443
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
444
+ >;
445
+ /** Delete an artboard by its `id` prop (whole-file undo seq). */
446
+ deleteArtboardOp(input: {
447
+ canvas?: unknown;
448
+ artboardId?: unknown;
449
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
450
+ /** Edit-scope verdict (local vs shared component instance) for the INV-3 badge. */
451
+ editScopeOp(input: {
452
+ canvas?: unknown;
453
+ id?: unknown;
454
+ rendered?: unknown;
455
+ }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>;
386
456
  // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
387
457
  // file content swap from the in-memory revert log — immune to the positional
388
458
  // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
@@ -497,6 +567,17 @@ export interface SaveAssetResult {
497
567
  path?: string;
498
568
  }
499
569
 
570
+ /** Stage F1 — one media asset in the AssetPicker listing. */
571
+ export interface AssetListing {
572
+ /** designRoot-relative path (`assets/<sha8>.<ext>`) — what a src/href uses. */
573
+ path: string;
574
+ name: string;
575
+ ext: string;
576
+ kind: 'image' | 'video';
577
+ size: number;
578
+ mtimeMs: number;
579
+ }
580
+
500
581
  /** DDR-148 — media category, decides which per-file cap applies. */
501
582
  export type AssetCategory = 'image' | 'video' | 'audio';
502
583
 
@@ -630,6 +711,51 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
630
711
  return seq;
631
712
  }
632
713
 
714
+ // ── Structural-write throttle + source-size ceiling (G3 security, DDR-152) ──
715
+ // The new structural verbs (delete / insert-element / insert-artboard /
716
+ // resize-artboard) are the first that let an UNTRUSTED active canvas both
717
+ // *remove* and *grow* its own source: the shell relays a canvas's `dgn:*`
718
+ // request after gating only on `e.source === activeWin` — there is no user
719
+ // gesture on the wire, so a hostile on-load script can drive these in a loop.
720
+ // Two bounds close the disk-fill / silent-shred DoS the adversarial review
721
+ // flagged (mirrors the ASSET_SESSION_BUDGET the /_api/asset lane already has,
722
+ // DDR-088 — this is the OTHER untrusted-origin disk-write surface):
723
+ // (a) a per-api token bucket caps the sustained rate. A human does a few
724
+ // structural edits/sec; a scripted loop can't beat the refill, which
725
+ // keeps every whole-file _history snapshot + RAM undo entry rate-bound.
726
+ // (b) a growth op is refused once the source already exceeds
727
+ // MAX_CANVAS_SOURCE, so inserts can't inflate the .tsx (and the snapshot
728
+ // + undo copies it holds) without bound. Deletes/shrinks always pass.
729
+ // Bucket state is per-createApi (each test/instance starts full); env-tunable.
730
+ const STRUCTURAL_BURST = (() => {
731
+ const env = Number(process.env.MAUDE_STRUCTURAL_BURST);
732
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 40;
733
+ })();
734
+ const STRUCTURAL_REFILL_PER_SEC = 8;
735
+ let structuralTokens = STRUCTURAL_BURST;
736
+ let structuralLastRefill = Date.now();
737
+ /** Consume one token; false when the caller is over the sustained rate. */
738
+ function takeStructuralToken(): boolean {
739
+ const now = Date.now();
740
+ structuralTokens = Math.min(
741
+ STRUCTURAL_BURST,
742
+ structuralTokens + ((now - structuralLastRefill) / 1000) * STRUCTURAL_REFILL_PER_SEC
743
+ );
744
+ structuralLastRefill = now;
745
+ if (structuralTokens < 1) return false;
746
+ structuralTokens -= 1;
747
+ return true;
748
+ }
749
+ const RATE_LIMITED = {
750
+ ok: false as const,
751
+ status: 429,
752
+ error: 'too many structural edits — slow down',
753
+ };
754
+ const MAX_CANVAS_SOURCE = (() => {
755
+ const env = Number(process.env.MAUDE_MAX_CANVAS_SOURCE);
756
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 512 * 1024;
757
+ })();
758
+
633
759
  function fileSlug(file: string): string {
634
760
  return canvasSlugFromRel(file, paths.designRel);
635
761
  }
@@ -1361,6 +1487,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1361
1487
  return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
1362
1488
  }
1363
1489
 
1490
+ // Stage F1 (feature-element-editing-robustness) — list the versioned content-
1491
+ // addressed media under <designRoot>/assets/ for the AssetPicker (Replace
1492
+ // image / insert image). Read-only; returns image + video assets only (never
1493
+ // arbitrary files), newest first, capped. MAIN-ORIGIN ONLY at the route layer.
1494
+ async function listAssets(): Promise<{ ok: true; assets: AssetListing[] }> {
1495
+ const assetsDir = path.join(paths.designRoot, 'assets');
1496
+ const IMG = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg']);
1497
+ const VID = new Set(['.mp4', '.webm', '.mov', '.m4v', '.ogg']);
1498
+ let entries: string[] = [];
1499
+ try {
1500
+ entries = await readdir(assetsDir);
1501
+ } catch {
1502
+ return { ok: true, assets: [] }; // no assets dir yet
1503
+ }
1504
+ const out: AssetListing[] = [];
1505
+ for (const name of entries) {
1506
+ const ext = path.extname(name).toLowerCase();
1507
+ const kind = IMG.has(ext) ? 'image' : VID.has(ext) ? 'video' : null;
1508
+ if (!kind) continue;
1509
+ let size = 0;
1510
+ let mtimeMs = 0;
1511
+ try {
1512
+ // lstat (not stat): a symlink planted under assets/ resolves to isFile()
1513
+ // === false here → skipped. Defends the listing against a symlink that
1514
+ // would otherwise be served by the /assets/<file> static route.
1515
+ const st = await lstat(path.join(assetsDir, name));
1516
+ if (!st.isFile()) continue;
1517
+ size = st.size;
1518
+ mtimeMs = st.mtimeMs;
1519
+ } catch {
1520
+ continue;
1521
+ }
1522
+ out.push({ path: `assets/${name}`, name, ext: ext.slice(1), kind, size, mtimeMs });
1523
+ }
1524
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
1525
+ return { ok: true, assets: out.slice(0, 500) };
1526
+ }
1527
+
1364
1528
  // Phase 31 follow-up — persist an image pasted straight from the clipboard into
1365
1529
  // the ACP composer (a screenshot has no path yet), so the chip can point Claude
1366
1530
  // at a real file to Read. Sibling of saveAsset, but:
@@ -1808,11 +1972,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1808
1972
  property?: unknown;
1809
1973
  value?: unknown;
1810
1974
  reset?: unknown;
1975
+ idIndex?: unknown;
1811
1976
  }): Promise<EditOpResult> {
1812
1977
  const r = resolveCanvasAbs(input.canvas);
1813
1978
  if (!r.ok) return r;
1814
1979
  const id = typeof input.id === 'string' ? input.id.trim() : '';
1815
1980
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1981
+ // Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
1982
+ // move/resize (reposition/resize-request); routes the write to the dragged
1983
+ // component instance's own `<Component/>` usage so it stays LOCAL. Absent for
1984
+ // knob / paste-style edits (those stay global on the shared inner element).
1985
+ const idIndex =
1986
+ typeof input.idIndex === 'number' && Number.isInteger(input.idIndex) && input.idIndex >= 0
1987
+ ? input.idIndex
1988
+ : undefined;
1816
1989
  const property = typeof input.property === 'string' ? input.property.trim() : '';
1817
1990
  // CSS property names are ASCII letters + hyphens only (optionally a leading
1818
1991
  // `-` for vendor prefixes) — reject anything that could smuggle a second key
@@ -1827,7 +2000,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1827
2000
  if (input.reset === true) {
1828
2001
  return suppressedEdit(
1829
2002
  r.abs,
1830
- () => removeAttribute(r.abs, id, `style.${camel}`),
2003
+ () => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
1831
2004
  'reset failed'
1832
2005
  );
1833
2006
  }
@@ -1840,7 +2013,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1840
2013
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
1841
2014
  return suppressedEdit(
1842
2015
  r.abs,
1843
- () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value)),
2016
+ () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
1844
2017
  'edit failed'
1845
2018
  );
1846
2019
  }
@@ -2140,6 +2313,326 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2140
2313
  }
2141
2314
  }
2142
2315
 
2316
+ // Stage I (feature-element-editing-robustness) — general element structural
2317
+ // edits (delete / insert / new-artboard) + free-hand artboard resize (D4). All
2318
+ // MAIN-ORIGIN ONLY at the route layer; each logs a whole-file {before, after}
2319
+ // under a seq (logUndo) so Cmd+Z reverts through /_api/reorder-revert (a
2320
+ // structural edit renumbers positional data-cd-ids → an inverse descriptor
2321
+ // goes stale). Same agent-rim suppression + pre-op snapshot as reorder.
2322
+
2323
+ /** Delete the element with `data-cd-id === id` (reused-component instance via idIndex). */
2324
+ async function deleteElementOp(input: {
2325
+ canvas?: unknown;
2326
+ id?: unknown;
2327
+ idIndex?: unknown;
2328
+ }): Promise<
2329
+ { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
2330
+ > {
2331
+ const r = resolveCanvasAbs(input.canvas);
2332
+ if (!r.ok) return r;
2333
+ if (!takeStructuralToken()) return RATE_LIMITED;
2334
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2335
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2336
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
2337
+ const rel = path.relative(paths.designRoot, r.abs);
2338
+ ctx.bus.emit('activity:suppress', rel);
2339
+ try {
2340
+ const before = await Bun.file(r.abs).text();
2341
+ const res = await deleteElement(r.abs, id, idIndex);
2342
+ const after = await Bun.file(r.abs).text();
2343
+ if (after === before) {
2344
+ ctx.bus.emit('activity:unsuppress', rel);
2345
+ return { ok: true, deletedId: res.deletedId };
2346
+ }
2347
+ try {
2348
+ await history.writeSnapshot(rel, before, 'pre-delete-element');
2349
+ } catch {
2350
+ /* snapshot best-effort */
2351
+ }
2352
+ return { ok: true, deletedId: res.deletedId, seq: logUndo(r.abs, before, after) };
2353
+ } catch (err) {
2354
+ ctx.bus.emit('activity:unsuppress', rel);
2355
+ return {
2356
+ ok: false,
2357
+ status: err instanceof CanvasEditError ? 422 : 500,
2358
+ error: err instanceof Error ? err.message : 'delete failed',
2359
+ };
2360
+ }
2361
+ }
2362
+
2363
+ /** Insert a synthesized element (div/text/image) relative to `refId`. */
2364
+ async function insertElementOp(input: {
2365
+ canvas?: unknown;
2366
+ refId?: unknown;
2367
+ position?: unknown;
2368
+ kind?: unknown;
2369
+ src?: unknown;
2370
+ refIndex?: unknown;
2371
+ }): Promise<
2372
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
2373
+ > {
2374
+ const r = resolveCanvasAbs(input.canvas);
2375
+ if (!r.ok) return r;
2376
+ if (!takeStructuralToken()) return RATE_LIMITED;
2377
+ const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
2378
+ if (!CD_ID_RE.test(refId)) {
2379
+ return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
2380
+ }
2381
+ const position = input.position;
2382
+ if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
2383
+ return { ok: false, status: 400, error: 'invalid position' };
2384
+ }
2385
+ const kind = input.kind;
2386
+ if (kind !== 'div' && kind !== 'text' && kind !== 'image') {
2387
+ return { ok: false, status: 400, error: 'invalid kind (div|text|image)' };
2388
+ }
2389
+ const src = typeof input.src === 'string' ? input.src : undefined;
2390
+ const refIndex = Number.isInteger(input.refIndex) ? (input.refIndex as number) : undefined;
2391
+ const rel = path.relative(paths.designRoot, r.abs);
2392
+ ctx.bus.emit('activity:suppress', rel);
2393
+ try {
2394
+ const before = await Bun.file(r.abs).text();
2395
+ if (before.length > MAX_CANVAS_SOURCE) {
2396
+ ctx.bus.emit('activity:unsuppress', rel);
2397
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2398
+ }
2399
+ const res = await insertElement(r.abs, refId, position as MovePosition, kind as InsertKind, {
2400
+ src,
2401
+ occurrence: refIndex,
2402
+ });
2403
+ const after = await Bun.file(r.abs).text();
2404
+ if (after === before) {
2405
+ ctx.bus.emit('activity:unsuppress', rel);
2406
+ return { ok: true, newId: res.newId };
2407
+ }
2408
+ try {
2409
+ await history.writeSnapshot(rel, before, 'pre-insert-element');
2410
+ } catch {
2411
+ /* snapshot best-effort */
2412
+ }
2413
+ return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
2414
+ } catch (err) {
2415
+ ctx.bus.emit('activity:unsuppress', rel);
2416
+ return {
2417
+ ok: false,
2418
+ status: err instanceof CanvasEditError ? 422 : 500,
2419
+ error: err instanceof Error ? err.message : 'insert failed',
2420
+ };
2421
+ }
2422
+ }
2423
+
2424
+ /** Insert a new empty artboard (id/label/width/height) after the last one. */
2425
+ async function insertArtboardOp(input: {
2426
+ canvas?: unknown;
2427
+ id?: unknown;
2428
+ label?: unknown;
2429
+ width?: unknown;
2430
+ height?: unknown;
2431
+ }): Promise<
2432
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
2433
+ > {
2434
+ const r = resolveCanvasAbs(input.canvas);
2435
+ if (!r.ok) return r;
2436
+ if (!takeStructuralToken()) return RATE_LIMITED;
2437
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2438
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(id)) {
2439
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2440
+ }
2441
+ const label = typeof input.label === 'string' ? input.label.slice(0, 120) : id;
2442
+ const width = Number.isFinite(Number(input.width))
2443
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.width))))
2444
+ : 0;
2445
+ const height = Number.isFinite(Number(input.height))
2446
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.height))))
2447
+ : 0;
2448
+ if (!width || !height) return { ok: false, status: 400, error: 'width and height required' };
2449
+ const rel = path.relative(paths.designRoot, r.abs);
2450
+ ctx.bus.emit('activity:suppress', rel);
2451
+ try {
2452
+ const before = await Bun.file(r.abs).text();
2453
+ if (before.length > MAX_CANVAS_SOURCE) {
2454
+ ctx.bus.emit('activity:unsuppress', rel);
2455
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2456
+ }
2457
+ const res = await insertArtboard(r.abs, { id, label, width, height });
2458
+ const after = await Bun.file(r.abs).text();
2459
+ if (after === before) {
2460
+ ctx.bus.emit('activity:unsuppress', rel);
2461
+ return { ok: true, artboardId: res.artboardId };
2462
+ }
2463
+ try {
2464
+ await history.writeSnapshot(rel, before, 'pre-insert-artboard');
2465
+ } catch {
2466
+ /* snapshot best-effort */
2467
+ }
2468
+ return { ok: true, artboardId: res.artboardId, seq: logUndo(r.abs, before, after) };
2469
+ } catch (err) {
2470
+ ctx.bus.emit('activity:unsuppress', rel);
2471
+ return {
2472
+ ok: false,
2473
+ status: err instanceof CanvasEditError ? 422 : 500,
2474
+ error: err instanceof Error ? err.message : 'insert-artboard failed',
2475
+ };
2476
+ }
2477
+ }
2478
+
2479
+ /** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
2480
+ async function resizeArtboardOp(input: {
2481
+ canvas?: unknown;
2482
+ artboardId?: unknown;
2483
+ width?: unknown;
2484
+ height?: unknown;
2485
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2486
+ const r = resolveCanvasAbs(input.canvas);
2487
+ if (!r.ok) return r;
2488
+ if (!takeStructuralToken()) return RATE_LIMITED;
2489
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2490
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2491
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2492
+ }
2493
+ const dim = (v: unknown): number | undefined =>
2494
+ Number.isFinite(Number(v)) ? Math.max(64, Math.min(8192, Math.round(Number(v)))) : undefined;
2495
+ const width = dim(input.width);
2496
+ const height = dim(input.height);
2497
+ if (width == null && height == null) {
2498
+ return { ok: false, status: 400, error: 'width or height required' };
2499
+ }
2500
+ const rel = path.relative(paths.designRoot, r.abs);
2501
+ ctx.bus.emit('activity:suppress', rel);
2502
+ try {
2503
+ const before = await Bun.file(r.abs).text();
2504
+ await resizeArtboard(r.abs, artboardId, width, height);
2505
+ const after = await Bun.file(r.abs).text();
2506
+ if (after === before) {
2507
+ ctx.bus.emit('activity:unsuppress', rel);
2508
+ return { ok: true };
2509
+ }
2510
+ try {
2511
+ await history.writeSnapshot(rel, before, 'pre-resize-artboard');
2512
+ } catch {
2513
+ /* snapshot best-effort */
2514
+ }
2515
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2516
+ } catch (err) {
2517
+ ctx.bus.emit('activity:unsuppress', rel);
2518
+ return {
2519
+ ok: false,
2520
+ status: err instanceof CanvasEditError ? 422 : 500,
2521
+ error: err instanceof Error ? err.message : 'resize-artboard failed',
2522
+ };
2523
+ }
2524
+ }
2525
+
2526
+ /** Duplicate an element (Cmd+D) — insert a copy as the next sibling. */
2527
+ async function duplicateElementOp(input: {
2528
+ canvas?: unknown;
2529
+ id?: unknown;
2530
+ idIndex?: unknown;
2531
+ }): Promise<
2532
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
2533
+ > {
2534
+ const r = resolveCanvasAbs(input.canvas);
2535
+ if (!r.ok) return r;
2536
+ if (!takeStructuralToken()) return RATE_LIMITED;
2537
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2538
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2539
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
2540
+ const rel = path.relative(paths.designRoot, r.abs);
2541
+ ctx.bus.emit('activity:suppress', rel);
2542
+ try {
2543
+ const before = await Bun.file(r.abs).text();
2544
+ if (before.length > MAX_CANVAS_SOURCE) {
2545
+ ctx.bus.emit('activity:unsuppress', rel);
2546
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2547
+ }
2548
+ const res = await duplicateElement(r.abs, id, idIndex);
2549
+ const after = await Bun.file(r.abs).text();
2550
+ if (after === before) {
2551
+ ctx.bus.emit('activity:unsuppress', rel);
2552
+ return { ok: true, newId: res.newId };
2553
+ }
2554
+ try {
2555
+ await history.writeSnapshot(rel, before, 'pre-duplicate-element');
2556
+ } catch {
2557
+ /* snapshot best-effort */
2558
+ }
2559
+ return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
2560
+ } catch (err) {
2561
+ ctx.bus.emit('activity:unsuppress', rel);
2562
+ return {
2563
+ ok: false,
2564
+ status: err instanceof CanvasEditError ? 422 : 500,
2565
+ error: err instanceof Error ? err.message : 'duplicate failed',
2566
+ };
2567
+ }
2568
+ }
2569
+
2570
+ /** Delete an artboard by its `id` prop (Backspace / context-menu on a frame). */
2571
+ async function deleteArtboardOp(input: {
2572
+ canvas?: unknown;
2573
+ artboardId?: unknown;
2574
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2575
+ const r = resolveCanvasAbs(input.canvas);
2576
+ if (!r.ok) return r;
2577
+ if (!takeStructuralToken()) return RATE_LIMITED;
2578
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2579
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2580
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2581
+ }
2582
+ const rel = path.relative(paths.designRoot, r.abs);
2583
+ ctx.bus.emit('activity:suppress', rel);
2584
+ try {
2585
+ const before = await Bun.file(r.abs).text();
2586
+ await deleteArtboard(r.abs, artboardId);
2587
+ const after = await Bun.file(r.abs).text();
2588
+ if (after === before) {
2589
+ ctx.bus.emit('activity:unsuppress', rel);
2590
+ return { ok: true };
2591
+ }
2592
+ try {
2593
+ await history.writeSnapshot(rel, before, 'pre-delete-artboard');
2594
+ } catch {
2595
+ /* snapshot best-effort */
2596
+ }
2597
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2598
+ } catch (err) {
2599
+ ctx.bus.emit('activity:unsuppress', rel);
2600
+ return {
2601
+ ok: false,
2602
+ status: err instanceof CanvasEditError ? 422 : 500,
2603
+ error: err instanceof Error ? err.message : 'delete-artboard failed',
2604
+ };
2605
+ }
2606
+ }
2607
+
2608
+ /**
2609
+ * Edit-scope verdict for the INV-3 predictability badge (Stage H). READ-only —
2610
+ * parses the canvas + returns whether an edit to `id` is local or shared. No
2611
+ * write, no undo, no rate-cap (a mere parse the shell runs on selection). The
2612
+ * client supplies `rendered` = the DOM occurrence count of the cd-id so the
2613
+ * `.map()` case is honest.
2614
+ */
2615
+ async function editScopeOp(input: {
2616
+ canvas?: unknown;
2617
+ id?: unknown;
2618
+ rendered?: unknown;
2619
+ }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }> {
2620
+ const r = resolveCanvasAbs(input.canvas);
2621
+ if (!r.ok) return r;
2622
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2623
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2624
+ const rendered = Number.isFinite(Number(input.rendered))
2625
+ ? Math.max(1, Math.round(Number(input.rendered)))
2626
+ : 1;
2627
+ try {
2628
+ const source = await Bun.file(r.abs).text();
2629
+ const scope = resolveEditScope(r.abs, source, id, rendered);
2630
+ return { ok: true, ...scope };
2631
+ } catch (err) {
2632
+ return { ok: false, status: 500, error: err instanceof Error ? err.message : 'scope failed' };
2633
+ }
2634
+ }
2635
+
2143
2636
  async function toggleHideOp(input: {
2144
2637
  canvas?: unknown;
2145
2638
  stableId?: unknown;
@@ -2753,6 +3246,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2753
3246
  loadAnnotations,
2754
3247
  saveAnnotations,
2755
3248
  saveAsset,
3249
+ listAssets,
2756
3250
  saveAssetFromStream,
2757
3251
  saveChatAttachment,
2758
3252
  resolveChatAttachment,
@@ -2769,6 +3263,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2769
3263
  compClips,
2770
3264
  reorder,
2771
3265
  retimeSequenceOp,
3266
+ deleteElementOp,
3267
+ insertElementOp,
3268
+ insertArtboardOp,
3269
+ resizeArtboardOp,
3270
+ deleteArtboardOp,
3271
+ duplicateElementOp,
3272
+ editScopeOp,
2772
3273
  reorderRevert,
2773
3274
  buildIndexData,
2774
3275
  buildSystemData,
@@ -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;
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // _canvas-rects-playwright.mjs — playwright fallback for the geometry
3
+ // manifest (feature-whiteboard-ai-toolkit), used by canvas-rects.sh only when
4
+ // agent-browser isn't on PATH. Mirrors _enumerate-artboards-playwright.mjs's
5
+ // shape: a subprocess (not a direct import) so `bun build --compile` of the
6
+ // dev-server binary doesn't pull in playwright + chromium-bidi deep deps.
7
+ //
8
+ // Navigates the canvas-shell URL, waits for `window.__maudeCanvasRects` to
9
+ // exist (the canvas-lib.tsx module-load side effect), and prints the
10
+ // manifest JSON to stdout.
11
+ //
12
+ // Usage: _canvas-rects-playwright.mjs --url <url> [--timeout <sec>]
13
+
14
+ import { launchChromium } from './_pw-launch.mjs';
15
+
16
+ const args = process.argv.slice(2);
17
+ let url;
18
+ let timeoutSec = 8;
19
+ for (let i = 0; i < args.length; i += 1) {
20
+ if (args[i] === '--url') {
21
+ i += 1;
22
+ url = args[i];
23
+ } else if (args[i] === '--timeout') {
24
+ i += 1;
25
+ timeoutSec = Number(args[i]) || timeoutSec;
26
+ }
27
+ }
28
+ if (!url) {
29
+ console.error('usage: _canvas-rects-playwright.mjs --url <url> [--timeout <sec>]');
30
+ process.exit(2);
31
+ }
32
+
33
+ const timeoutMs = timeoutSec * 1000;
34
+ const browser = await launchChromium();
35
+ try {
36
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
37
+ const page = await ctx.newPage();
38
+ await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
39
+ try {
40
+ await page.waitForFunction("typeof window.__maudeCanvasRects === 'function'", {
41
+ timeout: timeoutMs,
42
+ });
43
+ } catch {
44
+ // Non-canvas page (no .dc-canvas) or the hook never installed — the hook
45
+ // itself degrades gracefully (empty manifest), so proceed rather than fail.
46
+ }
47
+ const manifest = await page.evaluate(() =>
48
+ typeof window.__maudeCanvasRects === 'function'
49
+ ? window.__maudeCanvasRects()
50
+ : { artboards: [], elements: [], elementsTruncated: false }
51
+ );
52
+ console.log(JSON.stringify(manifest));
53
+ } finally {
54
+ await browser.close();
55
+ }