@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
@@ -87,6 +87,7 @@ import {
87
87
  createContext,
88
88
  Fragment,
89
89
  isValidElement,
90
+ type MouseEvent as ReactMouseEvent,
90
91
  type ReactNode,
91
92
  type PointerEvent as ReactPointerEvent,
92
93
  type RefObject,
@@ -106,6 +107,11 @@ import {
106
107
  diffLayoutPositions,
107
108
  } from './commands/move-artboards-command.ts';
108
109
  import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
110
+ // Photo editor (feature-photo-editor) — schema.ts is DEPENDENCY-FREE (no pixi),
111
+ // so this static import is safe and adds zero bundle cost. The WebGL compositor
112
+ // (photo/pipeline.ts, which imports pixi.js) is loaded via a DYNAMIC import
113
+ // inside <PhotoLayer> only when an edited photo actually mounts — see there.
114
+ import { isDefaultEdit, type PhotoEdit } from './photo/schema.ts';
109
115
  import { AgentPresenceProvider, useAgentPresence } from './use-agent-presence.tsx';
110
116
  import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
111
117
  import {
@@ -118,13 +124,14 @@ import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
118
124
  import { useSelectionSetOptional } from './use-selection-set.tsx';
119
125
  import { MaybeToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
120
126
  import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
127
+ // DDR-148 — video-comp canvas kind. Imported (not just re-exported below) so
128
+ // DCArtboard can identity-match <VideoComp> for the header badge. Its
129
+ // `remotion`/`@remotion/player` imports resolve through the canvas importmap
130
+ // (RUNTIME_PACKAGES), same as react/motion.
131
+ import { VideoComp } from './video-comp.tsx';
121
132
 
122
133
  export type { CompSnapshot, VideoCompMeta, VideoCompProps } from './video-comp.tsx';
123
- // DDR-148 video-comp canvas kind. Re-exported so `@maude/canvas-lib` exposes
124
- // <VideoComp> (Remotion composition mounted in <Player> + the deterministic
125
- // seek bridge). Its `remotion`/`@remotion/player` imports resolve through the
126
- // canvas importmap (RUNTIME_PACKAGES), same as react/motion.
127
- export { VideoComp } from './video-comp.tsx';
134
+ export { VideoComp };
128
135
 
129
136
  // ─────────────────────────────────────────────────────────────────────────────
130
137
  // Module constants
@@ -320,6 +327,28 @@ button.dc-artboard-label {
320
327
  width: 100%;
321
328
  }
322
329
  button.dc-artboard-label:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: -2px; }
330
+ /* DDR-148 — video-artboard badge, overlaid top-right of the header. Opens
331
+ the timeline panel (same postMessage the context-menu entry sends). */
332
+ .dc-canvas .dc-artboard-video-badge {
333
+ position: absolute;
334
+ top: 4px;
335
+ right: 6px;
336
+ z-index: 2;
337
+ display: inline-flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ width: 18px;
341
+ height: 18px;
342
+ padding: 0;
343
+ appearance: none;
344
+ border: none;
345
+ border-radius: 3px;
346
+ background: var(--bg-2, #e8e3d8);
347
+ color: var(--fg-1, #4a3f30);
348
+ cursor: pointer;
349
+ }
350
+ .dc-canvas .dc-artboard-video-badge:hover { background: color-mix(in oklab, var(--fg-0, #2a2520) 12%, var(--bg-2, #e8e3d8)); }
351
+ .dc-canvas .dc-artboard-video-badge:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: 1px; }
323
352
  /* Active-artboard ring is in canvas-shell HALO_CSS (subtle 1 px tint). */
324
353
  /* Phase 4.2 — drag chrome. */
325
354
  .dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
@@ -451,6 +480,29 @@ function harvestArtboards(children: ReactNode): ArtboardSeed[] {
451
480
  return out;
452
481
  }
453
482
 
483
+ /**
484
+ * Does this artboard's subtree contain a <VideoComp>? Same identity-match +
485
+ * displayName-fallback walk as harvestArtboards, short-circuiting on the
486
+ * first hit — used to badge video artboards in DCArtboard's header.
487
+ */
488
+ function subtreeHasVideoComp(children: ReactNode): boolean {
489
+ function visit(node: ReactNode): boolean {
490
+ if (node == null || typeof node === 'boolean') return false;
491
+ if (Array.isArray(node)) return node.some(visit);
492
+ if (!isValidElement(node)) return false;
493
+ const type = node.type;
494
+ if (
495
+ type === VideoComp ||
496
+ (typeof type === 'function' && (type as { displayName?: string }).displayName === 'VideoComp')
497
+ ) {
498
+ return true;
499
+ }
500
+ const childProp = (node.props as { children?: ReactNode } | null | undefined)?.children;
501
+ return childProp != null ? visit(childProp) : false;
502
+ }
503
+ return visit(children);
504
+ }
505
+
454
506
  function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
455
507
  // Render order (the order DCArtboards appear in JSX), not alphabetical —
456
508
  // authors label artboards DS-01 / DS-02 / CV-01 etc. and expect that
@@ -1761,6 +1813,21 @@ export function DCArtboard({
1761
1813
  // its presence color + show its funny name instead of the generic file label.
1762
1814
  const agent = useAgentPresence();
1763
1815
  const rect = ctx ? ctx.rectFor(id) : null;
1816
+ // DDR-148 — badge video artboards in the header; clicking the badge opens
1817
+ // the timeline panel via the same postMessage the context-menu "Open
1818
+ // Timeline" entry already sends (canvas-shell.tsx artboardHasVideo/handler).
1819
+ const hasVideo = useMemo(() => subtreeHasVideoComp(children), [children]);
1820
+ const openTimeline = useCallback(
1821
+ (e: ReactMouseEvent<HTMLButtonElement>) => {
1822
+ e.stopPropagation();
1823
+ try {
1824
+ window.parent.postMessage({ dgn: 'open-timeline-request', artboardId: id }, '*');
1825
+ } catch {
1826
+ /* detached / cross-origin */
1827
+ }
1828
+ },
1829
+ [id]
1830
+ );
1764
1831
 
1765
1832
  // Drag hook — always called (hook rules). Inert outside DesignCanvas
1766
1833
  // (allRects empty, enabled=false), so specimens / legacy uses get a plain
@@ -1872,6 +1939,33 @@ export function DCArtboard({
1872
1939
  <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1873
1940
  {label}
1874
1941
  </button>
1942
+ {hasVideo ? (
1943
+ <button
1944
+ type="button"
1945
+ className="dc-artboard-video-badge"
1946
+ aria-label={`Open timeline — ${label}`}
1947
+ title="Video artboard — open timeline"
1948
+ onClick={openTimeline}
1949
+ >
1950
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
1951
+ <rect
1952
+ x="1.5"
1953
+ y="4.5"
1954
+ width="8"
1955
+ height="7"
1956
+ rx="1.2"
1957
+ stroke="currentColor"
1958
+ strokeWidth="1.3"
1959
+ />
1960
+ <path
1961
+ d="M9.5 7l4-2.3v6.6l-4-2.3"
1962
+ stroke="currentColor"
1963
+ strokeWidth="1.3"
1964
+ strokeLinejoin="round"
1965
+ />
1966
+ </svg>
1967
+ </button>
1968
+ ) : null}
1875
1969
  <div className="dc-artboard-body">{children}</div>
1876
1970
  </article>
1877
1971
  {showActivity ? (
@@ -2136,6 +2230,430 @@ export function DrawProof({
2136
2230
  }
2137
2231
  DrawProof.displayName = 'DrawProof';
2138
2232
 
2233
+ // ─────────────────────────────────────────────────────────────────────────────
2234
+ // PhotoLayer (feature-photo-editor, Task 6) — the non-destructive WebGL photo
2235
+ // compositor surface. Renders a source photo (artboard `<img>` or annotation
2236
+ // `ImageStroke`) with a live `PhotoEdit` applied through pixi.js.
2237
+ //
2238
+ // LAZY-BUNDLE GUARANTEE (the plan's load-bearing acceptance criterion + BUILDER's
2239
+ // flagged top risk): an UNEDITED photo (`isDefaultEdit(edit)`) renders as the
2240
+ // plain `<img>` and NEVER touches pixi. The compositor module (photo/pipeline.ts)
2241
+ // is only reached through a DYNAMIC `import()` inside the effect, so a canvas
2242
+ // with zero edited photos pays zero pixi.js/bg-removal cost. Verified empirically
2243
+ // against `buildCanvasModule` (no eager `pixi.js` import in the default-edit
2244
+ // bundle) — see test/photo-canvas-bundle.test.ts.
2245
+ //
2246
+ // A11y: the pixi output is a `<canvas>` (a black box to AT), so it carries
2247
+ // `role="img"` + `aria-label` from `alt` (validation step 7 requirement).
2248
+ // Reduced-motion: the compositor renders statically (autoStart:false, no ticker).
2249
+
2250
+ export interface PhotoLayerProps {
2251
+ /** Relative `assets/<sha8>.<ext>` source (validated upstream). */
2252
+ source: string;
2253
+ /** Live non-destructive edit. Absent / neutral ⇒ plain `<img>`, no pixi. */
2254
+ edit?: PhotoEdit | null;
2255
+ width: number;
2256
+ height: number;
2257
+ alt?: string;
2258
+ className?: string;
2259
+ style?: CSSProperties;
2260
+ /** Resolve a relative asset path to a fetchable URL (defaults to identity —
2261
+ * relative `assets/…` already resolves against the canvas iframe origin). */
2262
+ resolveUrl?: (rel: string) => string;
2263
+ /** Fired once the pixi compositor has mounted + drawn its first frame. The
2264
+ * preview bridge hides the original element only AFTER this, so a background
2265
+ * cutout never flashes the untouched original underneath (and no flicker). */
2266
+ onReady?: () => void;
2267
+ }
2268
+
2269
+ export function PhotoLayer({
2270
+ source,
2271
+ edit,
2272
+ width,
2273
+ height,
2274
+ alt = '',
2275
+ className,
2276
+ style,
2277
+ resolveUrl,
2278
+ onReady,
2279
+ }: PhotoLayerProps) {
2280
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
2281
+ const rendererRef = useRef<{ destroy(): void; update(e: PhotoEdit): void } | null>(null);
2282
+ const active = !isDefaultEdit(edit);
2283
+
2284
+ // Mount / tear down the pixi compositor only while an edit is active. The
2285
+ // compositor is DYNAMICALLY imported (lazy-bundle guarantee — see header).
2286
+ // Re-created only when the source/box identity changes; edit-param changes are
2287
+ // pushed via the second effect below (no teardown → smooth live scrub).
2288
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `edit` is deliberately excluded — re-creating the pixi Application on every scrub would thrash; edit updates flow through the second effect's `update()` (mount seeds from the current edit).
2289
+ useEffect(() => {
2290
+ if (!active) return;
2291
+ const canvas = canvasRef.current;
2292
+ if (!canvas) return;
2293
+ let disposed = false;
2294
+ import('./photo/pipeline.ts')
2295
+ .then(({ PhotoRenderer }) =>
2296
+ PhotoRenderer.create({
2297
+ canvas,
2298
+ source,
2299
+ edit: (edit ?? {}) as PhotoEdit,
2300
+ width,
2301
+ height,
2302
+ resolveUrl,
2303
+ })
2304
+ )
2305
+ .then((r) => {
2306
+ if (disposed) {
2307
+ r.destroy();
2308
+ return;
2309
+ }
2310
+ rendererRef.current = r;
2311
+ onReady?.();
2312
+ })
2313
+ .catch((err) => {
2314
+ console.error('[PhotoLayer] compositor failed to mount', err);
2315
+ });
2316
+ return () => {
2317
+ disposed = true;
2318
+ rendererRef.current?.destroy();
2319
+ rendererRef.current = null;
2320
+ };
2321
+ }, [active, source, width, height, resolveUrl, onReady]);
2322
+
2323
+ // Live-update the mounted compositor on edit-param change (no re-create).
2324
+ useEffect(() => {
2325
+ if (active && edit && rendererRef.current) rendererRef.current.update(edit);
2326
+ }, [edit, active]);
2327
+
2328
+ if (!active) {
2329
+ const src = resolveUrl ? resolveUrl(source) : source;
2330
+ return (
2331
+ <img src={src} width={width} height={height} alt={alt} className={className} style={style} />
2332
+ );
2333
+ }
2334
+ return (
2335
+ <canvas
2336
+ ref={canvasRef}
2337
+ width={width}
2338
+ height={height}
2339
+ role="img"
2340
+ aria-label={alt || 'Edited photo'}
2341
+ className={className}
2342
+ style={{ width, height, display: 'block', ...style }}
2343
+ />
2344
+ );
2345
+ }
2346
+ PhotoLayer.displayName = 'PhotoLayer';
2347
+
2348
+ // PhotoPreviewBridge (feature-photo-editor) — applies a live/persisted
2349
+ // PhotoEdit DIRECTLY to the real photo element (artboard `<img>` or
2350
+ // annotation `<image>`) by swapping its `src`/`href` to a baked data URL,
2351
+ // instead of floating a separate WebGL-rendered decoy on top of it.
2352
+ //
2353
+ // Iteration 1 of this bridge did the "decoy" version: a `position:fixed` div
2354
+ // tracked the real element's screen rect via a per-frame rAF loop and hid the
2355
+ // original underneath (`visibility:hidden`) while a live `<PhotoLayer>` pixi
2356
+ // canvas rendered on top. That broke every bit of free native DOM behavior
2357
+ // the real element used to have:
2358
+ // - cmd+click / right-click hit-testing landed on whatever was BEHIND the
2359
+ // now-invisible original (a hidden element isn't hit-testable), while the
2360
+ // decoy was `pointer-events:none` so it couldn't take the click either —
2361
+ // net result, nothing was clickable.
2362
+ // - the decoy rendered at a STABLE "world" pixel size and was CSS-stretched
2363
+ // to the live screen box on zoom; the stretch didn't reliably track the
2364
+ // real box, so the visible photo grew/shrank relative to its own frame.
2365
+ // - it needed its own z-index (originally 30 — drew over the context menu)
2366
+ // instead of just sitting at the element's normal stacking position.
2367
+ // - it only knew about an edit via the transient postMessage below, so any
2368
+ // iframe remount (Cmd+R, HMR) reset it to nothing until a human reopened
2369
+ // the Inspector and nudged a knob.
2370
+ // Swapping the REAL element's `src`/`href` sidesteps all of it: resize, zoom,
2371
+ // hit-testing, and stacking become the browser's native `<img>`/`<image>`
2372
+ // behavior, not a hand-rolled tracker. Non-destructive still holds — only the
2373
+ // LIVE DOM attribute is mutated, never the authored TSX/SVG source; the
2374
+ // on-disk `PhotoEdit` sidecar (`/_api/photo-edit`) stays the persisted source
2375
+ // of truth, re-applied by the hydration scan below on every canvas (re)mount.
2376
+ // The bake is at the source's NATIVE resolution (`renderPhotoDataUrl`), so
2377
+ // the result stays sharp across any later resize/zoom with no re-bake.
2378
+
2379
+ /** `assets/<sha8>.<ext>` substring inside a `src`/`href`/`xlink:href`. */
2380
+ const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
2381
+
2382
+ /** Matches a photo element by the `data-photo-asset` tag `apply()` stamps on
2383
+ * first touch, falling back to a literal src/href substring match for an
2384
+ * element this bridge hasn't touched yet. The tag is load-bearing: once
2385
+ * baked, the element's `src`/`href` is a `data:` URL that no longer contains
2386
+ * the original asset path, so the substring match alone would lose track of
2387
+ * it on the very next edit. */
2388
+ function findPhotoEl(asset: string): Element | null {
2389
+ if (typeof document === 'undefined') return null;
2390
+ // Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
2391
+ // the canvas iframe is untrusted content (DDR-054); an authored canvas could
2392
+ // otherwise stamp the tag on an arbitrary element to redirect a bake.
2393
+ for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
2394
+ if (n.getAttribute('data-photo-asset') === asset) return n;
2395
+ }
2396
+ for (const n of document.querySelectorAll('img, image')) {
2397
+ const src =
2398
+ n.getAttribute('src') || n.getAttribute('href') || n.getAttribute('xlink:href') || '';
2399
+ if (src.includes(asset)) return n;
2400
+ }
2401
+ return null;
2402
+ }
2403
+
2404
+ function extractAssetRef(el: Element): string | null {
2405
+ // Only trust `data-photo-asset` when it actually has the `assets/<sha8>.<ext>`
2406
+ // shape — the tag is attacker-controllable (untrusted canvas content,
2407
+ // DDR-054), and an unshaped value would otherwise ride unbounded into
2408
+ // `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
2409
+ const tagged = el.getAttribute('data-photo-asset');
2410
+ if (tagged && ASSET_REF_RE.test(tagged)) return tagged;
2411
+ const ref =
2412
+ el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
2413
+ return ref.match(ASSET_REF_RE)?.[0] ?? null;
2414
+ }
2415
+
2416
+ function setPhotoElSrc(el: Element, url: string): void {
2417
+ if (el.tagName.toLowerCase() === 'image') {
2418
+ el.setAttribute(el.hasAttribute('xlink:href') ? 'xlink:href' : 'href', url);
2419
+ } else {
2420
+ el.setAttribute('src', url);
2421
+ }
2422
+ }
2423
+
2424
+ const BAKE_DEBOUNCE_MS = 80;
2425
+
2426
+ export function PhotoPreviewBridge() {
2427
+ // The ORIGINAL (unedited) src/href per asset, captured the first time this
2428
+ // bridge touches that element — so turning an edit off restores exactly
2429
+ // what the element pointed to, not a guess.
2430
+ const originalRef = useRef<Map<string, string>>(new Map());
2431
+ // Per-asset bake generation — guards a slow (or out-of-order) render from
2432
+ // clobbering a NEWER edit that already resolved first.
2433
+ const tokenRef = useRef<Map<string, number>>(new Map());
2434
+ const bakeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
2435
+
2436
+ const bake = useCallback((asset: string, original: string, edit: PhotoEdit) => {
2437
+ const token = (tokenRef.current.get(asset) ?? 0) + 1;
2438
+ tokenRef.current.set(asset, token);
2439
+ import('./photo/pipeline.ts')
2440
+ .then(({ renderPhotoDataUrl }) => renderPhotoDataUrl({ source: original, edit }))
2441
+ .then((dataUrl) => {
2442
+ if (tokenRef.current.get(asset) !== token) return; // superseded by a newer edit
2443
+ const live = findPhotoEl(asset);
2444
+ if (live) setPhotoElSrc(live, dataUrl);
2445
+ })
2446
+ .catch((err) => {
2447
+ console.error('[PhotoPreviewBridge] bake failed', err);
2448
+ });
2449
+ }, []);
2450
+
2451
+ const apply = useCallback(
2452
+ (asset: string, edit: PhotoEdit | null) => {
2453
+ const el = findPhotoEl(asset);
2454
+ if (!el) return;
2455
+ if (!el.hasAttribute('data-photo-asset')) el.setAttribute('data-photo-asset', asset);
2456
+ if (!originalRef.current.has(asset)) {
2457
+ const orig =
2458
+ el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
2459
+ originalRef.current.set(asset, orig);
2460
+ }
2461
+ const original = originalRef.current.get(asset) ?? '';
2462
+ const timers = bakeTimers.current;
2463
+ clearTimeout(timers.get(asset));
2464
+ if (!edit || isDefaultEdit(edit)) {
2465
+ timers.delete(asset);
2466
+ setPhotoElSrc(el, original);
2467
+ return;
2468
+ }
2469
+ timers.set(
2470
+ asset,
2471
+ setTimeout(() => bake(asset, original, edit), BAKE_DEBOUNCE_MS)
2472
+ );
2473
+ },
2474
+ [bake]
2475
+ );
2476
+
2477
+ useEffect(() => {
2478
+ const timers = bakeTimers.current;
2479
+ return () => {
2480
+ for (const t of timers.values()) clearTimeout(t);
2481
+ };
2482
+ }, []);
2483
+
2484
+ useEffect(() => {
2485
+ const onMsg = (e: MessageEvent) => {
2486
+ const m = e.data as { dgn?: string; asset?: unknown; edit?: unknown; busy?: unknown } | null;
2487
+ if (!m) return;
2488
+ // Background-removal busy shimmer (Task 12 reveal) — a `data-photo-busy`
2489
+ // attribute toggle on the real element, styled by inspect.ts's single CSS
2490
+ // injection point (mirrors `.dc-activity-scan`'s sweep language). No
2491
+ // separate tracked overlay — see the header comment above `apply()`.
2492
+ if (m.dgn === 'photo-busy' && typeof m.asset === 'string') {
2493
+ const el = findPhotoEl(m.asset);
2494
+ el?.toggleAttribute('data-photo-busy', !!m.busy);
2495
+ return;
2496
+ }
2497
+ if (m.dgn !== 'photo-preview' || typeof m.asset !== 'string') return;
2498
+ const asset = m.asset;
2499
+ const edit = (m.edit ?? null) as PhotoEdit | null;
2500
+ apply(asset, edit);
2501
+ };
2502
+ window.addEventListener('message', onMsg);
2503
+ return () => window.removeEventListener('message', onMsg);
2504
+ }, [apply]);
2505
+ // Boot-time (+ ongoing) hydration from the PERSISTED sidecar, not just the
2506
+ // live `photo-preview` message above. Without this, a saved PhotoEdit is
2507
+ // invisible after anything that re-mounts the canvas doc (Cmd+R, an
2508
+ // HMR remount, or a resize that recreates the photo's DOM node) — the
2509
+ // message-only bridge starts every fresh mount with an empty `edits` map,
2510
+ // and nothing re-sends the already-saved edit until a human happens to
2511
+ // reopen the Inspector Photo tab and touch a knob. A MutationObserver
2512
+ // re-scan (not just an initial one-shot) is what makes this self-heal after
2513
+ // those remounts, since the photo element's DOM node is often a NEW node
2514
+ // post-remount, not the one the original message targeted.
2515
+ useEffect(() => {
2516
+ if (typeof document === 'undefined') return;
2517
+ let cancelled = false;
2518
+ let raf = 0;
2519
+ // Every asset gets AT MOST one fetch attempt for this bridge's lifetime —
2520
+ // without this, a canvas whose DOM keeps mutating (an animation, a
2521
+ // re-rendering component) reissues the full unfetched set on every
2522
+ // mutation frame, forever (security review finding: unbounded fetch
2523
+ // amplification against the dev server from a zero-gesture background
2524
+ // scan).
2525
+ const attempted = new Set<string>();
2526
+ // Safety ceiling per pass — a pathological/hostile canvas DOM (thousands
2527
+ // of img/image elements) shouldn't be able to fan out unbounded fetches
2528
+ // in one scan; the next mutation-triggered pass picks up where this left
2529
+ // off since `attempted` persists across passes.
2530
+ const MAX_SCAN_PER_PASS = 500;
2531
+ const scan = () => {
2532
+ let scanned = 0;
2533
+ for (const n of document.querySelectorAll('img, image')) {
2534
+ const asset = extractAssetRef(n);
2535
+ if (!asset || attempted.has(asset)) continue;
2536
+ if (++scanned > MAX_SCAN_PER_PASS) break;
2537
+ attempted.add(asset);
2538
+ fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
2539
+ .then((r) => (r.ok ? r.json() : null))
2540
+ .then((edit) => {
2541
+ if (cancelled || !edit || isDefaultEdit(edit)) return;
2542
+ apply(asset, edit);
2543
+ })
2544
+ .catch(() => {});
2545
+ }
2546
+ };
2547
+ scan();
2548
+ const observer = new MutationObserver(() => {
2549
+ cancelAnimationFrame(raf);
2550
+ raf = requestAnimationFrame(scan);
2551
+ });
2552
+ observer.observe(document.body, {
2553
+ childList: true,
2554
+ subtree: true,
2555
+ attributes: true,
2556
+ attributeFilter: ['src', 'href'],
2557
+ });
2558
+ return () => {
2559
+ cancelled = true;
2560
+ cancelAnimationFrame(raf);
2561
+ observer.disconnect();
2562
+ };
2563
+ }, [apply]);
2564
+ return null; // mutates the real elements directly — no visible DOM of its own.
2565
+ }
2566
+ PhotoPreviewBridge.displayName = 'PhotoPreviewBridge';
2567
+
2568
+ // PhotoBgRemoveHarness (feature-photo-editor, Task 18) — the headless CLI proof
2569
+ // harness `photo-bg-remove.sh` mounts inside a throwaway `_photo/<slug>.bgremove.tsx`
2570
+ // canvas (mirrors DrawProof's role for the draw engine). Runs the EXACT SAME
2571
+ // client-side ML flow the interactive "Remove Background" button uses (Task 12,
2572
+ // app.jsx `onPhotoRemoveBackground`) — @imgly/background-removal, WASM/WebGPU,
2573
+ // pixels never leave the browser — then persists the result and reports back to
2574
+ // the driving CLI script via DOM attributes it polls (no return value crosses
2575
+ // the process boundary; agent-browser reads attributes off the DOM instead).
2576
+ //
2577
+ // `/_api/asset` and `/_api/photo-edit` are BOTH canvas-safe routes (see their
2578
+ // CANVAS_SAFE_API comments in http.ts), so this harness posts directly from the
2579
+ // canvas origin — no main-origin relay / cross-origin workaround needed, despite
2580
+ // the split-origin (DDR-054) boundary the rest of the canvas runs inside.
2581
+
2582
+ export interface PhotoBgRemoveHarnessProps {
2583
+ /** Relative `assets/<sha8>.<ext>` source to remove the background from. */
2584
+ source: string;
2585
+ }
2586
+
2587
+ export function PhotoBgRemoveHarness({ source }: PhotoBgRemoveHarnessProps) {
2588
+ const [status, setStatus] = useState<'pending' | 'done' | 'error'>('pending');
2589
+ const [result, setResult] = useState<string | null>(null);
2590
+ const [error, setError] = useState<string | null>(null);
2591
+ const ranRef = useRef(false);
2592
+
2593
+ useEffect(() => {
2594
+ if (ranRef.current) return; // one ML pass per mount — a dev-mode double-effect must not double-run it
2595
+ ranRef.current = true;
2596
+ let cancelled = false;
2597
+ (async () => {
2598
+ try {
2599
+ const srcRes = await fetch(`/${source.replace(/^\/+/, '')}`);
2600
+ if (!srcRes.ok) throw new Error(`source fetch failed: ${srcRes.status}`);
2601
+ const srcBlob = await srcRes.blob();
2602
+ const { removeBackground } = await import('@imgly/background-removal');
2603
+ const matte = await removeBackground(srcBlob);
2604
+ const up = await fetch('/_api/asset', {
2605
+ method: 'POST',
2606
+ headers: { 'content-type': matte.type || 'image/png' },
2607
+ body: matte,
2608
+ });
2609
+ const upJson = (await up.json().catch(() => ({}))) as { path?: string };
2610
+ if (!up.ok || !upJson.path) throw new Error(`asset upload failed: ${up.status}`);
2611
+ const maskAsset = upJson.path;
2612
+
2613
+ // Merge onto whatever's already in the sidecar (mirrors photo-adjust.sh's
2614
+ // merge-by-default behavior) instead of clobbering unrelated fields.
2615
+ const base: unknown = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`)
2616
+ .then((r) => (r.ok ? r.json() : {}))
2617
+ .catch(() => ({}));
2618
+ const nextEdit = {
2619
+ ...(base && typeof base === 'object' ? base : {}),
2620
+ backgroundRemoved: { enabled: true, maskAsset },
2621
+ };
2622
+ const put = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`, {
2623
+ method: 'PUT',
2624
+ headers: { 'Content-Type': 'application/json' },
2625
+ body: JSON.stringify(nextEdit),
2626
+ });
2627
+ if (!put.ok) throw new Error(`photo-edit save failed: ${put.status}`);
2628
+ if (cancelled) return;
2629
+ setResult(maskAsset);
2630
+ setStatus('done');
2631
+ } catch (err) {
2632
+ if (cancelled) return;
2633
+ setError(err instanceof Error ? err.message : String(err));
2634
+ setStatus('error');
2635
+ }
2636
+ })();
2637
+ return () => {
2638
+ cancelled = true;
2639
+ };
2640
+ }, [source]);
2641
+
2642
+ return (
2643
+ <div
2644
+ data-photo-bgremove-status={status}
2645
+ data-photo-bgremove-result={result ?? ''}
2646
+ data-photo-bgremove-error={error ?? ''}
2647
+ style={{ padding: 24, font: '13px monospace' }}
2648
+ >
2649
+ photo-bg-remove: {status}
2650
+ {result ? ` → ${result}` : ''}
2651
+ {error ? ` (${error})` : ''}
2652
+ </div>
2653
+ );
2654
+ }
2655
+ PhotoBgRemoveHarness.displayName = 'PhotoBgRemoveHarness';
2656
+
2139
2657
  // ─────────────────────────────────────────────────────────────────────────────
2140
2658
  // SnapGuideOverlay (Phase 4.2) — renders 1 px guide lines while a drag is in
2141
2659
  // flight. Mounted by canvas-shell as a chrome layer outside `.dc-world`, so
@@ -158,21 +158,65 @@ function jsxElementName(opening: AnyNode): string {
158
158
  return '?';
159
159
  }
160
160
 
161
- function hasDataCdIdAttr(opening: AnyNode): boolean {
161
+ function hasJsxAttr(opening: AnyNode, name: string): boolean {
162
162
  const attrs = opening?.attributes;
163
163
  if (!Array.isArray(attrs)) return false;
164
164
  for (const a of attrs) {
165
- if (
166
- a?.type === 'JSXAttribute' &&
167
- a.name?.type === 'JSXIdentifier' &&
168
- a.name.name === 'data-cd-id'
169
- ) {
165
+ if (a?.type === 'JSXAttribute' && a.name?.type === 'JSXIdentifier' && a.name.name === name) {
170
166
  return true;
171
167
  }
172
168
  }
173
169
  return false;
174
170
  }
175
171
 
172
+ /**
173
+ * Phase 6 (unified-text-editing) — mirror of `applyTextEdit`'s editability
174
+ * test (canvas-edit.ts): the element's children are exactly ONE meaningful
175
+ * node, and it is either a static JSXText or a `{'string literal'}`
176
+ * expression container (DDR-150 P1). Elements that pass get a build-time
177
+ * `data-cd-editable="text"` marker so the shell only offers inline editing
178
+ * where a commit will actually save — the rendered DOM cannot distinguish
179
+ * `<p>Total: {1 + 1} items</p>` (multiple text nodes, mixed source → the
180
+ * engine refuses the edit) from honest leaf text, but the AST can. KEEP THE
181
+ * TWO PREDICATES IN LOCKSTEP — if applyTextEdit's acceptance changes, this
182
+ * must change with it, or the marker starts lying.
183
+ */
184
+ function inlineEditableKind(node: AnyNode): 'text' | 'var' | null {
185
+ const children: AnyNode[] = Array.isArray(node?.children) ? node.children : [];
186
+ const meaningful = children.filter(
187
+ (c) => !(c?.type === 'JSXText' && typeof c.value === 'string' && c.value.trim() === '')
188
+ );
189
+ if (meaningful.length !== 1) return null;
190
+ const only = meaningful[0];
191
+ if (only?.type === 'JSXText') return 'text';
192
+ if (only?.type === 'JSXExpressionContainer') {
193
+ const expr = only.expression;
194
+ // A `{'string literal'}` rewrites in place (DDR-150 P1) — same as JSXText.
195
+ if (
196
+ expr &&
197
+ (expr.type === 'Literal' || expr.type === 'StringLiteral') &&
198
+ typeof expr.value === 'string'
199
+ ) {
200
+ return 'text';
201
+ }
202
+ // A `{variable}` or `{item.prop}` — the string lives in a const or a
203
+ // `.map()`ed array; the edit engine traces it back at commit time
204
+ // (canvas-edit.ts resolveDynamicTextSpan). Computed forms (calls, template
205
+ // strings, `a + b`, ternaries) are NOT offered — no single source string.
206
+ if (expr?.type === 'Identifier') return 'var';
207
+ if (
208
+ expr?.type === 'MemberExpression' &&
209
+ !expr.computed &&
210
+ expr.object?.type === 'Identifier' &&
211
+ expr.property?.type === 'Identifier'
212
+ ) {
213
+ return 'var';
214
+ }
215
+ return null;
216
+ }
217
+ return null;
218
+ }
219
+
176
220
  function computeId(componentName: string, idx: number): string {
177
221
  // 8 hex chars from Bun.hash — 32 bits of entropy. Per-component collision
178
222
  // probability for the canvas-scale we ship (≤ ~300 JSX elements) is < 1e-5.
@@ -215,7 +259,7 @@ function walkInjectIds(
215
259
  const idx = frame.jsxIndex;
216
260
  frame.jsxIndex += 1;
217
261
 
218
- if (!hasDataCdIdAttr(opening)) {
262
+ if (!hasJsxAttr(opening, 'data-cd-id')) {
219
263
  const id = computeId(frame.componentName, idx);
220
264
  // Insert " data-cd-id=\"<id>\"" right after the tag name. magic-string's
221
265
  // appendLeft puts the new text before any subsequent attribute. JSX
@@ -235,6 +279,20 @@ function walkInjectIds(
235
279
  }
236
280
  }
237
281
 
282
+ // Phase 6 (unified-text-editing) — build-time editability marker, so
283
+ // the shell's inline-edit entry and the edit engine can never disagree
284
+ // (inlineEditableKind mirrors applyTextEdit's acceptance). `text` = a
285
+ // literal rewritten in place; `var` = a `{variable}` traced back to its
286
+ // source string at commit (canvas-edit.ts). Independent of the id
287
+ // injection above: hand-authored data-cd-id elements still get it.
288
+ const editKind = inlineEditableKind(node);
289
+ if (editKind && !hasJsxAttr(opening, 'data-cd-editable')) {
290
+ const insertAt: number | undefined = opening?.name?.end;
291
+ if (typeof insertAt === 'number') {
292
+ s.appendLeft(insertAt, ` data-cd-editable="${editKind}"`);
293
+ }
294
+ }
295
+
238
296
  // Recurse into opening attributes + children, with the jsxPath extended.
239
297
  frame.jsxPath.push(elName);
240
298
  if (opening) visit(opening.attributes);