@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
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @file text-caret.ts — shared custom blinking caret for every editable
3
+ * text surface (artboard inline edit + the four annotation editors)
4
+ * @purpose Native caret BLINK is unreliable in WebKit under the transformed
5
+ * `.dc-world` (a compositing trigger on/near the editable freezes
6
+ * the blink to a static line — see CARET_FIX_STYLE in
7
+ * annotations-layer.tsx for the translateZ(0) post-mortem), and
8
+ * blink is temporal, so no automated tool can assert it. This
9
+ * module replaces the native caret with a CSS-animated
10
+ * `[data-maude-caret]` element: engine-independent, identical on
11
+ * every surface, and harness-assertable (presence + animationName
12
+ * + position). The native caret is hidden via
13
+ * `caret-color: transparent` while mounted.
14
+ *
15
+ * Positioning model: the caret element is appended to the document BODY with
16
+ * `position: fixed` and placed from the live selection's client rects —
17
+ * viewport coords, already post-transform, so the world's pan/zoom needs no
18
+ * special-casing. Positioning is driven THREE ways, deliberately redundant:
19
+ * a synchronous first paint on mount, the discrete event set (selectionchange
20
+ * / input / keyup / focus / blur / scroll), and a rAF loop for pan/zoom
21
+ * (which fires no DOM event). rAF alone is NOT enough — WKWebView pauses rAF
22
+ * for occluded windows (measured in the desktop-e2e harness), which would
23
+ * leave the caret invisible. Style is only touched on an actual rect change,
24
+ * and a change restarts the blink cycle exactly like a native caret (solid
25
+ * while typing/moving).
26
+ */
27
+
28
+ /**
29
+ * Place a collapsed caret inside a contentEditable at a viewport point —
30
+ * `caretRangeFromPoint` (WebKit/Chromium), `caretPositionFromPoint`
31
+ * (standard) fallback — THE unified entry-placement chain for the artboard
32
+ * inline editor and every annotation editor. A point that resolves outside
33
+ * the editable (click on padding / another element) is a miss. Without a
34
+ * point, or on a miss with `fallbackToSelectAll`, selects the whole content
35
+ * (the right default for keyboard entry, where select-all-then-retype is the
36
+ * rename convention). Returns true when a caret/selection was applied.
37
+ */
38
+ export function placeCaretAt(
39
+ editable: HTMLElement,
40
+ win: Window,
41
+ point?: { x: number; y: number },
42
+ fallbackToSelectAll = true
43
+ ): boolean {
44
+ const doc = editable.ownerDocument as Document & {
45
+ caretRangeFromPoint?: (x: number, y: number) => Range | null;
46
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
47
+ };
48
+ try {
49
+ let range: Range | null = null;
50
+ if (point) {
51
+ if (typeof doc.caretRangeFromPoint === 'function') {
52
+ range = doc.caretRangeFromPoint(point.x, point.y);
53
+ } else if (typeof doc.caretPositionFromPoint === 'function') {
54
+ const pos = doc.caretPositionFromPoint(point.x, point.y);
55
+ if (pos) {
56
+ range = doc.createRange();
57
+ range.setStart(pos.offsetNode, pos.offset);
58
+ range.collapse(true);
59
+ }
60
+ }
61
+ if (range && !editable.contains(range.startContainer)) range = null;
62
+ }
63
+ if (!range) {
64
+ if (!fallbackToSelectAll) return false;
65
+ range = doc.createRange();
66
+ range.selectNodeContents(editable);
67
+ }
68
+ const sel = win.getSelection();
69
+ if (!sel) return false;
70
+ sel.removeAllRanges();
71
+ sel.addRange(range);
72
+ return true;
73
+ } catch {
74
+ /* selection API unavailable */
75
+ return false;
76
+ }
77
+ }
78
+
79
+ const STYLE_ID = 'maude-caret-css';
80
+
81
+ const CARET_CSS = `
82
+ [data-maude-caret] {
83
+ animation: maude-caret-blink 1s steps(1, end) infinite;
84
+ }
85
+ @keyframes maude-caret-blink {
86
+ 0%, 49.9% { opacity: 1; }
87
+ 50%, 99.9% { opacity: 0; }
88
+ 100% { opacity: 1; }
89
+ }
90
+ @media (prefers-reduced-motion: reduce) {
91
+ /* No blink, but the caret stays visible — position is still the signal. */
92
+ [data-maude-caret] { animation: none; }
93
+ }
94
+ `;
95
+
96
+ function ensureCaretStyles(doc: Document): void {
97
+ if (doc.getElementById(STYLE_ID)) return;
98
+ const style = doc.createElement('style');
99
+ style.id = STYLE_ID;
100
+ style.textContent = CARET_CSS;
101
+ doc.head.appendChild(style);
102
+ }
103
+
104
+ interface CaretRect {
105
+ x: number;
106
+ y: number;
107
+ h: number;
108
+ }
109
+
110
+ /** Resolve where the caret should paint, in viewport coords — or null when it
111
+ * should hide (no collapsed selection inside the editable / editable not
112
+ * focused / a range selection is active, where the native highlight is the
113
+ * affordance). */
114
+ function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
115
+ const doc = editable.ownerDocument;
116
+ const active = doc.activeElement;
117
+ if (active !== editable && !editable.contains(active)) return null;
118
+ const sel = win.getSelection();
119
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
120
+ const node = sel.anchorNode;
121
+ if (!node || !editable.contains(node)) return null;
122
+ const range = sel.getRangeAt(0).cloneRange();
123
+ range.collapse(true);
124
+ // A collapsed range usually has one zero-width client rect; WebKit sometimes
125
+ // returns none (empty text node / line start) — getBoundingClientRect is the
126
+ // second try before falling back to the editable's own box metrics.
127
+ let r: DOMRect | null = range.getClientRects()[0] ?? null;
128
+ if (!r || (r.height === 0 && r.y === 0 && r.x === 0)) {
129
+ const br = range.getBoundingClientRect();
130
+ if (br && br.height > 0) r = br;
131
+ }
132
+ if (r && r.height > 0) return { x: r.left, y: r.top, h: r.height };
133
+ // Empty editable — no rect exists anywhere. Derive a caret box from the
134
+ // editable's padding box + line metrics. (The annotation editors avoid this
135
+ // path by mounting with a zero-width-space JUMP_SENTINEL, but the artboard
136
+ // editor can reach it by deleting all text.)
137
+ const er = editable.getBoundingClientRect();
138
+ const cs = win.getComputedStyle(editable);
139
+ const fs = Number.parseFloat(cs.fontSize) || 14;
140
+ const lh = cs.lineHeight === 'normal' ? fs * 1.25 : Number.parseFloat(cs.lineHeight) || fs * 1.25;
141
+ const padL = Number.parseFloat(cs.paddingLeft) || 0;
142
+ const padT = Number.parseFloat(cs.paddingTop) || 0;
143
+ return { x: er.left + padL, y: er.top + padT, h: lh };
144
+ }
145
+
146
+ /**
147
+ * Mount the custom blinking caret onto a contentEditable. Returns a disposer
148
+ * that removes the caret element and restores the editable's own caret-color.
149
+ * One mount per editing session — enter-edit mounts, teardown disposes.
150
+ */
151
+ export function mountCaret(editable: HTMLElement, win: Window): () => void {
152
+ const doc = editable.ownerDocument;
153
+ if (!doc.body) return () => {};
154
+ ensureCaretStyles(doc);
155
+
156
+ const caret = doc.createElement('span');
157
+ caret.setAttribute('data-maude-caret', '1');
158
+ caret.setAttribute('aria-hidden', 'true');
159
+ Object.assign(caret.style, {
160
+ position: 'fixed',
161
+ left: '0px',
162
+ top: '0px',
163
+ width: '2px',
164
+ height: '16px',
165
+ background: 'var(--maude-hud-accent, #4a63e7)',
166
+ borderRadius: '1px',
167
+ pointerEvents: 'none',
168
+ zIndex: '9999',
169
+ display: 'none',
170
+ } satisfies Partial<CSSStyleDeclaration>);
171
+ doc.body.appendChild(caret);
172
+
173
+ const prevCaretColor = editable.style.caretColor;
174
+ editable.style.caretColor = 'transparent';
175
+
176
+ let shown = false;
177
+ let last: CaretRect = { x: -1, y: -1, h: -1 };
178
+
179
+ const restartBlink = (): void => {
180
+ // Re-trigger the stylesheet animation from 0% so the caret is solid right
181
+ // after a move/keystroke — the native caret's exact behavior.
182
+ caret.style.animation = 'none';
183
+ void caret.offsetWidth; // reflow flushes the 'none'
184
+ caret.style.animation = '';
185
+ };
186
+
187
+ const position = (): void => {
188
+ const rc = caretRectFor(editable, win);
189
+ if (!rc) {
190
+ if (shown) {
191
+ caret.style.display = 'none';
192
+ shown = false;
193
+ }
194
+ } else {
195
+ if (rc.x !== last.x || rc.y !== last.y || rc.h !== last.h) {
196
+ caret.style.left = `${rc.x - 0.5}px`;
197
+ caret.style.top = `${rc.y}px`;
198
+ caret.style.height = `${Math.max(8, rc.h)}px`;
199
+ last = rc;
200
+ restartBlink();
201
+ }
202
+ if (!shown) {
203
+ caret.style.display = 'block';
204
+ shown = true;
205
+ }
206
+ }
207
+ };
208
+
209
+ // Synchronous first paint + discrete events carry the caret even when rAF
210
+ // is throttled (WKWebView pauses rAF for occluded windows — measured in the
211
+ // desktop-e2e harness); the rAF loop on top tracks pan/zoom, which fires no
212
+ // DOM event at all.
213
+ const onUpdate = (): void => position();
214
+ doc.addEventListener('selectionchange', onUpdate);
215
+ editable.addEventListener('input', onUpdate);
216
+ editable.addEventListener('keyup', onUpdate);
217
+ editable.addEventListener('focus', onUpdate);
218
+ editable.addEventListener('blur', onUpdate);
219
+ win.addEventListener('scroll', onUpdate, true);
220
+ let raf = 0;
221
+ const tick = (): void => {
222
+ position();
223
+ raf = win.requestAnimationFrame(tick);
224
+ };
225
+ position();
226
+ raf = win.requestAnimationFrame(tick);
227
+
228
+ return () => {
229
+ win.cancelAnimationFrame(raf);
230
+ doc.removeEventListener('selectionchange', onUpdate);
231
+ editable.removeEventListener('input', onUpdate);
232
+ editable.removeEventListener('keyup', onUpdate);
233
+ editable.removeEventListener('focus', onUpdate);
234
+ editable.removeEventListener('blur', onUpdate);
235
+ win.removeEventListener('scroll', onUpdate, true);
236
+ caret.remove();
237
+ editable.style.caretColor = prevCaretColor;
238
+ };
239
+ }
@@ -287,17 +287,22 @@ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
287
287
  * when the user hasn't right-clicked a specific element). Growth-only: an
