@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
@@ -18,9 +18,11 @@ import { fileURLToPath } from 'node:url';
18
18
  import {
19
19
  type ArrowStroke,
20
20
  type EllipseStroke,
21
+ type ImageStroke,
21
22
  type PenStroke,
22
23
  type PolygonStroke,
23
24
  type RectStroke,
25
+ type SectionStroke,
24
26
  type StickyStroke,
25
27
  type Stroke,
26
28
  strokesToSvg,
@@ -50,6 +52,18 @@ interface Annotation {
50
52
  text: string;
51
53
  } | null;
52
54
  target?: { source: string; selector: { type: string; value: string }; geometry: unknown };
55
+ href?: string;
56
+ authorName?: string;
57
+ members?: Array<{
58
+ id: string;
59
+ tool: string;
60
+ order: number;
61
+ x: number | null;
62
+ y: number | null;
63
+ w: number | null;
64
+ h: number | null;
65
+ href?: string;
66
+ }>;
53
67
  }
54
68
 
55
69
  // Mirror api.ts fileSlug for the common (design-root-relative .tsx) case so the
@@ -171,6 +185,20 @@ describe('read-annotations / sticky', () => {
171
185
  const { annotations } = read(REL, strokesToSvg([multi]));
172
186
  expect(annotations[0]?.text).toBe('line one\nline two');
173
187
  });
188
+
189
+ test('authorName containing `>` round-trips intact and does not corrupt sibling geometry (DDR-155/escAttr regression)', () => {
190
+ const authored: StickyStroke = { ...sticky, authorName: 'Bob> data-x="evil' };
191
+ const { annotations } = read(REL, strokesToSvg([authored]));
192
+ const [a] = annotations;
193
+ expect(a?.authorName).toBe('Bob> data-x="evil');
194
+ // The injected `>`/`"` must not have shifted the element scan and eaten
195
+ // the sibling geometry/text attributes.
196
+ expect(a?.x).toBe(40);
197
+ expect(a?.y).toBe(50);
198
+ expect(a?.w).toBe(200);
199
+ expect(a?.h).toBe(160);
200
+ expect(a?.text).toBe('Step 1: email + password');
201
+ });
174
202
  });
175
203
 
176
204
  describe('read-annotations / position-only strokes carry text:null + a bbox', () => {
@@ -561,3 +589,139 @@ describe('read-annotations / --rects element-level context', () => {
561
589
  expect(a && 'element' in a).toBe(false);
562
590
  });
563
591
  });
