@1agh/maude 0.41.0 → 0.43.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 (260) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -63
  6. package/apps/studio/bin/_html-playwright.mjs +26 -4
  7. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  8. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  9. package/apps/studio/bin/_png-playwright.mjs +15 -2
  10. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  11. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  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/_video-playwright.mjs +141 -71
  15. package/apps/studio/bin/ingest-footage.sh +27 -0
  16. package/apps/studio/bin/photo-adjust.sh +163 -0
  17. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  18. package/apps/studio/bin/probe-footage.sh +28 -0
  19. package/apps/studio/bin/read-annotations.mjs +125 -2
  20. package/apps/studio/bin/screenshot.sh +53 -4
  21. package/apps/studio/canvas-edit.ts +459 -10
  22. package/apps/studio/canvas-lib.tsx +523 -5
  23. package/apps/studio/canvas-pipeline.ts +65 -7
  24. package/apps/studio/canvas-shell.tsx +156 -16
  25. package/apps/studio/client/app.jsx +476 -88
  26. package/apps/studio/client/export-center.jsx +460 -0
  27. package/apps/studio/client/github.js +4 -0
  28. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  29. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  30. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  31. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  32. package/apps/studio/client/photo-knobs.jsx +437 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +59 -0
  34. package/apps/studio/client/styles/4-components.css +150 -0
  35. package/apps/studio/commands/edit-source-command.ts +31 -4
  36. package/apps/studio/config.schema.json +2 -2
  37. package/apps/studio/context-menu.tsx +31 -6
  38. package/apps/studio/dist/client.bundle.js +5561 -19
  39. package/apps/studio/dist/comment-mount.js +2 -2
  40. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  41. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  42. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/dom-selection.ts +25 -0
  45. package/apps/studio/export-dialog.tsx +18 -25
  46. package/apps/studio/exporters/_runtime.ts +104 -0
  47. package/apps/studio/exporters/html.ts +12 -20
  48. package/apps/studio/exporters/index.ts +14 -2
  49. package/apps/studio/exporters/jobs.ts +376 -0
  50. package/apps/studio/exporters/pdf.ts +16 -20
  51. package/apps/studio/exporters/png.ts +12 -20
  52. package/apps/studio/exporters/pptx.ts +22 -23
  53. package/apps/studio/exporters/scope.ts +1 -0
  54. package/apps/studio/exporters/svg.ts +14 -22
  55. package/apps/studio/exporters/video.ts +37 -19
  56. package/apps/studio/footage/schema.test.ts +195 -0
  57. package/apps/studio/footage/schema.ts +484 -0
  58. package/apps/studio/footage-store.ts +235 -0
  59. package/apps/studio/fs-watch.ts +10 -0
  60. package/apps/studio/git/endpoints.ts +68 -7
  61. package/apps/studio/git/service.ts +198 -20
  62. package/apps/studio/github/service.ts +67 -0
  63. package/apps/studio/handoff.ts +3 -2
  64. package/apps/studio/http.ts +318 -64
  65. package/apps/studio/input-router.tsx +10 -0
  66. package/apps/studio/inspect.ts +53 -1
  67. package/apps/studio/paths.ts +10 -0
  68. package/apps/studio/photo/filters.ts +285 -0
  69. package/apps/studio/photo/pipeline.ts +607 -0
  70. package/apps/studio/photo/schema.ts +367 -0
  71. package/apps/studio/photo-store.ts +154 -0
  72. package/apps/studio/runtime-bundle.ts +29 -6
  73. package/apps/studio/server.ts +8 -2
  74. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  75. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  81. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  82. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  83. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  84. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  85. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  86. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  87. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  88. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  89. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  90. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  91. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  92. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  93. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  94. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  95. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  96. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  97. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  98. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  99. package/apps/studio/stickers/life-style/best.png +0 -0
  100. package/apps/studio/stickers/life-style/come-on.png +0 -0
  101. package/apps/studio/stickers/life-style/cut.png +0 -0
  102. package/apps/studio/stickers/life-style/detox.png +0 -0
  103. package/apps/studio/stickers/life-style/just.png +0 -0
  104. package/apps/studio/stickers/life-style/manifest.json +68 -0
  105. package/apps/studio/stickers/life-style/mirror.png +0 -0
  106. package/apps/studio/stickers/life-style/nice.png +0 -0
  107. package/apps/studio/stickers/life-style/objects.png +0 -0
  108. package/apps/studio/stickers/life-style/ok.png +0 -0
  109. package/apps/studio/stickers/life-style/queen.png +0 -0
  110. package/apps/studio/stickers/life-style/star.png +0 -0
  111. package/apps/studio/stickers/life-style/sun.png +0 -0
  112. package/apps/studio/stickers/life-style/water.png +0 -0
  113. package/apps/studio/stickers/life-style/yeah.png +0 -0
  114. package/apps/studio/stickers/life-style/you-can.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  118. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  119. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  120. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  121. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  122. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  123. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  124. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  125. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  126. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  127. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  128. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  129. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  130. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  131. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  132. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  133. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  134. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  135. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  136. package/apps/studio/stickers/project-status/group-100.png +0 -0
  137. package/apps/studio/stickers/project-status/group-101.png +0 -0
  138. package/apps/studio/stickers/project-status/group-102.png +0 -0
  139. package/apps/studio/stickers/project-status/group-103.png +0 -0
  140. package/apps/studio/stickers/project-status/group-104.png +0 -0
  141. package/apps/studio/stickers/project-status/group-105.png +0 -0
  142. package/apps/studio/stickers/project-status/group-106.png +0 -0
  143. package/apps/studio/stickers/project-status/group-107.png +0 -0
  144. package/apps/studio/stickers/project-status/group-108.png +0 -0
  145. package/apps/studio/stickers/project-status/group-109.png +0 -0
  146. package/apps/studio/stickers/project-status/group-110.png +0 -0
  147. package/apps/studio/stickers/project-status/group-111.png +0 -0
  148. package/apps/studio/stickers/project-status/group-112.png +0 -0
  149. package/apps/studio/stickers/project-status/group-113.png +0 -0
  150. package/apps/studio/stickers/project-status/group-114.png +0 -0
  151. package/apps/studio/stickers/project-status/group-115.png +0 -0
  152. package/apps/studio/stickers/project-status/group-117.png +0 -0
  153. package/apps/studio/stickers/project-status/group-118.png +0 -0
  154. package/apps/studio/stickers/project-status/group-119.png +0 -0
  155. package/apps/studio/stickers/project-status/group-120.png +0 -0
  156. package/apps/studio/stickers/project-status/group-121.png +0 -0
  157. package/apps/studio/stickers/project-status/group-122.png +0 -0
  158. package/apps/studio/stickers/project-status/group-41.png +0 -0
  159. package/apps/studio/stickers/project-status/group-42.png +0 -0
  160. package/apps/studio/stickers/project-status/group-43.png +0 -0
  161. package/apps/studio/stickers/project-status/group-44.png +0 -0
  162. package/apps/studio/stickers/project-status/group-45.png +0 -0
  163. package/apps/studio/stickers/project-status/group-46.png +0 -0
  164. package/apps/studio/stickers/project-status/group-47.png +0 -0
  165. package/apps/studio/stickers/project-status/group-48.png +0 -0
  166. package/apps/studio/stickers/project-status/group-49.png +0 -0
  167. package/apps/studio/stickers/project-status/group-50.png +0 -0
  168. package/apps/studio/stickers/project-status/group-51.png +0 -0
  169. package/apps/studio/stickers/project-status/group-52.png +0 -0
  170. package/apps/studio/stickers/project-status/group-53.png +0 -0
  171. package/apps/studio/stickers/project-status/group-54.png +0 -0
  172. package/apps/studio/stickers/project-status/group-55.png +0 -0
  173. package/apps/studio/stickers/project-status/group-56.png +0 -0
  174. package/apps/studio/stickers/project-status/group-57.png +0 -0
  175. package/apps/studio/stickers/project-status/group-58.png +0 -0
  176. package/apps/studio/stickers/project-status/group-59.png +0 -0
  177. package/apps/studio/stickers/project-status/group-60.png +0 -0
  178. package/apps/studio/stickers/project-status/group-61.png +0 -0
  179. package/apps/studio/stickers/project-status/group-62.png +0 -0
  180. package/apps/studio/stickers/project-status/group-63.png +0 -0
  181. package/apps/studio/stickers/project-status/group-64.png +0 -0
  182. package/apps/studio/stickers/project-status/group-65.png +0 -0
  183. package/apps/studio/stickers/project-status/group-66.png +0 -0
  184. package/apps/studio/stickers/project-status/group-67.png +0 -0
  185. package/apps/studio/stickers/project-status/group-68.png +0 -0
  186. package/apps/studio/stickers/project-status/group-69.png +0 -0
  187. package/apps/studio/stickers/project-status/group-70.png +0 -0
  188. package/apps/studio/stickers/project-status/group-71.png +0 -0
  189. package/apps/studio/stickers/project-status/group-72.png +0 -0
  190. package/apps/studio/stickers/project-status/group-73.png +0 -0
  191. package/apps/studio/stickers/project-status/group-74.png +0 -0
  192. package/apps/studio/stickers/project-status/group-75.png +0 -0
  193. package/apps/studio/stickers/project-status/group-76.png +0 -0
  194. package/apps/studio/stickers/project-status/group-77.png +0 -0
  195. package/apps/studio/stickers/project-status/group-78.png +0 -0
  196. package/apps/studio/stickers/project-status/group-79.png +0 -0
  197. package/apps/studio/stickers/project-status/group-80.png +0 -0
  198. package/apps/studio/stickers/project-status/group-81.png +0 -0
  199. package/apps/studio/stickers/project-status/group-82.png +0 -0
  200. package/apps/studio/stickers/project-status/group-83.png +0 -0
  201. package/apps/studio/stickers/project-status/group-84.png +0 -0
  202. package/apps/studio/stickers/project-status/group-85.png +0 -0
  203. package/apps/studio/stickers/project-status/group-86.png +0 -0
  204. package/apps/studio/stickers/project-status/group-87.png +0 -0
  205. package/apps/studio/stickers/project-status/group-88.png +0 -0
  206. package/apps/studio/stickers/project-status/group-89.png +0 -0
  207. package/apps/studio/stickers/project-status/group-90.png +0 -0
  208. package/apps/studio/stickers/project-status/group-91.png +0 -0
  209. package/apps/studio/stickers/project-status/group-92.png +0 -0
  210. package/apps/studio/stickers/project-status/group-93.png +0 -0
  211. package/apps/studio/stickers/project-status/group-94.png +0 -0
  212. package/apps/studio/stickers/project-status/group-96.png +0 -0
  213. package/apps/studio/stickers/project-status/group-97.png +0 -0
  214. package/apps/studio/stickers/project-status/group-98.png +0 -0
  215. package/apps/studio/stickers/project-status/group-99.png +0 -0
  216. package/apps/studio/stickers/project-status/manifest.json +328 -0
  217. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  218. package/apps/studio/test/acp-commands.test.ts +2 -1
  219. package/apps/studio/test/asset-api.test.ts +3 -3
  220. package/apps/studio/test/canvas-edit.test.ts +17 -6
  221. package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
  222. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  223. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  224. package/apps/studio/test/edit-source-command.test.ts +23 -0
  225. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  226. package/apps/studio/test/export-center.test.tsx +287 -0
  227. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  228. package/apps/studio/test/exporters/history.test.ts +32 -3
  229. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  230. package/apps/studio/test/footage-store.test.ts +100 -0
  231. package/apps/studio/test/git-branches.test.ts +97 -0
  232. package/apps/studio/test/github-api.test.ts +56 -1
  233. package/apps/studio/test/input-router.test.ts +33 -0
  234. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  235. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  236. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  237. package/apps/studio/test/photo-filters.test.ts +149 -0
  238. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  239. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  240. package/apps/studio/test/read-annotations.test.ts +164 -0
  241. package/apps/studio/test/shell-importmap.test.ts +49 -0
  242. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  243. package/apps/studio/test/timeline-parse.test.ts +57 -0
  244. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  245. package/apps/studio/test/undo-stack.test.ts +33 -0
  246. package/apps/studio/test/video-asset.test.ts +5 -5
  247. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  248. package/apps/studio/text-caret.ts +239 -0
  249. package/apps/studio/tool-palette.tsx +43 -20
  250. package/apps/studio/undo-stack.ts +94 -2
  251. package/apps/studio/use-annotation-resize.tsx +4 -4
  252. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  253. package/apps/studio/use-selection-set.tsx +21 -0
  254. package/apps/studio/video-comp.tsx +48 -7
  255. package/apps/studio/whats-new.json +70 -0
  256. package/apps/studio/ws.ts +6 -0
  257. package/cli/commands/design.mjs +34 -3
  258. package/cli/lib/gitignore-block.mjs +2 -0
  259. package/package.json +10 -10
  260. package/plugins/design/templates/_shell.html +1 -0
