@1agh/maude 0.46.0 → 0.48.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 (81) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  3. package/apps/studio/acp/bridge.ts +121 -6
  4. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  5. package/apps/studio/annotations-layer.tsx +42 -0
  6. package/apps/studio/api.ts +417 -2
  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/agent-browser-safe.sh +29 -0
  13. package/apps/studio/bin/curl-local.sh +28 -0
  14. package/apps/studio/build.ts +1 -1
  15. package/apps/studio/canvas-cursors.ts +6 -0
  16. package/apps/studio/canvas-edit.ts +678 -11
  17. package/apps/studio/canvas-icons.tsx +13 -0
  18. package/apps/studio/canvas-lib.tsx +3 -0
  19. package/apps/studio/canvas-shell.tsx +646 -26
  20. package/apps/studio/client/app.jsx +898 -52
  21. package/apps/studio/client/export-center.jsx +7 -6
  22. package/apps/studio/client/github.js +11 -4
  23. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  24. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  25. package/apps/studio/contextual-toolbar.tsx +5 -3
  26. package/apps/studio/dist/client.bundle.js +1103 -1103
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  29. package/apps/studio/dist/styles.css +1 -1
  30. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  31. package/apps/studio/exporters/pdf.ts +35 -1
  32. package/apps/studio/git/service.ts +4 -1
  33. package/apps/studio/grid-track-handles.ts +179 -0
  34. package/apps/studio/handoff.ts +35 -0
  35. package/apps/studio/http.ts +118 -0
  36. package/apps/studio/input-router.tsx +73 -17
  37. package/apps/studio/paths.ts +37 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  39. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  40. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  41. package/apps/studio/test/browse-posture.test.tsx +107 -0
  42. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  43. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  45. package/apps/studio/test/comment-mount.test.ts +2 -1
  46. package/apps/studio/test/component-map.test.ts +48 -0
  47. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  48. package/apps/studio/test/detach-component.test.ts +94 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  50. package/apps/studio/test/element-structural-api.test.ts +74 -0
  51. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  52. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  53. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  54. package/apps/studio/test/handoff.test.ts +48 -0
  55. package/apps/studio/test/input-router.test.ts +82 -8
  56. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  57. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  58. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  59. package/apps/studio/tool-palette.tsx +3 -1
  60. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  61. package/apps/studio/use-element-resize.tsx +3 -1
  62. package/apps/studio/use-grid-track-handles.tsx +364 -0
  63. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  64. package/apps/studio/use-tool-mode.tsx +30 -3
  65. package/apps/studio/web-overlay-content.tsx +52 -0
  66. package/apps/studio/whats-new.json +27 -0
  67. package/cli/bin/maude.mjs +1 -0
  68. package/cli/commands/design.mjs +16 -0
  69. package/cli/commands/init.mjs +80 -3
  70. package/cli/commands/kg.mjs +368 -0
  71. package/cli/commands/kg.test.mjs +118 -0
  72. package/cli/lib/ddr-to-kgai.mjs +648 -0
  73. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  74. package/cli/lib/flow-design-integration.test.mjs +2 -2
  75. package/cli/lib/gitignore-block.mjs +4 -0
  76. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  77. package/package.json +8 -8
  78. package/plugins/design/dependencies.json +17 -0
  79. package/plugins/design/templates/_shell.html +4 -0
  80. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  81. package/plugins/flow/dependencies.json +17 -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,
@@ -442,6 +448,21 @@ export interface Api {
442
448
  }): Promise<
443
449
  { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
444
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 }>;
445
466
  /**
446
467
  * Insert a synthesized div/text/image relative to a reference element, OR —
447
468
  * when the artboard has no element to anchor on yet — as a direct child of
@@ -469,6 +490,14 @@ export interface Api {
469
490
  }): Promise<
470
491
  { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
471
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
+ >;
472
501
  /** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
473
502
  resizeArtboardOp(input: {
474
503
  canvas?: unknown;
@@ -529,6 +558,21 @@ export interface Api {
529
558
  id?: unknown;
530
559
  rendered?: unknown;
531
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
+ >;
532
576
  // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
533
577
  // file content swap from the in-memory revert log — immune to the positional
534
578
  // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
@@ -1262,6 +1306,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1262
1306
  return out;
1263
1307
  }
1264
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
+
1265
1326
  /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
1266
1327
  * read half of the read-modify-write both `saveCanvasView` and
1267
1328
  * `saveCanvasOverlays` need so writing one lane never clobbers the other. */
