@1agh/maude 0.45.2 → 0.47.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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -12,8 +12,14 @@ import {
12
12
  assembleCompSource,
13
13
  CanvasEditError,
14
14
  type ClipInfo,
15
+ type ConvertChildBox,
16
+ type ConvertContainerSpec,
17
+ componentMapForCanvas,
18
+ convertToAbsolute,
15
19
  deleteArtboard,
16
20
  deleteElement,
21
+ detachComponent,
22
+ duplicateArtboard,
17
23
  duplicateElement,
18
24
  type EditScope,
19
25
  editArrayElementString,
@@ -34,7 +40,10 @@ import {
34
40
  retimeSequence,
35
41
  retimeSequenceByClip,
36
42
  editText as runEditText,
43
+ setArtboardGuides,
37
44
  setArtboardHug,
45
+ setArtboardKind,
46
+ setArtboardPrint,
38
47
  setArtboardStyle,
39
48
  toggleClipHidden,
40
49
  } from './canvas-edit.ts';
@@ -47,6 +56,7 @@ import {
47
56
  } from './generation/audio-library.ts';
48
57
  import { createHistory } from './history.ts';
49
58
  import { STICKERS_DIR } from './paths.ts';
59
+ import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
50
60
 
51
61
  // Directories that never hold user-facing canvases. Exported so the
52
62
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -438,6 +448,21 @@ export interface Api {
438
448
  }): Promise<
