@1agh/maude 0.42.0 → 0.44.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 (332) 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/acp/bridge.ts +44 -10
  4. package/apps/studio/acp/env.ts +23 -0
  5. package/apps/studio/acp/login-state.ts +263 -0
  6. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  7. package/apps/studio/acp/probe.ts +152 -1
  8. package/apps/studio/annotations-layer.tsx +1451 -324
  9. package/apps/studio/annotations-model.ts +58 -0
  10. package/apps/studio/api.ts +568 -24
  11. package/apps/studio/bin/_audio-search.mjs +140 -0
  12. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  13. package/apps/studio/bin/_import-asset.mjs +815 -0
  14. package/apps/studio/bin/_import-brand.mjs +635 -0
  15. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  16. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  17. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  18. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  19. package/apps/studio/bin/_transcribe.mjs +419 -0
  20. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  21. package/apps/studio/bin/_video-playwright.mjs +141 -71
  22. package/apps/studio/bin/audio-search.sh +28 -0
  23. package/apps/studio/bin/generate.sh +154 -0
  24. package/apps/studio/bin/import-asset.sh +34 -0
  25. package/apps/studio/bin/import-brand.sh +29 -0
  26. package/apps/studio/bin/import-tokens.sh +33 -0
  27. package/apps/studio/bin/ingest-footage.sh +27 -0
  28. package/apps/studio/bin/photo-adjust.sh +163 -0
  29. package/apps/studio/bin/photo-bg-remove.sh +258 -0
  30. package/apps/studio/bin/probe-footage.sh +28 -0
  31. package/apps/studio/bin/read-annotations.mjs +125 -2
  32. package/apps/studio/bin/transcribe.sh +39 -0
  33. package/apps/studio/canvas-edit.ts +656 -12
  34. package/apps/studio/canvas-lib.tsx +637 -10
  35. package/apps/studio/canvas-pipeline.ts +65 -7
  36. package/apps/studio/canvas-shell.tsx +156 -16
  37. package/apps/studio/client/app.jsx +2385 -699
  38. package/apps/studio/client/export-center.jsx +35 -1
  39. package/apps/studio/client/generate-dialog.jsx +352 -0
  40. package/apps/studio/client/github.js +18 -0
  41. package/apps/studio/client/inspector-controls.jsx +781 -0
  42. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  43. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  44. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  45. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  46. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  47. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  48. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  49. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  50. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  51. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  52. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  53. package/apps/studio/client/photo-knobs.jsx +432 -0
  54. package/apps/studio/client/styles/3-shell-maude.css +376 -28
  55. package/apps/studio/client/styles/4-components.css +223 -0
  56. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  57. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  58. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  59. package/apps/studio/commands/edit-source-command.ts +31 -4
  60. package/apps/studio/config.schema.json +70 -0
  61. package/apps/studio/context-menu.tsx +42 -8
  62. package/apps/studio/context.ts +16 -0
  63. package/apps/studio/design-setup-readiness.ts +115 -0
  64. package/apps/studio/dist/client.bundle.js +5572 -21
  65. package/apps/studio/dist/comment-mount.js +2 -2
  66. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  67. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  68. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  69. package/apps/studio/dist/styles.css +1 -1
  70. package/apps/studio/dom-selection.ts +25 -0
  71. package/apps/studio/draw/palette.ts +34 -0
  72. package/apps/studio/exporters/jobs.ts +47 -5
  73. package/apps/studio/exporters/video.ts +24 -4
  74. package/apps/studio/footage/schema.test.ts +287 -0
  75. package/apps/studio/footage/schema.ts +662 -0
  76. package/apps/studio/footage-store.ts +235 -0
  77. package/apps/studio/fs-watch.ts +10 -0
  78. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  79. package/apps/studio/generation/adapters/gemini.ts +542 -0
  80. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  81. package/apps/studio/generation/adapters/groq.ts +247 -0
  82. package/apps/studio/generation/audio-library.test.ts +95 -0
  83. package/apps/studio/generation/audio-library.ts +156 -0
  84. package/apps/studio/generation/captions.test.ts +78 -0
  85. package/apps/studio/generation/captions.ts +132 -0
  86. package/apps/studio/generation/download.ts +76 -0
  87. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  88. package/apps/studio/generation/gemini.test.ts +350 -0
  89. package/apps/studio/generation/jobs.test.ts +128 -0
  90. package/apps/studio/generation/jobs.ts +243 -0
  91. package/apps/studio/generation/keys.test.ts +75 -0
  92. package/apps/studio/generation/keys.ts +162 -0
  93. package/apps/studio/generation/prefs.test.ts +95 -0
  94. package/apps/studio/generation/prefs.ts +82 -0
  95. package/apps/studio/generation/registry.ts +88 -0
  96. package/apps/studio/generation/types.test.ts +123 -0
  97. package/apps/studio/generation/types.ts +266 -0
  98. package/apps/studio/generation/whisper-models.test.ts +152 -0
  99. package/apps/studio/generation/whisper-models.ts +257 -0
  100. package/apps/studio/generation/whisper-spike-results.md +61 -0
  101. package/apps/studio/git/endpoints.ts +68 -7
  102. package/apps/studio/git/service.ts +199 -21
  103. package/apps/studio/github/service.ts +67 -0
  104. package/apps/studio/handoff.ts +3 -2
  105. package/apps/studio/http.ts +1039 -22
  106. package/apps/studio/input-router.tsx +10 -0
  107. package/apps/studio/inspect.ts +53 -1
  108. package/apps/studio/media/intro.mp4 +0 -0
  109. package/apps/studio/media-commit-chain.ts +119 -0
  110. package/apps/studio/paths.ts +20 -0
  111. package/apps/studio/photo/filters.ts +285 -0
  112. package/apps/studio/photo/pipeline.ts +607 -0
  113. package/apps/studio/photo/schema.ts +367 -0
  114. package/apps/studio/photo-store.ts +154 -0
  115. package/apps/studio/readiness.ts +108 -20
  116. package/apps/studio/runtime-bundle.ts +29 -6
  117. package/apps/studio/scaffold-design.ts +1145 -0
  118. package/apps/studio/server.ts +18 -2
  119. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  120. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  121. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  122. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  123. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  124. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  125. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  126. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  127. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  128. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  129. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  130. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  131. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  132. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  133. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  134. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  135. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  136. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  137. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  138. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  139. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  140. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  141. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  142. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  143. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  144. package/apps/studio/stickers/life-style/best.png +0 -0
  145. package/apps/studio/stickers/life-style/come-on.png +0 -0
  146. package/apps/studio/stickers/life-style/cut.png +0 -0
  147. package/apps/studio/stickers/life-style/detox.png +0 -0
  148. package/apps/studio/stickers/life-style/just.png +0 -0
  149. package/apps/studio/stickers/life-style/manifest.json +68 -0
  150. package/apps/studio/stickers/life-style/mirror.png +0 -0
  151. package/apps/studio/stickers/life-style/nice.png +0 -0
  152. package/apps/studio/stickers/life-style/objects.png +0 -0
  153. package/apps/studio/stickers/life-style/ok.png +0 -0
  154. package/apps/studio/stickers/life-style/queen.png +0 -0
  155. package/apps/studio/stickers/life-style/star.png +0 -0
  156. package/apps/studio/stickers/life-style/sun.png +0 -0
  157. package/apps/studio/stickers/life-style/water.png +0 -0
  158. package/apps/studio/stickers/life-style/yeah.png +0 -0
  159. package/apps/studio/stickers/life-style/you-can.png +0 -0
  160. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  161. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  162. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  163. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  164. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  165. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  166. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  167. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  168. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  169. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  170. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  171. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  172. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  173. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  174. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  175. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  176. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  177. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  178. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  179. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  180. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  181. package/apps/studio/stickers/project-status/group-100.png +0 -0
  182. package/apps/studio/stickers/project-status/group-101.png +0 -0
  183. package/apps/studio/stickers/project-status/group-102.png +0 -0
  184. package/apps/studio/stickers/project-status/group-103.png +0 -0
  185. package/apps/studio/stickers/project-status/group-104.png +0 -0
  186. package/apps/studio/stickers/project-status/group-105.png +0 -0
  187. package/apps/studio/stickers/project-status/group-106.png +0 -0
  188. package/apps/studio/stickers/project-status/group-107.png +0 -0
  189. package/apps/studio/stickers/project-status/group-108.png +0 -0
  190. package/apps/studio/stickers/project-status/group-109.png +0 -0
  191. package/apps/studio/stickers/project-status/group-110.png +0 -0
  192. package/apps/studio/stickers/project-status/group-111.png +0 -0
  193. package/apps/studio/stickers/project-status/group-112.png +0 -0
  194. package/apps/studio/stickers/project-status/group-113.png +0 -0
  195. package/apps/studio/stickers/project-status/group-114.png +0 -0
  196. package/apps/studio/stickers/project-status/group-115.png +0 -0
  197. package/apps/studio/stickers/project-status/group-117.png +0 -0
  198. package/apps/studio/stickers/project-status/group-118.png +0 -0
  199. package/apps/studio/stickers/project-status/group-119.png +0 -0
  200. package/apps/studio/stickers/project-status/group-120.png +0 -0
  201. package/apps/studio/stickers/project-status/group-121.png +0 -0
  202. package/apps/studio/stickers/project-status/group-122.png +0 -0
  203. package/apps/studio/stickers/project-status/group-41.png +0 -0
  204. package/apps/studio/stickers/project-status/group-42.png +0 -0
  205. package/apps/studio/stickers/project-status/group-43.png +0 -0
  206. package/apps/studio/stickers/project-status/group-44.png +0 -0
  207. package/apps/studio/stickers/project-status/group-45.png +0 -0
  208. package/apps/studio/stickers/project-status/group-46.png +0 -0
  209. package/apps/studio/stickers/project-status/group-47.png +0 -0
  210. package/apps/studio/stickers/project-status/group-48.png +0 -0
  211. package/apps/studio/stickers/project-status/group-49.png +0 -0
  212. package/apps/studio/stickers/project-status/group-50.png +0 -0
  213. package/apps/studio/stickers/project-status/group-51.png +0 -0
  214. package/apps/studio/stickers/project-status/group-52.png +0 -0
  215. package/apps/studio/stickers/project-status/group-53.png +0 -0
  216. package/apps/studio/stickers/project-status/group-54.png +0 -0
  217. package/apps/studio/stickers/project-status/group-55.png +0 -0
  218. package/apps/studio/stickers/project-status/group-56.png +0 -0
  219. package/apps/studio/stickers/project-status/group-57.png +0 -0
  220. package/apps/studio/stickers/project-status/group-58.png +0 -0
  221. package/apps/studio/stickers/project-status/group-59.png +0 -0
  222. package/apps/studio/stickers/project-status/group-60.png +0 -0
  223. package/apps/studio/stickers/project-status/group-61.png +0 -0
  224. package/apps/studio/stickers/project-status/group-62.png +0 -0
  225. package/apps/studio/stickers/project-status/group-63.png +0 -0
  226. package/apps/studio/stickers/project-status/group-64.png +0 -0
  227. package/apps/studio/stickers/project-status/group-65.png +0 -0
  228. package/apps/studio/stickers/project-status/group-66.png +0 -0
  229. package/apps/studio/stickers/project-status/group-67.png +0 -0
  230. package/apps/studio/stickers/project-status/group-68.png +0 -0
  231. package/apps/studio/stickers/project-status/group-69.png +0 -0
  232. package/apps/studio/stickers/project-status/group-70.png +0 -0
  233. package/apps/studio/stickers/project-status/group-71.png +0 -0
  234. package/apps/studio/stickers/project-status/group-72.png +0 -0
  235. package/apps/studio/stickers/project-status/group-73.png +0 -0
  236. package/apps/studio/stickers/project-status/group-74.png +0 -0
  237. package/apps/studio/stickers/project-status/group-75.png +0 -0
  238. package/apps/studio/stickers/project-status/group-76.png +0 -0
  239. package/apps/studio/stickers/project-status/group-77.png +0 -0
  240. package/apps/studio/stickers/project-status/group-78.png +0 -0
  241. package/apps/studio/stickers/project-status/group-79.png +0 -0
  242. package/apps/studio/stickers/project-status/group-80.png +0 -0
  243. package/apps/studio/stickers/project-status/group-81.png +0 -0
  244. package/apps/studio/stickers/project-status/group-82.png +0 -0
  245. package/apps/studio/stickers/project-status/group-83.png +0 -0
  246. package/apps/studio/stickers/project-status/group-84.png +0 -0
  247. package/apps/studio/stickers/project-status/group-85.png +0 -0
  248. package/apps/studio/stickers/project-status/group-86.png +0 -0
  249. package/apps/studio/stickers/project-status/group-87.png +0 -0
  250. package/apps/studio/stickers/project-status/group-88.png +0 -0
  251. package/apps/studio/stickers/project-status/group-89.png +0 -0
  252. package/apps/studio/stickers/project-status/group-90.png +0 -0
  253. package/apps/studio/stickers/project-status/group-91.png +0 -0
  254. package/apps/studio/stickers/project-status/group-92.png +0 -0
  255. package/apps/studio/stickers/project-status/group-93.png +0 -0
  256. package/apps/studio/stickers/project-status/group-94.png +0 -0
  257. package/apps/studio/stickers/project-status/group-96.png +0 -0
  258. package/apps/studio/stickers/project-status/group-97.png +0 -0
  259. package/apps/studio/stickers/project-status/group-98.png +0 -0
  260. package/apps/studio/stickers/project-status/group-99.png +0 -0
  261. package/apps/studio/stickers/project-status/manifest.json +328 -0
  262. package/apps/studio/test/_helpers.ts +15 -2
  263. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  264. package/apps/studio/test/acp-bridge.test.ts +34 -1
  265. package/apps/studio/test/acp-commands.test.ts +2 -1
  266. package/apps/studio/test/acp-env.test.ts +30 -0
  267. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  268. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  269. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  270. package/apps/studio/test/annotations-layer.test.ts +174 -0
  271. package/apps/studio/test/asset-api.test.ts +3 -3
  272. package/apps/studio/test/canvas-edit.test.ts +17 -6
  273. package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
  274. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  275. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  276. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  277. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  278. package/apps/studio/test/edit-source-command.test.ts +23 -0
  279. package/apps/studio/test/element-structural-edit.test.ts +154 -0
  280. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  281. package/apps/studio/test/footage-store.test.ts +100 -0
  282. package/apps/studio/test/generate-route.test.ts +106 -0
  283. package/apps/studio/test/git-branches.test.ts +97 -0
  284. package/apps/studio/test/github-api.test.ts +56 -1
  285. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  286. package/apps/studio/test/import-asset-route.test.ts +71 -0
  287. package/apps/studio/test/import-asset.test.ts +399 -0
  288. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  289. package/apps/studio/test/import-brand-route.test.ts +120 -0
  290. package/apps/studio/test/import-brand.test.ts +206 -0
  291. package/apps/studio/test/import-tokens.test.ts +722 -0
  292. package/apps/studio/test/input-router.test.ts +33 -0
  293. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  294. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  295. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  296. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  297. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  298. package/apps/studio/test/photo-filters.test.ts +149 -0
  299. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  300. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  301. package/apps/studio/test/read-annotations.test.ts +164 -0
  302. package/apps/studio/test/readiness.test.ts +13 -11
  303. package/apps/studio/test/scaffold-design.test.ts +122 -0
  304. package/apps/studio/test/shell-importmap.test.ts +49 -0
  305. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  306. package/apps/studio/test/timeline-parse.test.ts +57 -0
  307. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  308. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  309. package/apps/studio/test/undo-stack.test.ts +33 -0
  310. package/apps/studio/test/video-asset.test.ts +5 -5
  311. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  312. package/apps/studio/text-caret.ts +239 -0
  313. package/apps/studio/tool-palette.tsx +49 -21
  314. package/apps/studio/ui-prefs.ts +130 -0
  315. package/apps/studio/undo-stack.ts +94 -2
  316. package/apps/studio/use-annotation-resize.tsx +4 -4
  317. package/apps/studio/use-canvas-media-drop.tsx +40 -1
  318. package/apps/studio/use-chrome-visibility.tsx +8 -2
  319. package/apps/studio/use-selection-set.tsx +21 -0
  320. package/apps/studio/video-comp.tsx +48 -7
  321. package/apps/studio/whats-new.json +149 -0
  322. package/apps/studio/ws.ts +5 -0
  323. package/cli/bin/maude.mjs +6 -6
  324. package/cli/commands/design.mjs +67 -3
  325. package/cli/lib/gitignore-block.mjs +2 -0
  326. package/cli/lib/pkg-root.mjs +107 -0
  327. package/cli/lib/pkg-root.test.mjs +79 -0
  328. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  329. package/cli/lib/update-check.mjs +8 -0
  330. package/package.json +13 -11
  331. package/plugins/design/dependencies.json +18 -0
  332. 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; }
