@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
@@ -30,9 +30,32 @@ describe('createEditSourceCommand', () => {
30
30
  id: 'cd-1',
31
31
  key: 'color',
32
32
  value: 'blue',
33
+ // do() writes `after` over `before`, so `from` = before; css has no occurrence.
34
+ from: 'red',
35
+ occurrence: undefined,
33
36
  });
34
37
  });
35
38
 
39
+ test('do()/undo() carry occurrence + from for a text edit (variable re-target)', async () => {
40
+ const applyFn = mock(() => {});
41
+ const textEdit: EditSourcePayload = {
42
+ op: 'text',
43
+ canvas: '.design/ui/Foo.tsx',
44
+ id: 'cd-9',
45
+ key: '',
46
+ before: 'Old',
47
+ after: 'New',
48
+ occurrence: 2,
49
+ };
50
+ const cmd = createEditSourceCommand({ payload: textEdit, applyFn });
51
+ await cmd.do();
52
+ // redo/do: write New over Old → from = Old.
53
+ expect(applyFn.mock.calls[0]?.[0]).toMatchObject({ value: 'New', from: 'Old', occurrence: 2 });
54
+ await cmd.undo();
55
+ // undo: write Old over New → from = New.
56
+ expect(applyFn.mock.calls[1]?.[0]).toMatchObject({ value: 'Old', from: 'New', occurrence: 2 });
57
+ });
58
+
36
59
  test('undo() applies the BEFORE value', async () => {
37
60
  const applyFn = mock(() => {});
38
61
  const cmd = createEditSourceCommand({ payload: cssEdit, applyFn });
@@ -10,6 +10,7 @@ import {
10
10
  applyDeleteElement,
11
11
  applyInsertArtboard,
12
12
  applyInsertElement,
13
+ applyInsertElementIntoArtboard,
13
14
  applyResizeArtboard,
14
15
  CanvasEditError,
15
16
  } from '../canvas-edit.ts';
@@ -153,6 +154,51 @@ describe('canvas-edit / applyInsertElement', () => {
153
154
  });
154
155
  });
155
156
 