@@ -1281,6 +1342,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1281
1342
  async function loadCanvasView(file: string): Promise<{
1282
1343
  viewport?: { x: number; y: number; zoom: number };
1283
1344
  overlays?: Record<string, boolean>;
1345
+ locked?: string[];
1284
1346
  } | null> {
1285
1347
  const viewAbs = canvasViewPath(file);
1286
1348
  if (!viewAbs) return null;
@@ -1289,12 +1351,15 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1289
1351
  if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
1290
1352
  const vp = normalizeViewport((obj as { viewport?: unknown }).viewport);
1291
1353
  const ov = normalizeOverlays((obj as { overlays?: unknown }).overlays);
1354
+ const lk = normalizeLocked((obj as { locked?: unknown }).locked);
1292
1355
  const result: {
1293
1356
  viewport?: { x: number; y: number; zoom: number };
1294
1357
  overlays?: Record<string, boolean>;
1358
+ locked?: string[];
1295
1359
  } = {};
1296
1360
  if (vp) result.viewport = vp;
1297
1361
  if (ov && Object.keys(ov).length > 0) result.overlays = ov;
1362
+ if (lk && lk.length > 0) result.locked = lk;
1298
1363
  return result;
1299
1364
  }
1300
1365
  return null;
@@ -1382,13 +1447,16 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1382
1447
  // `viewport`; a versioned `.meta.json` should never carry one (sanitizer),
1383
1448
  // but strip defensively before overlaying the real per-user value below.
1384
1449
  obj.overlays = undefined;
1450
+ // feature-4 T7b — `locked` (per-user locked layer keys) is the same class.
1451
+ obj.locked = undefined;
1385
1452
  const view = await loadCanvasView(file);
1386
1453
  if (view?.viewport) obj.viewport = view.viewport;
1387
1454
  if (view?.overlays) obj.overlays = view.overlays;
1455
+ if (view?.locked) obj.locked = view.locked;
1388
1456
  // Preserve the historic contract: no meta AND no camera/overlays → null
1389
1457
  // (GET → {}, PATCH-on-rejected-path → 404). A view-only canvas still
1390
1458
  // returns its camera/overlays.
1391
- if (!hadMeta && !view?.viewport && !view?.overlays) return null;
1459
+ if (!hadMeta && !view?.viewport && !view?.overlays && !view?.locked) return null;
1392
1460
  return obj;
1393
1461
  }
1394
1462
 
@@ -1462,6 +1530,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1462
1530
  }
1463
1531
  }
1464
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
+
1465
1559
  // --- Shared document lane: layout → versioned meta, stamps last_modified. ---
1466
1560
  if (patch.layout !== undefined) {
1467
1561
  let current: Record<string, unknown> = {};
@@ -2394,7 +2488,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2394
2488
  }
2395
2489
 
2396
2490
  // 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
2397
- 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}$/;
2398
2497
 
2399
2498
  // RC1 (rca/issue-canvas-hmr-optimistic-update-consistency) — every inline
2400
2499
  // edit is USER-originated (inspector CSS knobs, inline text, attr panel, and
@@ -2934,6 +3033,229 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2934
3033
  }
2935
3034
  }
2936
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
+
2937
3259
  /** Insert a new empty artboard (id/label/width/height) after the last one. */
2938
3260
  async function insertArtboardOp(input: {
2939
3261
  canvas?: unknown;
@@ -2989,6 +3311,59 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2989
3311
  }
2990
3312
  }
2991
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
+
2992
3367
  /** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
2993
3368
  async function resizeArtboardOp(input: {
2994
3369
  canvas?: unknown;
@@ -3473,6 +3848,42 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3473
3848
  }
3474
3849
  }
3475
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
+
3476
3887
  async function toggleHideOp(input: {
3477
3888
  canvas?: unknown;
3478
3889
  stableId?: unknown;
@@ -4093,6 +4504,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4093
4504
  compClips,
4094
4505
  reorder,
4095
4506
  retimeSequenceOp,
4507
+ convertChildrenToAbsoluteOp,
4508
+ detachComponentOp,
4096
4509
  deleteElementOp,
4097
4510
  insertElementOp,
4098
4511
  insertArtboardOp,
@@ -4103,8 +4516,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4103
4516
  setArtboardGuidesOp,
4104
4517
  setArtboardPrintOp,
4105
4518
  deleteArtboardOp,
4519
+ duplicateArtboardOp,
4106
4520
  duplicateElementOp,
4107
4521
  editScopeOp,
4522
+ componentMapOp,
4108
4523
  reorderRevert,
4109
4524
  buildIndexData,
4110
4525
  buildSystemData,