@1agh/maude 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -262,7 +262,8 @@ export async function editAttribute(
262
262
  canvasAbsPath: string,
263
263
  id: string,
264
264
  attr: string,
265
- value: string
265
+ value: string,
266
+ occurrence?: number
266
267
  ): Promise<EditResult> {
267
268
  return withLock(canvasAbsPath, async () => {
268
269
  const file = Bun.file(canvasAbsPath);
@@ -273,7 +274,7 @@ export async function editAttribute(
273
274
  });
274
275
  }
275
276
  const source = await file.text();
276
- const next = applyEdit(canvasAbsPath, source, id, attr, value);
277
+ const next = applyEdit(canvasAbsPath, source, id, attr, value, occurrence);
277
278
  if (next.source === source) return { source, delta: 0, changed: false };
278
279
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
279
280
  await Bun.write(tmp, next.source);
@@ -294,7 +295,8 @@ export async function editAttribute(
294
295
  export async function removeAttribute(
295
296
  canvasAbsPath: string,
296
297
  id: string,
297
- attr: string
298
+ attr: string,
299
+ occurrence?: number
298
300
  ): Promise<EditResult> {
299
301
  return withLock(canvasAbsPath, async () => {
300
302
  const file = Bun.file(canvasAbsPath);
@@ -305,7 +307,7 @@ export async function removeAttribute(
305
307
  });
306
308
  }
307
309
  const source = await file.text();
308
- const next = applyRemove(canvasAbsPath, source, id, attr);
310
+ const next = applyRemove(canvasAbsPath, source, id, attr, occurrence);
309
311
  if (next.source === source) return { source, delta: 0, changed: false };
310
312
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
311
313
  await Bun.write(tmp, next.source);
@@ -320,7 +322,8 @@ export function applyRemove(
320
322
  canvasAbsPath: string,
321
323
  source: string,
322
324
  id: string,
323
- attr: string
325
+ attr: string,
326
+ occurrence?: number
324
327
  ): EditResult {
325
328
  const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
326
329
  if (parsed.errors && parsed.errors.length > 0) {
@@ -333,6 +336,11 @@ export function applyRemove(
333
336
  }
334
337
  );
335
338
  }
339
+ // Stage H3 — mirror applyEdit: a whole-instance reset routes to the dragged
340
+ // occurrence's `<Component/>` usage (no-op for a normal / single-usage element).
341
+ if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
342
+ id = resolveUsageId(parsed.program, id, occurrence);
343
+ }
336
344
  const hit = findOpening(parsed.program, id);
337
345
  if (!hit) {
338
346
  throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
@@ -398,7 +406,8 @@ export function applyEdit(
398
406
  source: string,
399
407
  id: string,
400
408
  attr: string,
401
- value: string
409
+ value: string,
410
+ occurrence?: number
402
411
  ): EditResult {
403
412
  const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
404
413
  if (parsed.errors && parsed.errors.length > 0) {
@@ -409,6 +418,19 @@ export function applyEdit(
409
418
  );
410
419
  }
411
420
 
421
+ // feature-element-editing-robustness Stage H3 — when the caller passes an
422
+ // explicit DOM-occurrence index (a whole-component-instance move/resize), route
423
+ // the write to that occurrence's parent `<Component/>` USAGE so the edit stays
424
+ // LOCAL to the dragged instance (its own left/top/width/height) instead of
425
+ // mutating the shared inner definition (which would move every instance). A
426
+ // no-op for a normal element or a `.map()`ed single-usage one (resolveUsageId
427
+ // returns `id`). Deliberately gated on `occurrence` being present: the CssKnobs
428
+ // / paste-style paths pass NO occurrence, so styling an INNER shared element
429
+ // stays global-and-labeled — the H2 badge is the answer there (the chosen model).
430
+ if (typeof occurrence === 'number' && Number.isFinite(occurrence)) {
431
+ id = resolveUsageId(parsed.program, id, occurrence);
432
+ }
433
+
412
434
  const hit = findOpening(parsed.program, id);
413
435
  if (!hit) {
414
436
  throw new CanvasEditError(`data-cd-id "${id}" not found in ${canvasAbsPath}`, {
@@ -426,7 +448,7 @@ export function applyEdit(
426
448
  id,
427
449
  });
428
450
  } else {
429
- editStringAttr(s, hit.opening, attr, value);
451
+ editStringAttr(s, hit.opening, attr, value, canvasAbsPath, id);
430
452
  }
431
453
 
432
454
  const out = s.toString();
@@ -746,6 +768,74 @@ function resolveUsageId(
746
768
  return usages[i]?.id ?? domId;
747
769
  }
748
770
 
771
+ /**
772
+ * Edit-scope verdict for the INV-3 predictability badge (feature-element-editing-
773
+ * robustness Stage H). Tells the Inspector whether a style/attr edit to `domId`
774
+ * stays LOCAL (this one rendered place) or is SHARED (changes N places).
775
+ */
776
+ export interface EditScope {
777
+ scope: 'local' | 'shared';
778
+ /** Enclosing reused-component name when the element lives inside one, else null. */
779
+ componentName: string | null;
780
+ /** How many rendered places an edit to this element touches. */
781
+ affects: number;
782
+ /** single = a lone element · component = inside an N-usage component · mapped =
783
+ * one source element rendered N× via `.map()` (DDR-139 §1). */
784
+ reason: 'single' | 'component' | 'mapped';
785
+ }
786
+
787
+ /**
788
+ * Resolve whether an edit to `domId` is local or shared — composing the SAME
789
+ * primitives `resolveUsageId` ships: the element's enclosing `componentName` plus
790
+ * the source usage count of that component. `renderedCount` is the number of DOM
791
+ * nodes carrying this cd-id (the client knows it): a single source element
792
+ * rendered N× through `.map()` is `shared` ('mapped') even with one source usage,
793
+ * so the badge never lies. A parse failure degrades to `local` (a badge must
794
+ * never crash selection). Pure — unit-tested without a DOM.
795
+ */
796
+ export function resolveEditScope(
797
+ canvasAbsPath: string,
798
+ source: string,
799
+ domId: string,
800
+ renderedCount = 1
801
+ ): EditScope {
802
+ const rendered =
803
+ Number.isFinite(renderedCount) && renderedCount > 0 ? Math.floor(renderedCount) : 1;
804
+ let program: AnyNode;
805
+ try {
806
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
807
+ if (parsed.errors && parsed.errors.length > 0) {
808
+ return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
809
+ }
810
+ program = parsed.program;
811
+ } catch {
812
+ return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
813
+ }
814
+ const all = collectElementsFull(program);
815
+ const target = all.find((e) => e.id === domId);
816
+ const compName = target?.componentName || '';
817
+ // Usages of the enclosing component = distinct `<Component/>` tags in the tree.
818
+ // 0 for a top-level artboard element (the Canvas fn is never `<Canvas/>`'d).
819
+ const usages = compName ? all.filter((e) => e.tag === compName).length : 0;
820
+ if (usages > 1) {
821
+ return {
822
+ scope: 'shared',
823
+ componentName: compName,
824
+ affects: Math.max(usages, rendered),
825
+ reason: 'component',
826
+ };
827
+ }
828
+ if (rendered > 1) {
829
+ return {
830
+ scope: 'shared',
831
+ componentName: compName || null,
832
+ affects: rendered,
833
+ reason: 'mapped',
834
+ };
835
+ }
836
+ return { scope: 'local', componentName: null, affects: 1, reason: 'single' };
837
+ }
838
+
749
839
  /**
750
840
  * Move the element with `data-cd-id === id` to a position relative to the element
751
841
  * with `data-cd-id === refId`. Async wrapper: read, apply, atomic write under the
@@ -2507,6 +2597,614 @@ export async function editArrayElementString(
2507
2597
  });
2508
2598
  }
2509
2599
 
2600
+ // ---------------------------------------------------------------------------
2601
+ // General element structural edits — delete / insert / new-artboard (Stage I,
2602
+ // feature-element-editing-robustness). These extend the DDR-138/139 move/reorder
2603
+ // model (and the DDR-150 clip delete/insert) from video-comp <Sequence> clips to
2604
+ // ARBITRARY canvas elements + whole artboards. Every op is a whole-file
2605
+ // {before, after} write (a structural edit renumbers positional data-cd-ids, so
2606
+ // an inverse *descriptor* goes stale — the caller logs before/after under a seq
2607
+ // and Cmd+Z reverts by whole-file swap through /_api/reorder-revert). Each runs
2608
+ // the same reparse gate as `applyMove`: never write source that doesn't parse.
2609
+
2610
+ /** Kind of element the insert palette can synthesize. */
2611
+ export type InsertKind = 'div' | 'text' | 'image';
2612
+
2613
+ /** Synthesize a minimal, self-styled JSX element for an insert. No `data-cd-id`
2614
+ * — the pipeline stamps it on the next transpile (canvas-pipeline stamping is
2615
+ * unconditional). The new element lands selectable + immediately styleable. */
2616
+ function synthInsertElement(kind: InsertKind, src?: string): string {
2617
+ if (kind === 'text') return `<p style={{ margin: 0 }}>Text</p>`;
2618
+ if (kind === 'image') {
2619
+ const s = (src ?? '').trim();
2620
+ return `<img src="${escapeAttr(s)}" alt="" style={{ width: 160, height: 120, objectFit: 'cover', borderRadius: 8 }} />`;
2621
+ }
2622
+ // div — a visible neutral placeholder box the user can immediately restyle.
2623
+ return `<div style={{ width: 120, height: 80, background: 'var(--bg-2)', borderRadius: 8 }} />`;
2624
+ }
2625
+
2626
+ /**
2627
+ * Delete the element with `data-cd-id === id` from a canvas. Pure; never mutates
2628
+ * disk (the async `deleteElement` wraps this under the per-file lock). For a
2629
+ * reused-component INSTANCE the id names an element inside the component
2630
+ * definition (shared across N usages); `occurrence` maps it to the specific
2631
+ * `<Component/>` USAGE so deleting one instance is artboard-local — deleting the
2632
+ * shared internal element is inherently global (there is only one). Mirrors
2633
+ * `applyRemoveClip`: remove the framed span (leading indent + newline), then the
2634
+ * reparse gate refuses a delete that would leave invalid JSX (e.g. an element
2635
+ * that was the sole child of a `{expr}` that now dangles).
2636
+ */
2637
+ export function applyDeleteElement(
2638
+ canvasAbsPath: string,
2639
+ source: string,
2640
+ id: string,
2641
+ occurrence?: number
2642
+ ): { source: string; deletedId: string } {
2643
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2644
+ if (parsed.errors && parsed.errors.length > 0) {
2645
+ throw new CanvasEditError(
2646
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2647
+ { canvas: canvasAbsPath, id }
2648
+ );
2649
+ }
2650
+ const targetId = resolveUsageId(parsed.program, id, occurrence);
2651
+ const hit = findOpening(parsed.program, targetId);
2652
+ if (!hit) {
2653
+ throw new CanvasEditError(`data-cd-id "${targetId}" not found in ${canvasAbsPath}`, {
2654
+ canvas: canvasAbsPath,
2655
+ id: targetId,
2656
+ });
2657
+ }
2658
+ const el = hit.element;
2659
+ const [rs, re] = spanWithFraming(source, el.start as number, el.end as number);
2660
+ const s = new MagicString(source);
2661
+ s.remove(rs, re);
2662
+ const out = s.toString();
2663
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2664
+ if (check.errors && check.errors.length > 0) {
2665
+ throw new CanvasEditError(
2666
+ `delete would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted — the element may be the sole child a parent requires`,
2667
+ { canvas: canvasAbsPath, id: targetId }
2668
+ );
2669
+ }
2670
+ return { source: out, deletedId: targetId };
2671
+ }
2672
+
2673
+ /** Delete an element on disk (atomic write + cross-process lock). */
2674
+ export async function deleteElement(
2675
+ canvasAbsPath: string,
2676
+ id: string,
2677
+ occurrence?: number
2678
+ ): Promise<{ source: string; deletedId: string }> {
2679
+ return withLock(canvasAbsPath, async () => {
2680
+ const file = Bun.file(canvasAbsPath);
2681
+ if (!(await file.exists())) {
2682
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2683
+ canvas: canvasAbsPath,
2684
+ id,
2685
+ });
2686
+ }
2687
+ const source = await file.text();
2688
+ const next = applyDeleteElement(canvasAbsPath, source, id, occurrence);
2689
+ if (next.source === source) return next;
2690
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2691
+ await Bun.write(tmp, next.source);
2692
+ const { rename } = await import('node:fs/promises');
2693
+ await rename(tmp, canvasAbsPath);
2694
+ return next;
2695
+ });
2696
+ }
2697
+
2698
+ /**
2699
+ * Insert a synthesized element (`div` / `text` / `image`) relative to the
2700
+ * element with `data-cd-id === refId`. Pure. Reuses `applyMove`'s per-position
2701
+ * anchor + indentation logic (the new element is a single line, so no
2702
+ * continuation re-indent is needed). The inserted element carries NO
2703
+ * `data-cd-id`; after the reparse gate we recompute its post-transpile
2704
+ * positional id (the id the DOM will carry once the pipeline stamps it) by
2705
+ * locating the freshly-inserted node, and return it so the caller can select the
2706
+ * new element. An `image` needs a contained asset `src` (the AssetPicker
2707
+ * supplies one — never a remote hotlink, which the CSP split origin blocks).
2708
+ */
2709
+ export function applyInsertElement(
2710
+ canvasAbsPath: string,
2711
+ source: string,
2712
+ refId: string,
2713
+ position: MovePosition,
2714
+ kind: InsertKind,
2715
+ opts?: { src?: string; occurrence?: number }
2716
+ ): { source: string; newId: string | null } {
2717
+ if (kind === 'image') {
2718
+ if (!opts?.src?.trim()) {
2719
+ throw new CanvasEditError('insert image requires a contained asset src (assets/…)', {
2720
+ canvas: canvasAbsPath,
2721
+ id: refId,
2722
+ });
2723
+ }
2724
+ assertContainedAssetSrc(opts.src, canvasAbsPath);
2725
+ }
2726
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2727
+ if (parsed.errors && parsed.errors.length > 0) {
2728
+ throw new CanvasEditError(
2729
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2730
+ { canvas: canvasAbsPath, id: refId }
2731
+ );
2732
+ }
2733
+ const targetRef = resolveUsageId(parsed.program, refId, opts?.occurrence);
2734
+ const ref = findOpening(parsed.program, targetRef);
2735
+ if (!ref) {
2736
+ throw new CanvasEditError(`reference data-cd-id "${targetRef}" not found in ${canvasAbsPath}`, {
2737
+ canvas: canvasAbsPath,
2738
+ id: targetRef,
2739
+ });
2740
+ }
2741
+ const refEl = ref.element;
2742
+ const rStart = refEl.start as number;
2743
+ const rEnd = refEl.end as number;
2744
+ const inside = position === 'inside-start' || position === 'inside-end';
2745
+ if (inside && (refEl.openingElement?.selfClosing || !refEl.closingElement)) {
2746
+ throw new CanvasEditError(
2747
+ `target "${targetRef}" is self-closing — cannot nest an element inside it`,
2748
+ { canvas: canvasAbsPath, id: targetRef }
2749
+ );
2750
+ }
2751
+
2752
+ const indentUnit = detectIndentUnit(source);
2753
+ const newText = synthInsertElement(kind, opts?.src);
2754
+
2755
+ let targetIndent: string;
2756
+ let anchor: number;
2757
+ let insertText: string;
2758
+ if (position === 'after') {
2759
+ targetIndent = lineStartInfo(source, rStart).indent;
2760
+ anchor = rEnd;
2761
+ insertText = `\n${targetIndent}${newText}`;
2762
+ } else if (position === 'before') {
2763
+ const rLine = lineStartInfo(source, rStart);
2764
+ targetIndent = rLine.indent;
2765
+ if (rLine.newlineBefore) {
2766
+ anchor = rLine.indentStart - 1;
2767
+ insertText = `\n${targetIndent}${newText}`;
2768
+ } else {
2769
+ anchor = rLine.indentStart;
2770
+ insertText = `${targetIndent}${newText}\n`;
2771
+ }
2772
+ } else if (position === 'inside-start') {
2773
+ targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
2774
+ anchor = refEl.openingElement.end as number;
2775
+ insertText = `\n${targetIndent}${newText}`;
2776
+ } else {
2777
+ // inside-end — last child, before the closing tag's own line.
2778
+ targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
2779
+ const cStart = refEl.closingElement.start as number;
2780
+ const cLine = lineStartInfo(source, cStart);
2781
+ if (cLine.newlineBefore) {
2782
+ anchor = cLine.indentStart - 1;
2783
+ insertText = `\n${targetIndent}${newText}`;
2784
+ } else {
2785
+ anchor = cStart;
2786
+ insertText = `${newText}`;
2787
+ }
2788
+ }
2789
+
2790
+ const s = new MagicString(source);
2791
+ s.appendLeft(anchor, insertText);
2792
+ const out = s.toString();
2793
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2794
+ if (check.errors && check.errors.length > 0) {
2795
+ throw new CanvasEditError(
2796
+ `insert would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
2797
+ { canvas: canvasAbsPath, id: targetRef }
2798
+ );
2799
+ }
2800
+ // Recompute the new element's post-insert positional id. There's a single
2801
+ // append (no removes), so the new element's `<` lands at `anchor + prefixLen`
2802
+ // in `out`; match by that exact offset (robust when the inserted text is a
2803
+ // duplicate of a sibling), falling back to a text match.
2804
+ const prefixLen = insertText.indexOf(newText);
2805
+ const elemStart = anchor + prefixLen;
2806
+ let newId: string | null = null;
2807
+ let fallback: string | null = null;
2808
+ for (const { id: eid, node } of collectElements(check.program)) {
2809
+ if ((node.start as number) === elemStart) {
2810
+ newId = eid;
2811
+ break;
2812
+ }
2813
+ if (out.slice(node.start as number, node.end as number) === newText) fallback = eid;
2814
+ }
2815
+ return { source: out, newId: newId ?? fallback };
2816
+ }
2817
+
2818
+ /** Insert an element on disk (atomic write + cross-process lock). */
2819
+ export async function insertElement(
2820
+ canvasAbsPath: string,
2821
+ refId: string,
2822
+ position: MovePosition,
2823
+ kind: InsertKind,
2824
+ opts?: { src?: string; occurrence?: number }
2825
+ ): Promise<{ source: string; newId: string | null }> {
2826
+ return withLock(canvasAbsPath, async () => {
2827
+ const file = Bun.file(canvasAbsPath);
2828
+ if (!(await file.exists())) {
2829
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2830
+ canvas: canvasAbsPath,
2831
+ id: refId,
2832
+ });
2833
+ }
2834
+ const source = await file.text();
2835
+ const next = applyInsertElement(canvasAbsPath, source, refId, position, kind, opts);
2836
+ if (next.source === source) return next;
2837
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2838
+ await Bun.write(tmp, next.source);
2839
+ const { rename } = await import('node:fs/promises');
2840
+ await rename(tmp, canvasAbsPath);
2841
+ return next;
2842
+ });
2843
+ }
2844
+
2845
+ /**
2846
+ * Duplicate the element with `data-cd-id === id` — insert a verbatim copy of its
2847
+ * source as the next sibling (Cmd+D, Task L3). Since the SOURCE carries no
2848
+ * data-cd-id (they're injected at transpile, not stored on disk), the clone gets
2849
+ * a fresh positional id on the next transpile — no id collision. Reused-component
2850
+ * instances resolve via resolveUsageId so the copy is the `<Card/>` USAGE
2851
+ * (artboard-local), consistent with the Stage-H scope model.
2852
+ */
2853
+ export function applyDuplicateElement(
2854
+ canvasAbsPath: string,
2855
+ source: string,
2856
+ id: string,
2857
+ occurrence?: number
2858
+ ): { source: string; newId: string | null } {
2859
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2860
+ if (parsed.errors && parsed.errors.length > 0) {
2861
+ throw new CanvasEditError(
2862
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2863
+ { canvas: canvasAbsPath, id }
2864
+ );
2865
+ }
2866
+ const targetId = resolveUsageId(parsed.program, id, occurrence);
2867
+ const hit = findOpening(parsed.program, targetId);
2868
+ if (!hit) {
2869
+ throw new CanvasEditError(`data-cd-id "${targetId}" not found in ${canvasAbsPath}`, {
2870
+ canvas: canvasAbsPath,
2871
+ id: targetId,
2872
+ });
2873
+ }
2874
+ const el = hit.element;
2875
+ const elStart = el.start as number;
2876
+ const elEnd = el.end as number;
2877
+ // The clone is the element's own source, placed at the same indent as the next
2878
+ // sibling (its internal lines are already indented relative to that level).
2879
+ const cloneText = source.slice(elStart, elEnd);
2880
+ const targetIndent = lineStartInfo(source, elStart).indent;
2881
+ const insertText = `\n${targetIndent}${cloneText}`;
2882
+ const s = new MagicString(source);
2883
+ s.appendLeft(elEnd, insertText);
2884
+ const out = s.toString();
2885
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
2886
+ if (check.errors && check.errors.length > 0) {
2887
+ throw new CanvasEditError(
2888
+ `duplicate would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
2889
+ { canvas: canvasAbsPath, id: targetId }
2890
+ );
2891
+ }
2892
+ // Single append (no removes) → the copy's `<` lands at this exact offset.
2893
+ const elemStart = elEnd + insertText.indexOf(cloneText);
2894
+ let newId: string | null = null;
2895
+ let fallback: string | null = null;
2896
+ for (const { id: eid, node } of collectElements(check.program)) {
2897
+ if ((node.start as number) === elemStart) {
2898
+ newId = eid;
2899
+ break;
2900
+ }
2901
+ if (out.slice(node.start as number, node.end as number) === cloneText) fallback = eid;
2902
+ }
2903
+ return { source: out, newId: newId ?? fallback };
2904
+ }
2905
+
2906
+ /** Duplicate an element on disk (atomic write + cross-process lock). */
2907
+ export async function duplicateElement(
2908
+ canvasAbsPath: string,
2909
+ id: string,
2910
+ occurrence?: number
2911
+ ): Promise<{ source: string; newId: string | null }> {
2912
+ return withLock(canvasAbsPath, async () => {
2913
+ const file = Bun.file(canvasAbsPath);
2914
+ if (!(await file.exists())) {
2915
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
2916
+ canvas: canvasAbsPath,
2917
+ id,
2918
+ });
2919
+ }
2920
+ const source = await file.text();
2921
+ const next = applyDuplicateElement(canvasAbsPath, source, id, occurrence);
2922
+ if (next.source === source) return next;
2923
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
2924
+ await Bun.write(tmp, next.source);
2925
+ const { rename } = await import('node:fs/promises');
2926
+ await rename(tmp, canvasAbsPath);
2927
+ return next;
2928
+ });
2929
+ }
2930
+
2931
+ /** Collect every JSXElement whose opening tag is `tagName` (document order). */
2932
+ function collectJsxByTag(program: AnyNode, tagName: string): AnyNode[] {
2933
+ const out: AnyNode[] = [];
2934
+ function visit(node: AnyNode): void {
2935
+ if (!node || typeof node !== 'object') return;
2936
+ if (Array.isArray(node)) {
2937
+ for (const c of node) visit(c);
2938
+ return;
2939
+ }
2940
+ if (typeof node.type !== 'string') return;
2941
+ if (node.type === 'JSXElement') {
2942
+ const n = node.openingElement?.name;
2943
+ if (n?.type === 'JSXIdentifier' && n.name === tagName) out.push(node);
2944
+ }
2945
+ for (const k of Object.keys(node)) {
2946
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
2947
+ visit(node[k]);
2948
+ }
2949
+ }
2950
+ visit(program);
2951
+ return out;
2952
+ }
2953
+
2954
+ /**
2955
+ * Write a NUMERIC JSX attribute (`width={430}`) — the shape `<DCArtboard>` size
2956
+ * props take (DDR-027). Distinct from `editStringAttr`, which would emit
2957
+ * `width="430"` and break the numeric prop. Overwrites an existing `{expr}` or
2958
+ * `"literal"`, or inserts the attribute after the tag name if missing.
2959
+ */
2960
+ function writeNumericAttr(s: MagicString, opening: AnyNode, name: string, value: number): void {
2961
+ const num = Math.max(1, Math.round(value));
2962
+ const attr = findAttribute(opening, name);
2963
+ if (attr) {
2964
+ const v = attr.value;
2965
+ if (!v) {
2966
+ s.appendLeft(attr.end as number, `={${num}}`);
2967
+ return;
2968
+ }
2969
+ // Any value shape (expression container or string literal) → a numeric expr.
2970
+ s.overwrite(v.start as number, v.end as number, `{${num}}`);
2971
+ return;
2972
+ }
2973
+ const insertAt: number | undefined = opening?.name?.end;
2974
+ if (typeof insertAt !== 'number') throw new Error('Opening element has no resolvable name range');
2975
+ s.appendLeft(insertAt, ` ${name}={${num}}`);
2976
+ }
2977
+
2978
+ /**
2979
+ * Free-hand artboard resize (Stage D4). Rewrites the `width` / `height` NUMERIC
2980
+ * props on the `<DCArtboard>` whose `id` prop equals `artboardId` — per DDR-027
2981
+ * artboard size is JSX-authoritative (not a `layout` field), and an artboard's
2982
+ * rendered `<article data-dc-screen>` carries no `data-cd-id`, so it's addressed
2983
+ * by its own `id` prop, not the cd-id lane. Pure; reparse-gated.
2984
+ */
2985
+ export function applyResizeArtboard(
2986
+ canvasAbsPath: string,
2987
+ source: string,
2988
+ artboardId: string,
2989
+ width: number | undefined,
2990
+ height: number | undefined
2991
+ ): { source: string } {
2992
+ if (width == null && height == null) return { source };
2993
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
2994
+ if (parsed.errors && parsed.errors.length > 0) {
2995
+ throw new CanvasEditError(
2996
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
2997
+ { canvas: canvasAbsPath, id: artboardId }
2998
+ );
2999
+ }
3000
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3001
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3002
+ if (!target) {
3003
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3004
+ canvas: canvasAbsPath,
3005
+ id: artboardId,
3006
+ });
3007
+ }
3008
+ const s = new MagicString(source);
3009
+ if (typeof width === 'number') writeNumericAttr(s, target.openingElement, 'width', width);
3010
+ if (typeof height === 'number') writeNumericAttr(s, target.openingElement, 'height', height);
3011
+ const out = s.toString();
3012
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3013
+ if (check.errors && check.errors.length > 0) {
3014
+ throw new CanvasEditError(
3015
+ `artboard resize produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3016
+ { canvas: canvasAbsPath, id: artboardId }
3017
+ );
3018
+ }
3019
+ return { source: out };
3020
+ }
3021
+
3022
+ /** Resize an artboard on disk (atomic write + cross-process lock). */
3023
+ export async function resizeArtboard(
3024
+ canvasAbsPath: string,
3025
+ artboardId: string,
3026
+ width: number | undefined,
3027
+ height: number | undefined
3028
+ ): Promise<{ source: string }> {
3029
+ return withLock(canvasAbsPath, async () => {
3030
+ const file = Bun.file(canvasAbsPath);
3031
+ if (!(await file.exists())) {
3032
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3033
+ canvas: canvasAbsPath,
3034
+ id: artboardId,
3035
+ });
3036
+ }
3037
+ const source = await file.text();
3038
+ const next = applyResizeArtboard(canvasAbsPath, source, artboardId, width, height);
3039
+ if (next.source === source) return next;
3040
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3041
+ await Bun.write(tmp, next.source);
3042
+ const { rename } = await import('node:fs/promises');
3043
+ await rename(tmp, canvasAbsPath);
3044
+ return next;
3045
+ });
3046
+ }
3047
+
3048
+ /**
3049
+ * Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
3050
+ * artboard counterpart of applyDeleteElement (an artboard is addressed by its id
3051
+ * PROP, since the rendered `<article data-dc-screen>` has no data-cd-id; same
3052
+ * convention as applyResizeArtboard). Removes the framed span + reparse-gates,
3053
+ * and refuses to delete the LAST artboard (a canvas with zero artboards renders
3054
+ * nothing). Whole-file-snapshot undo, like the other structural ops.
3055
+ */
3056
+ export function applyDeleteArtboard(
3057
+ canvasAbsPath: string,
3058
+ source: string,
3059
+ artboardId: string
3060
+ ): { source: string } {
3061
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3062
+ if (parsed.errors && parsed.errors.length > 0) {
3063
+ throw new CanvasEditError(
3064
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3065
+ { canvas: canvasAbsPath, id: artboardId }
3066
+ );
3067
+ }
3068
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3069
+ if (artboards.length <= 1) {
3070
+ throw new CanvasEditError('cannot delete the last artboard on the canvas', {
3071
+ canvas: canvasAbsPath,
3072
+ id: artboardId,
3073
+ });
3074
+ }
3075
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3076
+ if (!target) {
3077
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3078
+ canvas: canvasAbsPath,
3079
+ id: artboardId,
3080
+ });
3081
+ }
3082
+ const [rs, re] = spanWithFraming(source, target.start as number, target.end as number);
3083
+ const s = new MagicString(source);
3084
+ s.remove(rs, re);
3085
+ const out = s.toString();
3086
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3087
+ if (check.errors && check.errors.length > 0) {
3088
+ throw new CanvasEditError(
3089
+ `artboard delete produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3090
+ { canvas: canvasAbsPath, id: artboardId }
3091
+ );
3092
+ }
3093
+ return { source: out };
3094
+ }
3095
+
3096
+ /** Delete an artboard on disk (atomic write + cross-process lock). */
3097
+ export async function deleteArtboard(
3098
+ canvasAbsPath: string,
3099
+ artboardId: string
3100
+ ): Promise<{ source: string }> {
3101
+ return withLock(canvasAbsPath, async () => {
3102
+ const file = Bun.file(canvasAbsPath);
3103
+ if (!(await file.exists())) {
3104
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3105
+ canvas: canvasAbsPath,
3106
+ id: artboardId,
3107
+ });
3108
+ }
3109
+ const source = await file.text();
3110
+ const next = applyDeleteArtboard(canvasAbsPath, source, artboardId);
3111
+ if (next.source === source) return next;
3112
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3113
+ await Bun.write(tmp, next.source);
3114
+ const { rename } = await import('node:fs/promises');
3115
+ await rename(tmp, canvasAbsPath);
3116
+ return next;
3117
+ });
3118
+ }
3119
+
3120
+ /** Artboard id must be a safe JSX-attribute identifier (no quotes/markup). */
3121
+ const ARTBOARD_ID_RE = /^[A-Za-z][\w-]*$/;
3122
+
3123
+ /**
3124
+ * Insert a new EMPTY `<DCArtboard>` after the canvas's last artboard (Stage I4).
3125
+ * Pure. The size props (`width`/`height`) are JSX-authoritative (DDR-027); the
3126
+ * grid position is left to the caller's `patchCanvasMeta` (DDR-027 default-grid
3127
+ * places an unpositioned artboard at the next free slot). The artboard is empty
3128
+ * — a clean frame the user fills via insert-element — and non-self-closing so an
3129
+ * `inside-end` insert can target it. Reparse-gated. Rejects a duplicate `id`.
3130
+ */
3131
+ export function applyInsertArtboard(
3132
+ canvasAbsPath: string,
3133
+ source: string,
3134
+ opts: { id: string; label: string; width: number; height: number }
3135
+ ): { source: string; artboardId: string } {
3136
+ const id = opts.id.trim();
3137
+ const label = opts.label.trim();
3138
+ const width = Math.max(1, Math.round(opts.width));
3139
+ const height = Math.max(1, Math.round(opts.height));
3140
+ if (!ARTBOARD_ID_RE.test(id)) {
3141
+ throw new CanvasEditError(`invalid artboard id "${id}" (must match ${ARTBOARD_ID_RE})`, {
3142
+ canvas: canvasAbsPath,
3143
+ id,
3144
+ });
3145
+ }
3146
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3147
+ if (parsed.errors && parsed.errors.length > 0) {
3148
+ throw new CanvasEditError(
3149
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3150
+ { canvas: canvasAbsPath, id }
3151
+ );
3152
+ }
3153
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3154
+ if (artboards.length === 0) {
3155
+ throw new CanvasEditError(
3156
+ 'no <DCArtboard> to anchor a new artboard — scaffold a canvas first',
3157
+ { canvas: canvasAbsPath, id }
3158
+ );
3159
+ }
3160
+ for (const a of artboards) {
3161
+ if (getStringAttr(a.openingElement, 'id') === id) {
3162
+ throw new CanvasEditError(`artboard id "${id}" already exists`, {
3163
+ canvas: canvasAbsPath,
3164
+ id,
3165
+ });
3166
+ }
3167
+ }
3168
+ const last = artboards[artboards.length - 1] as AnyNode;
3169
+ const indent = lineStartInfo(source, last.start as number).indent;
3170
+ const newText = `<DCArtboard id="${escapeAttr(id)}" label="${escapeAttr(label)}" width={${width}} height={${height}}></DCArtboard>`;
3171
+ const s = new MagicString(source);
3172
+ s.appendLeft(last.end as number, `\n${indent}${newText}`);
3173
+ const out = s.toString();
3174
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3175
+ if (check.errors && check.errors.length > 0) {
3176
+ throw new CanvasEditError(
3177
+ `insert-artboard produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3178
+ { canvas: canvasAbsPath, id }
3179
+ );
3180
+ }
3181
+ return { source: out, artboardId: id };
3182
+ }
3183
+
3184
+ /** Insert a new empty artboard on disk (atomic write + cross-process lock). */
3185
+ export async function insertArtboard(
3186
+ canvasAbsPath: string,
3187
+ opts: { id: string; label: string; width: number; height: number }
3188
+ ): Promise<{ source: string; artboardId: string }> {
3189
+ return withLock(canvasAbsPath, async () => {
3190
+ const file = Bun.file(canvasAbsPath);
3191
+ if (!(await file.exists())) {
3192
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3193
+ canvas: canvasAbsPath,
3194
+ id: opts.id,
3195
+ });
3196
+ }
3197
+ const source = await file.text();
3198
+ const next = applyInsertArtboard(canvasAbsPath, source, opts);
3199
+ if (next.source === source) return next;
3200
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3201
+ await Bun.write(tmp, next.source);
3202
+ const { rename } = await import('node:fs/promises');
3203
+ await rename(tmp, canvasAbsPath);
3204
+ return next;
3205
+ });
3206
+ }
3207
+
2510
3208
  /** A clip in an assemble request — a dropped reference chip's src + kind. */
2511
3209
  export interface AssembleClip {
2512
3210
  src: string;
@@ -2518,9 +3216,16 @@ export interface AssembleClip {
2518
3216
  /** Reject a src that isn't a contained relative asset path (no ../, no scheme). */
2519
3217
  function assertContainedAssetSrc(src: string, canvasAbsPath: string): void {
2520
3218
  const t = src.trim();
2521
- if (!t || /\.\./.test(t) || /^\s*(javascript|vbscript|file|data|https?):/i.test(t)) {
3219
+ // ALLOWLIST (G3 security, DDR-152 hardened from a scheme denylist per the
3220
+ // adversarial review): a contained asset src MUST be `assets/<flat-name>` and
3221
+ // nothing else. This structurally excludes `..` traversal, absolute (`/etc/…`)
3222
+ // and protocol-relative (`//evil/x.png`) hosts, and every URL scheme in ONE
3223
+ // positive rule — the denylist the comment used to imply was never the real
3224
+ // control (CSP was); this makes the source-write gate match that intent.
3225
+ // Content-addressed assets are always flat (`assets/<sha8>.<ext>`).
3226
+ if (!t || /\.\./.test(t) || !/^assets\/[A-Za-z0-9._-]+$/.test(t)) {
2522
3227
  throw new CanvasEditError(
2523
- 'clip src must be a contained asset path (assets/…, no ../ or scheme)',
3228
+ 'asset src must be a contained path (assets/<name>, no ../ or scheme)',
2524
3229
  { canvas: canvasAbsPath, id: '' }
2525
3230
  );
2526
3231
  }
@@ -2604,13 +3309,24 @@ export function assembleCompSource(
2604
3309
  // ---------------------------------------------------------------------------
2605
3310
  // Edit shapes.
2606
3311
 
2607
- function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: string): void {
3312
+ function editStringAttr(
3313
+ s: MagicString,
3314
+ opening: AnyNode,
3315
+ name: string,
3316
+ value: string,
3317
+ canvasAbsPath: string,
3318
+ id: string
3319
+ ): void {
2608
3320
  const attr = findAttribute(opening, name);
2609
3321
  if (attr) {
2610
3322
  // Replace existing value. JSX attribute value forms we handle:
2611
3323
  // - <Tag name="literal" /> → replace inside the quotes
2612
3324
  // - <Tag name={'literal'} /> → wrap quotes around new value
2613
3325
  // - <Tag name={expr} /> → replace the whole expression text
3326
+ // (ONLY when the expression is itself a trivial string literal — a
3327
+ // non-trivial expression is a BINDING, not a static value, and
3328
+ // blindly overwriting it would silently discard the binding. See the
3329
+ // `src` guard below.)
2614
3330
  // - <Tag name /> → no value node; add `="..."`
2615
3331
  const v = attr.value;
2616
3332
  if (!v) {
@@ -2629,6 +3345,20 @@ function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: s
2629
3345
  return;
2630
3346
  }
2631
3347
  if (v.type === 'JSXExpressionContainer') {
3348
+ const inner = v.expression;
3349
+ // feature-element-editing-robustness Stage F2 — `src={someVar}` (or any
3350
+ // other non-literal binding) is NOT a static value; overwriting it with a
3351
+ // plain string literal would silently discard the binding, corrupting the
3352
+ // author's intent (the exact "Replace image…" gotcha the plan calls out —
3353
+ // canvas-shell.tsx's context-menu item assumed this refusal already
3354
+ // existed; it didn't). `{'literal'}`/`{"literal"}` IS a trivial, safely
3355
+ // overwritable case — only refuse a genuinely dynamic expression.
3356
+ if (name === 'src' && !(inner?.type === 'Literal' || inner?.type === 'StringLiteral')) {
3357
+ throw new CanvasEditError(
3358
+ `"${name}" is bound to a JS expression ({…}), not a static value — edit it via /design:edit`,
3359
+ { canvas: canvasAbsPath, id }
3360
+ );
3361
+ }
2632
3362
  // Replace the whole `{...}` with a plain quoted literal — keeps the
2633
3363
  // resulting JSX readable. Same JSX-attribute escaping as above (NOT
2634
3364
  // `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
@@ -2636,7 +3366,10 @@ function editStringAttr(s: MagicString, opening: AnyNode, name: string, value: s
2636
3366
  return;
2637
3367
  }
2638
3368
  // Unknown shape — refuse rather than corrupt.
2639
- throw new Error(`Unsupported JSX attribute value shape: ${v.type}`);
3369
+ throw new CanvasEditError(`Unsupported JSX attribute value shape: ${v.type}`, {
3370
+ canvas: canvasAbsPath,
3371
+ id,
3372
+ });
2640
3373
  }
2641
3374
  // Attribute missing — insert right after the tag name (mirrors pipeline's
2642
3375
  // injection point so attribute order stays predictable).