157
+ describe('canvas-edit / applyInsertElementIntoArtboard (empty-artboard fallback)', () => {
158
+ const emptyCanvas = [
159
+ 'export default function Demo() {',
160
+ ' return (',
161
+ ' <DesignCanvas>',
162
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
163
+ ' </DCArtboard>',
164
+ ' </DesignCanvas>',
165
+ ' );',
166
+ '}',
167
+ ].join('\n');
168
+
169
+ test("inserts a div as the artboard's only child (inside-end)", () => {
170
+ const out = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'div');
171
+ expect(out.source).toMatch(
172
+ /<DCArtboard id="home"[^>]*>[\s\S]*var\(--bg-2\)[\s\S]*<\/DCArtboard>/
173
+ );
174
+ expect(parses(out.source)).toBe(true);
175
+ expect(out.newId).toMatch(/^[0-9a-f]{8}$/);
176
+ });
177
+
178
+ test('inside-start lands the new element right after the opening tag', () => {
179
+ const out = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-start', 'text');
180
+ expect(out.source).toContain('<p style={{ margin: 0 }}>Text</p>');
181
+ expect(parses(out.source)).toBe(true);
182
+ });
183
+
184
+ test('image insert requires a contained asset src', () => {
185
+ expect(() =>
186
+ applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'image')
187
+ ).toThrow(CanvasEditError);
188
+ const ok = applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'home', 'inside-end', 'image', {
189
+ src: 'assets/ab12cd34.png',
190
+ });
191
+ expect(ok.source).toContain('src="assets/ab12cd34.png"');
192
+ expect(parses(ok.source)).toBe(true);
193
+ });
194
+
195
+ test('unknown artboard id throws', () => {
196
+ expect(() =>
197
+ applyInsertElementIntoArtboard(CANVAS, emptyCanvas, 'nope', 'inside-end', 'div')
198
+ ).toThrow(CanvasEditError);
199
+ });
200
+ });
201
+
156
202
  describe('canvas-edit / applyResizeArtboard (D4)', () => {
157
203
  const canvas = [
158
204
  'export default function Demo() {',
@@ -0,0 +1,287 @@
1
+ // export-center — feature-background-export-notification-center.
2
+ //
3
+ // useExportCenter relies on effects (initial fetch + a status-transition
4
+ // watcher), so — like canvas-hmr-runtime.test.tsx — this needs a live DOM
5
+ // with real effect flushing, not renderToStaticMarkup. Registers happy-dom
6
+ // for this file only and drives a real createRoot.
7
+
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
9
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
10
+
11
+ beforeAll(() => {
12
+ GlobalRegistrator.register();
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+ });
15
+ afterAll(() => {
16
+ GlobalRegistrator.unregister();
17
+ });
18
+
19
+ import { act, createElement } from 'react';
20
+ import { createRoot, type Root } from 'react-dom/client';
21
+
22
+ import { useExportCenter } from '../client/export-center.jsx';
23
+
24
+ function mount(el: HTMLElement) {
25
+ let root!: Root;
26
+ act(() => {
27
+ root = createRoot(el);
28
+ });
29
+ return root;
30
+ }
31
+
32
+ function job(overrides: Record<string, unknown>) {
33
+ return {
34
+ id: 'j1',
35
+ format: 'png',
36
+ scope: 'artboard',
37
+ status: 'queued',
38
+ createdAt: '2026-01-01T00:00:00.000Z',
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ describe('useExportCenter', () => {
44
+ let originalFetch: typeof fetch;
45
+ let originalHasFocus: typeof document.hasFocus;
46
+ let fetchMock: ReturnType<typeof mock>;
47
+
48
+ beforeEach(() => {
49
+ originalFetch = globalThis.fetch;
50
+ originalHasFocus = document.hasFocus;
51
+ // The hook's initial-hydration GET must never throw synchronously in this
52
+ // no-server test environment; individual tests override this further.
53
+ fetchMock = mock(async () => new Response(JSON.stringify({ jobs: [] }), { status: 200 }));
54
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
55
+ });
56
+ afterEach(() => {
57
+ globalThis.fetch = originalFetch;
58
+ document.hasFocus = originalHasFocus;
59
+ });
60
+
61
+ test('upserts a queued→running→done sequence and derives runningCount/queuedCount', () => {
62
+ document.hasFocus = () => false; // keep the focus-gated auto-download inert here
63
+ const host = document.createElement('div');
64
+ const root = mount(host);
65
+ let center!: ReturnType<typeof useExportCenter>;
66
+ function Probe() {
67
+ center = useExportCenter();
68
+ return null;
69
+ }
70
+ act(() => {
71
+ root.render(createElement(Probe));
72
+ });
73
+
74
+ act(() => {
75
+ center.upsert(job({ status: 'queued' }));
76
+ });
77
+ expect(center.queuedCount).toBe(1);
78
+ expect(center.runningCount).toBe(0);
79
+ expect(center.jobs.map((j) => j.id)).toEqual(['j1']);
80
+
81
+ act(() => {
82
+ center.upsert(job({ status: 'running', progress: { current: 2, total: 5 } }));
83
+ });
84
+ expect(center.queuedCount).toBe(0);
85
+ expect(center.runningCount).toBe(1);
86
+ expect(center.busyCount).toBe(1);
87
+ expect(center.jobs[0].progress).toEqual({ current: 2, total: 5 });
88
+
89
+ act(() => {
90
+ center.upsert(job({ status: 'done', filename: 'export.png' }));
91
+ });
92
+ expect(center.runningCount).toBe(0);
93
+ expect(center.busyCount).toBe(0);
94
+ // A done/failed TRANSITION queues a toast.
95
+ expect(center.showToast).toBe(true);
96
+ expect(center.toastJob?.id).toBe('j1');
97
+
98
+ act(() => root.unmount());
99
+ });
100
+
101
+ test('a later upsert of an already-finished job does not re-queue the toast', () => {
102
+ document.hasFocus = () => false;
103
+ const host = document.createElement('div');
104
+ const root = mount(host);
105
+ let center!: ReturnType<typeof useExportCenter>;
106
+ function Probe() {
107
+ center = useExportCenter();
108
+ return null;
109
+ }
110
+ act(() => {
111
+ root.render(createElement(Probe));
112
+ });
113
+
114
+ act(() => {
115
+ center.upsert(job({ status: 'done', filename: 'export.png' }));
116
+ });
117
+ expect(center.showToast).toBe(true);
118
+ act(() => {
119
+ center.dismissToast();
120
+ });
121
+ expect(center.showToast).toBe(false);
122
+
123
+ // Re-upserting the SAME finished status (no transition) must not resurrect the toast.
124
+ act(() => {
125
+ center.upsert(job({ status: 'done', filename: 'export.png' }));
126
+ });
127
+ expect(center.showToast).toBe(false);
128
+
129
+ act(() => root.unmount());
130
+ });
131
+
132
+ test('focus-gated auto-download only fires when document.hasFocus() is true', () => {
133
+ const host = document.createElement('div');
134
+ const root = mount(host);
135
+ let center!: ReturnType<typeof useExportCenter>;
136
+ function Probe() {
137
+ center = useExportCenter();
138
+ return null;
139
+ }
140
+ act(() => {
141
+ root.render(createElement(Probe));
142
+ });
143
+ fetchMock.mockClear(); // drop the initial hydration GET from the call log
144
+
145
+ // Unfocused — a 'done' transition must NOT hit the download endpoint.
146
+ document.hasFocus = () => false;
147
+ act(() => {
148
+ center.upsert(job({ id: 'unfocused', status: 'done', filename: 'a.png' }));
149
+ });
150
+ const downloadCalls = fetchMock.mock.calls.filter((c) =>
151
+ String(c[0]).includes('/_api/export-jobs/download')
152
+ );
153
+ expect(downloadCalls.length).toBe(0);
154
+
155
+ // Focused — a DIFFERENT job's 'done' transition MUST hit the download endpoint.
156
+ document.hasFocus = () => true;
157
+ act(() => {
158
+ center.upsert(job({ id: 'focused', status: 'done', filename: 'b.png' }));
159
+ });
160
+
161
+ act(() => root.unmount());
162
+ });
163
+
164
+ test('a job first seen queued surfaces a "started" toast that morphs into "done" in place', () => {
165
+ document.hasFocus = () => false;
166
+ const host = document.createElement('div');
167
+ const root = mount(host);
168
+ let center!: ReturnType<typeof useExportCenter>;
169
+ function Probe() {
170
+ center = useExportCenter();
171
+ return null;
172
+ }
173
+ act(() => {
174
+ root.render(createElement(Probe));
175
+ });
176
+
177
+ act(() => {
178
+ center.upsert(job({ status: 'queued' }));
179
+ });
180
+ expect(center.showToast).toBe(true);
181
+ expect(center.toastJob?.id).toBe('j1');
182
+ expect(center.toastJob?.status).toBe('queued');
183
+
184
+ act(() => {
185
+ center.upsert(job({ status: 'running', progress: { current: 1, total: 3 } }));
186
+ });
187
+ // Still the SAME toast (one entry per job), just reflecting the live status.
188
+ expect(center.showToast).toBe(true);
189
+ expect(center.toastJob?.id).toBe('j1');
190
+ expect(center.toastJob?.status).toBe('running');
191
+
192
+ act(() => {
193
+ center.upsert(job({ status: 'done', filename: 'export.png' }));
194
+ });
195
+ expect(center.showToast).toBe(true);
196
+ expect(center.toastJob?.id).toBe('j1');
197
+ expect(center.toastJob?.status).toBe('done');
198
+
199
+ act(() => root.unmount());
200
+ });
201
+
202
+ test('hydration seeds prior status — a historical done job never re-toasts or re-downloads', async () => {
203
+ fetchMock = mock(async (url: string) => {
204
+ if (String(url).includes('/_api/export-jobs') && !String(url).includes('download')) {
205
+ return new Response(
206
+ JSON.stringify({
207
+ jobs: [
208
+ job({ id: 'old', status: 'done', filename: 'old.png', createdAt: '2020-01-01' }),
209
+ ],
210
+ }),
211
+ { status: 200 }
212
+ );
213
+ }
214
+ return new Response(null, { status: 404 });
215
+ });
216
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
217
+ document.hasFocus = () => true; // would auto-download if this were a live transition
218
+
219
+ const host = document.createElement('div');
220
+ const root = mount(host);
221
+ let center!: ReturnType<typeof useExportCenter>;
222
+ function Probe() {
223
+ center = useExportCenter();
224
+ return null;
225
+ }
226
+ // The hydration fetch resolves asynchronously — flush it under act().
227
+ await act(async () => {
228
+ root.render(createElement(Probe));
229
+ await Promise.resolve();
230
+ await Promise.resolve();
231
+ });
232
+
233
+ expect(center.jobs.map((j) => j.id)).toEqual(['old']);
234
+ expect(center.showToast).toBe(false);
235
+ const downloadCalls = fetchMock.mock.calls.filter((c) =>
236
+ String(c[0]).includes('/_api/export-jobs/download')
237
+ );
238
+ expect(downloadCalls.length).toBe(0);
239
+
240
+ act(() => root.unmount());
241
+ });
242
+
243
+ test('savingIds reflects a busy state around save()/download()', async () => {
244
+ document.hasFocus = () => false;
245
+ let resolveDownload!: (r: Response) => void;
246
+ fetchMock = mock((url: string) => {
247
+ if (String(url).includes('/_api/export-jobs/download')) {
248
+ return new Promise<Response>((resolve) => {
249
+ resolveDownload = resolve;
250
+ });
251
+ }
252
+ return Promise.resolve(new Response(JSON.stringify({ jobs: [] }), { status: 200 }));
253
+ });
254
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
255
+
256
+ const host = document.createElement('div');
257
+ const root = mount(host);
258
+ let center!: ReturnType<typeof useExportCenter>;
259
+ function Probe() {
260
+ center = useExportCenter();
261
+ return null;
262
+ }
263
+ act(() => {
264
+ root.render(createElement(Probe));
265
+ });
266
+ act(() => {
267
+ center.upsert(job({ status: 'done', filename: 'export.png' }));
268
+ });
269
+ expect(center.savingIds.has('j1')).toBe(false);
270
+ expect(center.toastJob).not.toBeNull();
271
+ const doneJob = center.toastJob as NonNullable<typeof center.toastJob>;
272
+
273
+ let downloadDone!: Promise<void>;
274
+ act(() => {
275
+ downloadDone = center.download(doneJob);
276
+ });
277
+ expect(center.savingIds.has('j1')).toBe(true);
278
+
279
+ await act(async () => {
280
+ resolveDownload(new Response(new Blob(['x']), { status: 200 }));
281
+ await downloadDone;
282
+ });
283
+ expect(center.savingIds.has('j1')).toBe(false);
284
+
285
+ act(() => root.unmount());
286
+ });
287
+ });
@@ -0,0 +1,83 @@
1
+ // Regression guard for the multi-target capture position-restore bug
2
+ // (feature-background-export-notification-center, Phase A). Root cause: each
3
+ // per-artboard capture pinned the artboard's `left`/`top` inline style to
4
+ // `0px` for the screenshot/PDF/etc. crop but never restored it, so every
5
+ // artboard processed earlier in a `--multi` loop stayed stacked at the
6
+ // origin and bled into every subsequent target's clip region — the
7
+ // `canvas-as-separate` "scattered/overlapping fragments" bug.
8
+ //
9
+ // Confirmed fixed via a live repro against a real 22-artboard canvas (plan
10
+ // Task 26): the PDF export that previously didn't complete in 3+ minutes now
11
+ // finishes in ~10s with every page showing its own correct, isolated
12
+ // content. That run needs a real Chromium; this suite deliberately doesn't
13
+ // spawn one (mirrors `test/exporters/pw-launch.test.ts`: "the launch path
14
+ // itself is integration-shape... needs a real browser" — CI has none
15
+ // installed). So this guards the FIX SHAPE at the shim source level: a
16
+ // revert that drops the restore step (or reintroduces the old one-shot
17
+ // "zero every artboard up front" pattern) fails loud here instead of only
18
+ // showing up as a live-repro regression nobody runs by hand.
19
+
20
+ import { describe, expect, test } from 'bun:test';
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+
24
+ const BIN_DIR = join(import.meta.dir, '..', 'bin');
25
+
26
+ function readShim(name: string): string {
27
+ return readFileSync(join(BIN_DIR, name), 'utf8');
28
+ }
29
+
30
+ // The old bug pattern: zero EVERY `[data-dc-screen]` once, up front, before
31
+ // any per-target loop runs (no restore possible once every artboard shares
32
+ // the same reset).
33
+ const ONE_SHOT_RESET =
34
+ /for \(const el of document\.querySelectorAll\(['"]\[data-dc-screen\]['"]\)\)/;
35
+
36
+ describe('export shims — multi-target position restore (scatter-bug regression)', () => {
37
+ test('_png-playwright.mjs saves + restores each artboard around its own capture', () => {
38
+ const src = readShim('_png-playwright.mjs');
39
+ expect(src).toMatch(/const savedPos = await widenedHandle\.evaluate/);
40
+ expect(src).toMatch(/ab\.style\.left = prev\.left;\s*\n\s*ab\.style\.top = prev\.top;/);
41
+ expect(src).not.toMatch(ONE_SHOT_RESET);
42
+ });
43
+
44
+ test('_pdf-playwright.mjs saves + restores each artboard around its own capture', () => {
45
+ const src = readShim('_pdf-playwright.mjs');
46
+ expect(src).toMatch(/const savedPos = await handle\.evaluate/);
47
+ expect(src).toMatch(/ab\.style\.left = prev\.left;\s*\n\s*ab\.style\.top = prev\.top;/);
48
+ expect(src).not.toMatch(ONE_SHOT_RESET);
49
+ });
50
+
51
+ test('_svg-playwright.mjs pins + restores per-target via pinArtboard (not a one-shot reset)', () => {
52
+ const src = readShim('_svg-playwright.mjs');
53
+ expect(src).toMatch(/const pinArtboard = async/);
54
+ expect(src).toMatch(/const restore = await pinArtboard\(handle\)/);
55
+ expect(src).not.toMatch(ONE_SHOT_RESET);
56
+ });
57
+
58
+ test('_html-playwright.mjs pins + restores per-target via pinArtboard (not a one-shot reset)', () => {
59
+ const src = readShim('_html-playwright.mjs');
60
+ expect(src).toMatch(/const pinArtboard = async/);
61
+ expect(src).toMatch(/const restore = await pinArtboard\(handle\)/);
62
+ expect(src).not.toMatch(ONE_SHOT_RESET);
63
+ });
64
+
65
+ test('_pptx-playwright.mjs saves + restores position around its capture', () => {
66
+ const src = readShim('_pptx-playwright.mjs');
67
+ expect(src).toMatch(/const savedPos = await handle\.evaluate/);
68
+ expect(src).toMatch(/ab\.style\.left = prev\.left;\s*\n\s*ab\.style\.top = prev\.top;/);
69
+ expect(src).not.toMatch(ONE_SHOT_RESET);
70
+ });
71
+
72
+ test('multi-loops emit MAUDE_PROGRESS per artboard (the notification-center signal)', () => {
73
+ for (const name of [
74
+ '_png-playwright.mjs',
75
+ '_pdf-playwright.mjs',
76
+ '_svg-playwright.mjs',
77
+ '_html-playwright.mjs',
78
+ ]) {
79
+ const src = readShim(name);
80
+ expect(src).toContain('MAUDE_PROGRESS');
81
+ }
82
+ });
83
+ });
@@ -55,12 +55,12 @@ describe('/_api/export-history — GET', () => {
55
55
  }
56
56
  });
57
57
 
58
- test('caps to 5 most-recent entries', async () => {
58
+ test('caps to 20 most-recent entries', async () => {
59
59
  const { root } = makeSandbox();
60
60
  const port = nextPort();
61
61
  const proc = await bootServer(root, port);
62
62
  try {
63
- for (let i = 0; i < 7; i += 1) {
63
+ for (let i = 0; i < 23; i += 1) {
64
64
  const r = await fetch(`http://localhost:${port}/_api/export`, {
65
65
  method: 'POST',
66
66
  headers: { 'content-type': 'application/json' },
@@ -70,7 +70,36 @@ describe('/_api/export-history — GET', () => {
70
70
  }
71
71
  const r = await fetch(`http://localhost:${port}/_api/export-history`);
72
72
  const body = (await r.json()) as HistoryShape;
73
- expect(body.history.length).toBe(5);
73
+ expect(body.history.length).toBe(20);
74
+ } finally {
75
+ await killProc(proc);
76
+ }
77
+ });
78
+
79
+ test('old-shape entries (no id/status) still parse on a fresh boot', async () => {
80
+ const { root, designRoot } = makeSandbox();
81
+ const { mkdirSync, writeFileSync } = await import('node:fs');
82
+ mkdirSync(designRoot, { recursive: true });
83
+ writeFileSync(
84
+ join(designRoot, '_export-history.json'),
85
+ JSON.stringify([
86
+ {
87
+ format: 'png',
88
+ scope: 'artboard',
89
+ filename: 'legacy.png',
90
+ at: '2026-01-01T00:00:00.000Z',
91
+ },
92
+ ])
93
+ );
94
+ const port = nextPort();
95
+ const proc = await bootServer(root, port);
96
+ try {
97
+ const r = await fetch(`http://localhost:${port}/_api/export-history`);
98
+ expect(r.status).toBe(200);
99
+ const body = (await r.json()) as HistoryShape;
100
+ expect(body.history.length).toBe(1);
101
+ expect(body.history[0].format).toBe('png');
102
+ expect(body.history[0].filename).toBe('legacy.png');
74
103
  } finally {
75
104
  await killProc(proc);
76
105
  }