592
+
593
+ describe('read-annotations / section membership + reading order', () => {
594
+ test('members are ordered spatially (top-to-bottom, left-to-right), not by z/paint order', () => {
595
+ // Drawn out of visual order: B (top-right) first, A (top-left) second,
596
+ // C (bottom-left) third — so z = [B, A, C] but reading order must be
597
+ // [A, B, C].
598
+ const section: SectionStroke = {
599
+ id: 'sec1',
600
+ tool: 'section',
601
+ x: 0,
602
+ y: 0,
603
+ w: 400,
604
+ h: 400,
605
+ label: 'Board',
606
+ color: '#8884',
607
+ };
608
+ const stickyB: StickyStroke = {
609
+ id: 'stickyB',
610
+ tool: 'sticky',
611
+ color: '#fce8a6',
612
+ x: 200,
613
+ y: 20,
614
+ w: 120,
615
+ h: 120,
616
+ text: 'B',
617
+ fontSize: 14,
618
+ };
619
+ const stickyA: StickyStroke = {
620
+ id: 'stickyA',
621
+ tool: 'sticky',
622
+ color: '#fce8a6',
623
+ x: 20,
624
+ y: 20,
625
+ w: 120,
626
+ h: 120,
627
+ text: 'A',
628
+ fontSize: 14,
629
+ };
630
+ const stickyC: StickyStroke = {
631
+ id: 'stickyC',
632
+ tool: 'sticky',
633
+ color: '#fce8a6',
634
+ x: 20,
635
+ y: 220,
636
+ w: 120,
637
+ h: 120,
638
+ text: 'C',
639
+ fontSize: 14,
640
+ };
641
+ const { annotations } = read(REL, strokesToSvg([section, stickyB, stickyA, stickyC]));
642
+ const sec = annotations.find((a) => a.id === 'sec1');
643
+ expect(sec?.members?.map((m) => m.id)).toEqual(['stickyA', 'stickyB', 'stickyC']);
644
+ expect(sec?.members?.map((m) => m.order)).toEqual([0, 1, 2]);
645
+ });
646
+
647
+ test('image members carry their resolvable asset href', () => {
648
+ const section: SectionStroke = {
649
+ id: 'sec1',
650
+ tool: 'section',
651
+ x: 0,
652
+ y: 0,
653
+ w: 400,
654
+ h: 400,
655
+ label: 'Board',
656
+ color: '#8884',
657
+ };
658
+ const img: ImageStroke = {
659
+ id: 'img1',
660
+ tool: 'image',
661
+ x: 20,
662
+ y: 20,
663
+ w: 120,
664
+ h: 120,
665
+ href: 'assets/deadbeef.png',
666
+ };
667
+ const { annotations } = read(REL, strokesToSvg([section, img]));
668
+ const sec = annotations.find((a) => a.id === 'sec1');
669
+ expect(sec?.members).toEqual([
670
+ {
671
+ id: 'img1',
672
+ tool: 'image',
673
+ order: 0,
674
+ x: 20,
675
+ y: 20,
676
+ w: 120,
677
+ h: 120,
678
+ href: 'assets/deadbeef.png',
679
+ },
680
+ ]);
681
+ });
682
+
683
+ test('a stroke outside the section rect is not a member; a section with nothing inside gets an empty array', () => {
684
+ const section: SectionStroke = {
685
+ id: 'sec1',
686
+ tool: 'section',
687
+ x: 0,
688
+ y: 0,
689
+ w: 100,
690
+ h: 100,
691
+ label: 'Board',
692
+ color: '#8884',
693
+ };
694
+ const outside: StickyStroke = {
695
+ id: 'outside',
696
+ tool: 'sticky',
697
+ color: '#fce8a6',
698
+ x: 500,
699
+ y: 500,
700
+ w: 60,
701
+ h: 60,
702
+ text: 'far away',
703
+ fontSize: 14,
704
+ };
705
+ const { annotations } = read(REL, strokesToSvg([section, outside]));
706
+ const sec = annotations.find((a) => a.id === 'sec1');
707
+ expect(sec?.members).toEqual([]);
708
+ const other = annotations.find((a) => a.id === 'outside');
709
+ expect(other?.members).toBeUndefined();
710
+ });
711
+
712
+ test('a non-section stroke never gets a members field', () => {
713
+ const sticky: StickyStroke = {
714
+ id: 'lone',
715
+ tool: 'sticky',
716
+ color: '#fce8a6',
717
+ x: 0,
718
+ y: 0,
719
+ w: 60,
720
+ h: 60,
721
+ text: 'no section here',
722
+ fontSize: 14,
723
+ };
724
+ const { annotations } = read(REL, strokesToSvg([sticky]));
725
+ expect(annotations[0] && 'members' in annotations[0]).toBe(false);
726
+ });
727
+ });
@@ -0,0 +1,49 @@
1
+ // shell-importmap.test.ts — regression guard for the bug that broke
2
+ // PhotoBgRemoveHarness's first real run (feature-photo-editor, Task 18):
3
+ // `@imgly/background-removal` was added to `RUNTIME_PACKAGES` (runtime-bundle.ts,
4
+ // Task 7/11) and its `/_canvas-runtime/@imgly_background-removal.js` bundle was
5
+ // built + floor-checked, but the hand-maintained `<script type="importmap">` in
6
+ // `_shell.html` never got the matching entry — so any CANVAS-side code
7
+ // (`import('@imgly/background-removal')`, only ever exercised by the shell's
8
+ // OWN client bundle until this task) hit a browser-native "Failed to resolve
9
+ // module specifier" the moment it actually ran inside a canvas iframe.
10
+ //
11
+ // The invariant: every entry in RUNTIME_PACKAGES must have a matching
12
+ // `_shell.html` importmap key pointing at `/_canvas-runtime/<slugFor(pkg)>.js`.
13
+ // canvas-build.ts externalises the WHOLE list unconditionally (`externalSpecifiers
14
+ // = new Set(RUNTIME_PACKAGES)`), so a package added there without a matching
15
+ // importmap entry silently ships a canvas-side dead import until something
16
+ // actually calls it at runtime — this test catches it at build time instead.
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ import { DEV_SERVER_ROOT } from '../paths.ts';
23
+ import { RUNTIME_PACKAGES, slugFor } from '../runtime-bundle.ts';
24
+
25
+ const SHELL_HTML_PATH = join(
26
+ DEV_SERVER_ROOT,
27
+ '..',
28
+ '..',
29
+ 'plugins',
30
+ 'design',
31
+ 'templates',
32
+ '_shell.html'
33
+ );
34
+
35
+ describe('_shell.html importmap agrees with RUNTIME_PACKAGES', () => {
36
+ const shellHtml = readFileSync(SHELL_HTML_PATH, 'utf8');
37
+ const importmapMatch = shellHtml.match(/<script type="importmap">([\s\S]*?)<\/script>/);
38
+ test('the importmap script block exists', () => {
39
+ expect(importmapMatch).not.toBeNull();
40
+ });
41
+ const importmap = JSON.parse(importmapMatch?.[1] ?? '{}') as { imports?: Record<string, string> };
42
+ const imports = importmap.imports ?? {};
43
+
44
+ for (const pkg of RUNTIME_PACKAGES) {
45
+ test(`"${pkg}" resolves to its /_canvas-runtime/ bundle`, () => {
46
+ expect(imports[pkg]).toBe(`/_canvas-runtime/${slugFor(pkg)}.js`);
47
+ });
48
+ }
49
+ });
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { applyTextEdit } from '../canvas-edit.ts';
3
+ import { transpileCanvasSource } from '../canvas-pipeline.ts';
4
+
5
+ // Phase 6 (unified-text-editing) — the build-time `data-cd-editable="text"`
6
+ // marker must agree EXACTLY with applyTextEdit's acceptance (canvas-edit.ts):
7
+ // stamped ⇔ a commit would save. See isInlineEditableText in
8
+ // canvas-pipeline.ts (the mirrored predicate).
9
+
10
+ const FIXTURE = `export default function Fixture() {
11
+ return (
12
+ <div>
13
+ <h1>Maude desktop E2E</h1>
14
+ <p>
15
+ Deterministic, static canvas — proves the native shell booted the
16
+ sidecar, navigated the webview, and rendered a canvas.
17
+ </p>
18
+ <p>Total: {1 + 1} items</p>
19
+ <span>{title}</span>
20
+ <h2>{'Quoted title'}</h2>
21
+ <button>
22
+ Save
23
+ </button>
24
+ <section>
25
+ <em>nested</em>
26
+ </section>
27
+ <div />
28
+ </div>
29
+ );
30
+ }
31
+ const title = 'x';
32
+ `;
33
+
34
+ function stamped(withIds: string, tag: string): boolean {
35
+ const m = withIds.match(new RegExp(`<${tag}[^>]*>`));
36
+ if (!m) throw new Error(`tag <${tag}> not found in output`);
37
+ return m[0].includes('data-cd-editable="text"');
38
+ }
39
+
40
+ describe('data-cd-editable build-time stamp', () => {
41
+ const { withIds } = transpileCanvasSource('/virtual/Fixture.tsx', FIXTURE);
42
+
43
+ test('single static JSXText gets the marker', () => {
44
+ expect(stamped(withIds, 'h1')).toBe(true);
45
+ expect(stamped(withIds, 'button')).toBe(true); // whitespace-framed text
46
+ });
47
+
48
+ test('multi-line static JSXText gets the marker', () => {
49
+ expect(stamped(withIds, 'p')).toBe(true); // first <p> — static prose
50
+ });
51
+
52
+ test('mixed static+expression content does NOT get the marker', () => {
53
+ // <p>Total: {1 + 1} items</p> — renders as leaf-looking text nodes, but
54
+ // the source is mixed → applyTextEdit refuses → must not be offered.
55
+ const second = withIds.match(/<p[^>]*>Total:/);
56
+ if (!second) throw new Error('mixed <p> not found');
57
+ expect(second[0].includes('data-cd-editable')).toBe(false);
58
+ });
59
+
60
+ test('dynamic expression content does NOT get the marker', () => {
61
+ expect(stamped(withIds, 'span')).toBe(false); // {title}
62
+ });
63
+
64
+ test("{'string literal'} single child DOES get the marker (DDR-150 P1)", () => {
65
+ expect(stamped(withIds, 'h2')).toBe(true);
66
+ });
67
+
68
+ test('containers and empty elements do NOT get the marker', () => {
69
+ expect(stamped(withIds, 'section')).toBe(false); // element child
70
+ const selfClosed = withIds.match(/<div [^>]*\/>/);
71
+ if (!selfClosed) throw new Error('self-closed div not found');
72
+ expect(selfClosed[0].includes('data-cd-editable')).toBe(false);
73
+ });
74
+
75
+ test('marker agrees with applyTextEdit acceptance for every stamped element', () => {
76
+ // Every data-cd-id that carries the marker must be accepted by the
77
+ // engine; every one without it (that has text-ish content) refused.
78
+ const opens = withIds.match(/<[a-z][^>]*data-cd-id="[^"]+"[^>]*>/g) ?? [];
79
+ for (const open of opens) {
80
+ const id = /data-cd-id="([^"]+)"/.exec(open)?.[1];
81
+ if (!id) continue;
82
+ const kind = /data-cd-editable="(text|var)"/.exec(open)?.[1];
83
+ let accepted = true;
84
+ try {
85
+ applyTextEdit('/virtual/Fixture.tsx', withIds, id, 'replacement');
86
+ } catch {
87
+ accepted = false;
88
+ }
89
+ // `text` (literal) MUST be accepted with no runtime context. `var`
90
+ // (a {variable}) is traced back at commit and needs occurrence/before —
91
+ // its acceptance is covered by dynamic-text-edit.test.ts, so only assert
92
+ // it isn't offered where the engine would hard-refuse a literal edit.
93
+ // Unmarked text-ish elements MUST be refused (no dead-end editor).
94
+ if (kind === 'text') {
95
+ expect(`${open} accepted=${accepted}`).toBe(`${open} accepted=true`);
96
+ } else if (!kind) {
97
+ expect(`${open} accepted=${accepted}`).toBe(`${open} accepted=false`);
98
+ }
99
+ }
100
+ });
101
+ });
102
+
103
+ describe('applyTextEdit sibling integrity (Task 6.3)', () => {
104
+ test('editing the h1 leaves every sibling byte-identical', () => {
105
+ const { withIds } = transpileCanvasSource('/virtual/Fixture.tsx', FIXTURE);
106
+ const h1Id = /<h1[^>]*data-cd-id="([^"]+)"/.exec(withIds)?.[1];
107
+ if (!h1Id) throw new Error('no h1 id');
108
+ const { source: out } = applyTextEdit('/virtual/Fixture.tsx', withIds, h1Id, 'New title');
109
+ // The h1 text changed…
110
+ expect(out).toMatch(/<h1[^>]*data-cd-editable="text"/);
111
+ expect(out).toContain('New title');
112
+ expect(out).not.toContain('Maude desktop E2E');
113
+ // …and EVERYTHING ELSE is byte-identical: strip the h1 element line from
114
+ // both versions and compare the rest wholesale.
115
+ const strip = (s: string) => s.replace(/<h1[^>]*>[^<]*<\/h1>/, '<h1/>');
116
+ expect(strip(out)).toBe(strip(withIds));
117
+ });
118
+
119
+ test('editing the multi-line p preserves its own whitespace framing', () => {
120
+ const { withIds } = transpileCanvasSource('/virtual/Fixture.tsx', FIXTURE);
121
+ const pId = /<p[^>]*data-cd-id="([^"]+)"[^>]*>\s*\n\s*Deterministic/.exec(withIds)?.[1];
122
+ if (!pId) throw new Error('no p id');
123
+ const { source: out } = applyTextEdit('/virtual/Fixture.tsx', withIds, pId, 'Short.');
124
+ // Leading/trailing whitespace of the JSXText survives (JSX indentation).
125
+ expect(out).toMatch(/<p[^>]*>\s*\n\s*Short\.\s*\n\s*<\/p>/);
126
+ // Siblings untouched.
127
+ expect(out).toMatch(/<h1[^>]*data-cd-editable="text"/);
128
+ expect(out).toContain('Maude desktop E2E');
129
+ expect(out).toContain('Total: {1 + 1} items');
130
+ });
131
+ });
@@ -124,4 +124,61 @@ export default function Canvas(){ return (<DesignCanvas>
124
124
  expect(reel.total).toBe(120);
125
125
  expect(reel.audio).toHaveLength(1);
126
126
  });
