@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
@@ -463,6 +463,31 @@ export function hoverTargetToSelection(target: HoverTarget, file?: string): Sele
463
463
  worldW: el instanceof HTMLElement ? Math.round(el.offsetWidth) : undefined,
464
464
  worldH: el instanceof HTMLElement ? Math.round(el.offsetHeight) : undefined,
465
465
  html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
466
+ // feature-photo-editor (Task 14) — flag a content-addressed artboard `<img>`
467
+ // so the Inspector can offer the Photo tab. Only a real `assets/<sha8>.<ext>`
468
+ // src qualifies (an external URL / SVG icon / data: URI has no sidecar).
469
+ // `data-photo-asset` (stamped by canvas-lib's PhotoPreviewBridge the first
470
+ // time it bakes an edit into this element) is checked FIRST — once an edit
471
+ // is applied, the live `src` is a `data:` URL (the baked composite), which
472
+ // no longer matches the asset regex. Without this, an already-edited photo
473
+ // would lose its Photo tab the moment you select it (the exact regression
474
+ // this fixes — the bridge's own direct-src-swap made the element's `src`
475
+ // stop being a reliable asset key).
476
+ ...(() => {
477
+ if (el?.tagName?.toLowerCase() !== 'img') return {};
478
+ // The canvas iframe is untrusted content (DDR-054) — an authored `<img
479
+ // data-photo-asset="...">` is attacker-controllable, so the tag is only
480
+ // trusted when it actually has the `assets/<sha8>.<ext>` shape (security
481
+ // review finding: an unshaped value would ride unbounded into
482
+ // `_active.json`/the WS broadcast via inspect.ts's `enrich()`).
483
+ const assetRe = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
484
+ const tagged = (el as HTMLElement).getAttribute?.('data-photo-asset');
485
+ if (tagged && assetRe.test(tagged))
486
+ return { photoKind: 'artboard-img' as const, photoAsset: tagged };
487
+ const src = (el as HTMLImageElement).getAttribute?.('src') || '';
488
+ const m = assetRe.exec(src);
489
+ return m ? { photoKind: 'artboard-img' as const, photoAsset: m[0] } : {};
490
+ })(),
466
491
  ...styleMapsFor(el),
467
492
  };
468
493
  }
@@ -3,8 +3,10 @@
3
3
  * @scope apps/studio/export-dialog.tsx
4
4
  * @purpose Native `<dialog>`-based export modal. Three controls — format,
5
5
  * scope, per-format options — plus a Recent tab populated by
6
- * `/_api/export-history`. Submit fires `POST /_api/export`, the
7
- * response is piped to a Blob URL anchor download. `⌘E` opens
6
+ * `/_api/export-history`. Submit POSTs `/_api/export-jobs` and
7
+ * closes immediately (feature-background-export-notification-
8
+ * center) — the main-shell notification center owns status/
9
+ * progress/completion from there, not this dialog. `⌘E` opens
8
10
  * the dialog from anywhere inside the canvas; `⌘⇧E` re-runs the
9
11
  * most recent export without opening (T10 fast path).
10
12
  *
@@ -293,7 +295,9 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
293
295
  dialogRef.current?.close();
294
296
  }, []);
295
297
 