288
288
  * artboard's `<article data-dc-screen>` carries NO `data-cd-id` of its own
289
289
  * (`DCArtboard` doesn't forward it to the DOM — only its SOURCE JSX tag is
290
- * stamped, unresolvable from here), so `applyInsertElement`'s `refId` can't
291
- * target the artboard directly. Anchor on the artboard's LAST direct child
292
- * that DOES carry a `data-cd-id` and insert `position:'after'` it — the exact
293
- * relation the context-menu's element-relative insert already uses. Returns
294
- * `null` for an empty (or artboard-less) canvas a documented v1 limit; the
295
- * per-element context-menu "Insert …" still works there once a first
296
- * element exists. `doc` is injectable for testing without a real iframe.
290
+ * stamped, unresolvable from here), so the primary anchor is the artboard's
291
+ * LAST direct child that DOES carry a `data-cd-id`, inserted `after` it — the
292
+ * exact relation the context-menu's element-relative insert already uses.
293
+ * When the artboard has NO such child yet (fresh/emptied artboard — the bug
294
+ * this fallback fixes, was previously a silent no-op), fall back to the
295
+ * artboard's own `data-dc-screen` id (its `DCArtboard id` prop verbatim) and
296
+ * insert `inside-end` of it directly `applyInsertElementIntoArtboard`
297
+ * addresses the `<DCArtboard>` JSX node by that same id, mirroring
298
+ * `applyResizeArtboard`/`applyDeleteArtboard`. Returns `null` only when there
299
+ * is no artboard at all (or no `.dc-artboard-body` wrapper) — nothing to
300
+ * anchor on, artboard-less canvas. `doc` is injectable for testing without a
301
+ * real iframe.
297
302
  */