@@ -369,6 +398,11 @@ export interface WorldContextValue {
369
398
  rectFor: (id: string) => ArtboardRect | null;
370
399
  /** All artboards in render order, with resolved world-coord positions. */
371
400
  artboards: ArtboardRect[];
401
+ /** Hug-mode artboards report their measured DOM height here so the shared
402
+ * ArtboardRect model (drag hit-test, marquee, fit-to-screen, culling) stays
403
+ * in sync with content that grows/shrinks. Runtime-only — never persisted
404
+ * to meta; a fixed-mode board never calls this. */
405
+ reportMeasuredHeight: (id: string, h: number) => void;
372
406
  /** Current pan/zoom state. `null` until the first useLayoutEffect runs. */
373
407
  viewport: ViewportState | null;
374
408
  /** id of the artboard closest to the viewport center (recomputed on settle). */
@@ -451,6 +485,29 @@ function harvestArtboards(children: ReactNode): ArtboardSeed[] {
451
485
  return out;
452
486
  }
453
487
 
488
+ /**
489
+ * Does this artboard's subtree contain a <VideoComp>? Same identity-match +
490
+ * displayName-fallback walk as harvestArtboards, short-circuiting on the
491
+ * first hit — used to badge video artboards in DCArtboard's header.
492
+ */
493
+ function subtreeHasVideoComp(children: ReactNode): boolean {
494
+ function visit(node: ReactNode): boolean {
495
+ if (node == null || typeof node === 'boolean') return false;
496
+ if (Array.isArray(node)) return node.some(visit);
497
+ if (!isValidElement(node)) return false;
498
+ const type = node.type;
499
+ if (
500
+ type === VideoComp ||
501
+ (typeof type === 'function' && (type as { displayName?: string }).displayName === 'VideoComp')
502
+ ) {
503
+ return true;
504
+ }
505
+ const childProp = (node.props as { children?: ReactNode } | null | undefined)?.children;
506
+ return childProp != null ? visit(childProp) : false;
507
+ }
508
+ return visit(children);
509
+ }
510
+
454
511
  function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
455
512
  // Render order (the order DCArtboards appear in JSX), not alphabetical —
456
513
  // authors label artboards DS-01 / DS-02 / CV-01 etc. and expect that
@@ -1583,16 +1640,33 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1583
1640
  // useLayoutEffect writes the initial transform before first paint.
1584
1641
  const worldStyle: CSSProperties = { visibility: 'hidden' };
1585
1642
 
1643
+ // Hug-mode height feedback (artboard "hug height" default). A hug board's
1644
+ // CSS height is `auto` (content-driven) — the DOM is authoritative, and this
1645
+ // just mirrors the measured height into the shared ArtboardRect model so
1646
+ // OTHER consumers (marquee, distribute, fit-to-screen, off-screen culling)
1647
+ // see the current box. Never touches meta/JSX — purely runtime state, unlike
1648
+ // commitArtboardPositions/applyArtboardLayout which persist. A no-op guard
1649
+ // on <1px delta avoids a ResizeObserver → setState → render → (stable, since
1650
+ // CSS height never reads back rect.h in hug mode) loop.
1651
+ const reportMeasuredHeight = useCallback((id: string, h: number) => {
1652
+ setArtboards((prev) => {
1653
+ const cur = prev.find((r) => r.id === id);
1654
+ if (!cur || Math.abs(cur.h - h) < 1) return prev;
1655
+ return prev.map((r) => (r.id === id ? { ...r, h } : r));
1656
+ });
1657
+ }, []);
1658
+
1586
1659
  const ctxValue = useMemo<WorldContextValue>(
1587
1660
  () => ({
1588
1661
  rectFor,
1589
1662
  artboards,
1663
+ reportMeasuredHeight,
1590
1664
  viewport: controller.viewport,
1591
1665
  activeArtboardId,
1592
1666
  hostRef,
1593
1667
  worldRef,
1594
1668
  }),
1595
- [rectFor, artboards, controller.viewport, activeArtboardId]
1669
+ [rectFor, artboards, reportMeasuredHeight, controller.viewport, activeArtboardId]
1596
1670
  );
1597
1671
 
1598
1672
  const showMiniMap = controls?.minimap !== false;
@@ -1741,12 +1815,30 @@ export function DCArtboard({
1741
1815
  label,
1742
1816
  width,
1743
1817
  height,
1818
+ fixed,
1819
+ background,
1820
+ padding,
1821
+ layout,
1822
+ gap,
1744
1823
  children,
1745
1824
  }: {
1746
1825
  id: string;
1747
1826
  label: string;
1748
1827
  width: number;
1749
1828
  height: number;
1829
+ /** Height sizing mode. Default (omitted/false) = HUG — the board grows to
1830
+ * fit content; `height` becomes a min-height floor rather than an exact
1831
+ * size. `true` = FIXED — today's behavior, `height` is exact and overflow
1832
+ * clips. Width is always exact regardless of this flag. */
1833
+ fixed?: boolean;
1834
+ /** `.dc-artboard-body` background (CSS color/token, e.g. "var(--bg-1)"). */
1835
+ background?: string;
1836
+ /** `.dc-artboard-body` padding, px, all sides. */
1837
+ padding?: number;
1838
+ /** `.dc-artboard-body` layout mode. Default = the engine's plain block flow. */
1839
+ layout?: 'block' | 'flex-col' | 'flex-row' | 'grid';
1840
+ /** `.dc-artboard-body` gap, px — only visible under flex-col/flex-row/grid. */
1841
+ gap?: number;
1750
1842
  children: ReactNode;
1751
1843
  }) {
1752
1844
  const ctx = useWorldContext();
@@ -1761,6 +1853,21 @@ export function DCArtboard({
1761
1853
  // its presence color + show its funny name instead of the generic file label.
1762
1854
  const agent = useAgentPresence();
1763
1855
  const rect = ctx ? ctx.rectFor(id) : null;
1856
+ // DDR-148 — badge video artboards in the header; clicking the badge opens
1857
+ // the timeline panel via the same postMessage the context-menu "Open
1858
+ // Timeline" entry already sends (canvas-shell.tsx artboardHasVideo/handler).
1859
+ const hasVideo = useMemo(() => subtreeHasVideoComp(children), [children]);
1860
+ const openTimeline = useCallback(
1861
+ (e: ReactMouseEvent<HTMLButtonElement>) => {
1862
+ e.stopPropagation();
1863
+ try {
1864
+ window.parent.postMessage({ dgn: 'open-timeline-request', artboardId: id }, '*');
1865
+ } catch {
1866
+ /* detached / cross-origin */
1867
+ }
1868
+ },
1869
+ [id]
1870
+ );
1764
1871
 
1765
1872
  // Drag hook — always called (hook rules). Inert outside DesignCanvas
1766
1873
  // (allRects empty, enabled=false), so specimens / legacy uses get a plain
@@ -1793,11 +1900,67 @@ export function DCArtboard({
1793
1900
  }
1794
1901
  }, [dragHook.dragState, dragBus]);
1795
1902
 
1903
+ // Hug default (artboard "hug height") — the authored `height` prop is a
1904
+ // FLOOR (min-height), not an exact size, unless `fixed` pins it. The floor
1905
+ // stays stable across renders (it only changes when the JSX prop itself is
1906
+ // edited), so it never ratchets up from a previously-larger measured value
1907
+ // the way feeding `rect.h` back as the floor would.
1908
+ const heightFloor = typeof height === 'number' ? height : VP_GRID.h;
1909
+ const articleRef = useRef<HTMLElement | null>(null);
1910
+ const measured = useArtboardBounds(articleRef as RefObject<HTMLElement | null>);
1911
+ useEffect(() => {
1912
+ if (!ctx || fixed) return;
1913
+ if (dragHook.dragState.kind !== 'idle') return;
1914
+ if (measured.height <= 0) return;
1915
+ ctx.reportMeasuredHeight(id, Math.round(measured.height));
1916
+ }, [ctx, fixed, dragHook.dragState.kind, measured.height, id]);
1917
+
1918
+ // Phase 2 — background/padding/layout/gap apply to the BODY (content box),
1919
+ // not the frame (label header stays chrome-styled). Undefined keys are
1920
+ // simply absent from the style object — no engine-CSS default is clobbered.
1921
+ const bodyStyle = useMemo<CSSProperties>(() => {
1922
+ const st: CSSProperties = {};
1923
+ if (background) st.background = background;
1924
+ if (typeof padding === 'number') st.padding = padding;
1925
+ if (typeof gap === 'number') st.gap = gap;
1926
+ if (layout === 'flex-col') {
1927
+ st.display = 'flex';
1928
+ st.flexDirection = 'column';
1929
+ } else if (layout === 'flex-row') {
1930
+ st.display = 'flex';
1931
+ st.flexDirection = 'row';
1932
+ } else if (layout === 'grid') {
1933
+ st.display = 'grid';
1934
+ }
1935
+ return st;
1936
+ }, [background, padding, gap, layout]);
1937
+
1938
+ // Read-back surface for the Inspector's ArtboardKnobs panel — the SAME
1939
+ // generic "custom HTML attributes" escape hatch dom-selection.ts already
1940
+ // scrapes off the selected element (styleMapsFor → Selection.attrs), so the
1941
+ // panel pre-fills current state with no new plumbing. React omits an
1942
+ // attribute entirely when its value is `undefined`.
1943
+ const readBackAttrs = {
1944
+ 'data-dc-fixed': fixed ? 'true' : undefined,
1945
+ 'data-dc-bg': background,
1946
+ 'data-dc-padding': typeof padding === 'number' ? String(padding) : undefined,
1947
+ 'data-dc-layout': layout,
1948
+ 'data-dc-gap': typeof gap === 'number' ? String(gap) : undefined,
1949
+ };
1950
+
1796
1951
  if (!ctx || !rect) {
1797
1952
  return (
1798
- <article className="dc-artboard" data-dc-screen={id} style={{ width, height }}>
1953
+ <article
1954
+ className="dc-artboard"
1955
+ data-dc-screen={id}
1956
+ ref={articleRef}
1957
+ style={fixed ? { width, height } : { width, height: 'auto', minHeight: heightFloor }}
1958
+ {...readBackAttrs}
1959
+ >
1799
1960
  <header className="dc-artboard-label sku">{label}</header>
1800
- <div className="dc-artboard-body">{children}</div>
1961
+ <div className="dc-artboard-body" style={bodyStyle}>
1962
+ {children}
1963
+ </div>
1801
1964
  </article>
1802
1965
  );
1803
1966
  }
@@ -1850,11 +2013,16 @@ export function DCArtboard({
1850
2013
  className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
1851
2014
  data-dc-screen={id}
1852
2015
  aria-current={isActive ? 'true' : undefined}
2016
+ ref={articleRef}
2017
+ {...readBackAttrs}
1853
2018
  style={{
1854
2019
  left: liveX,
1855
2020
  top: liveY,
1856
2021
  width: rect.w,
1857
- height: rect.h,
2022
+ // Hug default: height:auto + minHeight floor, content dictates the
2023
+ // box; the ResizeObserver above mirrors the settled size back into
2024
+ // rect.h for other consumers. Fixed: today's exact-height behavior.
2025
+ ...(fixed ? { height: rect.h } : { height: 'auto', minHeight: heightFloor }),
1858
2026
  // Off-screen artboards skip layout+paint. On a large multi-board
1859
2027
  // canvas (e.g. an 18-board moodboard) every board otherwise paints
1860
2028
  // onto one huge .dc-world plane whose device-pixel size exceeds
@@ -1872,7 +2040,36 @@ export function DCArtboard({
1872
2040
  <button type="button" className="dc-artboard-label sku" aria-label={`Artboard ${label}`}>
1873
2041
  {label}
1874
2042
  </button>
1875
- <div className="dc-artboard-body">{children}</div>
2043
+ {hasVideo ? (
2044
+ <button
2045
+ type="button"
2046
+ className="dc-artboard-video-badge"
2047
+ aria-label={`Open timeline — ${label}`}
2048
+ title="Video artboard — open timeline"
2049
+ onClick={openTimeline}
2050
+ >
2051
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" aria-hidden="true">
2052
+ <rect
2053
+ x="1.5"
2054
+ y="4.5"
2055
+ width="8"
2056
+ height="7"
2057
+ rx="1.2"
2058
+ stroke="currentColor"
2059
+ strokeWidth="1.3"
2060
+ />
2061
+ <path
2062
+ d="M9.5 7l4-2.3v6.6l-4-2.3"
2063
+ stroke="currentColor"
2064
+ strokeWidth="1.3"
2065
+ strokeLinejoin="round"
2066
+ />
2067
+ </svg>
2068
+ </button>
2069
+ ) : null}
2070
+ <div className="dc-artboard-body" style={bodyStyle}>
2071
+ {children}
2072
+ </div>
1876
2073
  </article>
1877
2074
  {showActivity ? (
1878
2075
  <ArtboardActivityOverlay
@@ -2136,6 +2333,436 @@ export function DrawProof({
2136
2333
  }
2137
2334
  DrawProof.displayName = 'DrawProof';
2138
2335
 
2336
+ // ─────────────────────────────────────────────────────────────────────────────
2337
+ // PhotoLayer (feature-photo-editor, Task 6) — the non-destructive WebGL photo
2338
+ // compositor surface. Renders a source photo (artboard `<img>` or annotation
2339
+ // `ImageStroke`) with a live `PhotoEdit` applied through pixi.js.
2340
+ //
2341
+ // LAZY-BUNDLE GUARANTEE (the plan's load-bearing acceptance criterion + BUILDER's
2342
+ // flagged top risk): an UNEDITED photo (`isDefaultEdit(edit)`) renders as the
2343
+ // plain `<img>` and NEVER touches pixi. The compositor module (photo/pipeline.ts)
2344
+ // is only reached through a DYNAMIC `import()` inside the effect, so a canvas
2345
+ // with zero edited photos pays zero pixi.js/bg-removal cost. Verified empirically
2346
+ // against `buildCanvasModule` (no eager `pixi.js` import in the default-edit
2347
+ // bundle) — see test/photo-canvas-bundle.test.ts.
2348
+ //
2349
+ // A11y: the pixi output is a `<canvas>` (a black box to AT), so it carries
2350
+ // `role="img"` + `aria-label` from `alt` (validation step 7 requirement).
2351
+ // Reduced-motion: the compositor renders statically (autoStart:false, no ticker).
2352
+
2353
+ export interface PhotoLayerProps {
2354
+ /** Relative `assets/<sha8>.<ext>` source (validated upstream). */
2355
+ source: string;
2356
+ /** Live non-destructive edit. Absent / neutral ⇒ plain `<img>`, no pixi. */
2357
+ edit?: PhotoEdit | null;
2358
+ width: number;
2359
+ height: number;
2360
+ alt?: string;
2361
+ className?: string;
2362
+ style?: CSSProperties;
2363
+ /** Resolve a relative asset path to a fetchable URL (defaults to identity —
2364
+ * relative `assets/…` already resolves against the canvas iframe origin). */
2365
+ resolveUrl?: (rel: string) => string;
2366
+ /** Fired once the pixi compositor has mounted + drawn its first frame. The
2367
+ * preview bridge hides the original element only AFTER this, so a background
2368
+ * cutout never flashes the untouched original underneath (and no flicker). */
2369
+ onReady?: () => void;
2370
+ }
2371
+
2372
+ export function PhotoLayer({
2373
+ source,
2374
+ edit,
2375
+ width,
2376
+ height,
2377
+ alt = '',
2378
+ className,
2379
+ style,
2380
+ resolveUrl,
2381
+ onReady,
2382
+ }: PhotoLayerProps) {
2383
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
2384
+ const rendererRef = useRef<{ destroy(): void; update(e: PhotoEdit): void } | null>(null);
2385
+ const active = !isDefaultEdit(edit);
2386
+
2387
+ // Mount / tear down the pixi compositor only while an edit is active. The
2388
+ // compositor is DYNAMICALLY imported (lazy-bundle guarantee — see header).
2389
+ // Re-created only when the source/box identity changes; edit-param changes are
2390
+ // pushed via the second effect below (no teardown → smooth live scrub).
2391
+ // 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).
2392
+ useEffect(() => {
2393
+ if (!active) return;
2394
+ const canvas = canvasRef.current;
2395
+ if (!canvas) return;
2396
+ let disposed = false;
2397
+ import('./photo/pipeline.ts')
2398
+ .then(({ PhotoRenderer }) =>
2399
+ PhotoRenderer.create({
2400
+ canvas,
2401
+ source,
2402
+ edit: (edit ?? {}) as PhotoEdit,
2403
+ width,
2404
+ height,
2405
+ resolveUrl,
2406
+ })
2407
+ )
2408
+ .then((r) => {
2409
+ if (disposed) {
2410
+ r.destroy();
2411
+ return;
2412
+ }
2413
+ rendererRef.current = r;
2414
+ onReady?.();
2415
+ })
2416
+ .catch((err) => {
2417
+ console.error('[PhotoLayer] compositor failed to mount', err);
2418
+ });
2419
+ return () => {
2420
+ disposed = true;
2421
+ rendererRef.current?.destroy();
2422
+ rendererRef.current = null;
2423
+ };
2424
+ }, [active, source, width, height, resolveUrl, onReady]);
2425
+
2426
+ // Live-update the mounted compositor on edit-param change (no re-create).
2427
+ useEffect(() => {
2428
+ if (active && edit && rendererRef.current) rendererRef.current.update(edit);
2429
+ }, [edit, active]);
2430
+
2431
+ if (!active) {
2432
+ const src = resolveUrl ? resolveUrl(source) : source;
2433
+ return (
2434
+ <img src={src} width={width} height={height} alt={alt} className={className} style={style} />
2435
+ );
2436
+ }
2437
+ return (
2438
+ <canvas
2439
+ ref={canvasRef}
2440
+ width={width}
2441
+ height={height}
2442
+ role="img"
2443
+ aria-label={alt || 'Edited photo'}
2444
+ className={className}
2445
+ style={{ width, height, display: 'block', ...style }}
2446
+ />
2447
+ );
2448
+ }
2449
+ PhotoLayer.displayName = 'PhotoLayer';
2450
+
2451
+ // PhotoPreviewBridge (feature-photo-editor) — applies a live/persisted
2452
+ // PhotoEdit DIRECTLY to the real photo element (artboard `<img>` or
2453
+ // annotation `<image>`) by swapping its `src`/`href` to a baked data URL,
2454
+ // instead of floating a separate WebGL-rendered decoy on top of it.
2455
+ //
2456
+ // Iteration 1 of this bridge did the "decoy" version: a `position:fixed` div
2457
+ // tracked the real element's screen rect via a per-frame rAF loop and hid the
2458
+ // original underneath (`visibility:hidden`) while a live `<PhotoLayer>` pixi
2459
+ // canvas rendered on top. That broke every bit of free native DOM behavior
2460
+ // the real element used to have:
2461
+ // - cmd+click / right-click hit-testing landed on whatever was BEHIND the
2462
+ // now-invisible original (a hidden element isn't hit-testable), while the
2463
+ // decoy was `pointer-events:none` so it couldn't take the click either —
2464
+ // net result, nothing was clickable.
2465
+ // - the decoy rendered at a STABLE "world" pixel size and was CSS-stretched
2466
+ // to the live screen box on zoom; the stretch didn't reliably track the
2467
+ // real box, so the visible photo grew/shrank relative to its own frame.
2468
+ // - it needed its own z-index (originally 30 — drew over the context menu)
2469
+ // instead of just sitting at the element's normal stacking position.
2470
+ // - it only knew about an edit via the transient postMessage below, so any
2471
+ // iframe remount (Cmd+R, HMR) reset it to nothing until a human reopened
2472
+ // the Inspector and nudged a knob.
2473
+ // Swapping the REAL element's `src`/`href` sidesteps all of it: resize, zoom,
2474
+ // hit-testing, and stacking become the browser's native `<img>`/`<image>`
2475
+ // behavior, not a hand-rolled tracker. Non-destructive still holds — only the
2476
+ // LIVE DOM attribute is mutated, never the authored TSX/SVG source; the
2477
+ // on-disk `PhotoEdit` sidecar (`/_api/photo-edit`) stays the persisted source
2478
+ // of truth, re-applied by the hydration scan below on every canvas (re)mount.
2479
+ // The bake is at the source's NATIVE resolution (`renderPhotoDataUrl`), so
2480
+ // the result stays sharp across any later resize/zoom with no re-bake.
2481
+
2482
+ /** `assets/<sha8>.<ext>` substring inside a `src`/`href`/`xlink:href`. */
2483
+ const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
2484
+
2485
+ /** Matches a photo element by the `data-photo-asset` tag `apply()` stamps on
2486
+ * first touch, falling back to a literal src/href substring match for an
2487
+ * element this bridge hasn't touched yet. The tag is load-bearing: once
2488
+ * baked, the element's `src`/`href` is a `data:` URL that no longer contains
2489
+ * the original asset path, so the substring match alone would lose track of
2490
+ * it on the very next edit. */
2491
+ function findPhotoEl(asset: string): Element | null {
2492
+ if (typeof document === 'undefined') return null;
2493
+ // Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
2494
+ // the canvas iframe is untrusted content (DDR-054); an authored canvas could
2495
+ // otherwise stamp the tag on an arbitrary element to redirect a bake.
2496
+ for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
2497
+ if (n.getAttribute('data-photo-asset') === asset) return n;
2498
+ }
2499
+ for (const n of document.querySelectorAll('img, image')) {
2500
+ const src =
2501
+ n.getAttribute('src') || n.getAttribute('href') || n.getAttribute('xlink:href') || '';
2502
+ if (src.includes(asset)) return n;
2503
+ }
2504
+ return null;
2505
+ }
2506
+
2507
+ function extractAssetRef(el: Element): string | null {
2508
+ // Only trust `data-photo-asset` when it actually has the `assets/<sha8>.<ext>`
2509
+ // shape — the tag is attacker-controllable (untrusted canvas content,
2510
+ // DDR-054), and an unshaped value would otherwise ride unbounded into
2511
+ // `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
2512
+ const tagged = el.getAttribute('data-photo-asset');
2513
+ if (tagged && ASSET_REF_RE.test(tagged)) return tagged;
2514
+ const ref =
2515
+ el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
2516
+ return ref.match(ASSET_REF_RE)?.[0] ?? null;
2517
+ }
2518
+
2519
+ function setPhotoElSrc(el: Element, url: string): void {
2520
+ if (el.tagName.toLowerCase() === 'image') {
2521
+ el.setAttribute(el.hasAttribute('xlink:href') ? 'xlink:href' : 'href', url);
2522
+ } else {
2523
+ el.setAttribute('src', url);
2524
+ }
2525
+ }
2526
+
2527
+ const BAKE_DEBOUNCE_MS = 80;
2528
+
2529
+ export function PhotoPreviewBridge() {
2530
+ // The ORIGINAL (unedited) src/href per asset, captured the first time this
2531
+ // bridge touches that element — so turning an edit off restores exactly
2532
+ // what the element pointed to, not a guess.
2533
+ const originalRef = useRef<Map<string, string>>(new Map());
2534
+ // Per-asset bake generation — guards a slow (or out-of-order) render from
2535
+ // clobbering a NEWER edit that already resolved first.
2536
+ const tokenRef = useRef<Map<string, number>>(new Map());
2537
+ const bakeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
2538
+
2539
+ const bake = useCallback((asset: string, original: string, edit: PhotoEdit) => {
2540
+ const token = (tokenRef.current.get(asset) ?? 0) + 1;
2541
+ tokenRef.current.set(asset, token);
2542
+ import('./photo/pipeline.ts')
2543
+ .then(({ renderPhotoDataUrl }) => renderPhotoDataUrl({ source: original, edit }))
2544
+ .then((dataUrl) => {
2545
+ if (tokenRef.current.get(asset) !== token) return; // superseded by a newer edit
2546
+ const live = findPhotoEl(asset);
2547
+ if (live) setPhotoElSrc(live, dataUrl);
2548
+ })
2549
+ .catch((err) => {
2550
+ console.error('[PhotoPreviewBridge] bake failed', err);
2551
+ });
2552
+ }, []);
2553
+
2554
+ const apply = useCallback(
2555
+ (asset: string, edit: PhotoEdit | null) => {
2556
+ const el = findPhotoEl(asset);
2557
+ if (!el) return;
2558
+ if (!el.hasAttribute('data-photo-asset')) el.setAttribute('data-photo-asset', asset);
2559
+ if (!originalRef.current.has(asset)) {
2560
+ const orig =
2561
+ el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
2562
+ originalRef.current.set(asset, orig);
2563
+ }
2564
+ const original = originalRef.current.get(asset) ?? '';
2565
+ const timers = bakeTimers.current;
2566
+ clearTimeout(timers.get(asset));
2567
+ if (!edit || isDefaultEdit(edit)) {
2568
+ timers.delete(asset);
2569
+ setPhotoElSrc(el, original);
2570
+ return;
2571
+ }
2572
+ timers.set(
2573
+ asset,
2574
+ setTimeout(() => bake(asset, original, edit), BAKE_DEBOUNCE_MS)
2575
+ );
2576
+ },
2577
+ [bake]
2578
+ );
2579
+
2580
+ useEffect(() => {
2581
+ const timers = bakeTimers.current;
2582
+ return () => {
2583
+ for (const t of timers.values()) clearTimeout(t);
2584
+ };
2585
+ }, []);
2586
+
2587
+ useEffect(() => {
2588
+ const onMsg = (e: MessageEvent) => {
2589
+ const m = e.data as { dgn?: string; asset?: unknown; edit?: unknown; busy?: unknown } | null;
2590
+ if (!m) return;
2591
+ // Background-removal busy shimmer (Task 12 reveal) — a `data-photo-busy`
2592
+ // attribute toggle on the real element, styled by inspect.ts's single CSS
2593
+ // injection point (mirrors `.dc-activity-scan`'s sweep language). No
2594
+ // separate tracked overlay — see the header comment above `apply()`.
2595
+ // `m.asset` must pass the same shape check `extractAssetRef` applies
2596
+ // elsewhere in this file (fix-photo-editor-followup-debt, Task 8) —
2597
+ // an empty string previously matched EVERY photo element via
2598
+ // `findPhotoEl`'s substring-match fallback (`src.includes('')` is always
2599
+ // true), so an empty/malformed `asset` is now a no-op instead of toggling
2600
+ // the busy shimmer on every photo on the canvas.
2601
+ if (m.dgn === 'photo-busy' && typeof m.asset === 'string' && ASSET_REF_RE.test(m.asset)) {
2602
+ const el = findPhotoEl(m.asset);
2603
+ el?.toggleAttribute('data-photo-busy', !!m.busy);
2604
+ return;
2605
+ }
2606
+ if (m.dgn !== 'photo-preview' || typeof m.asset !== 'string') return;
2607
+ const asset = m.asset;
2608
+ const edit = (m.edit ?? null) as PhotoEdit | null;
2609
+ apply(asset, edit);
2610
+ };
2611
+ window.addEventListener('message', onMsg);
2612
+ return () => window.removeEventListener('message', onMsg);
2613
+ }, [apply]);
2614
+ // Boot-time (+ ongoing) hydration from the PERSISTED sidecar, not just the
2615
+ // live `photo-preview` message above. Without this, a saved PhotoEdit is
2616
+ // invisible after anything that re-mounts the canvas doc (Cmd+R, an
2617
+ // HMR remount, or a resize that recreates the photo's DOM node) — the
2618
+ // message-only bridge starts every fresh mount with an empty `edits` map,
2619
+ // and nothing re-sends the already-saved edit until a human happens to
2620
+ // reopen the Inspector Photo tab and touch a knob. A MutationObserver
2621
+ // re-scan (not just an initial one-shot) is what makes this self-heal after
2622
+ // those remounts, since the photo element's DOM node is often a NEW node
2623
+ // post-remount, not the one the original message targeted.
2624
+ useEffect(() => {
2625
+ if (typeof document === 'undefined') return;
2626
+ let cancelled = false;
2627
+ let raf = 0;
2628
+ // Every asset gets AT MOST one fetch attempt for this bridge's lifetime —
2629
+ // without this, a canvas whose DOM keeps mutating (an animation, a
2630
+ // re-rendering component) reissues the full unfetched set on every
2631
+ // mutation frame, forever (security review finding: unbounded fetch
2632
+ // amplification against the dev server from a zero-gesture background
2633
+ // scan).
2634
+ const attempted = new Set<string>();
2635
+ // Safety ceiling per pass — a pathological/hostile canvas DOM (thousands
2636
+ // of img/image elements) shouldn't be able to fan out unbounded fetches
2637
+ // in one scan; the next mutation-triggered pass picks up where this left
2638
+ // off since `attempted` persists across passes.
2639
+ const MAX_SCAN_PER_PASS = 500;
2640
+ const scan = () => {
2641
+ let scanned = 0;
2642
+ for (const n of document.querySelectorAll('img, image')) {
2643
+ const asset = extractAssetRef(n);
2644
+ if (!asset || attempted.has(asset)) continue;
2645
+ if (++scanned > MAX_SCAN_PER_PASS) break;
2646
+ attempted.add(asset);
2647
+ fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
2648
+ .then((r) => (r.ok ? r.json() : null))
2649
+ .then((edit) => {
2650
+ if (cancelled || !edit || isDefaultEdit(edit)) return;
2651
+ apply(asset, edit);
2652
+ })
2653
+ .catch(() => {});
2654
+ }
2655
+ };
2656
+ scan();
2657
+ const observer = new MutationObserver(() => {
2658
+ cancelAnimationFrame(raf);
2659
+ raf = requestAnimationFrame(scan);
2660
+ });
2661
+ observer.observe(document.body, {
2662
+ childList: true,
2663
+ subtree: true,
2664
+ attributes: true,
2665
+ attributeFilter: ['src', 'href'],
2666
+ });
2667
+ return () => {
2668
+ cancelled = true;
2669
+ cancelAnimationFrame(raf);
2670
+ observer.disconnect();
2671
+ };
2672
+ }, [apply]);
2673
+ return null; // mutates the real elements directly — no visible DOM of its own.
2674
+ }
2675
+ PhotoPreviewBridge.displayName = 'PhotoPreviewBridge';
2676
+
2677
+ // PhotoBgRemoveHarness (feature-photo-editor, Task 18) — the headless CLI proof
2678
+ // harness `photo-bg-remove.sh` mounts inside a throwaway `_photo/<slug>.bgremove.tsx`
2679
+ // canvas (mirrors DrawProof's role for the draw engine). Runs the EXACT SAME
2680
+ // client-side ML flow the interactive "Remove Background" button uses (Task 12,
2681
+ // app.jsx `onPhotoRemoveBackground`) — @imgly/background-removal, WASM/WebGPU,
2682
+ // pixels never leave the browser — then persists the result and reports back to
2683
+ // the driving CLI script via DOM attributes it polls (no return value crosses
2684
+ // the process boundary; agent-browser reads attributes off the DOM instead).
2685
+ //
2686
+ // `/_api/asset` and `/_api/photo-edit` are BOTH canvas-safe routes (see their
2687
+ // CANVAS_SAFE_API comments in http.ts), so this harness posts directly from the
2688
+ // canvas origin — no main-origin relay / cross-origin workaround needed, despite
2689
+ // the split-origin (DDR-054) boundary the rest of the canvas runs inside.
2690
+
2691
+ export interface PhotoBgRemoveHarnessProps {
2692
+ /** Relative `assets/<sha8>.<ext>` source to remove the background from. */
2693
+ source: string;
2694
+ }
2695
+
2696
+ export function PhotoBgRemoveHarness({ source }: PhotoBgRemoveHarnessProps) {
2697
+ const [status, setStatus] = useState<'pending' | 'done' | 'error'>('pending');
2698
+ const [result, setResult] = useState<string | null>(null);
2699
+ const [error, setError] = useState<string | null>(null);
2700
+ const ranRef = useRef(false);
2701
+
2702
+ useEffect(() => {
2703
+ if (ranRef.current) return; // one ML pass per mount — a dev-mode double-effect must not double-run it
2704
+ ranRef.current = true;
2705
+ let cancelled = false;
2706
+ (async () => {
2707
+ try {
2708
+ const srcRes = await fetch(`/${source.replace(/^\/+/, '')}`);
2709
+ if (!srcRes.ok) throw new Error(`source fetch failed: ${srcRes.status}`);
2710
+ const srcBlob = await srcRes.blob();
2711
+ const { removeBackground } = await import('@imgly/background-removal');
2712
+ const matte = await removeBackground(srcBlob);
2713
+ const up = await fetch('/_api/asset', {
2714
+ method: 'POST',
2715
+ headers: { 'content-type': matte.type || 'image/png' },
2716
+ body: matte,
2717
+ });
2718
+ const upJson = (await up.json().catch(() => ({}))) as { path?: string };
2719
+ if (!up.ok || !upJson.path) throw new Error(`asset upload failed: ${up.status}`);
2720
+ const maskAsset = upJson.path;
2721
+
2722
+ // Merge onto whatever's already in the sidecar (mirrors photo-adjust.sh's
2723
+ // merge-by-default behavior) instead of clobbering unrelated fields.
2724
+ const base: unknown = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`)
2725
+ .then((r) => (r.ok ? r.json() : {}))
2726
+ .catch(() => ({}));
2727
+ const nextEdit = {
2728
+ ...(base && typeof base === 'object' ? base : {}),
2729
+ backgroundRemoved: { enabled: true, maskAsset },
2730
+ };
2731
+ const put = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`, {
2732
+ method: 'PUT',
2733
+ headers: { 'Content-Type': 'application/json' },
2734
+ body: JSON.stringify(nextEdit),
2735
+ });
2736
+ if (!put.ok) throw new Error(`photo-edit save failed: ${put.status}`);
2737
+ if (cancelled) return;
2738
+ setResult(maskAsset);
2739
+ setStatus('done');
2740
+ } catch (err) {
2741
+ if (cancelled) return;
2742
+ setError(err instanceof Error ? err.message : String(err));
2743
+ setStatus('error');
2744
+ }
2745
+ })();
2746
+ return () => {
2747
+ cancelled = true;
2748
+ };
2749
+ }, [source]);
2750
+
2751
+ return (
2752
+ <div
2753
+ data-photo-bgremove-status={status}
2754
+ data-photo-bgremove-result={result ?? ''}
2755
+ data-photo-bgremove-error={error ?? ''}
2756
+ style={{ padding: 24, font: '13px monospace' }}
2757
+ >
2758
+ photo-bg-remove: {status}
2759
+ {result ? ` → ${result}` : ''}
2760
+ {error ? ` (${error})` : ''}
2761
+ </div>
2762
+ );
2763
+ }
2764
+ PhotoBgRemoveHarness.displayName = 'PhotoBgRemoveHarness';
2765
+
2139
2766
  // ─────────────────────────────────────────────────────────────────────────────
2140
2767
  // SnapGuideOverlay (Phase 4.2) — renders 1 px guide lines while a drag is in
2141
2768
  // flight. Mounted by canvas-shell as a chrome layer outside `.dc-world`, so