@1agh/maude 0.42.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 (236) 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 -22
  6. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  7. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  8. package/apps/studio/bin/_video-playwright.mjs +141 -71
  9. package/apps/studio/bin/ingest-footage.sh +27 -0
  10. package/apps/studio/bin/photo-adjust.sh +163 -0
  11. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  12. package/apps/studio/bin/probe-footage.sh +28 -0
  13. package/apps/studio/bin/read-annotations.mjs +125 -2
  14. package/apps/studio/canvas-edit.ts +459 -10
  15. package/apps/studio/canvas-lib.tsx +523 -5
  16. package/apps/studio/canvas-pipeline.ts +65 -7
  17. package/apps/studio/canvas-shell.tsx +156 -16
  18. package/apps/studio/client/app.jsx +451 -34
  19. package/apps/studio/client/export-center.jsx +35 -1
  20. package/apps/studio/client/github.js +4 -0
  21. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  23. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  24. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  25. package/apps/studio/client/photo-knobs.jsx +437 -0
  26. package/apps/studio/client/styles/3-shell-maude.css +42 -0
  27. package/apps/studio/commands/edit-source-command.ts +31 -4
  28. package/apps/studio/context-menu.tsx +31 -6
  29. package/apps/studio/dist/client.bundle.js +5561 -19
  30. package/apps/studio/dist/comment-mount.js +2 -2
  31. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  32. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  33. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  34. package/apps/studio/dist/styles.css +1 -1
  35. package/apps/studio/dom-selection.ts +25 -0
  36. package/apps/studio/exporters/jobs.ts +47 -5
  37. package/apps/studio/exporters/video.ts +24 -4
  38. package/apps/studio/footage/schema.test.ts +195 -0
  39. package/apps/studio/footage/schema.ts +484 -0
  40. package/apps/studio/footage-store.ts +235 -0
  41. package/apps/studio/fs-watch.ts +10 -0
  42. package/apps/studio/git/endpoints.ts +68 -7
  43. package/apps/studio/git/service.ts +196 -20
  44. package/apps/studio/github/service.ts +67 -0
  45. package/apps/studio/handoff.ts +3 -2
  46. package/apps/studio/http.ts +173 -14
  47. package/apps/studio/input-router.tsx +10 -0
  48. package/apps/studio/inspect.ts +53 -1
  49. package/apps/studio/paths.ts +10 -0
  50. package/apps/studio/photo/filters.ts +285 -0
  51. package/apps/studio/photo/pipeline.ts +607 -0
  52. package/apps/studio/photo/schema.ts +367 -0
  53. package/apps/studio/photo-store.ts +154 -0
  54. package/apps/studio/runtime-bundle.ts +29 -6
  55. package/apps/studio/server.ts +5 -1
  56. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  57. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  58. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  59. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  60. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  61. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  62. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  63. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  64. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  65. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  66. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  67. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  68. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  69. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  70. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  71. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  72. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  73. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  74. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  75. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  81. package/apps/studio/stickers/life-style/best.png +0 -0
  82. package/apps/studio/stickers/life-style/come-on.png +0 -0
  83. package/apps/studio/stickers/life-style/cut.png +0 -0
  84. package/apps/studio/stickers/life-style/detox.png +0 -0
  85. package/apps/studio/stickers/life-style/just.png +0 -0
  86. package/apps/studio/stickers/life-style/manifest.json +68 -0
  87. package/apps/studio/stickers/life-style/mirror.png +0 -0
  88. package/apps/studio/stickers/life-style/nice.png +0 -0
  89. package/apps/studio/stickers/life-style/objects.png +0 -0
  90. package/apps/studio/stickers/life-style/ok.png +0 -0
  91. package/apps/studio/stickers/life-style/queen.png +0 -0
  92. package/apps/studio/stickers/life-style/star.png +0 -0
  93. package/apps/studio/stickers/life-style/sun.png +0 -0
  94. package/apps/studio/stickers/life-style/water.png +0 -0
  95. package/apps/studio/stickers/life-style/yeah.png +0 -0
  96. package/apps/studio/stickers/life-style/you-can.png +0 -0
  97. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  98. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  99. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  100. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  101. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  102. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  103. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  104. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  105. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  106. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  107. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  108. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  109. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  110. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  111. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  112. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  113. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  114. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  118. package/apps/studio/stickers/project-status/group-100.png +0 -0
  119. package/apps/studio/stickers/project-status/group-101.png +0 -0
  120. package/apps/studio/stickers/project-status/group-102.png +0 -0
  121. package/apps/studio/stickers/project-status/group-103.png +0 -0
  122. package/apps/studio/stickers/project-status/group-104.png +0 -0
  123. package/apps/studio/stickers/project-status/group-105.png +0 -0
  124. package/apps/studio/stickers/project-status/group-106.png +0 -0
  125. package/apps/studio/stickers/project-status/group-107.png +0 -0
  126. package/apps/studio/stickers/project-status/group-108.png +0 -0
  127. package/apps/studio/stickers/project-status/group-109.png +0 -0
  128. package/apps/studio/stickers/project-status/group-110.png +0 -0
  129. package/apps/studio/stickers/project-status/group-111.png +0 -0
  130. package/apps/studio/stickers/project-status/group-112.png +0 -0
  131. package/apps/studio/stickers/project-status/group-113.png +0 -0
  132. package/apps/studio/stickers/project-status/group-114.png +0 -0
  133. package/apps/studio/stickers/project-status/group-115.png +0 -0
  134. package/apps/studio/stickers/project-status/group-117.png +0 -0
  135. package/apps/studio/stickers/project-status/group-118.png +0 -0
  136. package/apps/studio/stickers/project-status/group-119.png +0 -0
  137. package/apps/studio/stickers/project-status/group-120.png +0 -0
  138. package/apps/studio/stickers/project-status/group-121.png +0 -0
  139. package/apps/studio/stickers/project-status/group-122.png +0 -0
  140. package/apps/studio/stickers/project-status/group-41.png +0 -0
  141. package/apps/studio/stickers/project-status/group-42.png +0 -0
  142. package/apps/studio/stickers/project-status/group-43.png +0 -0
  143. package/apps/studio/stickers/project-status/group-44.png +0 -0
  144. package/apps/studio/stickers/project-status/group-45.png +0 -0
  145. package/apps/studio/stickers/project-status/group-46.png +0 -0
  146. package/apps/studio/stickers/project-status/group-47.png +0 -0
  147. package/apps/studio/stickers/project-status/group-48.png +0 -0
  148. package/apps/studio/stickers/project-status/group-49.png +0 -0
  149. package/apps/studio/stickers/project-status/group-50.png +0 -0
  150. package/apps/studio/stickers/project-status/group-51.png +0 -0
  151. package/apps/studio/stickers/project-status/group-52.png +0 -0
  152. package/apps/studio/stickers/project-status/group-53.png +0 -0
  153. package/apps/studio/stickers/project-status/group-54.png +0 -0
  154. package/apps/studio/stickers/project-status/group-55.png +0 -0
  155. package/apps/studio/stickers/project-status/group-56.png +0 -0
  156. package/apps/studio/stickers/project-status/group-57.png +0 -0
  157. package/apps/studio/stickers/project-status/group-58.png +0 -0
  158. package/apps/studio/stickers/project-status/group-59.png +0 -0
  159. package/apps/studio/stickers/project-status/group-60.png +0 -0
  160. package/apps/studio/stickers/project-status/group-61.png +0 -0
  161. package/apps/studio/stickers/project-status/group-62.png +0 -0
  162. package/apps/studio/stickers/project-status/group-63.png +0 -0
  163. package/apps/studio/stickers/project-status/group-64.png +0 -0
  164. package/apps/studio/stickers/project-status/group-65.png +0 -0
  165. package/apps/studio/stickers/project-status/group-66.png +0 -0
  166. package/apps/studio/stickers/project-status/group-67.png +0 -0
  167. package/apps/studio/stickers/project-status/group-68.png +0 -0
  168. package/apps/studio/stickers/project-status/group-69.png +0 -0
  169. package/apps/studio/stickers/project-status/group-70.png +0 -0
  170. package/apps/studio/stickers/project-status/group-71.png +0 -0
  171. package/apps/studio/stickers/project-status/group-72.png +0 -0
  172. package/apps/studio/stickers/project-status/group-73.png +0 -0
  173. package/apps/studio/stickers/project-status/group-74.png +0 -0
  174. package/apps/studio/stickers/project-status/group-75.png +0 -0
  175. package/apps/studio/stickers/project-status/group-76.png +0 -0
  176. package/apps/studio/stickers/project-status/group-77.png +0 -0
  177. package/apps/studio/stickers/project-status/group-78.png +0 -0
  178. package/apps/studio/stickers/project-status/group-79.png +0 -0
  179. package/apps/studio/stickers/project-status/group-80.png +0 -0
  180. package/apps/studio/stickers/project-status/group-81.png +0 -0
  181. package/apps/studio/stickers/project-status/group-82.png +0 -0
  182. package/apps/studio/stickers/project-status/group-83.png +0 -0
  183. package/apps/studio/stickers/project-status/group-84.png +0 -0
  184. package/apps/studio/stickers/project-status/group-85.png +0 -0
  185. package/apps/studio/stickers/project-status/group-86.png +0 -0
  186. package/apps/studio/stickers/project-status/group-87.png +0 -0
  187. package/apps/studio/stickers/project-status/group-88.png +0 -0
  188. package/apps/studio/stickers/project-status/group-89.png +0 -0
  189. package/apps/studio/stickers/project-status/group-90.png +0 -0
  190. package/apps/studio/stickers/project-status/group-91.png +0 -0
  191. package/apps/studio/stickers/project-status/group-92.png +0 -0
  192. package/apps/studio/stickers/project-status/group-93.png +0 -0
  193. package/apps/studio/stickers/project-status/group-94.png +0 -0
  194. package/apps/studio/stickers/project-status/group-96.png +0 -0
  195. package/apps/studio/stickers/project-status/group-97.png +0 -0
  196. package/apps/studio/stickers/project-status/group-98.png +0 -0
  197. package/apps/studio/stickers/project-status/group-99.png +0 -0
  198. package/apps/studio/stickers/project-status/manifest.json +328 -0
  199. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  200. package/apps/studio/test/acp-commands.test.ts +2 -1
  201. package/apps/studio/test/asset-api.test.ts +3 -3
  202. package/apps/studio/test/canvas-edit.test.ts +17 -6
  203. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  204. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  205. package/apps/studio/test/edit-source-command.test.ts +23 -0
  206. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  207. package/apps/studio/test/footage-store.test.ts +100 -0
  208. package/apps/studio/test/git-branches.test.ts +97 -0
  209. package/apps/studio/test/github-api.test.ts +56 -1
  210. package/apps/studio/test/input-router.test.ts +33 -0
  211. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  212. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  213. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  214. package/apps/studio/test/photo-filters.test.ts +149 -0
  215. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  216. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  217. package/apps/studio/test/read-annotations.test.ts +164 -0
  218. package/apps/studio/test/shell-importmap.test.ts +49 -0
  219. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  220. package/apps/studio/test/timeline-parse.test.ts +57 -0
  221. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  222. package/apps/studio/test/undo-stack.test.ts +33 -0
  223. package/apps/studio/test/video-asset.test.ts +5 -5
  224. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  225. package/apps/studio/text-caret.ts +239 -0
  226. package/apps/studio/tool-palette.tsx +43 -20
  227. package/apps/studio/undo-stack.ts +94 -2
  228. package/apps/studio/use-annotation-resize.tsx +4 -4
  229. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  230. package/apps/studio/use-selection-set.tsx +21 -0
  231. package/apps/studio/video-comp.tsx +48 -7
  232. package/apps/studio/whats-new.json +53 -0
  233. package/cli/commands/design.mjs +34 -3
  234. package/cli/lib/gitignore-block.mjs +1 -0
  235. package/package.json +10 -10
  236. package/plugins/design/templates/_shell.html +1 -0