298
303
  export function resolveInsertAnchor(
299
304
  doc: Document = document
300
- ): { refId: string; position: 'after' } | null {
305
+ ): { refId: string; position: 'after' } | { artboardId: string; position: 'inside-end' } | null {
301
306
  const current = doc.querySelector('[data-dc-screen][aria-current="true"]');
302
307
  const artboardEl = current ?? doc.querySelector('[data-dc-screen]');
303
308
  if (!artboardEl) return null;
@@ -306,7 +311,9 @@ export function resolveInsertAnchor(
306
311
  const kids = Array.from(body.children).reverse();
307
312
  const last = kids.find((el) => el.hasAttribute('data-cd-id'));
308
313
  const refId = last?.getAttribute('data-cd-id');
309
- return refId ? { refId, position: 'after' } : null;
314
+ if (refId) return { refId, position: 'after' };
315
+ const artboardId = artboardEl.getAttribute('data-dc-screen');
316
+ return artboardId ? { artboardId, position: 'inside-end' } : null;
310
317
  }
311
318
 
312
319
  // ─────────────────────────────────────────────────────────────────────────────
@@ -349,25 +356,25 @@ export function ToolPalette() {
349
356
  }, [tool]);
350
357
 
351
358
  // Stage I3 tail — insert a Div/Text/Image relative to the active artboard's