296
- // Pre-load history when the dialog opens; refresh after each export.
298
+ // Loaded when the dialog opens. Submit now closes the dialog immediately
299
+ // (the job runs in the background) instead of refreshing this in place —
300
+ // the next open picks up whatever's finished by then.
297
301
  const loadHistory = useCallback(async () => {
298
302
  try {
299
303
  if (isCrossOriginFramed()) {
@@ -364,10 +368,13 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
364
368
  setStatus(null);
365
369
  try {
366
370
  if (isCrossOriginFramed()) {
367
- // Bridge through the main shell — the iframe can't reach /_api/export
368
- // (canvas origin 403s it). The parent runs the export + triggers the
369
- // download, then replies with the saved filename or an error string.
370
- const res = await bridgeRequest<{ ok?: boolean; filename?: string; error?: string }>(
371
+ // Bridge through the main shell — the iframe can't reach
372
+ // /_api/export-jobs (canvas origin 403s it, DDR-060). The parent
373
+ // enqueues the job and replies with the id immediately; the
374
+ // trusted main-shell notification center (which already owns the
375
+ // WS connection) is the single place status/progress/completion
376
+ // live from here — this dialog no longer polls or receives bytes.
377
+ const res = await bridgeRequest<{ ok?: boolean; jobId?: string; error?: string }>(
371
378
  'export-request',
372
379
  'export-result',
373
380
  { payload: { format, scope, options } }
@@ -376,11 +383,10 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
376
383
  setStatus({ text: `Export failed: ${res.error || 'unknown'}`, isError: true });
377
384
  return;
378
385
  }
379
- setStatus({ text: `Saved ${res.filename ?? 'export'}`, isError: false });
380
- void loadHistory();
386
+ close();
381
387
  return;
382
388
  }
383
- const r = await fetch('/_api/export', {
389
+ const r = await fetch('/_api/export-jobs', {
384
390
  method: 'POST',
385
391
  headers: { 'content-type': 'application/json' },
386
392
  body: JSON.stringify({ format, scope, options }),
@@ -390,20 +396,7 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
390
396
  setStatus({ text: `Export failed: ${text || r.status}`, isError: true });
391
397
  return;
392
398
  }
393
- const disp = r.headers.get('Content-Disposition') ?? '';
394
- const filename =
395
- /filename="([^"]+)"/.exec(disp)?.[1] ?? `export${FORMAT_META[format].defaultExt}`;
396
- const blob = await r.blob();
397
- const url = URL.createObjectURL(blob);
398
- const a = document.createElement('a');
399
- a.href = url;
400
- a.download = filename;
401
- document.body.appendChild(a);
402
- a.click();
403
- a.remove();
404
- URL.revokeObjectURL(url);
405
- setStatus({ text: `Saved ${filename}`, isError: false });
406
- void loadHistory();
399
+ close();
407
400
  } catch (err) {
408
401
  const msg = err instanceof Error ? err.message : String(err);
409
402
  setStatus({ text: `Export failed: ${msg}`, isError: true });
@@ -411,7 +404,7 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
411
404
  setSubmitting(false);
412
405
  }
413
406
  },
414
- [loadHistory]
407
+ [close]
415
408
  );
416
409
 
417
410
  const rerunLast = useCallback(async () => {
@@ -67,3 +67,107 @@ export function resolveExportRuntime(): string {
67
67
  }
68
68
  return runtime;
69
69
  }
70
+
71
+ export interface SpawnShimOptions {
72
+ /** cwd for the spawned shim — callers pass `path.dirname(<SHIM>)`. */
73
+ cwd: string;
74
+ /** Aborts the render — kills the child process. */
75
+ signal?: AbortSignal;
76
+ /** Fired for each `MAUDE_PROGRESS {"current":N,"total":M}` stdout line. */
77
+ onProgress?: (update: { current: number; total: number }) => void;
78
+ }
79
+
80
+ export interface SpawnShimResult {
81
+ code: number;
82
+ /** stdout lines with `MAUDE_PROGRESS` lines filtered out (written-file paths etc.). */
83
+ stdoutLines: string[];
84
+ stderr: string;
85
+ }
86
+
87
+ const PROGRESS_LINE = /^MAUDE_PROGRESS (.+)$/;
88
+
89
+ /**
90
+ * Spawn a render shim (`bin/_{png,pdf,svg,html,pptx,video}-playwright.mjs`)
91
+ * via a resolved node/bun runtime, reading stdout INCREMENTALLY (not
92
+ * buffered via `new Response(proc.stdout).text()`) so a `MAUDE_PROGRESS`
93
+ * line written mid-render reaches `onProgress` as soon as it's flushed,
94
+ * instead of only after the whole process exits. Non-progress lines
95
+ * (written-file paths, the existing per-adapter contract) collect into
96
+ * `stdoutLines` in the order they arrived. Exit-code handling stays with the
97
+ * caller — this returns the same shape the old `Promise.all([...text()])`
98
+ * block did, so adapters keep their existing `if (code !== 0) throw …`.
99
+ */
100
+ export async function spawnShim(args: string[], opts: SpawnShimOptions): Promise<SpawnShimResult> {
101
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
102
+ cwd: opts.cwd,
103
+ stdout: 'pipe',
104
+ stderr: 'pipe',
105
+ signal: opts.signal,
106
+ });
107
+ // Bun.spawn's `signal` option kills the child on abort; this listener is a
108
+ // defensive fallback in case a given Bun build ignores it — cheap and
109
+ // idempotent (killing an already-exited process is a no-op).
110
+ const onAbort = () => {
111
+ try {
112
+ proc.kill();
113
+ } catch {
114
+ /* already exited */
115
+ }
116
+ };
117
+ opts.signal?.addEventListener('abort', onAbort);
118
+
119
+ const stderrPromise = new Response(proc.stderr).text();
120
+ const stdoutLines: string[] = [];
121
+ let buffer = '';
122
+ const consumeLine = (line: string) => {
123
+ if (!line) return;
124
+ const match = PROGRESS_LINE.exec(line);
125
+ if (!match) {
126
+ stdoutLines.push(line);
127
+ return;
128
+ }
129
+ try {
130
+ const update = JSON.parse(match[1]) as { current: number; total: number };
131
+ opts.onProgress?.(update);
132
+ } catch {
133
+ /* malformed progress line — drop it rather than treat it as a file path */
134
+ }
135
+ };
136
+
137
+ try {
138
+ const reader = proc.stdout.pipeThrough(new TextDecoderStream()).getReader();
139
+ for (;;) {
140
+ const { done, value } = await reader.read();
141
+ if (done) break;
142
+ buffer += value;
143
+ let newlineIdx = buffer.indexOf('\n');
144
+ while (newlineIdx !== -1) {
145
+ consumeLine(buffer.slice(0, newlineIdx).trim());
146
+ buffer = buffer.slice(newlineIdx + 1);
147
+ newlineIdx = buffer.indexOf('\n');
148
+ }
149
+ }
150
+ if (buffer.trim()) consumeLine(buffer.trim());
151
+ } finally {
152
+ opts.signal?.removeEventListener('abort', onAbort);
153
+ }
154
+
155
+ const [stderr, code] = await Promise.all([stderrPromise, proc.exited]);
156
+ return { code, stdoutLines, stderr };
157
+ }
158
+
159
+ /**
160
+ * `spawnShim()` + the exit-code check every adapter did identically
161
+ * (`if (code !== 0) throw new Error(...)`; `code review, /flow:done` — six
162
+ * near-copies collapsed into one). `args[0]` is the shim path by convention
163
+ * (every adapter builds it that way), used only for the error message.
164
+ */
165
+ export async function runShim(args: string[], opts: SpawnShimOptions): Promise<string[]> {
166
+ const { code, stdoutLines, stderr } = await spawnShim(args, opts);
167
+ if (code !== 0) {
168
+ throw new Error(
169
+ `${path.basename(args[0])} exited ${code}: ${stderr.trim() || stdoutLines.join('\n').trim()}`
170
+ );
171
+ }
172
+ return stdoutLines;
173
+ }
@@ -11,10 +11,11 @@ import { tmpdir } from 'node:os';
11
11
  import path from 'node:path';
12
12
 
13
13
  import JSZip from 'jszip';
14
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
14
+ import { exportShimPath, runShim } from './_runtime.ts';
15
15
  import {
16
16
  canvasShellUrl,
17
17
  type ExportContext,
18
+ type ExportHooks,
18
19
  type ExportOptions,
19
20
  type ExportResult,
20
21
  } from './index.ts';
@@ -27,7 +28,8 @@ async function captureHtml(
27
28
  target: Extract<Target, { kind: 'element' }>,
28
29
  ctx: ExportContext,
29
30
  outDir: string,
30
- timeoutSec: number
31
+ timeoutSec: number,
32
+ hooks?: ExportHooks
31
33
  ): Promise<string[]> {
32
34
  const args = [
33
35
  HTML_PLAYWRIGHT,
@@ -46,29 +48,18 @@ async function captureHtml(
46
48
  if (target.widen) args.push('--widen-to-artboard', '1');
47
49
  args.push('--out', path.join(outDir, `${target.canvasSlug}.html`));
48
50
  }
49
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
51
+ return runShim(args, {
50
52
  cwd: path.dirname(HTML_PLAYWRIGHT),
51
- stdout: 'pipe',
52
- stderr: 'pipe',
53
+ signal: hooks?.signal,
54
+ onProgress: hooks?.onProgress,
53
55
  });
54
- const [stdout, stderr] = await Promise.all([
55
- new Response(proc.stdout).text(),
56
- new Response(proc.stderr).text(),
57
- ]);
58
- const code = await proc.exited;
59
- if (code !== 0) {
60
- throw new Error(`_html-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
61
- }
62
- return stdout
63
- .split('\n')
64
- .map((s) => s.trim())
65
- .filter(Boolean);
66
56
  }
67
57
 
68
58
  export async function run(
69
59
  targets: Target[],
70
60
  options: ExportOptions,
71
- ctx: ExportContext
61
+ ctx: ExportContext,
62
+ hooks?: ExportHooks
72
63
  ): Promise<ExportResult> {
73
64
  if (!targets.length) {
74
65
  return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
@@ -83,9 +74,10 @@ export async function run(
83
74
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-html-'));
84
75
  try {
85
76
  const written: string[] = [];
86
- for (const t of elementTargets) {
87
- const paths = await captureHtml(t, ctx, tmp, timeoutSec);
77
+ for (let i = 0; i < elementTargets.length; i += 1) {
78
+ const paths = await captureHtml(elementTargets[i], ctx, tmp, timeoutSec, hooks);
88
79
  written.push(...paths);
80
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
89
81
  }
90
82
  if (!written.length) {
91
83
  return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
@@ -74,8 +74,19 @@ export interface ExportResult {
74
74
  body: Uint8Array;
75
75
  }
76
76
 
77
+ /** Progress + cancellation hooks threaded through to the shim-spawning layer. */
78
+ export interface ExportHooks {
79
+ onProgress?: (update: { current: number; total: number }) => void;
80
+ signal?: AbortSignal;
81
+ }
82
+
77
83
  export interface Adapter {
78
- run(targets: Target[], options: ExportOptions, ctx: ExportContext): Promise<ExportResult>;
84
+ run(
85
+ targets: Target[],
86
+ options: ExportOptions,
87
+ ctx: ExportContext,
88
+ hooks?: ExportHooks
89
+ ): Promise<ExportResult>;
79
90
  }
80
91
 
81
92
  const REGISTRY: Record<Format, Adapter> = {
@@ -114,6 +125,7 @@ export async function runExport(args: {
114
125
  options: ExportOptions;
115
126
  resolve: Omit<ResolveScopeArgs, 'scope'>;
116
127
  ctx: ExportContext;
128
+ hooks?: ExportHooks;
117
129
  }): Promise<ExportResult> {
118
130
  // Thread the options bag into the resolver so submit-time selection /
119
131
  // active-artboard hints (DDR — selection-passthrough) win over the persisted
@@ -123,7 +135,7 @@ export async function runExport(args: {
123
135
  if (!adapter) {
124
136
  throw new Error(`unknown format: ${args.format}`);
125
137
  }
126
- return adapter.run(targets, args.options, args.ctx);
138
+ return adapter.run(targets, args.options, args.ctx, args.hooks);
127
139
  }
128
140
 
129
141
  export type { Scope, Target };