127
+
128
+ test('a clips array built via .flatMap() collapses to one generic row (documents the parser limit — see video-comp SKILL.md "Never build ... with .map()/.flatMap()")', () => {
129
+ const src = `
130
+ const CLIP = 60; const XF = 15;
131
+ const clips = ['assets/a.mp4', 'assets/b.mp4', 'assets/c.mp4', 'assets/d.mp4'];
132
+ const TOTAL = clips.length * CLIP - (clips.length - 1) * XF;
133
+ const ReelClip = ({ src }) => <AbsoluteFill/>;
134
+ function Reel(){
135
+ return (<AbsoluteFill><TransitionSeries>
136
+ {clips.flatMap((src, i) => {
137
+ const seq = <TransitionSeries.Sequence key={\`s\${i}\`} durationInFrames={CLIP}><ReelClip src={src}/></TransitionSeries.Sequence>;
138
+ if (i === clips.length - 1) return [seq];
139
+ return [seq, <TransitionSeries.Transition key={\`t\${i}\`} presentation={fade()} timing={linearTiming({ durationInFrames: XF })}/>];
140
+ })}
141
+ </TransitionSeries></AbsoluteFill>);
142
+ }
143
+ `;
144
+ // Only ONE literal <TransitionSeries.Sequence> occurrence exists in the
145
+ // source text (written once inside the callback) — not the 4 clips that
146
+ // exist at runtime — and its durationInFrames={CLIP} is fine but the
147
+ // array-driven TOTAL isn't resolvable arithmetic, so scopedTotal falls
148
+ // back to the derived span.
149
+ const r = parseCompTimeline(src, 0);
150
+ expect(r.sequences).toHaveLength(1);
151
+ expect(r.sequences[0]).toMatchObject({ label: 'ReelClip', duration: 60 });
152
+ });
153
+
154
+ test('the corrected 4-literal-block worked example (video-comp SKILL.md) parses all 4 named clips + the literal-sum total', () => {
155
+ const src = `
156
+ const XF = 15;
157
+ const TOTAL = 60 + 60 + 60 + 60 - XF * 3;
158
+ const Clip = ({ src, label }) => <AbsoluteFill/>;
159
+ const Reel = () => (
160
+ <AbsoluteFill>
161
+ <TransitionSeries>
162
+ <TransitionSeries.Sequence name="clip-1" durationInFrames={60}><Clip src="assets/a.mp4" label="01" /></TransitionSeries.Sequence>
163
+ <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />
164
+ <TransitionSeries.Sequence name="clip-2" durationInFrames={60}><Clip src="assets/b.mp4" label="02" /></TransitionSeries.Sequence>
165
+ <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />
166
+ <TransitionSeries.Sequence name="clip-3" durationInFrames={60}><Clip src="assets/c.mp4" label="03" /></TransitionSeries.Sequence>
167
+ <TransitionSeries.Transition presentation={fade()} timing={linearTiming({ durationInFrames: XF })} />
168
+ <TransitionSeries.Sequence name="clip-4" durationInFrames={60}><Clip src="assets/d.mp4" label="04" /></TransitionSeries.Sequence>
169
+ </TransitionSeries>
170
+ </AbsoluteFill>
171
+ );
172
+ `;
173
+ const r = parseCompTimeline(src, 0);
174
+ expect(r.sequences).toHaveLength(4);
175
+ expect(r.sequences.map((s) => s.label)).toEqual(['clip-1', 'clip-2', 'clip-3', 'clip-4']);
176
+ expect(r.sequences.map((s) => s.duration)).toEqual([60, 60, 60, 60]);
177
+ // Sequence 1 at 0; each next clip starts durationInFrames - XF after the
178
+ // previous (the transition overlaps back), same crossfade math as the
179
+ // existing 2-clip test above.
180
+ expect(r.sequences.map((s) => s.from)).toEqual([0, 45, 90, 135]);
181
+ // 60*4 - 15*3 = 195, and it's a literal-arithmetic sum the parser resolves.
182
+ expect(r.total).toBe(195);
183
+ });
127
184
  });