@@ -74,11 +74,21 @@ export interface ArrowBind {
74
74
  * element). Absent / empty = ungrouped. Serialized as `data-group-ids`.
75
75
  * - `author` — provenance. `'ai'` when created through the
76
76
  * `maude design annotate` write surface. Absent = human-drawn.
77
+ * - `authorName` / `authorId` — Phase 3 (whiteboard-improvements): who drew
78
+ * a human-authored stroke, stamped from presence identity (`useCollab`'s
79
+ * `myName`/`myConnId` — git `user.name`, else an `anonymous-*` fallback).
80
+ * Absent on AI-authored strokes and on any stroke drawn before this
81
+ * field existed (back-compat — no author badge, not an error). Never
82
+ * trust a wire/stored COLOR for this identity — badges re-derive their
83
+ * color from the name via `colorForName` so they match the author's live
84
+ * presence hue.
77
85
  */
78
86
  interface StrokeBase {
79
87
  id: string;
80
88
  groupIds?: string[];
81
89
  author?: 'ai';
90
+ authorName?: string;
91
+ authorId?: string;
82
92
  /**
83
93
  * FigJam v3 — rotation in degrees (clockwise) around the stroke's bbox
84
94
  * center. Absent / 0 = axis-aligned (back-compat). Honoured by the box-
@@ -553,6 +563,11 @@ export const IMAGE_MIN_SIZE = 16;
553
563
  export const IMAGE_MAX_DROP_SIDE = 480;
554
564
  export const LINK_DEFAULT_W = 260;
555
565
  export const LINK_DEFAULT_H = 76;
566
+ // Phase 4 (whiteboard-improvements) — a sticker dropped from the picker has no
567
+ // natural size to probe (unlike createImageFromFile's Image.onload path — the
568
+ // shell already resolved it to a project asset by the time the canvas hears
569
+ // about it) — one fixed, friendly on-board size for every sticker.
570
+ export const STICKER_DROP_SIZE = 160;
556
571
  // DDR-150 P4 — media-reference chip (dropped video/audio → assets/ pointer).
557
572
  export const MEDIAREF_DEFAULT_W = 280;
558
573
  export const MEDIAREF_DEFAULT_H = 76;
@@ -926,6 +941,12 @@ function rootExtraAttrs(s: Stroke): string {
926
941
  extra += ` data-group-ids="${esc(s.groupIds.join(' '))}"`;
927
942
  }
928
943
  if (s.author === 'ai') extra += ' data-author="ai"';
944
+ // Phase 3 (whiteboard-improvements) — human author identity, independent of
945
+ // `data-author="ai"` (never both: a stroke is either agent- or human-drawn).
946
+ // escAttr (not esc) — these are attribute values, not element content; see
947
+ // the Phase 23 escAttr() docblock above for why a bare `>` here is unsafe.
948
+ if (s.authorName) extra += ` data-author-name="${escAttr(s.authorName)}"`;
949
+ if (s.authorId) extra += ` data-author-id="${escAttr(s.authorId)}"`;
929
950
  // FigJam v3 — rotation: `data-rot` is the round-trip source of truth; the
930
951
  // transform is presentational so the persisted SVG renders rotated in any
931
952
  // viewer. The pivot derives from stored geometry → idempotent re-serialize.
@@ -1302,6 +1323,33 @@ function parseBindAttr(raw: string | null): ArrowBind | null {
1302
1323
  return bind;
1303
1324
  }
1304
1325
 
1326
+ const AUTHOR_NAME_MAX_LEN = 64;
1327
+
1328
+ /**
1329
+ * Phase 3 (whiteboard-improvements) — an `authorName` parsed off disk may
1330
+ * have come from a peer's synced `.annotations.svg` (untrusted foreign
1331
+ * input, same class of risk `use-collab.tsx`'s `sanitizeForeignState` /
1332
+ * `sanitizeName` already guard presence names against). Strips control
1333
+ * chars, bidi overrides/isolates, zero-width chars, and the BOM; caps
1334
+ * length. Mirrors that function's code-point ranges rather than importing
1335
+ * it — this module stays dependency-free of the stateful collab layer.
1336
+ */
1337
+ function sanitizeAuthorName(raw: string): string {
1338
+ let cleaned = '';
1339
+ for (const ch of raw) {
1340
+ const cp = ch.codePointAt(0) ?? 0;
1341
+ const unsafe =
1342
+ cp <= 0x1f ||
1343
+ (cp >= 0x7f && cp <= 0x9f) ||
1344
+ (cp >= 0x200b && cp <= 0x200f) ||
1345
+ (cp >= 0x202a && cp <= 0x202e) ||
1346
+ (cp >= 0x2066 && cp <= 0x2069) ||
1347
+ cp === 0xfeff;
1348
+ if (!unsafe) cleaned += ch;
1349
+ }
1350
+ return cleaned.trim().slice(0, AUTHOR_NAME_MAX_LEN);
1351
+ }
1352
+
1305
1353
  /** FigJam v3 — read the cross-tool root attrs back onto a parsed stroke. */
1306
1354
  function readSharedAttrs(el: Element, s: Stroke): void {
1307
1355
  const g = el.getAttribute('data-group-ids');
@@ -1310,6 +1358,16 @@ function readSharedAttrs(el: Element, s: Stroke): void {
1310
1358
  if (ids.length) s.groupIds = ids;
1311
1359
  }
1312
1360
  if (el.getAttribute('data-author') === 'ai') s.author = 'ai';
1361
+ const authorName = el.getAttribute('data-author-name');
1362
+ if (authorName) {
1363
+ const cleaned = sanitizeAuthorName(authorName);
1364
+ if (cleaned) s.authorName = cleaned;
1365
+ }
1366
+ const authorId = el.getAttribute('data-author-id');
1367
+ if (authorId) {
1368
+ const cleanedId = sanitizeAuthorName(authorId);
1369
+ if (cleanedId) s.authorId = cleanedId;
1370
+ }
1313
1371
  // FigJam v3 — rotation. `data-rot` is authoritative (the transform attr is
1314
1372
  // presentational); malformed / out-of-range values drop to axis-aligned.
1315
1373
  const rotRaw = el.getAttribute('data-rot');
@@ -23,6 +23,7 @@ import {
23
23
  insertArtboard,
24
24
  insertClip,
25
25
  insertElement,
26
+ insertElementIntoArtboard,
26
27
  type MovePosition,
27
28
  moveElement,
28
29
  removeAttribute,
@@ -37,6 +38,7 @@ import {
37
38
  } from './canvas-edit.ts';
38
39
  import type { Context } from './context.ts';
39
40
  import { createHistory } from './history.ts';
41
+ import { STICKERS_DIR } from './paths.ts';
40
42
 
41
43
  // Directories that never hold user-facing canvases. Exported so the
42
44
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -255,6 +257,9 @@ export interface Api {
255
257
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
256
258
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
257
259
  listAssets(): Promise<{ ok: true; assets: AssetListing[] }>;
260
+ /** Phase 4 (feature-whiteboard-annotation-improvements) — the bundled sticker
261
+ * catalogue (MAUDE's own, not the served project's) for the StickerPicker. */
262
+ listStickers(): Promise<{ ok: true; packs: StickerPack[] }>;
258
263
  /** DDR-148 — streaming variant for the HTTP route (100 MB video without a
259
264
  * full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
260
265
  saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
@@ -288,7 +293,13 @@ export interface Api {
288
293
  idIndex?: unknown;
289
294
  }): Promise<EditOpResult>;
290
295
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
291
- editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
296
+ editText(input: {
297
+ canvas?: unknown;
298
+ id?: unknown;
299
+ text?: unknown;
300
+ occurrence?: unknown;
301
+ before?: unknown;
302
+ }): Promise<EditOpResult>;
292
303
  // Phase 12.2 (DDR-104) — custom HTML attribute edit (POST /_api/edit-attr). Main-origin
293
304
  // only. The CSS panel's "custom HTML attribute" escape hatch (data-*, aria-*, role, …);
294
305
  // writes a plain JSX attribute via editAttribute's non-`style.` path.
@@ -396,10 +407,16 @@ export interface Api {
396
407
  }): Promise<
397
408
  { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
398
409
  >;
399
- /** Insert a synthesized div/text/image relative to a reference element. */
410
+ /**
411
+ * Insert a synthesized div/text/image relative to a reference element, OR —
412
+ * when the artboard has no element to anchor on yet — as a direct child of
413
+ * `artboardId` (the tool-palette "+ Element" empty-artboard fallback).
414
+ * Exactly one of `refId` / `artboardId` must be provided.
415
+ */
400
416
  insertElementOp(input: {
401
417
  canvas?: unknown;
402
418
  refId?: unknown;
419
+ artboardId?: unknown;
403
420
  position?: unknown;
404
421
  kind?: unknown;
405
422
  src?: unknown;
@@ -472,8 +489,19 @@ export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.
472
489
 
473
490
  import { sanitizeAnnotationSvg } from './annotations-model.ts';
474
491
 
475
- /** Phase 23 — hard ceiling on a single uploaded asset (10 MB). */
476
- export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
492
+ /**
493
+ * Phase 23 — per-file ceiling for a still image. Raised 10 MB 50 MB (still
494
+ * well under the video cap / MAX_REQUEST_BODY headroom) after a real drone
495
+ * photo tripped the old ceiling. Overridable via `MAUDE_ASSET_MAX_IMAGE_BYTES`
496
+ * (bytes), mirroring {@link ASSET_MAX_VIDEO_BYTES}'s override. The route lives
497
+ * on the (untrusted) canvas origin (DDR-088), and images stream through the
498
+ * same category-capped writer as video/audio (`saveAssetFromStream`), so
499
+ * raising this doesn't reintroduce the memory-amplification risk DDR-088 capped.
500
+ */
501
+ export const ASSET_MAX_BYTES = (() => {
502
+ const env = Number(process.env.MAUDE_ASSET_MAX_IMAGE_BYTES);
503
+ return Number.isFinite(env) && env > 0 ? env : 50 * 1024 * 1024;
504
+ })();
477
505
 
478
506
  /**
479
507
  * Phase 23 security review (DDR-088 follow-up) — aggregate per-server-instance
@@ -565,6 +593,24 @@ export interface AssetListing {
565
593
  mtimeMs: number;
566
594
  }
567
595
 
596
+ /** Phase 4 (feature-whiteboard-annotation-improvements) — one bundled sticker. */
597
+ export interface StickerItem {
598
+ file: string;
599
+ keywords: string[];
600
+ /** Servable path — `/_stickers/<pack>/<file>` (main-origin static route). */
601
+ url: string;
602
+ }
603
+
604
+ /** A bundled sticker pack — one `apps/studio/stickers/<slug>/manifest.json`. */
605
+ export interface StickerPack {
606
+ slug: string;
607
+ name: string;
608
+ author: string;
609
+ attributionUrl: string;
610
+ license: string;
611
+ stickers: StickerItem[];
612
+ }
613
+
568
614
  /** DDR-148 — media category, decides which per-file cap applies. */
569
615
  export type AssetCategory = 'image' | 'video' | 'audio';
570
616
 
@@ -576,7 +622,7 @@ export interface AssetTypeInfo {
576
622
 
577
623
  /**
578
624
  * DDR-148 — per-file ceiling for time-based media (video + audio). Images keep
579
- * the tighter {@link ASSET_MAX_BYTES} 10 MB cap. Overridable via
625
+ * the tighter {@link ASSET_MAX_BYTES} cap. Overridable via
580
626
  * `MAUDE_ASSET_MAX_VIDEO_BYTES` (bytes) for power users. The route lives on the
581
627
  * (untrusted) canvas origin, so this cap + the session budget + the streamed
582
628
  * write are the trust mitigation, exactly like the image caps (DDR-088).
@@ -595,7 +641,9 @@ const UNSUPPORTED_ASSET_MSG =
595
641
  'unsupported media type — png/jpeg/gif/webp images or mp4/mov/webm/mp3/wav/m4a media only (SVG/script rejected)';
596
642
 
597
643
  function capError(category?: AssetCategory): string {
598
- if (category === 'image') return 'image exceeds the 10 MB cap';
644
+ if (category === 'image') {
645
+ return `image exceeds the ${Math.round(ASSET_MAX_BYTES / (1024 * 1024))} MB cap`;
646
+ }
599
647
  const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
600
648
  return `media exceeds the ${mb} MB cap`;
601
649
  }
@@ -1316,8 +1364,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1316
1364
  // optional (DDR Task 9):
1317
1365
  // • magic-byte sniff → true type ∈ {png,jpg,gif,webp}; a header lie or an
1318
1366
  // SVG (script-bearing vector) is rejected — bytes decide, name is ignored.
1319
- // • 10 MB ceiling (assets get their OWN cap; never routed through the 1 MB
1320
- // SVG-text gate in saveAnnotations).
1367
+ // • per-category ceiling ({@link ASSET_MAX_BYTES} for images; assets get
1368
+ // their OWN cap; never routed through the 1 MB SVG-text gate in
1369
+ // saveAnnotations).
1321
1370
  // • content-addressed name `assets/<sha8-of-bytes>.<ext>` → identical drops
1322
1371
  // dedupe → a malicious canvas can't fill the disk with N copies of one
1323
1372
  // image, and orphan-on-delete is safe (shared content survives).
@@ -1331,8 +1380,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1331
1380
  * request body chunk-by-chunk so a 100 MB video never lands as a single
1332
1381
  * ArrayBuffer in RAM (the memory-amplification vector DDR-088 §review flagged
1333
1382
  * for the untrusted canvas origin — worse now that the per-file cap is 100 MB,
1334
- * not 10 MB). Sniffs the type from the head, applies the CATEGORY cap while
1335
- * streaming (image 10 MB · video/audio {@link ASSET_MAX_VIDEO_BYTES}), writes
1383
+ * not a few MB). Sniffs the type from the head, applies the CATEGORY cap while
1384
+ * streaming (image {@link ASSET_MAX_BYTES} · video/audio {@link ASSET_MAX_VIDEO_BYTES}), writes
1336
1385
  * to a temp file, then content-addresses (sha8) → dedupe-rename. Nothing is
1337
1386
  * ever parsed — only magic bytes are read. `saveAsset(bytes)` wraps this so
1338
1387
  * both the route and any programmatic caller share ONE tested path.
@@ -1512,6 +1561,59 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1512
1561
  return { ok: true, assets: out.slice(0, 500) };
1513
1562
  }
1514
1563
 
1564
+ // Phase 4 (feature-whiteboard-annotation-improvements) — the bundled sticker
1565
+ // catalogue for the StickerPicker. Reads from MAUDE's OWN `STICKERS_DIR`
1566
+ // (paths.ts, DDR-045) — never the served project's designRoot, unlike
1567
+ // listAssets above. A pack directory missing or with an invalid
1568
+ // manifest.json is skipped, not fatal — one bad pack shouldn't blank the
1569
+ // whole picker. MAIN-ORIGIN ONLY at the route layer (the picker is shell UI,
1570
+ // same posture as listAssets/AssetPicker).
1571
+ async function listStickers(): Promise<{ ok: true; packs: StickerPack[] }> {
1572
+ let packDirs: string[] = [];
1573
+ try {
1574
+ const entries = await readdir(STICKERS_DIR, { withFileTypes: true });
1575
+ packDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1576
+ } catch {
1577
+ return { ok: true, packs: [] }; // no bundled stickers in this layout
1578
+ }
1579
+ const packs: StickerPack[] = [];
1580
+ for (const slug of packDirs) {
1581
+ try {
1582
+ const manifestPath = path.join(STICKERS_DIR, slug, 'manifest.json');
1583
+ const raw = await readFile(manifestPath, 'utf8');
1584
+ const manifest = JSON.parse(raw) as {
1585
+ name?: string;
1586
+ author?: string;
1587
+ attributionUrl?: string;
1588
+ license?: string;
1589
+ stickers?: Array<{ file?: string; keywords?: string[] }>;
1590
+ };
1591
+ const stickers: StickerItem[] = (manifest.stickers ?? [])
1592
+ .filter(
1593
+ (s): s is { file: string; keywords?: string[] } =>
1594
+ typeof s.file === 'string' && !!s.file
1595
+ )
1596
+ .map((s) => ({
1597
+ file: s.file,
1598
+ keywords: Array.isArray(s.keywords)
1599
+ ? s.keywords.filter((k) => typeof k === 'string')
1600
+ : [],
1601
+ url: `/_stickers/${slug}/${encodeURIComponent(s.file)}`,
1602
+ }));
1603
+ packs.push({
1604
+ slug,
1605
+ name: manifest.name ?? slug,
1606
+ author: manifest.author ?? '',
1607
+ attributionUrl: manifest.attributionUrl ?? '',
1608
+ license: manifest.license ?? '',
1609
+ stickers,
1610
+ });
1611
+ } catch {}
1612
+ }
1613
+ packs.sort((a, b) => a.name.localeCompare(b.name));
1614
+ return { ok: true, packs };
1615
+ }
1616
+
1515
1617
  // Phase 31 follow-up — persist an image pasted straight from the clipboard into
1516
1618
  // the ACP composer (a screenshot has no path yet), so the chip can point Claude
1517
1619
  // at a real file to Read. Sibling of saveAsset, but:
@@ -1519,13 +1621,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1519
1621
  // the versioned `assets/`; pasted screenshots are ephemeral, not canvas media.
1520
1622
  // • returns an ABSOLUTE path — Claude runs with its own cwd, so a project-
1521
1623
  // relative string could miss; an absolute path always resolves.
1522
- // Same load-bearing caps as saveAsset (magic-byte sniff → no SVG/script, 10 MB,
1523
- // content-addressed name, shared session write budget). MAIN-ORIGIN ONLY at the
1524
- // route layer (the untrusted canvas can't reach it).
1624
+ // Same load-bearing caps as saveAsset (magic-byte sniff → no SVG/script,
1625
+ // ASSET_MAX_BYTES, content-addressed name, shared session write budget).
1626
+ // MAIN-ORIGIN ONLY at the route layer (the untrusted canvas can't reach it).
1525
1627
  async function saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult> {
1526
1628
  if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
1527
1629
  if (bytes.length > ASSET_MAX_BYTES) {
1528
- return { ok: false, status: 413, error: 'attachment exceeds the 10 MB cap' };
1630
+ return {
1631
+ ok: false,
1632
+ status: 413,
1633
+ error: `attachment exceeds the ${Math.round(ASSET_MAX_BYTES / (1024 * 1024))} MB cap`,
1634
+ };
1529
1635
  }
1530
1636
  const kind = sniffImageType(bytes);
1531
1637
  if (!kind) {
@@ -2009,6 +2115,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2009
2115
  canvas?: unknown;
2010
2116
  id?: unknown;
2011
2117
  text?: unknown;
2118
+ occurrence?: unknown;
2119
+ before?: unknown;
2012
2120
  }): Promise<EditOpResult> {
2013
2121
  const r = resolveCanvasAbs(input.canvas);
2014
2122
  if (!r.ok) return r;
@@ -2017,7 +2125,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2017
2125
  if (typeof input.text !== 'string') return { ok: false, status: 400, error: 'text required' };
2018
2126
  if (input.text.length > 5000) return { ok: false, status: 413, error: 'text too long' };
2019
2127
  const text = input.text;
2020
- return suppressedEdit(r.abs, () => runEditText(r.abs, id, text), 'edit failed');
2128
+ // Optional context for editing `{variable}` text (unified-text-editing
2129
+ // follow-up): which rendered instance, and its pre-edit text — both used
2130
+ // only to target the right source string; a bad/absent value just makes an
2131
+ // unresolvable expression edit fail loud (route to /design:edit).
2132
+ const occurrence =
2133
+ typeof input.occurrence === 'number' &&
2134
+ Number.isInteger(input.occurrence) &&
2135
+ input.occurrence >= 0
2136
+ ? input.occurrence
2137
+ : undefined;
2138
+ const before =
2139
+ typeof input.before === 'string' && input.before.length <= 5000 ? input.before : undefined;
2140
+ return suppressedEdit(
2141
+ r.abs,
2142
+ () => runEditText(r.abs, id, text, { occurrence, before }),
2143
+ 'edit failed'
2144
+ );
2021
2145
  }
2022
2146
 
2023
2147
  async function editAttr(input: {
@@ -2347,10 +2471,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2347
2471
  }
2348
2472
  }
2349
2473
 
2350
- /** Insert a synthesized element (div/text/image) relative to `refId`. */
2474
+ /** Insert a synthesized element (div/text/image) relative to `refId`, or —
2475
+ * empty-artboard fallback — as a direct child of `artboardId`. */
2351
2476
  async function insertElementOp(input: {
2352
2477
  canvas?: unknown;
2353
2478
  refId?: unknown;
2479
+ artboardId?: unknown;
2354
2480
  position?: unknown;
2355
2481
  kind?: unknown;
2356
2482
  src?: unknown;
@@ -2361,14 +2487,30 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2361
2487
  const r = resolveCanvasAbs(input.canvas);
2362
2488
  if (!r.ok) return r;
2363
2489
  if (!takeStructuralToken()) return RATE_LIMITED;
2364
- const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
2365
- if (!CD_ID_RE.test(refId)) {
2490
+ const hasRefId = typeof input.refId === 'string' && input.refId.trim() !== '';
2491
+ const hasArtboardId = typeof input.artboardId === 'string' && input.artboardId.trim() !== '';
2492
+ if (hasRefId === hasArtboardId) {
2493
+ return { ok: false, status: 400, error: 'provide exactly one of refId / artboardId' };
2494
+ }
2495
+ const refId = hasRefId ? (input.refId as string).trim() : '';
2496
+ if (hasRefId && !CD_ID_RE.test(refId)) {
2366
2497
  return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
2367
2498
  }
2499
+ const artboardId = hasArtboardId ? (input.artboardId as string).trim() : '';
2500
+ if (hasArtboardId && !/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2501
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2502
+ }
2368
2503
  const position = input.position;
2369
2504
  if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
2370
2505
  return { ok: false, status: 400, error: 'invalid position' };
2371
2506
  }
2507
+ if (hasArtboardId && position !== 'inside-start' && position !== 'inside-end') {
2508
+ return {
2509
+ ok: false,
2510
+ status: 400,
2511
+ error: 'artboardId insert requires inside-start/inside-end',
2512
+ };
2513
+ }
2372
2514
  const kind = input.kind;
2373
2515
  if (kind !== 'div' && kind !== 'text' && kind !== 'image') {
2374
2516
  return { ok: false, status: 400, error: 'invalid kind (div|text|image)' };
@@ -2383,10 +2525,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2383
2525
  ctx.bus.emit('activity:unsuppress', rel);
2384
2526
  return { ok: false, status: 413, error: 'canvas source too large to grow' };
2385
2527
  }
2386
- const res = await insertElement(r.abs, refId, position as MovePosition, kind as InsertKind, {
2387
- src,
2388
- occurrence: refIndex,
2389
- });
2528
+ const res = hasArtboardId
2529
+ ? await insertElementIntoArtboard(
2530
+ r.abs,
2531
+ artboardId,
2532
+ position as 'inside-start' | 'inside-end',
2533
+ kind as InsertKind,
2534
+ { src }
2535
+ )
2536
+ : await insertElement(r.abs, refId, position as MovePosition, kind as InsertKind, {
2537
+ src,
2538
+ occurrence: refIndex,
2539
+ });
2390
2540
  const after = await Bun.file(r.abs).text();
2391
2541
  if (after === before) {
2392
2542
  ctx.bus.emit('activity:unsuppress', rel);
@@ -3208,6 +3358,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3208
3358
  saveAnnotations,
3209
3359
  saveAsset,
3210
3360
  listAssets,
3361
+ listStickers,
3211
3362
  saveAssetFromStream,
3212
3363
  saveChatAttachment,
3213
3364
  resolveChatAttachment,