439
449
  { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
440
450
  >;
451
+ /**
452
+ * feature-4 T8 (convert-to-absolute, DDR-188) — rewrite a container's stamped
453
+ * children to `position:absolute` with frozen boxes (+ container relative), in
454
+ * ONE whole-file write with ONE undo `seq`.
455
+ */
456
+ convertChildrenToAbsoluteOp(input: {
457
+ canvas?: unknown;
458
+ containerId?: unknown;
459
+ containerIdIndex?: unknown;
460
+ containerSetRelative?: unknown;
461
+ allowShared?: unknown;
462
+ children?: unknown;
463
+ containers?: unknown;
464
+ dissolve?: unknown;
465
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
441
466
  /**
442
467
  * Insert a synthesized div/text/image relative to a reference element, OR —
443
468
  * when the artboard has no element to anchor on yet — as a direct child of
@@ -465,6 +490,14 @@ export interface Api {
465
490
  }): Promise<
466
491
  { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
467
492
  >;
493
+ /** Duplicate an artboard at a new width (feature-3-web-artboards T3). */
494
+ duplicateArtboardOp(input: {
495
+ canvas?: unknown;
496
+ artboardId?: unknown;
497
+ width?: unknown;
498
+ }): Promise<
499
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
500
+ >;
468
501
  /** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
469
502
  resizeArtboardOp(input: {
470
503
  canvas?: unknown;
@@ -488,6 +521,24 @@ export interface Api {
488
521
  layout?: unknown;
489
522
  gap?: unknown;
490
523
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
524
+ /** Kind-switch surfaces (T8) — context menu + Inspector picker. */
525
+ setArtboardKindOp(input: {
526
+ canvas?: unknown;
527
+ artboardId?: unknown;
528
+ kind?: unknown;
529
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
530
+ /** Generic layout guides (T5) — replace-whole-prop write. */
531
+ setArtboardGuidesOp(input: {
532
+ canvas?: unknown;
533
+ artboardId?: unknown;
534
+ guides?: unknown;
535
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
536
+ /** feature-2-print-artboards T2 — paper/orientation/bleed/margins, replace-whole-prop write. */
537
+ setArtboardPrintOp(input: {
538
+ canvas?: unknown;
539
+ artboardId?: unknown;
540
+ print?: unknown;
541
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
491
542
  /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
492
543
  duplicateElementOp(input: {
493
544
  canvas?: unknown;
@@ -507,6 +558,21 @@ export interface Api {
507
558
  id?: unknown;
508
559
  rendered?: unknown;
509
560
  }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>;
561
+ /** feature-4 T7a — Layers-panel component map (purple instance rows). */
562
+ componentMapOp(input: {
563
+ canvas?: unknown;
564
+ }): Promise<
565
+ | { ok: true; map: Record<string, { component: string; root: boolean; usages: number }> }
566
+ | { ok: false; status: number; error: string }
567
+ >;
568
+ /** feature-4 detach-component — clone the definition + repoint ONE usage. */
569
+ detachComponentOp(input: {
570
+ canvas?: unknown;
571
+ id?: unknown;
572
+ idIndex?: unknown;
573
+ }): Promise<
574
+ { ok: true; detachedName: string; seq?: number } | { ok: false; status: number; error: string }
575
+ >;
510
576
  // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
511
577
  // file content swap from the in-memory revert log — immune to the positional
512
578
  // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
@@ -1214,16 +1280,87 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1214
1280
  return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
1215
1281
  }
1216
1282
 
1217
- async function loadCanvasView(
1218
- file: string
1219
- ): Promise<{ viewport?: { x: number; y: number; zoom: number } } | null> {
1283
+ const MAX_OVERLAY_KEYS = 32;
1284
+ const MAX_OVERLAY_KEY_LEN = 64;
1285
+
1286
+ /**
1287
+ * Validate a candidate overlay-visibility bag — feature-1-artboard-kinds-
1288
+ * foundation T6. A flat string→boolean map (`{ guides: true }`); downstream
1289
+ * plans (print bleed, web breakpoints) add their own keys without a schema
1290
+ * change here. Reachable from the untrusted canvas origin (DDR-054) via the
1291
+ * same `/_api/canvas-meta` PATCH the viewport lane already uses, so it's
1292
+ * capped the same way `set-artboard-style` caps its patch shape — bounded
1293
+ * key COUNT and key LENGTH, not just type. Empty object is valid (an
1294
+ * explicit "nothing on" state); malformed input is null (silent no-op).
1295
+ */
1296
+ function normalizeOverlays(v: unknown): Record<string, boolean> | null {
1297
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
1298
+ const entries = Object.entries(v as Record<string, unknown>);
1299
+ if (entries.length > MAX_OVERLAY_KEYS) return null;
1300
+ const out: Record<string, boolean> = {};
1301
+ for (const [k, val] of entries) {
1302
+ if (k.length === 0 || k.length > MAX_OVERLAY_KEY_LEN) return null;
1303
+ if (typeof val !== 'boolean') return null;
1304
+ out[k] = val;
1305
+ }
1306
+ return out;
1307
+ }
1308
+
1309
+ // feature-4 T7b — per-user LOCKED layer keys (`"<cdId>:<occurrenceIndex>"`).
1310
+ // Runtime state per DDR-115 (like the camera + overlays): view.json, never the
1311
+ // versioned `.meta.json`. Reachable from the untrusted canvas origin via the
1312
+ // same PATCH lane, so shape + count are bounded.
1313
+ const MAX_LOCKED_KEYS = 500;
1314
+ const LOCKED_KEY_RE = /^[\w-]{1,64}:\d{1,4}$/;
1315
+
1316
+ function normalizeLocked(v: unknown): string[] | null {
1317
+ if (!Array.isArray(v) || v.length > MAX_LOCKED_KEYS) return null;
1318
+ const out: string[] = [];
1319
+ for (const k of v) {
1320
+ if (typeof k !== 'string' || !LOCKED_KEY_RE.test(k)) return null;
1321
+ out.push(k);
1322
+ }
1323
+ return [...new Set(out)];
1324
+ }
1325
+
1326
+ /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
1327
+ * read half of the read-modify-write both `saveCanvasView` and
1328
+ * `saveCanvasOverlays` need so writing one lane never clobbers the other. */
1329
+ async function readCanvasViewRaw(file: string): Promise<Record<string, unknown>> {
1330
+ const viewAbs = canvasViewPath(file);
1331
+ if (!viewAbs) return {};
1332
+ try {
1333
+ const obj = JSON.parse(await Bun.file(viewAbs).text());
1334
+ return obj && typeof obj === 'object' && !Array.isArray(obj)
1335
+ ? (obj as Record<string, unknown>)
1336
+ : {};
1337
+ } catch {
1338
+ return {};
1339
+ }
1340
+ }
1341
+
1342
+ async function loadCanvasView(file: string): Promise<{
1343
+ viewport?: { x: number; y: number; zoom: number };
1344
+ overlays?: Record<string, boolean>;
1345
+ locked?: string[];
1346
+ } | null> {
1220
1347
  const viewAbs = canvasViewPath(file);
1221
1348
  if (!viewAbs) return null;
1222
1349
  try {
1223
1350
  const obj = JSON.parse(await Bun.file(viewAbs).text());
1224
1351
  if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
1225
1352
  const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
1226
- return vp ? { viewport: vp } : {};
1353
+ const ov = normalizeOverlays((obj as { overlays?: unknown }).overlays);
1354
+ const lk = normalizeLocked((obj as { locked?: unknown }).locked);
1355
+ const result: {
1356
+ viewport?: { x: number; y: number; zoom: number };
1357
+ overlays?: Record<string, boolean>;
1358
+ locked?: string[];
1359
+ } = {};
1360
+ if (vp) result.viewport = vp;
1361
+ if (ov && Object.keys(ov).length > 0) result.overlays = ov;
1362
+ if (lk && lk.length > 0) result.locked = lk;
1363
+ return result;
1227
1364
  }
1228
1365
  return null;
1229
1366
  } catch {
@@ -1233,7 +1370,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1233
1370
 
1234
1371
  /** Persist the per-user camera. Validates + clamps; best-effort (mkdir the
1235
1372
  * bucket if absent). Returns the normalized viewport on write, null when the
1236
- * path is rejected or the viewport is invalid (no write). */
1373
+ * path is rejected or the viewport is invalid (no write). Read-modify-write
1374
+ * so an existing `overlays` key in the same view file survives. */
1237
1375
  async function saveCanvasView(
1238
1376
  file: string,
1239
1377
  viewport: unknown
@@ -1244,13 +1382,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1244
1382
  if (!vp) return null;
1245
1383
  try {
1246
1384
  await mkdir(paths.canvasStateDir, { recursive: true });
1247
- await Bun.write(viewAbs, `${JSON.stringify({ viewport: vp }, null, 2)}\n`);
1385
+ const current = await readCanvasViewRaw(file);
1386
+ await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`);
1248
1387
  return vp;
1249
1388
  } catch {
1250
1389
  return null;
1251
1390
  }
1252
1391
  }
1253
1392
 
1393
+ /**
1394
+ * Persist the per-user overlay-visibility bag (T6). Shallow-merges into
1395
+ * whatever `overlays` the view file already has — a `{ guides: true }` patch
1396
+ * doesn't erase a `bleed` key a downstream print-plan toggle already set.
1397
+ * Never touches the versioned `.meta.json`. Read-modify-write so an existing
1398
+ * `viewport` key survives.
1399
+ */
1400
+ async function saveCanvasOverlays(
1401
+ file: string,
1402
+ overlays: unknown
1403
+ ): Promise<Record<string, boolean> | null> {
1404
+ const viewAbs = canvasViewPath(file);
1405
+ if (!viewAbs) return null;
1406
+ const ov = normalizeOverlays(overlays);
1407
+ if (ov === null) return null;
1408
+ try {
1409
+ await mkdir(paths.canvasStateDir, { recursive: true });
1410
+ const current = await readCanvasViewRaw(file);
1411
+ const prevOverlays =
1412
+ current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays)
1413
+ ? (current.overlays as Record<string, unknown>)
1414
+ : {};
1415
+ const merged = normalizeOverlays({ ...prevOverlays, ...ov }) ?? ov;
1416
+ await Bun.write(viewAbs, `${JSON.stringify({ ...current, overlays: merged }, null, 2)}\n`);
1417
+ return merged;
1418
+ } catch {
1419
+ return null;
1420
+ }
1421
+ }
1422
+
1254
1423
  async function loadCanvasMeta(file: string): Promise<Record<string, unknown> | null> {
1255
1424
  const metaAbs = canvasMetaPath(file);
1256
1425
  if (!metaAbs) return null;
@@ -1274,11 +1443,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1274
1443
  // codebase convention + biome's noDelete.)
1275
1444
  obj.viewport = undefined;
1276
1445
  obj.last_modified = undefined;
1446
+ // T6 — `overlays` (per-user guide/mark visibility) is runtime state same as
1447
+ // `viewport`; a versioned `.meta.json` should never carry one (sanitizer),
1448
+ // but strip defensively before overlaying the real per-user value below.
1449
+ obj.overlays = undefined;
1450
+ // feature-4 T7b — `locked` (per-user locked layer keys) is the same class.
1451
+ obj.locked = undefined;
1277
1452
  const view = await loadCanvasView(file);
1278
1453
  if (view?.viewport) obj.viewport = view.viewport;
1279
- // Preserve the historic contract: no meta AND no camera → null (GET → {},
1280
- // PATCH-on-rejected-path → 404). A view-only canvas still returns its camera.
1281
- if (!hadMeta && !view?.viewport) return null;
1454
+ if (view?.overlays) obj.overlays = view.overlays;
1455
+ if (view?.locked) obj.locked = view.locked;
1456
+ // Preserve the historic contract: no meta AND no camera/overlays → null
1457
+ // (GET → {}, PATCH-on-rejected-path → 404). A view-only canvas still
1458
+ // returns its camera/overlays.
1459
+ if (!hadMeta && !view?.viewport && !view?.overlays && !view?.locked) return null;
1282
1460
  return obj;
1283
1461
  }
1284
1462
 
@@ -1331,6 +1509,53 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1331
1509
  }
1332
1510
  }
1333
1511
 
1512
+ // --- Per-user overlay-visibility lane (T6): overlays → view file, never
1513
+ // the versioned meta. Same shape as the viewport lane above — `null`
1514
+ // clears, an invalid bag is a silent no-op (normalizeOverlays → null).
1515
+ if (patch.overlays !== undefined) {
1516
+ if (patch.overlays === null) {
1517
+ const viewAbs = canvasViewPath(file);
1518
+ if (viewAbs) {
1519
+ try {
1520
+ const current = await readCanvasViewRaw(file);
1521
+ const { overlays: _drop, ...rest } = current;
1522
+ await mkdir(paths.canvasStateDir, { recursive: true });
1523
+ await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1524
+ } catch {
1525
+ /* best-effort clear */
1526
+ }
1527
+ }
1528
+ } else {
1529
+ await saveCanvasOverlays(file, patch.overlays);
1530
+ }
1531
+ }
1532
+
1533
+ // --- Per-user locked-layers lane (feature-4 T7b): locked → view file,
1534
+ // never the versioned meta. REPLACE semantics (the client sends the full
1535
+ // set — merge semantics would make unlocking impossible); `null` clears; an
1536
+ // invalid array is a silent no-op (normalizeLocked → null).
1537
+ if (patch.locked !== undefined) {
1538
+ const viewAbs = canvasViewPath(file);
1539
+ if (viewAbs) {
1540
+ try {
1541
+ const current = await readCanvasViewRaw(file);
1542
+ if (patch.locked === null) {
1543
+ const { locked: _drop, ...rest } = current;
1544
+ await mkdir(paths.canvasStateDir, { recursive: true });
1545
+ await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1546
+ } else {
1547
+ const lk = normalizeLocked(patch.locked);
1548
+ if (lk !== null) {
1549
+ await mkdir(paths.canvasStateDir, { recursive: true });
1550
+ await Bun.write(viewAbs, `${JSON.stringify({ ...current, locked: lk }, null, 2)}\n`);
1551
+ }
1552
+ }
1553
+ } catch {
1554
+ /* best-effort */
1555
+ }
1556
+ }
1557
+ }
1558
+
1334
1559
  // --- Shared document lane: layout → versioned meta, stamps last_modified. ---
1335
1560
  if (patch.layout !== undefined) {
1336
1561
  let current: Record<string, unknown> = {};
@@ -2263,7 +2488,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2263
2488
  }
2264
2489
 
2265
2490
  // 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
2266
- const CD_ID_RE = /^[0-9a-f]{8}$/;
2491
+ // Pipeline-injected ids are 8-hex, but hand-AUTHORED `data-cd-id` attrs are
2492
+ // preserved verbatim by the transpile (canvas-pipeline.ts) and are equally
2493
+ // valid targets (dogfood 2026-07-20: `data-cd-id="wal-hero-nav"`). Accept
2494
+ // both — word chars + dashes, bounded; the AST walkers do exact matching, so
2495
+ // this is only a shape gate.
2496
+ const CD_ID_RE = /^[\w-]{1,64}$/;
2267
2497
 
2268
2498
  // RC1 (rca/issue-canvas-hmr-optimistic-update-consistency) — every inline
2269
2499
  // edit is USER-originated (inspector CSS knobs, inline text, attr panel, and
@@ -2803,6 +3033,229 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2803
3033
  }
2804
3034
  }
2805
3035
 
3036
+ /**
3037
+ * feature-4 T8 (convert-to-absolute, DDR-188) — the "Remove auto layout"
3038
+ * analogue. Rewrite every stamped child of a container to `position:absolute`
3039
+ * with the frozen box the canvas measured, and set the container
3040
+ * `position:relative` when static — ONE whole-file write, ONE undo `seq`. The
3041
+ * canvas iframe pre-filters to plain, globally-unique children (no unstamped /
3042
+ * repeated / component instances); the AST writer's `resolveUsageId` guard is
3043
+ * the server-side backstop for a shared-component usage.
3044
+ */
3045
+ async function convertChildrenToAbsoluteOp(input: {
3046
+ canvas?: unknown;
3047
+ containerId?: unknown;
3048
+ containerIdIndex?: unknown;
3049
+ containerSetRelative?: unknown;
3050
+ allowShared?: unknown;
3051
+ children?: unknown;
3052
+ containers?: unknown;
3053
+ dissolve?: unknown;
3054
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3055
+ const r = resolveCanvasAbs(input.canvas);
3056
+ if (!r.ok) return r;
3057
+ if (!takeStructuralToken()) return RATE_LIMITED;
3058
+ const num = (v: unknown): number | null =>
3059
+ typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= 1_000_000
3060
+ ? Math.round(v)
3061
+ : null;
3062
+ // Simplifier fix (review fan-out, 2026-07-21) — reuse canvas-edit.ts's own
3063
+ // exported spec types instead of a locally re-declared shape, which had
3064
+ // drifted (missing `freezeSize?`, populated a few lines below via a spread
3065
+ // that silently bypassed excess-property checking).
3066
+ const parseChildren = (raw: unknown): ConvertChildBox[] | string => {
3067
+ if (!Array.isArray(raw) || raw.length === 0) return 'children required';
3068
+ const out: ConvertChildBox[] = [];
3069
+ for (const c of raw as Array<Record<string, unknown>>) {
3070
+ const id = typeof c?.id === 'string' ? c.id.trim() : '';
3071
+ if (!CD_ID_RE.test(id)) return 'invalid child data-cd-id';
3072
+ const left = num(c.left);
3073
+ const top = num(c.top);
3074
+ const width = num(c.width);
3075
+ const height = num(c.height);
3076
+ if (left === null || top === null || width === null || height === null) {
3077
+ return 'invalid child box (left/top/width/height)';
3078
+ }
3079
+ const idIndex = Number.isInteger(c.idIndex) ? (c.idIndex as number) : undefined;
3080
+ out.push({ id, idIndex, left, top, width, height });
3081
+ }
3082
+ return out;
3083
+ };
3084
+
3085
+ // feature-4 artboard-level convert (2026-07-19) — MULTI-container batch.
3086
+ // Each container: optional containerId (absent = the artboard-body root
3087
+ // level), a setRelative flag, and its children boxes. Total child count is
3088
+ // capped across the batch.
3089
+ let containersSpec: ConvertContainerSpec[] | undefined;
3090
+ if (input.containers !== undefined) {
3091
+ if (!Array.isArray(input.containers) || input.containers.length === 0) {
3092
+ return { ok: false, status: 400, error: 'invalid containers' };
3093
+ }
3094
+ if (input.containers.length > 200) {
3095
+ return { ok: false, status: 400, error: 'too many containers' };
3096
+ }
3097
+ containersSpec = [];
3098
+ let total = 0;
3099
+ for (const c of input.containers as Array<Record<string, unknown>>) {
3100
+ let containerId: string | undefined;
3101
+ if (c?.containerId !== undefined && c.containerId !== null) {
3102
+ const cid = typeof c.containerId === 'string' ? c.containerId.trim() : '';
3103
+ if (!CD_ID_RE.test(cid)) {
3104
+ return {
3105
+ ok: false,
3106
+ status: 400,
3107
+ error: `invalid container data-cd-id (${String(c.containerId).slice(0, 64)})`,
3108
+ };
3109
+ }
3110
+ containerId = cid;
3111
+ }
3112
+ const kids = parseChildren(c?.children);
3113
+ if (typeof kids === 'string') return { ok: false, status: 400, error: kids };
3114
+ total += kids.length;
3115
+ if (total > 500) return { ok: false, status: 400, error: 'too many children' };
3116
+ let freezeSize: { width: number; height: number } | undefined;
3117
+ if (c?.freezeSize && typeof c.freezeSize === 'object') {
3118
+ const fw = num((c.freezeSize as Record<string, unknown>).width);
3119
+ const fh = num((c.freezeSize as Record<string, unknown>).height);
3120
+ if (fw !== null && fh !== null) freezeSize = { width: fw, height: fh };
3121
+ }
3122
+ containersSpec.push({
3123
+ containerId,
3124
+ containerIdIndex: Number.isInteger(c?.containerIdIndex)
3125
+ ? (c.containerIdIndex as number)
3126
+ : undefined,
3127
+ containerSetRelative: c?.containerSetRelative === true,
3128
+ ...(freezeSize ? { freezeSize } : {}),
3129
+ children: kids,
3130
+ });
3131
+ }
3132
+ }
3133
+
3134
+ // Legacy single-container shape (the element context-menu action).
3135
+ let containerId = '';
3136
+ let containerIdIndex: number | undefined;
3137
+ let containerSetRelative = false;
3138
+ let children: ConvertChildBox[] = [];
3139
+ if (!containersSpec) {
3140
+ containerId = typeof input.containerId === 'string' ? input.containerId.trim() : '';
3141
+ if (!CD_ID_RE.test(containerId)) {
3142
+ return { ok: false, status: 400, error: 'invalid container data-cd-id' };
3143
+ }
3144
+ containerIdIndex = Number.isInteger(input.containerIdIndex)
3145
+ ? (input.containerIdIndex as number)
3146
+ : undefined;
3147
+ containerSetRelative = input.containerSetRelative === true;
3148
+ if (Array.isArray(input.children) && input.children.length > 500) {
3149
+ return { ok: false, status: 400, error: 'too many children' };
3150
+ }
3151
+ const kids = parseChildren(input.children);
3152
+ if (typeof kids === 'string') return { ok: false, status: 400, error: kids };
3153
+ children = kids;
3154
+ }
3155
+ const rel = path.relative(paths.designRoot, r.abs);
3156
+ ctx.bus.emit('activity:suppress', rel);
3157
+ try {
3158
+ const before = await Bun.file(r.abs).text();
3159
+ if (before.length > MAX_CANVAS_SOURCE) {
3160
+ ctx.bus.emit('activity:unsuppress', rel);
3161
+ return { ok: false, status: 413, error: 'canvas source too large' };
3162
+ }
3163
+ // feature-4 TRUE FLATTEN — validated dissolve list (batch shape only).
3164
+ let dissolve: string[] | undefined;
3165
+ if (input.dissolve !== undefined) {
3166
+ if (!Array.isArray(input.dissolve) || input.dissolve.length > 200) {
3167
+ return { ok: false, status: 400, error: 'invalid dissolve list' };
3168
+ }
3169
+ dissolve = [];
3170
+ for (const d of input.dissolve) {
3171
+ const id = typeof d === 'string' ? d.trim() : '';
3172
+ if (!CD_ID_RE.test(id)) {
3173
+ return { ok: false, status: 400, error: 'invalid dissolve data-cd-id' };
3174
+ }
3175
+ dissolve.push(id);
3176
+ }
3177
+ }
3178
+ const res = await convertToAbsolute(
3179
+ r.abs,
3180
+ containersSpec
3181
+ ? { allowShared: input.allowShared === true, containers: containersSpec, dissolve }
3182
+ : {
3183
+ containerId,
3184
+ containerIdIndex,
3185
+ containerSetRelative,
3186
+ allowShared: input.allowShared === true,
3187
+ children,
3188
+ }
3189
+ );
3190
+ const after = res.source;
3191
+ if (!res.changed || after === before) {
3192
+ ctx.bus.emit('activity:unsuppress', rel);
3193
+ return { ok: true };
3194
+ }
3195
+ try {
3196
+ await history.writeSnapshot(rel, before, 'pre-convert-absolute');
3197
+ } catch {
3198
+ /* snapshot best-effort */
3199
+ }
3200
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3201
+ } catch (err) {
3202
+ ctx.bus.emit('activity:unsuppress', rel);
3203
+ return {
3204
+ ok: false,
3205
+ status: err instanceof CanvasEditError ? 422 : 500,
3206
+ error: err instanceof Error ? err.message : 'convert-to-absolute failed',
3207
+ };
3208
+ }
3209
+ }
3210
+
3211
+ /**
3212
+ * feature-4 detach-component (2026-07-19) — clone the component definition
3213
+ * under a fresh name + repoint THIS usage at the clone, so subsequent edits
3214
+ * stay local to this instance. Whole-file undo seq (Stage-I lane).
3215
+ */
3216
+ async function detachComponentOp(input: {
3217
+ canvas?: unknown;
3218
+ id?: unknown;
3219
+ idIndex?: unknown;
3220
+ }): Promise<
3221
+ { ok: true; detachedName: string; seq?: number } | { ok: false; status: number; error: string }
3222
+ > {
3223
+ const r = resolveCanvasAbs(input.canvas);
3224
+ if (!r.ok) return r;
3225
+ if (!takeStructuralToken()) return RATE_LIMITED;
3226
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
3227
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3228
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
3229
+ const rel = path.relative(paths.designRoot, r.abs);
3230
+ ctx.bus.emit('activity:suppress', rel);
3231
+ try {
3232
+ const before = await Bun.file(r.abs).text();
3233
+ if (before.length > MAX_CANVAS_SOURCE) {
3234
+ ctx.bus.emit('activity:unsuppress', rel);
3235
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
3236
+ }
3237
+ const res = await detachComponent(r.abs, id, idIndex);
3238
+ const after = res.source;
3239
+ if (!res.changed || after === before) {
3240
+ ctx.bus.emit('activity:unsuppress', rel);
3241
+ return { ok: true, detachedName: res.detachedName };
3242
+ }
3243
+ try {
3244
+ await history.writeSnapshot(rel, before, 'pre-detach-component');
3245
+ } catch {
3246
+ /* snapshot best-effort */
3247
+ }
3248
+ return { ok: true, detachedName: res.detachedName, seq: logUndo(r.abs, before, after) };
3249
+ } catch (err) {
3250
+ ctx.bus.emit('activity:unsuppress', rel);
3251
+ return {
3252
+ ok: false,
3253
+ status: err instanceof CanvasEditError ? 422 : 500,
3254
+ error: err instanceof Error ? err.message : 'detach failed',
3255
+ };
3256
+ }
3257
+ }
3258
+
2806
3259
  /** Insert a new empty artboard (id/label/width/height) after the last one. */
2807
3260
  async function insertArtboardOp(input: {
2808
3261
  canvas?: unknown;
@@ -2858,6 +3311,59 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2858
3311
  }
2859
3312
  }
2860
3313
 
3314
+ /**
3315
+ * Duplicate an artboard at a new width (feature-3-web-artboards T3,
3316
+ * "Duplicate at width…") — clones id/label/width, everything else
3317
+ * (kind/guides/print/style/children) carries over verbatim.
3318
+ */
3319
+ async function duplicateArtboardOp(input: {
3320
+ canvas?: unknown;
3321
+ artboardId?: unknown;
3322
+ width?: unknown;
3323
+ }): Promise<
3324
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
3325
+ > {
3326
+ const r = resolveCanvasAbs(input.canvas);
3327
+ if (!r.ok) return r;
3328
+ if (!takeStructuralToken()) return RATE_LIMITED;
3329
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3330
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3331
+ return { ok: false, status: 400, error: 'invalid artboardId' };
3332
+ }
3333
+ const width = Number.isFinite(Number(input.width))
3334
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.width))))
3335
+ : 0;
3336
+ if (!width) return { ok: false, status: 400, error: 'width required' };
3337
+ const rel = path.relative(paths.designRoot, r.abs);
3338
+ ctx.bus.emit('activity:suppress', rel);
3339
+ try {
3340
+ const before = await Bun.file(r.abs).text();
3341
+ if (before.length > MAX_CANVAS_SOURCE) {
3342
+ ctx.bus.emit('activity:unsuppress', rel);
3343
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
3344
+ }
3345
+ const res = await duplicateArtboard(r.abs, artboardId, width);
3346
+ const after = await Bun.file(r.abs).text();
3347
+ if (after === before) {
3348
+ ctx.bus.emit('activity:unsuppress', rel);
3349
+ return { ok: true, artboardId: res.artboardId };
3350
+ }
3351
+ try {
3352
+ await history.writeSnapshot(rel, before, 'pre-duplicate-artboard');
3353
+ } catch {
3354
+ /* snapshot best-effort */
3355
+ }
3356
+ return { ok: true, artboardId: res.artboardId, seq: logUndo(r.abs, before, after) };
3357
+ } catch (err) {
3358
+ ctx.bus.emit('activity:unsuppress', rel);
3359
+ return {
3360
+ ok: false,
3361
+ status: err instanceof CanvasEditError ? 422 : 500,
3362
+ error: err instanceof Error ? err.message : 'duplicate-artboard failed',
3363
+ };
3364
+ }
3365
+ }
3366
+
2861
3367
  /** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
2862
3368
  async function resizeArtboardOp(input: {
2863
3369
  canvas?: unknown;
@@ -2952,6 +3458,193 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2952
3458
  }
2953
3459
  }
2954
3460
 
3461
+ const ARTBOARD_KIND_VALUES = new Set(['digital', 'print', 'web', 'video']);
3462
+
3463
+ /** Kind-switch surfaces (T8): context menu + Inspector picker. */
3464
+ async function setArtboardKindOp(input: {
3465
+ canvas?: unknown;
3466
+ artboardId?: unknown;
3467
+ kind?: unknown;
3468
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3469
+ const r = resolveCanvasAbs(input.canvas);
3470
+ if (!r.ok) return r;
3471
+ if (!takeStructuralToken()) return RATE_LIMITED;
3472
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3473
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3474
+ return { ok: false, status: 400, error: 'invalid artboard id' };
3475
+ }
3476
+ let kind: string | null;
3477
+ if (input.kind === null) {
3478
+ kind = null;
3479
+ } else if (typeof input.kind === 'string' && ARTBOARD_KIND_VALUES.has(input.kind)) {
3480
+ kind = input.kind;
3481
+ } else {
3482
+ return { ok: false, status: 400, error: 'invalid kind' };
3483
+ }
3484
+ const rel = path.relative(paths.designRoot, r.abs);
3485
+ ctx.bus.emit('activity:suppress', rel);
3486
+ try {
3487
+ const before = await Bun.file(r.abs).text();
3488
+ await setArtboardKind(r.abs, artboardId, kind);
3489
+ const after = await Bun.file(r.abs).text();
3490
+ if (after === before) {
3491
+ ctx.bus.emit('activity:unsuppress', rel);
3492
+ return { ok: true };
3493
+ }
3494
+ try {
3495
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-kind');
3496
+ } catch {
3497
+ /* snapshot best-effort */
3498
+ }
3499
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3500
+ } catch (err) {
3501
+ ctx.bus.emit('activity:unsuppress', rel);
3502
+ return {
3503
+ ok: false,
3504
+ status: err instanceof CanvasEditError ? 422 : 500,
3505
+ error: err instanceof Error ? err.message : 'set-artboard-kind failed',
3506
+ };
3507
+ }
3508
+ }
3509
+
3510
+ const MAX_GUIDES_JSON_BYTES = 4096;
3511
+
3512
+ /** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
3513
+ * never a deep merge (see applySetArtboardGuides's own doc comment). */
3514
+ async function setArtboardGuidesOp(input: {
3515
+ canvas?: unknown;
3516
+ artboardId?: unknown;
3517
+ guides?: unknown;
3518
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3519
+ const r = resolveCanvasAbs(input.canvas);
3520
+ if (!r.ok) return r;
3521
+ if (!takeStructuralToken()) return RATE_LIMITED;
3522
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3523
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3524
+ return { ok: false, status: 400, error: 'invalid artboard id' };
3525
+ }
3526
+ let guides: Record<string, unknown> | null;
3527
+ if (input.guides === null) {
3528
+ guides = null;
3529
+ } else if (
3530
+ input.guides &&
3531
+ typeof input.guides === 'object' &&
3532
+ !Array.isArray(input.guides) &&
3533
+ JSON.stringify(input.guides).length <= MAX_GUIDES_JSON_BYTES
3534
+ ) {
3535
+ guides = input.guides as Record<string, unknown>;
3536
+ } else {
3537
+ return { ok: false, status: 400, error: 'invalid guides' };
3538
+ }
3539
+ const rel = path.relative(paths.designRoot, r.abs);
3540
+ ctx.bus.emit('activity:suppress', rel);
3541
+ try {
3542
+ const before = await Bun.file(r.abs).text();
3543
+ await setArtboardGuides(r.abs, artboardId, guides);
3544
+ const after = await Bun.file(r.abs).text();
3545
+ if (after === before) {
3546
+ ctx.bus.emit('activity:unsuppress', rel);
3547
+ return { ok: true };
3548
+ }
3549
+ try {
3550
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-guides');
3551
+ } catch {
3552
+ /* snapshot best-effort */
3553
+ }
3554
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3555
+ } catch (err) {
3556
+ ctx.bus.emit('activity:unsuppress', rel);
3557
+ return {
3558
+ ok: false,
3559
+ status: err instanceof CanvasEditError ? 422 : 500,
3560
+ error: err instanceof Error ? err.message : 'set-artboard-guides failed',
3561
+ };
3562
+ }
3563
+ }
3564
+
3565
+ const PRINT_ORIENTATION_VALUES = new Set(['portrait', 'landscape']);
3566
+
3567
+ function isFiniteMmOrUndefined(v: unknown): v is number | undefined {
3568
+ return (
3569
+ v === undefined ||
3570
+ (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= MAX_PRINT_MM)
3571
+ );
3572
+ }
3573
+
3574
+ /**
3575
+ * feature-2-print-artboards T2 — paper/orientation/bleed/margins. Replace-
3576
+ * whole-prop (see applySetArtboardPrint's own doc comment) — the caller
3577
+ * sends the full merged object, not a delta. `print: null` clears the
3578
+ * prop. Shape validation lives HERE (canvas-edit.ts stays generic
3579
+ * Record<string, unknown> AST surgery, same split as setArtboardGuidesOp).
3580
+ */
3581
+ async function setArtboardPrintOp(input: {
3582
+ canvas?: unknown;
3583
+ artboardId?: unknown;
3584
+ print?: unknown;
3585
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
3586
+ const r = resolveCanvasAbs(input.canvas);
3587
+ if (!r.ok) return r;
3588
+ if (!takeStructuralToken()) return RATE_LIMITED;
3589
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
3590
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
3591
+ return { ok: false, status: 400, error: 'invalid artboard id' };
3592
+ }
3593
+ let print: Record<string, unknown> | null;
3594
+ if (input.print === null) {
3595
+ print = null;
3596
+ } else if (input.print && typeof input.print === 'object' && !Array.isArray(input.print)) {
3597
+ const p = input.print as Record<string, unknown>;
3598
+ if (typeof p.paper !== 'string' || !getPaperPreset(p.paper)) {
3599
+ return { ok: false, status: 400, error: 'invalid or unknown paper preset' };
3600
+ }
3601
+ if (p.orientation !== undefined && !PRINT_ORIENTATION_VALUES.has(p.orientation as string)) {
3602
+ return { ok: false, status: 400, error: 'invalid orientation' };
3603
+ }
3604
+ if (!isFiniteMmOrUndefined(p.bleedMm)) {
3605
+ return { ok: false, status: 400, error: 'invalid bleedMm' };
3606
+ }
3607
+ if (p.marginsMm !== undefined) {
3608
+ if (typeof p.marginsMm !== 'object' || p.marginsMm === null || Array.isArray(p.marginsMm)) {
3609
+ return { ok: false, status: 400, error: 'invalid marginsMm' };
3610
+ }
3611
+ const m = p.marginsMm as Record<string, unknown>;
3612
+ for (const side of ['top', 'right', 'bottom', 'left']) {
3613
+ if (!isFiniteMmOrUndefined(m[side])) {
3614
+ return { ok: false, status: 400, error: `invalid marginsMm.${side}` };
3615
+ }
3616
+ }
3617
+ }
3618
+ print = p;
3619
+ } else {
3620
+ return { ok: false, status: 400, error: 'invalid print' };
3621
+ }
3622
+ const rel = path.relative(paths.designRoot, r.abs);
3623
+ ctx.bus.emit('activity:suppress', rel);
3624
+ try {
3625
+ const before = await Bun.file(r.abs).text();
3626
+ await setArtboardPrint(r.abs, artboardId, print);
3627
+ const after = await Bun.file(r.abs).text();
3628
+ if (after === before) {
3629
+ ctx.bus.emit('activity:unsuppress', rel);
3630
+ return { ok: true };
3631
+ }
3632
+ try {
3633
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-print');
3634
+ } catch {
3635
+ /* snapshot best-effort */
3636
+ }
3637
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3638
+ } catch (err) {
3639
+ ctx.bus.emit('activity:unsuppress', rel);
3640
+ return {
3641
+ ok: false,
3642
+ status: err instanceof CanvasEditError ? 422 : 500,
3643
+ error: err instanceof Error ? err.message : 'set-artboard-print failed',
3644
+ };
3645
+ }
3646
+ }
3647
+
2955
3648
  const ARTBOARD_LAYOUT_VALUES = new Set(['block', 'flex-col', 'flex-row', 'grid']);
2956
3649
 
2957
3650
  /** Set artboard "more settings" — background / padding / layout / gap. */
@@ -2972,9 +3665,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2972
3665
  }
2973
3666
  const patch: {
2974
3667
  background?: string | null;
2975
- padding?: number | null;
3668
+ padding?: number | string | null;
2976
3669
  layout?: string | null;
2977
- gap?: number | null;
3670
+ gap?: number | string | null;
2978
3671
  } = {};
2979
3672
  if ('background' in input) {
2980
3673
  if (input.background === null) patch.background = null;
@@ -2992,19 +3685,28 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2992
3685
  return { ok: false, status: 400, error: 'invalid layout' };
2993
3686
  }
2994
3687
  }
2995
- const clampBoxDim = (v: unknown): number | null | undefined => {
3688
+ // Dogfood (artboard panel ↔ shared inspector controls) — padding/gap
3689
+ // accept a design-token binding alongside raw px. STRICT single-var shape
3690
+ // (not a free-form CSS string like `background`): the value lands in a
3691
+ // JSX prop AND in `.dc-artboard-body`'s inline style, so anything beyond
3692
+ // one `var(--…)` reference is rejected rather than round-tripped.
3693
+ const BOX_TOKEN_RE = /^var\(--[a-zA-Z0-9_-]{1,64}\)$/;
3694
+ const clampBoxDim = (v: unknown): number | string | null | undefined => {
2996
3695
  if (v === null) return null;
2997
3696
  if (v === undefined) return undefined;
3697
+ if (typeof v === 'string' && BOX_TOKEN_RE.test(v)) return v;
2998
3698
  return Number.isFinite(Number(v)) ? Math.max(0, Math.min(512, Math.round(Number(v)))) : NaN;
2999
3699
  };
3000
3700
  if ('padding' in input) {
3001
3701
  const p = clampBoxDim(input.padding);
3002
- if (Number.isNaN(p)) return { ok: false, status: 400, error: 'invalid padding' };
3702
+ if (typeof p === 'number' && Number.isNaN(p))
3703
+ return { ok: false, status: 400, error: 'invalid padding' };
3003
3704
  patch.padding = p;
3004
3705
  }
3005
3706
  if ('gap' in input) {
3006
3707
  const g = clampBoxDim(input.gap);
3007
- if (Number.isNaN(g)) return { ok: false, status: 400, error: 'invalid gap' };
3708
+ if (typeof g === 'number' && Number.isNaN(g))
3709
+ return { ok: false, status: 400, error: 'invalid gap' };
3008
3710
  patch.gap = g;
3009
3711
  }
3010
3712
  if (Object.keys(patch).length === 0) {
@@ -3146,6 +3848,42 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3146
3848
  }
3147
3849
  }
3148
3850
 
3851
+ /**
3852
+ * feature-4 T7a (layers purple instances) — the component map for the shell's
3853
+ * Layers panel: `{ [cdId]: { component, root, usages } }` for every element
3854
+ * that renders through an instantiated component. READ-only parse (same
3855
+ * posture as editScopeOp).
3856
+ */
3857
+ async function componentMapOp(input: {
3858
+ canvas?: unknown;
3859
+ }): Promise<
3860
+ | { ok: true; map: Record<string, { component: string; root: boolean; usages: number }> }
3861
+ | { ok: false; status: number; error: string }
3862
+ > {
3863
+ const r = resolveCanvasAbs(input.canvas);
3864
+ if (!r.ok) return r;
3865
+ try {
3866
+ const file = Bun.file(r.abs);
3867
+ // Security fix (review fan-out, 2026-07-21) — this route is AUTO-fetched
3868
+ // by the shell on every canvas/artboard switch (no user action), unlike
3869
+ // the sibling edit-scope route it mirrors. Per this repo's hub-push
3870
+ // threat model (DDR-060), a peer could otherwise cost a collaborator
3871
+ // CPU/memory the moment they merely open a pathologically large synced
3872
+ // canvas — cap it the same way the structural-write ops cap `before`.
3873
+ if (file.size > MAX_CANVAS_SOURCE) {
3874
+ return { ok: false, status: 413, error: 'canvas source too large' };
3875
+ }
3876
+ const source = await file.text();
3877
+ return { ok: true, map: componentMapForCanvas(r.abs, source) };
3878
+ } catch (err) {
3879
+ return {
3880
+ ok: false,
3881
+ status: 500,
3882
+ error: err instanceof Error ? err.message : 'component map failed',
3883
+ };
3884
+ }
3885
+ }
3886
+
3149
3887
  async function toggleHideOp(input: {
3150
3888
  canvas?: unknown;
3151
3889
  stableId?: unknown;
@@ -3766,15 +4504,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3766
4504
  compClips,
3767
4505
  reorder,
3768
4506
  retimeSequenceOp,
4507
+ convertChildrenToAbsoluteOp,
4508
+ detachComponentOp,
3769
4509
  deleteElementOp,
3770
4510
  insertElementOp,
3771
4511
  insertArtboardOp,
3772
4512
  resizeArtboardOp,
3773
4513
  setArtboardHugOp,
3774
4514
  setArtboardStyleOp,
4515
+ setArtboardKindOp,
4516
+ setArtboardGuidesOp,
4517
+ setArtboardPrintOp,
3775
4518
  deleteArtboardOp,
4519
+ duplicateArtboardOp,
3776
4520
  duplicateElementOp,
3777
4521
  editScopeOp,
4522
+ componentMapOp,
3778
4523
  reorderRevert,
3779
4524
  buildIndexData,
3780
4525
  buildSystemData,