@@ -375,7 +375,8 @@ export function applyRemove(
375
375
  export async function editText(
376
376
  canvasAbsPath: string,
377
377
  id: string,
378
- text: string
378
+ text: string,
379
+ opts?: DynamicTextOpts
379
380
  ): Promise<EditResult> {
380
381
  return withLock(canvasAbsPath, async () => {
381
382
  const file = Bun.file(canvasAbsPath);
@@ -386,7 +387,16 @@ export async function editText(
386
387
  });
387
388
  }
388
389
  const source = await file.text();
389
- const next = applyTextEdit(canvasAbsPath, source, id, text);
390
+ // Byte cap before the AST walk (ethical-hacker F-C) a real canvas is well
391
+ // under this; a pathologically large attacker-authored file that would make
392
+ // the resolver's per-candidate walk expensive is refused up front.
393
+ if (source.length > 4_000_000) {
394
+ throw new CanvasEditError(`canvas too large to edit inline (${source.length} bytes)`, {
395
+ canvas: canvasAbsPath,
396
+ id,
397
+ });
398
+ }
399
+ const next = applyTextEdit(canvasAbsPath, source, id, text, opts);
390
400
  if (next.source === source) return { source, delta: 0, changed: false };
391
401
  const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
392
402
  await Bun.write(tmp, next.source);
@@ -455,17 +465,35 @@ export function applyEdit(
455
465
  return { source: out, delta: out.length - source.length };
456
466
  }
457
467
 
468
+ /**
469
+ * Extra context for editing text that comes from a `{variable}` rather than a
470
+ * literal (unified-text-editing follow-up). `occurrence` = which rendered
471
+ * instance the user edited (index among DOM nodes carrying this same cd-id — a
472
+ * `.map()` renders one source element N×); `before` = the pre-edit rendered
473
+ * text, used both to pick the right `.map()` item when the index drifts (a
474
+ * `.filter().map()` etc.) and to refuse a rewrite we can't confidently target.
475
+ */
476
+ export interface DynamicTextOpts {
477
+ occurrence?: number;
478
+ before?: string;
479
+ }
480
+
458
481
  /**
459
482
  * Pure variant of `editText` — parse, locate the JSXText child, overwrite its
460
483
  * source span (preserving the original leading/trailing whitespace so JSX
461
- * indentation survives), escaping the new text. Throws `CanvasEditError` for a
462
- * missing id, no text content, or mixed/expression children.
484
+ * indentation survives), escaping the new text. A single `{'literal'}` child is
485
+ * rewritten in place (DDR-150 P1). A single `{variable}` / `{item.prop}` child
486
+ * is resolved back to its source string (a local `const` or a `.map()`ed array
487
+ * element) when `opts` carries enough to target it unambiguously — otherwise it
488
+ * throws `CanvasEditError` (genuinely dynamic → route to /design:edit), same as
489
+ * mixed content.
463
490
  */
464
491
  export function applyTextEdit(
465
492
  canvasAbsPath: string,
466
493
  source: string,
467
494
  id: string,
468
- text: string
495
+ text: string,
496
+ opts?: DynamicTextOpts
469
497
  ): EditResult {
470
498
  const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
471
499
  if (parsed.errors && parsed.errors.length > 0) {
@@ -507,16 +535,24 @@ export function applyTextEdit(
507
535
  // dynamic: refuse and route to /design:edit rather than delete the binding.
508
536
  if (meaningful.length === 1 && only?.type === 'JSXExpressionContainer') {
509
537
  const expr = (only as AnyNode).expression;
510
- if (
511
- expr &&
512
- (expr.type === 'Literal' || expr.type === 'StringLiteral') &&
513
- typeof expr.value === 'string'
514
- ) {
538
+ // A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
539
+ if (isStringLit(expr)) {
515
540
  const s = new MagicString(source);
516
541
  s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
517
542
  const out = s.toString();
518
543
  return { source: out, delta: out.length - source.length };
519
544
  }
545
+ // A single `{variable}` / `{item.prop}` — trace it back to its source string
546
+ // (a local const, or a `.map()`ed array element) and rewrite THERE. The
547
+ // literal that comes back is a JS string, so it round-trips through
548
+ // JSON.stringify like the `{'literal'}` case above (no JSX-entity surface).
549
+ const span = resolveDynamicTextSpan(parsed.program, hit.element, expr, id, opts);
550
+ if (span) {
551
+ const s = new MagicString(source);
552
+ s.overwrite(span.start as number, span.end as number, JSON.stringify(text));
553
+ const out = s.toString();
554
+ return { source: out, delta: out.length - source.length };
555
+ }
520
556
  throw new CanvasEditError(`element "${id}" has dynamic content — edit it via /design:edit`, {
521
557
  canvas: canvasAbsPath,
522
558
  id,
@@ -541,6 +577,287 @@ export function applyTextEdit(
541
577
  return { source: out, delta: out.length - source.length };
542
578
  }
543
579
 
580
+ // ---------------------------------------------------------------------------
581
+ // Dynamic-text resolution (unified-text-editing follow-up). A `{variable}` /
582
+ // `{item.prop}` text child has no literal to rewrite at the element — the
583
+ // string lives in a `const` or a `.map()`ed data array. These helpers trace it
584
+ // back to that source StringLiteral so inline editing works there too, WITHOUT
585
+ // ever risking the wrong rewrite: the occurrence index picks the `.map()` item,
586
+ // and the pre-edit text (`before`) both verifies that pick and rescues it when
587
+ // the index drifts (`.filter().map()`, reorders). Anything we can't target
588
+ // unambiguously returns null → the caller throws → routes to /design:edit.
589
+
590
+ function isStringLit(n: AnyNode): boolean {
591
+ return !!(
592
+ n &&
593
+ (n.type === 'Literal' || n.type === 'StringLiteral') &&
594
+ typeof n.value === 'string'
595
+ );
596
+ }
597
+
598
+ /** Depth-first visit every AST node (skips location metadata keys). */
599
+ function walkAst(root: AnyNode, fn: (node: AnyNode) => void): void {
600
+ function visit(node: AnyNode): void {
601
+ if (!node || typeof node !== 'object') return;
602
+ if (Array.isArray(node)) {
603
+ for (const c of node) visit(c);
604
+ return;
605
+ }
606
+ if (typeof node.type !== 'string') return;
607
+ fn(node);
608
+ for (const k of Object.keys(node)) {
609
+ if (k === 'loc' || k === 'range' || k === 'start' || k === 'end' || k === 'type') continue;
610
+ visit(node[k]);
611
+ }
612
+ }
613
+ visit(root);
614
+ }
615
+
616
+ /** The innermost `xs.map((param) => …)` whose callback param is `paramName`
617
+ * and whose body byte-range encloses `element`. Returns the mapped array
618
+ * expression (an Identifier or an inline ArrayExpression), or null. */
619
+ function findEnclosingMapArray(
620
+ program: AnyNode,
621
+ element: AnyNode,
622
+ paramName: string
623
+ ): AnyNode | null {
624
+ const es = element.start as number;
625
+ const ee = element.end as number;
626
+ let best: AnyNode | null = null;
627
+ let bestSize = Number.POSITIVE_INFINITY;
628
+ walkAst(program, (node) => {
629
+ if (node.type !== 'CallExpression') return;
630
+ const callee = node.callee;
631
+ if (callee?.type !== 'MemberExpression' || callee.computed) return;
632
+ if (callee.property?.name !== 'map') return;
633
+ const cb = node.arguments?.[0];
634
+ if (cb?.type !== 'ArrowFunctionExpression' && cb?.type !== 'FunctionExpression') return;
635
+ const p0 = cb.params?.[0];
636
+ if (p0?.type !== 'Identifier' || p0.name !== paramName) return;
637
+ const body = cb.body;
638
+ if (typeof body?.start !== 'number' || typeof body?.end !== 'number') return;
639
+ if (!(body.start <= es && ee <= body.end)) return;
640
+ const size = (body.end as number) - (body.start as number);
641
+ if (size < bestSize) {
642
+ bestSize = size;
643
+ best = callee.object;
644
+ }
645
+ });
646
+ return best;
647
+ }
648
+
649
+ /** Resolve an array expression (an inline `[…]` or an Identifier bound to a
650
+ * `const xs = […]`) to its ArrayExpression node. */
651
+ function resolveArrayExpr(program: AnyNode, arrayExpr: AnyNode): AnyNode | null {
652
+ if (arrayExpr?.type === 'ArrayExpression') return arrayExpr;
653
+ if (arrayExpr?.type === 'Identifier') {
654
+ let found: AnyNode | null = null;
655
+ walkAst(program, (node) => {
656
+ if (
657
+ node.type === 'VariableDeclarator' &&
658
+ node.id?.type === 'Identifier' &&
659
+ node.id.name === arrayExpr.name &&
660
+ node.init?.type === 'ArrayExpression'
661
+ ) {
662
+ found = node.init;
663
+ }
664
+ });
665
+ return found;
666
+ }
667
+ return null;
668
+ }
669
+
670
+ /** The value node of property `propName` on an ObjectExpression, or null. */
671
+ function objPropValue(objExpr: AnyNode, propName: string): AnyNode | null {
672
+ if (objExpr?.type !== 'ObjectExpression') return null;
673
+ for (const p of objExpr.properties ?? []) {
674
+ if (p?.type !== 'Property' || p.computed) continue;
675
+ const keyOk =
676
+ (p.key?.type === 'Identifier' && p.key.name === propName) ||
677
+ (isStringLit(p.key) && p.key.value === propName);
678
+ if (keyOk) return p.value ?? null;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ /**
684
+ * Bounded expression evaluator — resolve `expr` to a concrete value node
685
+ * (a StringLiteral, ObjectExpression, or ArrayExpression) by following const
686
+ * bindings, numeric array indices (`XS[0]`), and object-field access
687
+ * (`obj.field`). Returns null for anything genuinely computed (calls, template
688
+ * strings, arithmetic). Depth-capped so a cyclic const can't loop.
689
+ */
690
+ function resolveValueNode(program: AnyNode, expr: AnyNode, depth = 0): AnyNode | null {
691
+ if (!expr || depth > 8) return null;
692
+ if (isStringLit(expr)) return expr;
693
+ if (expr.type === 'ObjectExpression' || expr.type === 'ArrayExpression') return expr;
694
+ if (expr.type === 'Identifier') {
695
+ let init: AnyNode | null = null;
696
+ walkAst(program, (n) => {
697
+ if (
698
+ n.type === 'VariableDeclarator' &&
699
+ n.id?.type === 'Identifier' &&
700
+ n.id.name === expr.name &&
701
+ n.init
702
+ ) {
703
+ init = n.init;
704
+ }
705
+ });
706
+ return init ? resolveValueNode(program, init, depth + 1) : null;
707
+ }
708
+ if (expr.type === 'MemberExpression') {
709
+ const obj = resolveValueNode(program, expr.object, depth + 1);
710
+ if (!obj) return null;
711
+ if (expr.computed) {
712
+ const idx = expr.property;
713
+ if (
714
+ (idx?.type === 'Literal' || idx?.type === 'NumericLiteral') &&
715
+ typeof idx.value === 'number' &&
716
+ obj.type === 'ArrayExpression'
717
+ ) {
718
+ return resolveValueNode(program, obj.elements?.[idx.value], depth + 1);
719
+ }
720
+ return null;
721
+ }
722
+ if (expr.property?.type === 'Identifier') {
723
+ return resolveValueNode(program, objPropValue(obj, expr.property.name), depth + 1);
724
+ }
725
+ return null;
726
+ }
727
+ return null;
728
+ }
729
+
730
+ /** Ordered value-expressions of a `<CompName propName={…}>` usage, one per
731
+ * usage in source order (aligns with the DOM occurrence of the element the
732
+ * prop feeds). null slot = a usage that omits the prop. */
733
+ function componentUsageValues(
734
+ program: AnyNode,
735
+ componentName: string,
736
+ propName: string
737
+ ): Array<AnyNode | null> | null {
738
+ if (!componentName) return null;
739
+ const out: Array<AnyNode | null> = [];
740
+ walkAst(program, (node) => {
741
+ if (node.type !== 'JSXElement') return;
742
+ const name = node.openingElement?.name;
743
+ if (name?.type !== 'JSXIdentifier' || name.name !== componentName) return;
744
+ let val: AnyNode | null = null;
745
+ for (const a of node.openingElement?.attributes ?? []) {
746
+ if (
747
+ a?.type === 'JSXAttribute' &&
748
+ a.name?.type === 'JSXIdentifier' &&
749
+ a.name.name === propName
750
+ ) {
751
+ val =
752
+ a.value?.type === 'JSXExpressionContainer'
753
+ ? a.value.expression
754
+ : isStringLit(a.value)
755
+ ? a.value
756
+ : null;
757
+ break;
758
+ }
759
+ }
760
+ out.push(val);
761
+ });
762
+ return out.length ? out : null;
763
+ }
764
+
765
+ /**
766
+ * Trace a single `{base}` / `{base.field}` text expression back to the
767
+ * StringLiteral node that holds its text, or null when it can't be targeted
768
+ * confidently. `base` is resolved through three bindings — a `.map()` callback
769
+ * param (array items), a component PROP (each `<Comp prop={…}>` usage), or a
770
+ * local `const` — and each candidate is run through the bounded evaluator
771
+ * (`beat.caption` where `beat={BEATS[0]}` → `BEATS[0].caption`). The occurrence
772
+ * index picks the slot; `before` verifies it and rescues a drifted index; ties
773
+ * are never guessed. `occurrence`/`before` come from the edited DOM instance.
774
+ */
775
+ function resolveDynamicTextSpan(
776
+ program: AnyNode,
777
+ element: AnyNode,
778
+ expr: AnyNode,
779
+ id: string,
780
+ opts?: DynamicTextOpts
781
+ ): AnyNode | null {
782
+ // Decompose into base Identifier + optional single field.
783
+ let baseName: string;
784
+ let field: string | null;
785
+ if (expr?.type === 'Identifier') {
786
+ baseName = expr.name;
787
+ field = null;
788
+ } else if (
789
+ expr?.type === 'MemberExpression' &&
790
+ !expr.computed &&
791
+ expr.object?.type === 'Identifier' &&
792
+ expr.property?.type === 'Identifier'
793
+ ) {
794
+ baseName = expr.object.name;
795
+ field = expr.property.name;
796
+ } else {
797
+ return null;
798
+ }
799
+
800
+ // Candidate value-expressions for `base`, one per rendered slot.
801
+ let candidates: Array<AnyNode | null> | null = null;
802
+ // (1) a `.map()` callback param → the array items.
803
+ const arrExpr = findEnclosingMapArray(program, element, baseName);
804
+ if (arrExpr) {
805
+ const arr = resolveArrayExpr(program, arrExpr);
806
+ candidates = arr ? (arr.elements ?? []) : null;
807
+ }
808
+ // (2) a component prop → each `<Comp base={…}>` usage's value.
809
+ if (!candidates) {
810
+ const componentName =
811
+ collectElementsFull(program).find((e) => e.id === id)?.componentName ?? '';
812
+ candidates = componentUsageValues(program, componentName, baseName);
813
+ }
814
+ // (3) a local const → a single candidate.
815
+ if (!candidates) {
816
+ let init: AnyNode | null = null;
817
+ walkAst(program, (n) => {
818
+ if (
819
+ n.type === 'VariableDeclarator' &&
820
+ n.id?.type === 'Identifier' &&
821
+ n.id.name === baseName
822
+ ) {
823
+ init = n.init ?? null;
824
+ }
825
+ });
826
+ candidates = init ? [init] : null;
827
+ }
828
+ if (!candidates) return null;
829
+ // Complexity cap (ethical-hacker F-C): each candidate runs a full-program
830
+ // walkAst per resolution step, so an attacker-authored canvas with a huge
831
+ // array of `{identifier}` items could make this O(N × program). A real
832
+ // `.map()`/usage list is tiny; anything past the cap is refused (a single
833
+ // edit couldn't confidently target one of thousands of slots anyway).
834
+ const MAX_CANDIDATES = 1000;
835
+ if (candidates.length > MAX_CANDIDATES) return null;
836
+
837
+ // Resolve each candidate to a StringLiteral (applying `.field` when present).
838
+ const lits: Array<AnyNode | null> = candidates.map((c) => {
839
+ const base = resolveValueNode(program, c);
840
+ if (!base) return null;
841
+ if (field) {
842
+ const v = resolveValueNode(program, objPropValue(base, field));
843
+ return isStringLit(v) ? v : null;
844
+ }
845
+ return isStringLit(base) ? base : null;
846
+ });
847
+
848
+ // Pick: occurrence index (verified by `before`), else the UNIQUE `before`
849
+ // match, else — only with no `before` to verify — the raw index. No guessing.
850
+ const beforeTrim = (opts?.before ?? '').trim();
851
+ const occ = typeof opts?.occurrence === 'number' && opts.occurrence >= 0 ? opts.occurrence : null;
852
+ const at = occ != null ? lits[occ] : null;
853
+ if (at && (!beforeTrim || String(at.value).trim() === beforeTrim)) return at;
854
+ if (beforeTrim) {
855
+ const hits = lits.filter((c): c is AnyNode => !!c && String(c.value).trim() === beforeTrim);
856
+ return hits.length === 1 ? (hits[0] ?? null) : null;
857
+ }
858
+ return at ?? null;
859
+ }
860
+
544
861
  // ---------------------------------------------------------------------------
545
862
  // Node-move reorder (DDR-138, phase-12.1). Moving a whole JSXElement to a new
546
863
  // sibling/parent position — the one structural edit `editAttribute`/`editText`
@@ -2842,6 +3159,138 @@ export async function insertElement(
2842
3159
  });
2843
3160
  }
2844
3161
 
3162
+ /**
3163
+ * Insert a synthesized element as a child of the `<DCArtboard id="…">` itself —
3164
+ * the empty-artboard fallback for the tool-palette "+ Element" affordance
3165
+ * (Stage I3 tail addendum). `applyInsertElement` needs a sibling `data-cd-id`
3166
+ * to anchor on; a freshly-scaffolded or fully-cleared artboard has none, so
3167
+ * this variant anchors on the DCArtboard JSX node itself (addressed by its
3168
+ * `id` PROP, same convention as `applyResizeArtboard`/`applyDeleteArtboard` —
3169
+ * an artboard's rendered `<article data-dc-screen>` carries no `data-cd-id`).
3170
+ * Only `inside-start`/`inside-end` make sense here (no sibling to be
3171
+ * before/after). Pure; reparse-gated like `applyInsertElement`.
3172
+ */
3173
+ export function applyInsertElementIntoArtboard(
3174
+ canvasAbsPath: string,
3175
+ source: string,
3176
+ artboardId: string,
3177
+ position: 'inside-start' | 'inside-end',
3178
+ kind: InsertKind,
3179
+ opts?: { src?: string }
3180
+ ): { source: string; newId: string | null } {
3181
+ if (kind === 'image') {
3182
+ if (!opts?.src?.trim()) {
3183
+ throw new CanvasEditError('insert image requires a contained asset src (assets/…)', {
3184
+ canvas: canvasAbsPath,
3185
+ id: artboardId,
3186
+ });
3187
+ }
3188
+ assertContainedAssetSrc(opts.src, canvasAbsPath);
3189
+ }
3190
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3191
+ if (parsed.errors && parsed.errors.length > 0) {
3192
+ throw new CanvasEditError(
3193
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3194
+ { canvas: canvasAbsPath, id: artboardId }
3195
+ );
3196
+ }
3197
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3198
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3199
+ if (!target) {
3200
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3201
+ canvas: canvasAbsPath,
3202
+ id: artboardId,
3203
+ });
3204
+ }
3205
+ if (target.openingElement?.selfClosing || !target.closingElement) {
3206
+ throw new CanvasEditError(
3207
+ `<DCArtboard id="${artboardId}"> is self-closing — cannot nest an element inside it`,
3208
+ { canvas: canvasAbsPath, id: artboardId }
3209
+ );
3210
+ }
3211
+
3212
+ const rStart = target.start as number;
3213
+ const indentUnit = detectIndentUnit(source);
3214
+ const newText = synthInsertElement(kind, opts?.src);
3215
+ const targetIndent = lineStartInfo(source, rStart).indent + indentUnit;
3216
+
3217
+ let anchor: number;
3218
+ let insertText: string;
3219
+ if (position === 'inside-start') {
3220
+ anchor = target.openingElement.end as number;
3221
+ insertText = `\n${targetIndent}${newText}`;
3222
+ } else {
3223
+ const cStart = target.closingElement.start as number;
3224
+ const cLine = lineStartInfo(source, cStart);
3225
+ if (cLine.newlineBefore) {
3226
+ anchor = cLine.indentStart - 1;
3227
+ insertText = `\n${targetIndent}${newText}`;
3228
+ } else {
3229
+ anchor = cStart;
3230
+ insertText = `${newText}`;
3231
+ }
3232
+ }
3233
+
3234
+ const s = new MagicString(source);
3235
+ s.appendLeft(anchor, insertText);
3236
+ const out = s.toString();
3237
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3238
+ if (check.errors && check.errors.length > 0) {
3239
+ throw new CanvasEditError(
3240
+ `insert would produce invalid source (${check.errors[0]?.message ?? 'parse error'}); aborted`,
3241
+ { canvas: canvasAbsPath, id: artboardId }
3242
+ );
3243
+ }
3244
+ // Same post-insert id recomputation as applyInsertElement — single append,
3245
+ // no removes, so the new element's `<` lands at this exact offset in `out`.
3246
+ const prefixLen = insertText.indexOf(newText);
3247
+ const elemStart = anchor + prefixLen;
3248
+ let newId: string | null = null;
3249
+ let fallback: string | null = null;
3250
+ for (const { id: eid, node } of collectElements(check.program)) {
3251
+ if ((node.start as number) === elemStart) {
3252
+ newId = eid;
3253
+ break;
3254
+ }
3255
+ if (out.slice(node.start as number, node.end as number) === newText) fallback = eid;
3256
+ }
3257
+ return { source: out, newId: newId ?? fallback };
3258
+ }
3259
+
3260
+ /** Insert an element into an artboard by artboardId (no ref sibling) on disk. */
3261
+ export async function insertElementIntoArtboard(
3262
+ canvasAbsPath: string,
3263
+ artboardId: string,
3264
+ position: 'inside-start' | 'inside-end',
3265
+ kind: InsertKind,
3266
+ opts?: { src?: string }
3267
+ ): Promise<{ source: string; newId: string | null }> {
3268
+ return withLock(canvasAbsPath, async () => {
3269
+ const file = Bun.file(canvasAbsPath);
3270
+ if (!(await file.exists())) {
3271
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3272
+ canvas: canvasAbsPath,
3273
+ id: artboardId,
3274
+ });
3275
+ }
3276
+ const source = await file.text();
3277
+ const next = applyInsertElementIntoArtboard(
3278
+ canvasAbsPath,
3279
+ source,
3280
+ artboardId,
3281
+ position,
3282
+ kind,
3283
+ opts
3284
+ );
3285
+ if (next.source === source) return next;
3286
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3287
+ await Bun.write(tmp, next.source);
3288
+ const { rename } = await import('node:fs/promises');
3289
+ await rename(tmp, canvasAbsPath);
3290
+ return next;
3291
+ });
3292
+ }
3293
+
2845
3294
  /**
2846
3295
  * Duplicate the element with `data-cd-id === id` — insert a verbatim copy of its
2847
3296
  * source as the next sibling (Cmd+D, Task L3). Since the SOURCE carries no