352
- // last existing element (`resolveInsertAnchor`). Same request verbs the
359
+ // last existing element, or empty-artboard fallback — as a child of the
360
+ // artboard itself (`resolveInsertAnchor`). Same request verbs the
353
361
  // context-menu's per-element "Insert ▸ …" already posts (`insert-request` /
354
- // `insert-image-request`, main-origin shell WRITES — DDR-054); a null anchor
355
- // (empty/artboard-less canvas) is a silent no-op a documented v1 limit.
362
+ // `insert-image-request`, main-origin shell WRITES — DDR-054), extended with
363
+ // an `artboardId` field for the fallback case; a null anchor (no artboard on
364
+ // canvas at all) is still a no-op — nothing to insert into.
356
365
  const insertViaPalette = (kind: 'div' | 'text' | 'image') => {
357
366
  setInsertOpen(false);
358
367
  const anchor = resolveInsertAnchor();
359
368
  if (!anchor) return;
369
+ const anchorFields =
370
+ 'artboardId' in anchor
371
+ ? { artboardId: anchor.artboardId, position: anchor.position }
372
+ : { refId: anchor.refId, position: anchor.position };
360
373
  try {
361
374
  if (kind === 'image') {
362
- window.parent.postMessage(
363
- { dgn: 'insert-image-request', refId: anchor.refId, position: anchor.position },
364
- '*'
365
- );
375
+ window.parent.postMessage({ dgn: 'insert-image-request', ...anchorFields }, '*');
366
376
  } else {
367
- window.parent.postMessage(
368
- { dgn: 'insert-request', refId: anchor.refId, position: anchor.position, kind },
369
- '*'
370
- );
377
+ window.parent.postMessage({ dgn: 'insert-request', ...anchorFields, kind }, '*');
371
378
  }
372
379
  } catch {
373
380
  /* detached / cross-origin teardown */
@@ -513,6 +520,22 @@ export function ToolPalette() {
513
520
  </div>
514
521
  ) : null}
515
522
  </span>
523
+ <button
524
+ type="button"
525
+ aria-label="Stickers"
526
+ title="Stickers — searchable sticker picker"
527
+ onClick={() => {
528
+ try {
529
+ window.parent.postMessage({ dgn: 'open-sticker-picker' }, '*');
530
+ } catch {
531
+ /* detached / cross-origin teardown */
532
+ }
533
+ }}
534
+ >
535
+ <span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
536
+ 🙂
537
+ </span>
538
+ </button>
516
539
  <button
517
540
  type="button"
518
541
  aria-label="Export (⌘E)"
@@ -256,17 +256,109 @@ function ensureStoreMap(): Map<string, UndoStackState> {
256
256
  return host.__maude_undo_stacks;
257
257
  }
258
258
 
259
+ // ── Durable layer: sessionStorage (DDR-050 follow-up) ────────────────────────
260
+ // The window/window.top Map above is the fast in-memory path, but it lives on a
261
+ // window object — and in the segregated canvas origin (DDR-054) that IS the
262
+ // canvas iframe's OWN window, which a full iframe reload destroys. An artboard
263
+ // TEXT edit rewrites the `.tsx` → HMR reload of the canvas iframe → the Map is
264
+ // wiped → Cmd+Z after the edit finds an empty stack. (Annotation edits write a
265
+ // sidecar SVG, never reload the iframe, so their history survives — the exact
266
+ // asymmetry users hit.) sessionStorage is per-ORIGIN and survives a reload, so
267
+ // it's the durable backstop: every save mirrors here, every load falls back to
268
+ // it when the Map misses (i.e. right after a reload rebuilt the window).
269
+ const SS_PREFIX = 'maude-undo:';
270
+ const SS_MAX_BYTES = 512 * 1024;
271
+
272
+ function getSessionStore(): Storage | null {
273
+ try {
274
+ return typeof window !== 'undefined' && window.sessionStorage ? window.sessionStorage : null;
275
+ } catch {
276
+ return null; // sessionStorage can throw in sandboxed / disabled contexts
277
+ }
278
+ }
279
+
280
+ // DDR-054 §2g — strip dangerous keys at parse time so a poisoned sessionStorage
281
+ // entry can't seed `__proto__` / `constructor` / `prototype` own-properties into
282
+ // a rehydrated record (mirrors the sync-lane reviver, sync/codec.ts).
283
+ function parseJsonSafe(s: string): unknown {
284
+ return JSON.parse(s, (key, value) => {
285
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
286
+ return value;
287
+ });
288
+ }
289
+
290
+ /** A well-formed persisted record — `kind`/`label` strings, a `payload`
291
+ * property present. Rejects a malformed/poisoned sessionStorage entry BEFORE
292
+ * it can reach the command rebuilder (defence-in-depth on the durable layer —
293
+ * the apply boundary is already path-contained + inert-write-only). */
294
+ function isCommandRecord(v: unknown): v is CommandRecord {
295
+ return (
296
+ !!v &&
297
+ typeof v === 'object' &&
298
+ typeof (v as CommandRecord).kind === 'string' &&
299
+ typeof (v as CommandRecord).label === 'string' &&
300
+ 'payload' in (v as object)
301
+ );
302
+ }
303
+
304
+ function isUndoStackState(v: unknown): v is UndoStackState {
305
+ return (
306
+ !!v &&
307
+ typeof v === 'object' &&
308
+ Array.isArray((v as UndoStackState).past) &&
309
+ Array.isArray((v as UndoStackState).future) &&
310
+ (v as UndoStackState).past.every(isCommandRecord) &&
311
+ (v as UndoStackState).future.every(isCommandRecord)
312
+ );
313
+ }
314
+
259
315
  /** Load saved state for a canvas file. Returns empty state on miss. */
260
316
  export function loadStackState(canvasFile: string): UndoStackState {
261
- return ensureStoreMap().get(canvasFile) ?? createUndoStackState();
317
+ const map = ensureStoreMap();
318
+ const cached = map.get(canvasFile);
319
+ if (cached) return cached;
320
+ // Map missed (fresh window after an iframe reload) — restore from the durable
321
+ // per-origin layer, then warm the Map so subsequent reads are in-memory.
322
+ try {
323
+ const raw = getSessionStore()?.getItem(SS_PREFIX + canvasFile);
324
+ if (raw) {
325
+ const parsed = parseJsonSafe(raw);
326
+ if (isUndoStackState(parsed)) {
327
+ map.set(canvasFile, parsed);
328
+ return parsed;
329
+ }
330
+ }
331
+ } catch {
332
+ /* corrupt / unavailable — fall through to empty */
333
+ }
334
+ return createUndoStackState();
262
335
  }
263
336
 
264
- /** Persist state for a canvas file. */
337
+ /** Persist state for a canvas file — to the in-memory Map AND the durable
338
+ * sessionStorage layer, so it survives the HMR iframe reload a text edit
339
+ * triggers. */
265
340
  export function saveStackState(canvasFile: string, state: UndoStackState): void {
266
341
  ensureStoreMap().set(canvasFile, state);
342
+ try {
343
+ const s = JSON.stringify(state);
344
+ if (s.length <= SS_MAX_BYTES) getSessionStore()?.setItem(SS_PREFIX + canvasFile, s);
345
+ } catch {
346
+ /* quota / serialization failure — the in-memory Map still holds it */
347
+ }
267
348
  }
268
349
 
269
350
  /** Test seam — wipe the cross-iframe store between bun:test cases. */
270
351
  export function _clearStackStore(): void {
271
352
  getStoreHost().__maude_undo_stacks = new Map();
353
+ try {
354
+ const ss = getSessionStore();
355
+ if (ss) {
356
+ for (let i = ss.length - 1; i >= 0; i--) {
357
+ const k = ss.key(i);
358
+ if (k?.startsWith(SS_PREFIX)) ss.removeItem(k);
359
+ }
360
+ }
361
+ } catch {
362
+ /* no sessionStorage in this runtime */
363
+ }
272
364
  }
@@ -98,7 +98,7 @@ function ensureResizeStyles(): void {
98
98
  document.head.appendChild(s);
99
99
  }
100
100
 
101
- type Corner =
101
+ export type Corner =
102
102
  | 'nw'
103
103
  | 'ne'
104
104
  | 'sw'
@@ -132,9 +132,9 @@ function isRotCorner(c: Corner): boolean {
132
132
  * `padDX/padDY` shift a dragged handle's cursor back ONTO the true bbox
133
133
  * corner/edge so the shape doesn't grow by the pad on first move.
134
134
  */
135
- const padDX = (c: Corner): number =>
135
+ export const padDX = (c: Corner): number =>
136
136
  c === 'nw' || c === 'sw' || c === 'w' ? 1 : c === 'ne' || c === 'se' || c === 'e' ? -1 : 0;
137
- const padDY = (c: Corner): number =>
137
+ export const padDY = (c: Corner): number =>
138
138
  c === 'nw' || c === 'ne' || c === 'n' ? 1 : c === 'sw' || c === 'se' || c === 's' ? -1 : 0;
139
139
 
140
140
  /** Stroke types that expose resize handles. Text inherits its anchor bbox. */
@@ -227,7 +227,7 @@ function rotatedAnchorShift(
227
227
  * The no-modifier branch is algebraically identical to the previous
228
228
  * min/max corner math (verified against the resize round-trip tests).
229
229
  */
230
- function bboxResize(
230
+ export function bboxResize(
231
231
  bbox: { x: number; y: number; w: number; h: number },
232
232
  corner: Corner,
233
233
  wx: number,
@@ -301,6 +301,10 @@ export async function uploadAndAnnounceMedia(
301
301
  showCanvasToast(`Added ${res.path}${warn} — snippet copied: ${snippet}`);
302
302
  }
303
303
 
304
+ /** World-px stagger between cascaded drop targets so a batch Finder drop
305
+ * doesn't stack every clip on the exact same point. */
306
+ const BATCH_DROP_CASCADE_PX = 28;
307
+
304
308
  export function useCanvasMediaDrop(opts: {
305
309
  enabled: boolean;
306
310
  screenToWorld: (cx: number, cy: number) => [number, number];
@@ -339,7 +343,40 @@ export function useCanvasMediaDrop(opts: {
339
343
  const onDrop = (e: DragEvent) => {
340
344
  body.classList.remove('dc-media-dragover');
341
345
  if (!e.dataTransfer) return;
342
- const intent = classifyMediaPayload(payloadFromTransfer(e.dataTransfer));
346
+ const payload = payloadFromTransfer(e.dataTransfer);
347
+ // A multi-file Finder drop (select N clips → drag) previously fell through
348
+ // classifyMediaPayload's single-intent contract (designed for the
349
+ // drop-carries-both-a-file-and-a-uri-list case) and silently dropped every
350
+ // file past the first — forcing users to drop one at a time. Batch-dispatch
351
+ // every image/video/audio file here; classifyMediaPayload still owns the
352
+ // single-item path (including link/URL drops, which have no "files" list).
353
+ const mediaFiles = payload.files.filter(
354
+ (f) =>
355
+ typeof f.type === 'string' &&
356
+ (f.type.startsWith('image/') ||
357
+ f.type.startsWith('video/') ||
358
+ f.type.startsWith('audio/'))
359
+ );
360
+ if (mediaFiles.length > 1) {
361
+ e.preventDefault();
362
+ const [ox, oy] = screenToWorld(e.clientX, e.clientY);
363
+ mediaFiles.forEach((file, i) => {
364
+ const world: [number, number] = [
365
+ ox + i * BATCH_DROP_CASCADE_PX,
366
+ oy + i * BATCH_DROP_CASCADE_PX,
367
+ ];
368
+ const intent: MediaIntent = file.type.startsWith('image/')
369
+ ? { kind: 'image', file }
370
+ : {
371
+ kind: 'media',
372
+ file,
373
+ mediaKind: file.type.startsWith('video/') ? 'video' : 'audio',
374
+ };
375
+ dispatchIntent(intent, world);
376
+ });
377
+ return;
378
+ }
379
+ const intent = classifyMediaPayload(payload);
343
380
  if (!intent) return; // not media — leave the event for other handlers
344
381
  e.preventDefault();
345
382
  const world = screenToWorld(e.clientX, e.clientY);
@@ -84,6 +84,27 @@ export interface Selection {
84
84
  * block child. Absent for a detached node / no parent. */
85
85
  parentDisplay?: string;
86
86
  parentFlexDirection?: string;
87
+ /** feature-photo-editor (Task 14) — set at selection resolution when the hit is
88
+ * a content-addressed photo. `artboard-img` = an `<img src="assets/<sha8>.<ext>">`
89
+ * authored in artboard TSX; `annotation-image` is threaded separately (the
90
+ * annotation model has no data-cd-id, so it never rides this DOM-selection
91
+ * path — see app.jsx's `edit-annotation-photo-request` handler). Absent for a
92
+ * non-photo element or an `<img>` whose src isn't content-addressed. */
93
+ photoKind?: 'artboard-img' | 'annotation-image';
94
+ /** The resolved `assets/<sha8>.<ext>` source, when `photoKind` is set — the key
95
+ * the Photo tab passes to `/_api/photo-edit`. */
96
+ photoAsset?: string;
97
+ }
98
+
99
+ /** feature-photo-editor — pull the content-addressed `assets/<sha8>.<ext>` out of
100
+ * an image src (relative, absolute, or embedded in an outerHTML excerpt). Returns
101
+ * null for a non-content-addressed src (external URL, SVG icon, data: URI) — those
102
+ * aren't editable by the sidecar-keyed photo pipeline. Exported so both the
103
+ * selection resolver and the Inspector's fallback derivation share one regex. */
104
+ export function photoAssetFromString(s: string | null | undefined): string | null {
105
+ if (!s) return null;
106
+ const m = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i.exec(s);
107
+ return m ? m[0] : null;
87
108
  }
88
109
 
89
110
  interface SelectionSetValue {