@@ -2,7 +2,9 @@
2
2
  // "+ Element" affordance's anchor resolver: appends relative to the ACTIVE
3
3
  // artboard's last existing child (mirrors the context-menu's element-relative
4
4
  // insert, since an artboard's own `<article data-dc-screen>` carries no
5
- // `data-cd-id` of its own — DCArtboard doesn't forward it to the DOM).
5
+ // `data-cd-id` of its own — DCArtboard doesn't forward it to the DOM), falling
6
+ // back to the artboard's own `data-dc-screen` id (inside-end) when it has no
7
+ // such child yet — the empty-artboard fix for the previously-silent no-op.
6
8
  //
7
9
  // Needs a live DOM (querySelector/:scope) — register happy-dom for this file
8
10
  // only, like canvas-hmr-runtime.test.tsx.
@@ -62,13 +64,13 @@ describe('tool-palette / resolveInsertAnchor', () => {
62
64
  expect(resolveInsertAnchor(document)).toEqual({ refId: '11111111', position: 'after' });
63
65
  });
64
66
 
65
- test('returns null for an EMPTY artboard (no data-cd-id child to anchor on)', () => {
67
+ test('falls back to the artboard id (inside-end) for an EMPTY artboard no longer a silent no-op', () => {
66
68
  document.body.innerHTML = `
67
69
  <article data-dc-screen="a1" aria-current="true">
68
70
  <div class="dc-artboard-body"></div>
69
71
  </article>
70
72
  `;
71
- expect(resolveInsertAnchor(document)).toBeNull();
73
+ expect(resolveInsertAnchor(document)).toEqual({ artboardId: 'a1', position: 'inside-end' });
72
74
  });
73
75
 
74
76
  test('returns null when there is no artboard at all', () => {
@@ -208,4 +208,37 @@ describe('cross-iframe persistence (loadStackState / saveStackState)', () => {
208
208
  expect(reloaded.past.length).toBe(2);
209
209
  expect(reloaded.future.length).toBe(1);
210
210
  });
211
+
212
+ test('survives a WINDOW-STORE WIPE via sessionStorage (split-origin iframe reload)', () => {
213
+ // Split-origin canvas: the store lives on the iframe window, which a full
214
+ // reload (the HMR reload a `.tsx` text edit triggers) destroys. The durable
215
+ // sessionStorage layer must restore it. Install a fake window + sessionStorage.
216
+ const backing = new Map<string, string>();
217
+ const fakeSS = {
218
+ getItem: (k: string) => backing.get(k) ?? null,
219
+ setItem: (k: string, v: string) => backing.set(k, v),
220
+ removeItem: (k: string) => backing.delete(k),
221
+ key: (i: number) => [...backing.keys()][i] ?? null,
222
+ clear: () => backing.clear(),
223
+ get length() {
224
+ return backing.size;
225
+ },
226
+ };
227
+ const g = globalThis as unknown as { window?: unknown };
228
+ const prev = g.window;
229
+ g.window = { sessionStorage: fakeSS }; // no `top` → store host = this window
230
+ try {
231
+ _clearStackStore();
232
+ saveStackState('ui/Split.tsx', { past: [rec('kept')], future: [] });
233
+ expect(backing.size).toBe(1); // mirrored to sessionStorage
234
+ // Simulate the iframe reload: the window's in-memory Map is gone.
235
+ (g.window as { __maude_undo_stacks?: unknown }).__maude_undo_stacks = new Map();
236
+ const restored = loadStackState('ui/Split.tsx');
237
+ expect(restored.past.length).toBe(1);
238
+ expect(restored.past[0]?.label).toBe('kept');
239
+ } finally {
240
+ _clearStackStore();
241
+ g.window = prev;
242
+ }
243
+ });
211
244
  });
@@ -5,14 +5,14 @@
5
5
  // the stored type AND category (→ which cap applies); a header lie / SVG /
6
6
  // script is caught here;
7
7
  // (2) the streaming POST endpoint (boots a real server) — video/audio happy
8
- // path, per-category cap (image 10 MB stays tight; video honors the
9
- // MAUDE_ASSET_MAX_VIDEO_BYTES override), content-addressed dedupe.
8
+ // path, per-category cap (image stays tighter than video; video honors
9
+ // the MAUDE_ASSET_MAX_VIDEO_BYTES override), content-addressed dedupe.
10
10
 
11
11
  import { describe, expect, test } from 'bun:test';
12
12
  import { existsSync, readdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
 
15
- import { assetCapForCategory, sniffAssetType } from '../api.ts';
15
+ import { ASSET_MAX_BYTES, assetCapForCategory, sniffAssetType } from '../api.ts';
16
16
  import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
17
17
 
18
18
  // ── Minimal valid magic-byte headers (padded so the ≥12-byte sniff has data). ──
@@ -59,8 +59,8 @@ describe('video-asset / sniffAssetType (the widened type gate)', () => {
59
59
  expect(sniffAssetType(pad([...ascii('RIFF'), 0, 0, 0, 0, ...ascii('AVI ')]))).toBeNull();
60
60
  });
61
61
 
62
- test('caps: image 10 MB tight, video/audio share the larger cap', () => {
63
- expect(assetCapForCategory('image')).toBe(10 * 1024 * 1024);
62
+ test('caps: image stays tight, video/audio share the larger cap', () => {
63
+ expect(assetCapForCategory('image')).toBe(ASSET_MAX_BYTES);
64
64
  expect(assetCapForCategory('video')).toBeGreaterThan(assetCapForCategory('image'));
65
65
  expect(assetCapForCategory('audio')).toBe(assetCapForCategory('video'));
66
66
  });
@@ -8,7 +8,12 @@
8
8
  // (DOM containment resolution) and the `__maude_render_video__` bridge's
9
9
  // routing contract — it must merge the registered comp's component/meta with
10
10
  // the caller's opts and delegate to the injected `__maudeRenderVideo__`, and
11
- // fail clearly when either the comp or the render-lib injection is missing.
11
+ // fail clearly when either the comp or the render-lib injection is missing, and
12
+ // PROPAGATE a renderer failure as a rejection (the contract the capture shim's
13
+ // try/catch relies on to fall back to frame-step screenshots — RCA
14
+ // issue-video-mp4-rendermediaonweb-stack-overflow). The shim-level fallback
15
+ // itself (renderMediaOnWeb throws → frame-step, video-only, degraded=true) is
16
+ // verified live against a deep-precompositing comp — see that RCA's repro.
12
17
 
13
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
14
19
  import { GlobalRegistrator } from '@happy-dom/global-registrator';
@@ -146,4 +151,21 @@ describe('__maude_render_video__ bridge', () => {
146
151
  /render-lib bundle not injected/i
147
152
  );
148
153
  });
154
+
155
+ test('propagates a renderer failure (e.g. stack overflow) as a rejection the shim can catch', async () => {
156
+ // The capture shim (_video-playwright.mjs) wraps the __maude_render_video__
157
+ // page.evaluate in try/catch and degrades to frame-step screenshots when it
158
+ // rejects. That only works if the bridge surfaces __maudeRenderVideo__'s
159
+ // throw as a rejection rather than swallowing it — the exact failure mode
160
+ // of the RCA (@remotion/web-renderer overflowing on deep precompositing).
161
+ const w = window as unknown as RenderWindow;
162
+ installMaudeSeekBridge();
163
+ registerComp('reel', { fps: 30, durationInFrames: 228, width: 960, height: 540 }, () => null);
164
+ w.__maudeRenderVideo__ = async () => {
165
+ throw new RangeError('Maximum call stack size exceeded');
166
+ };
167
+ await expect(w.__maude_render_video__?.('reel', { container: 'mp4' })).rejects.toThrow(
168
+ /Maximum call stack size exceeded/
169
+ );
170
+ });
149
171
  });