@1agh/maude 1.0.11 → 1.3.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 (567) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/bridge.ts +18 -13
  3. package/apps/studio/acp/index.ts +43 -7
  4. package/apps/studio/acp/transcript-io.ts +394 -0
  5. package/apps/studio/acp/transcript.ts +103 -38
  6. package/apps/studio/annotations-layer.tsx +191 -116
  7. package/apps/studio/annotations-model.ts +39 -0
  8. package/apps/studio/annotations-sync.ts +50 -0
  9. package/apps/studio/api.ts +821 -55
  10. package/apps/studio/bin/_import-figma.mjs +57 -11
  11. package/apps/studio/bin/_seed-fixture.mjs +163 -0
  12. package/apps/studio/bin/annotate.mjs +3 -1
  13. package/apps/studio/bin/server-up.sh +17 -2
  14. package/apps/studio/canvas-edit.ts +391 -7
  15. package/apps/studio/canvas-imports.ts +89 -0
  16. package/apps/studio/canvas-lib.tsx +82 -18
  17. package/apps/studio/canvas-notice-message.ts +16 -0
  18. package/apps/studio/canvas-notifications.tsx +19 -0
  19. package/apps/studio/canvas-shell.tsx +57 -6
  20. package/apps/studio/client/app.jsx +943 -248
  21. package/apps/studio/client/apply-edit-request.ts +55 -0
  22. package/apps/studio/client/export-center.jsx +125 -67
  23. package/apps/studio/client/github.js +38 -1
  24. package/apps/studio/client/index-loader.ts +76 -0
  25. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  26. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  27. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  28. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  29. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  30. package/apps/studio/client/panels/SyncPanel.jsx +238 -4
  31. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  32. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  33. package/apps/studio/client/photo-knobs.jsx +3 -0
  34. package/apps/studio/client/report-bug.jsx +19 -1
  35. package/apps/studio/client/share-dialog.jsx +91 -0
  36. package/apps/studio/client/share-link.js +94 -0
  37. package/apps/studio/client/styles/3-shell-maude.css +69 -18
  38. package/apps/studio/client/styles/4-components.css +44 -1
  39. package/apps/studio/client/tree-row-menu.jsx +48 -3
  40. package/apps/studio/client/whats-new.jsx +26 -32
  41. package/apps/studio/cloud/endpoints.ts +132 -0
  42. package/apps/studio/collab/index.ts +4 -0
  43. package/apps/studio/collab/persistence.ts +137 -17
  44. package/apps/studio/collab/registry.ts +19 -21
  45. package/apps/studio/collab/room.ts +13 -2
  46. package/apps/studio/context.ts +59 -0
  47. package/apps/studio/debug-bundle.ts +13 -0
  48. package/apps/studio/dist/client.bundle.js +1911 -1879
  49. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  50. package/apps/studio/dist/runtime/sonner.js +1 -0
  51. package/apps/studio/dist/styles.css +1 -1
  52. package/apps/studio/export-dialog.tsx +54 -0
  53. package/apps/studio/exporters/degraded.ts +19 -3
  54. package/apps/studio/exporters/ghostscript.ts +194 -0
  55. package/apps/studio/exporters/pdf-fonts.ts +261 -0
  56. package/apps/studio/exporters/pdf.ts +152 -3
  57. package/apps/studio/exporters/remote.ts +36 -1
  58. package/apps/studio/fs-watch.ts +45 -1
  59. package/apps/studio/http.ts +296 -19
  60. package/apps/studio/inspect.ts +19 -0
  61. package/apps/studio/managed-projects.ts +150 -0
  62. package/apps/studio/mem.ts +45 -9
  63. package/apps/studio/notifications.tsx +292 -0
  64. package/apps/studio/runtime-bundle.ts +2 -0
  65. package/apps/studio/server.ts +69 -6
  66. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  67. package/apps/studio/sync/accepted-link.ts +320 -0
  68. package/apps/studio/sync/action-stage.ts +343 -0
  69. package/apps/studio/sync/agent.ts +104 -45
  70. package/apps/studio/sync/asset-push.ts +1 -47
  71. package/apps/studio/sync/cell-file-events.ts +3 -0
  72. package/apps/studio/sync/codec.ts +192 -34
  73. package/apps/studio/sync/cold-start-apply.ts +7 -0
  74. package/apps/studio/sync/cold-start.ts +118 -22
  75. package/apps/studio/sync/comment-identity.ts +68 -0
  76. package/apps/studio/sync/connection-state.ts +15 -1
  77. package/apps/studio/sync/ctl-provider.ts +15 -2
  78. package/apps/studio/sync/document-discovery.ts +81 -0
  79. package/apps/studio/sync/file-ledger.ts +117 -0
  80. package/apps/studio/sync/file-membership.ts +10 -2
  81. package/apps/studio/sync/file-plane.ts +926 -47
  82. package/apps/studio/sync/index.ts +1786 -36
  83. package/apps/studio/sync/journal-client.ts +13 -1
  84. package/apps/studio/sync/migrate-seed.ts +150 -22
  85. package/apps/studio/sync/poke.ts +4 -2
  86. package/apps/studio/sync/presentation.ts +311 -5
  87. package/apps/studio/sync/projection.ts +820 -23
  88. package/apps/studio/sync/repeated-module.ts +73 -0
  89. package/apps/studio/sync/retry-after.ts +169 -0
  90. package/apps/studio/sync/revision-barrier.ts +129 -0
  91. package/apps/studio/sync/seed-progress.ts +218 -0
  92. package/apps/studio/sync/seed-repair.ts +46 -0
  93. package/apps/studio/sync/source-merge.ts +100 -0
  94. package/apps/studio/sync/source-ops.ts +289 -0
  95. package/apps/studio/sync/source-recovery.ts +70 -0
  96. package/apps/studio/sync/source-validation.ts +56 -0
  97. package/apps/studio/sync/status.ts +201 -6
  98. package/apps/studio/sync/transaction-client.ts +460 -0
  99. package/apps/studio/sync/transport-error.ts +111 -0
  100. package/apps/studio/sync/writer-registry.ts +236 -0
  101. package/apps/studio/text-caret.ts +35 -0
  102. package/apps/studio/undo-hud.tsx +9 -87
  103. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  104. package/apps/studio/whats-new.json +173 -0
  105. package/cli/bin/maude.mjs +2 -0
  106. package/cli/commands/codex.mjs +191 -0
  107. package/cli/commands/harness.mjs +1051 -0
  108. package/cli/commands/help.mjs +13 -0
  109. package/cli/lib/design-link.mjs +190 -2
  110. package/cli/lib/harness/capabilities.mjs +302 -0
  111. package/cli/lib/harness/codex-runtime.mjs +892 -0
  112. package/cli/lib/harness/compatibility.mjs +27 -0
  113. package/cli/lib/harness/discover-claude.mjs +1393 -0
  114. package/cli/lib/harness/managed-state.mjs +1296 -0
  115. package/cli/lib/harness/model.mjs +86 -0
  116. package/cli/lib/harness/secrets.mjs +390 -0
  117. package/cli/lib/harness/targets/codex.mjs +1141 -0
  118. package/cli/lib/harness/targets/opencode.mjs +807 -0
  119. package/cli/lib/harness/transaction.mjs +1099 -0
  120. package/cli/lib/hubs-config.mjs +35 -8
  121. package/cli/templates/harness/opencode/maude-projector.ts +58 -0
  122. package/docs/harness-capability-matrix.md +125 -0
  123. package/docs/harness-environment-projection.md +95 -0
  124. package/package.json +33 -9
  125. package/plugins/design/dependencies.json +3 -3
  126. package/plugins/design/templates/_shell.html +47 -0
  127. package/plugins/flow/.claude-plugin/config.schema.json +8 -0
  128. package/plugins/flow/dependencies.json +3 -3
  129. package/apps/studio/.ai/cache/_stats.json +0 -7
  130. package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
  131. package/apps/studio/bin/_curl-local.test.mjs +0 -280
  132. package/apps/studio/bin/_pw-launch.test.mjs +0 -90
  133. package/apps/studio/bin/_smart-frames.test.mjs +0 -195
  134. package/apps/studio/bin/_transcribe.test.mjs +0 -80
  135. package/apps/studio/bun.lock +0 -928
  136. package/apps/studio/draw/test/animate.test.ts +0 -172
  137. package/apps/studio/draw/test/brush.test.ts +0 -213
  138. package/apps/studio/draw/test/composition.test.ts +0 -141
  139. package/apps/studio/draw/test/geometry.test.ts +0 -199
  140. package/apps/studio/draw/test/gradient.test.ts +0 -167
  141. package/apps/studio/draw/test/layout.test.ts +0 -120
  142. package/apps/studio/draw/test/morph.test.ts +0 -107
  143. package/apps/studio/draw/test/optimize.test.ts +0 -40
  144. package/apps/studio/draw/test/palette.test.ts +0 -123
  145. package/apps/studio/draw/test/primitives.test.ts +0 -129
  146. package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
  147. package/apps/studio/draw/test/serialize.test.ts +0 -105
  148. package/apps/studio/figma/assets.test.ts +0 -464
  149. package/apps/studio/figma/client.test.ts +0 -395
  150. package/apps/studio/figma/codegen-client.test.ts +0 -276
  151. package/apps/studio/figma/codegen-fonts.test.ts +0 -103
  152. package/apps/studio/figma/codegen-values.test.ts +0 -179
  153. package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
  154. package/apps/studio/figma/fig-decode.test.ts +0 -788
  155. package/apps/studio/figma/fig-differential.test.ts +0 -182
  156. package/apps/studio/figma/fig-translator.test.ts +0 -192
  157. package/apps/studio/figma/fig-vector.test.ts +0 -113
  158. package/apps/studio/figma/from-codegen.test.ts +0 -408
  159. package/apps/studio/figma/sanitize.test.ts +0 -325
  160. package/apps/studio/figma/tailwind-map.test.ts +0 -142
  161. package/apps/studio/figma/to-artboard.test.ts +0 -808
  162. package/apps/studio/figma/to-render.test.ts +0 -180
  163. package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
  164. package/apps/studio/figma/to-strokes.test.ts +0 -705
  165. package/apps/studio/figma/to-tokens.test.ts +0 -321
  166. package/apps/studio/figma/url.test.ts +0 -167
  167. package/apps/studio/footage/schema.test.ts +0 -287
  168. package/apps/studio/generation/adapters/groq.test.ts +0 -80
  169. package/apps/studio/generation/audio-library.test.ts +0 -95
  170. package/apps/studio/generation/captions.test.ts +0 -78
  171. package/apps/studio/generation/elevenlabs.test.ts +0 -223
  172. package/apps/studio/generation/gemini.test.ts +0 -350
  173. package/apps/studio/generation/jobs.test.ts +0 -128
  174. package/apps/studio/generation/keys.test.ts +0 -75
  175. package/apps/studio/generation/prefs.test.ts +0 -95
  176. package/apps/studio/generation/types.test.ts +0 -123
  177. package/apps/studio/generation/whisper-models.test.ts +0 -179
  178. package/apps/studio/test/_helpers.ts +0 -108
  179. package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
  180. package/apps/studio/test/acp-activity.test.ts +0 -154
  181. package/apps/studio/test/acp-ai-activity.test.ts +0 -182
  182. package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
  183. package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
  184. package/apps/studio/test/acp-branch-guard.test.ts +0 -123
  185. package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
  186. package/apps/studio/test/acp-bridge.test.ts +0 -545
  187. package/apps/studio/test/acp-capabilities.test.ts +0 -123
  188. package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
  189. package/apps/studio/test/acp-commands.test.ts +0 -119
  190. package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
  191. package/apps/studio/test/acp-elicitation.test.ts +0 -251
  192. package/apps/studio/test/acp-env.test.ts +0 -95
  193. package/apps/studio/test/acp-mode-banner.test.ts +0 -45
  194. package/apps/studio/test/acp-origin-gate.test.ts +0 -160
  195. package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
  196. package/apps/studio/test/acp-permission.test.ts +0 -278
  197. package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
  198. package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
  199. package/apps/studio/test/acp-session-plugins.test.ts +0 -209
  200. package/apps/studio/test/acp-toolgroup.test.ts +0 -76
  201. package/apps/studio/test/acp-transcript-view.test.ts +0 -71
  202. package/apps/studio/test/acp-transcript.test.ts +0 -238
  203. package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
  204. package/apps/studio/test/acp-usage.test.ts +0 -143
  205. package/apps/studio/test/acp-write-gate.test.ts +0 -323
  206. package/apps/studio/test/acp-write-scope.test.ts +0 -533
  207. package/apps/studio/test/active-state.test.ts +0 -186
  208. package/apps/studio/test/activity.test.ts +0 -189
  209. package/apps/studio/test/ai-activity.test.ts +0 -113
  210. package/apps/studio/test/annotate-write.test.ts +0 -1074
  211. package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
  212. package/apps/studio/test/annotations-align.test.ts +0 -88
  213. package/apps/studio/test/annotations-api.test.ts +0 -145
  214. package/apps/studio/test/annotations-bindings.test.ts +0 -262
  215. package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
  216. package/apps/studio/test/annotations-groups.test.ts +0 -231
  217. package/apps/studio/test/annotations-layer.test.ts +0 -1011
  218. package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
  219. package/apps/studio/test/annotations-snap.test.ts +0 -135
  220. package/apps/studio/test/annotations-text-format.test.ts +0 -396
  221. package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
  222. package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
  223. package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
  224. package/apps/studio/test/artboard-kinds.test.tsx +0 -83
  225. package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
  226. package/apps/studio/test/asset-api.test.ts +0 -149
  227. package/apps/studio/test/assets-s3.test.ts +0 -249
  228. package/apps/studio/test/binary-smoke.test.ts +0 -47
  229. package/apps/studio/test/boot-self-heal.test.ts +0 -87
  230. package/apps/studio/test/browse-posture.test.tsx +0 -107
  231. package/apps/studio/test/bundle-smoke.test.ts +0 -46
  232. package/apps/studio/test/camera-reveal.test.tsx +0 -173
  233. package/apps/studio/test/canvas-artifacts.test.ts +0 -125
  234. package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
  235. package/apps/studio/test/canvas-build.test.ts +0 -141
  236. package/apps/studio/test/canvas-create-api.test.ts +0 -676
  237. package/apps/studio/test/canvas-cursors.test.ts +0 -164
  238. package/apps/studio/test/canvas-edit.test.ts +0 -470
  239. package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
  240. package/apps/studio/test/canvas-header.test.ts +0 -127
  241. package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
  242. package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
  243. package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
  244. package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
  245. package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
  246. package/apps/studio/test/canvas-list-watch.test.ts +0 -388
  247. package/apps/studio/test/canvas-media-drop.test.ts +0 -187
  248. package/apps/studio/test/canvas-meta-api.test.ts +0 -707
  249. package/apps/studio/test/canvas-move-api.test.ts +0 -587
  250. package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
  251. package/apps/studio/test/canvas-pipeline.test.ts +0 -180
  252. package/apps/studio/test/canvas-rects.test.ts +0 -198
  253. package/apps/studio/test/canvas-reorder.test.ts +0 -211
  254. package/apps/studio/test/canvas-route.test.ts +0 -179
  255. package/apps/studio/test/canvas-shell-base.test.ts +0 -107
  256. package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
  257. package/apps/studio/test/canvas-url.test.ts +0 -138
  258. package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
  259. package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
  260. package/apps/studio/test/capture-core.test.ts +0 -32
  261. package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
  262. package/apps/studio/test/chat-attachments.test.ts +0 -117
  263. package/apps/studio/test/chat-context.test.ts +0 -129
  264. package/apps/studio/test/chat-markdown.test.tsx +0 -58
  265. package/apps/studio/test/clip-addressing.test.ts +0 -738
  266. package/apps/studio/test/clip-ops.test.ts +0 -922
  267. package/apps/studio/test/cloud-build.test.ts +0 -95
  268. package/apps/studio/test/cloud-connect-note.test.ts +0 -135
  269. package/apps/studio/test/cloud-endpoints.test.ts +0 -590
  270. package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
  271. package/apps/studio/test/cloud-history-posture.test.ts +0 -230
  272. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
  273. package/apps/studio/test/cloud-renew.test.ts +0 -205
  274. package/apps/studio/test/cloud-session-role.test.ts +0 -161
  275. package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
  276. package/apps/studio/test/cold-start-apply.test.ts +0 -303
  277. package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
  278. package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
  279. package/apps/studio/test/collab-bridge.test.ts +0 -79
  280. package/apps/studio/test/collab-loopback.test.ts +0 -63
  281. package/apps/studio/test/collab-origin-gate.test.ts +0 -323
  282. package/apps/studio/test/collab-protocol.test.ts +0 -146
  283. package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
  284. package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
  285. package/apps/studio/test/collab-room.test.ts +0 -188
  286. package/apps/studio/test/collab-session-survival.test.tsx +0 -176
  287. package/apps/studio/test/collab-stress.test.ts +0 -129
  288. package/apps/studio/test/comment-mount.test.ts +0 -97
  289. package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
  290. package/apps/studio/test/comments-api.test.ts +0 -277
  291. package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
  292. package/apps/studio/test/comments-overlay.test.ts +0 -117
  293. package/apps/studio/test/compile-entry.test.ts +0 -133
  294. package/apps/studio/test/component-map.test.ts +0 -48
  295. package/apps/studio/test/config-projection.test.ts +0 -104
  296. package/apps/studio/test/config-reload.test.ts +0 -230
  297. package/apps/studio/test/config-version.test.ts +0 -88
  298. package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
  299. package/apps/studio/test/convert-to-absolute.test.ts +0 -333
  300. package/apps/studio/test/credential-grammar.test.ts +0 -107
  301. package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
  302. package/apps/studio/test/csrf-write-guard.test.ts +0 -141
  303. package/apps/studio/test/debug-bundle.test.ts +0 -105
  304. package/apps/studio/test/design-setup-readiness.test.ts +0 -155
  305. package/apps/studio/test/detach-component.test.ts +0 -94
  306. package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
  307. package/apps/studio/test/dom-selection.test.ts +0 -198
  308. package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
  309. package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
  310. package/apps/studio/test/edit-persistence.test.ts +0 -91
  311. package/apps/studio/test/edit-scope-api.test.ts +0 -119
  312. package/apps/studio/test/edit-source-command.test.ts +0 -142
  313. package/apps/studio/test/edit-suppress.test.ts +0 -170
  314. package/apps/studio/test/editing-presence.test.ts +0 -103
  315. package/apps/studio/test/element-resize.test.ts +0 -136
  316. package/apps/studio/test/element-structural-api.test.ts +0 -434
  317. package/apps/studio/test/element-structural-edit.test.ts +0 -750
  318. package/apps/studio/test/ensure-browser.test.ts +0 -92
  319. package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
  320. package/apps/studio/test/export-assemble.test.ts +0 -53
  321. package/apps/studio/test/export-browser-lane.test.ts +0 -126
  322. package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
  323. package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
  324. package/apps/studio/test/export-center.test.tsx +0 -287
  325. package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
  326. package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
  327. package/apps/studio/test/export-lane.test.ts +0 -245
  328. package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
  329. package/apps/studio/test/exporters/canva.test.ts +0 -63
  330. package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
  331. package/apps/studio/test/exporters/endpoint.test.ts +0 -121
  332. package/apps/studio/test/exporters/history.test.ts +0 -107
  333. package/apps/studio/test/exporters/html.test.ts +0 -26
  334. package/apps/studio/test/exporters/jobs.test.ts +0 -342
  335. package/apps/studio/test/exporters/pdf.test.ts +0 -85
  336. package/apps/studio/test/exporters/png.test.ts +0 -103
  337. package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
  338. package/apps/studio/test/exporters/pptx.test.ts +0 -31
  339. package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
  340. package/apps/studio/test/exporters/runtime.test.ts +0 -59
  341. package/apps/studio/test/exporters/scope.test.ts +0 -292
  342. package/apps/studio/test/exporters/svg.test.ts +0 -29
  343. package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
  344. package/apps/studio/test/exporters/zip.test.ts +0 -104
  345. package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
  346. package/apps/studio/test/figjam-v3-model.test.ts +0 -342
  347. package/apps/studio/test/figma-explode.test.ts +0 -438
  348. package/apps/studio/test/figma-provenance.test.ts +0 -108
  349. package/apps/studio/test/figma-routes.test.ts +0 -294
  350. package/apps/studio/test/file-lock.test.ts +0 -84
  351. package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
  352. package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
  353. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
  354. package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
  355. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
  356. package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
  357. package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
  358. package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
  359. package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
  360. package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
  361. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
  362. package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
  363. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
  364. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
  365. package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
  366. package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
  367. package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
  368. package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
  369. package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
  370. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  371. package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
  372. package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
  373. package/apps/studio/test/footage-store.test.ts +0 -100
  374. package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
  375. package/apps/studio/test/fs-watch.test.ts +0 -41
  376. package/apps/studio/test/gemma-models.test.ts +0 -245
  377. package/apps/studio/test/generate-route.test.ts +0 -106
  378. package/apps/studio/test/git-api.test.ts +0 -0
  379. package/apps/studio/test/git-branches.test.ts +0 -341
  380. package/apps/studio/test/git-cloud-posture.test.ts +0 -59
  381. package/apps/studio/test/git-grouping.test.ts +0 -106
  382. package/apps/studio/test/git-lifecycle.test.ts +0 -100
  383. package/apps/studio/test/git-log-format.test.ts +0 -95
  384. package/apps/studio/test/git-safe-rel.test.ts +0 -132
  385. package/apps/studio/test/git-system-engine.test.ts +0 -135
  386. package/apps/studio/test/git-watch.test.ts +0 -97
  387. package/apps/studio/test/github-api.test.ts +0 -577
  388. package/apps/studio/test/grid-track-handles.test.ts +0 -160
  389. package/apps/studio/test/handoff-static-frames.test.ts +0 -215
  390. package/apps/studio/test/handoff.test.ts +0 -329
  391. package/apps/studio/test/history-rollback.test.ts +0 -88
  392. package/apps/studio/test/hmr-broadcast.test.ts +0 -236
  393. package/apps/studio/test/hmr-classify.test.ts +0 -87
  394. package/apps/studio/test/hub-link.test.ts +0 -80
  395. package/apps/studio/test/identity-cache.test.ts +0 -83
  396. package/apps/studio/test/import-asset-browser.test.ts +0 -64
  397. package/apps/studio/test/import-asset-route.test.ts +0 -71
  398. package/apps/studio/test/import-asset.test.ts +0 -427
  399. package/apps/studio/test/import-brand-browser.test.ts +0 -88
  400. package/apps/studio/test/import-brand-route.test.ts +0 -120
  401. package/apps/studio/test/import-brand.test.ts +0 -206
  402. package/apps/studio/test/import-figma.test.ts +0 -667
  403. package/apps/studio/test/import-tokens.test.ts +0 -722
  404. package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
  405. package/apps/studio/test/input-router.test.ts +0 -757
  406. package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
  407. package/apps/studio/test/inspect-selections.test.ts +0 -219
  408. package/apps/studio/test/knob-props-authored.test.ts +0 -87
  409. package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
  410. package/apps/studio/test/locator.test.ts +0 -214
  411. package/apps/studio/test/marquee-overlay.test.ts +0 -94
  412. package/apps/studio/test/media-commit-chain.test.ts +0 -210
  413. package/apps/studio/test/mode-toggle.test.tsx +0 -226
  414. package/apps/studio/test/motion-sample.test.ts +0 -35
  415. package/apps/studio/test/move-artboards-command.test.ts +0 -108
  416. package/apps/studio/test/participants-chrome.test.ts +0 -65
  417. package/apps/studio/test/paths.test.ts +0 -88
  418. package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
  419. package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
  420. package/apps/studio/test/perf-harness.ts +0 -193
  421. package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
  422. package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
  423. package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
  424. package/apps/studio/test/photo-edit-api.test.ts +0 -201
  425. package/apps/studio/test/photo-filters.test.ts +0 -149
  426. package/apps/studio/test/photo-pipeline.test.ts +0 -106
  427. package/apps/studio/test/photo-taxonomy.test.ts +0 -96
  428. package/apps/studio/test/print-marks.test.ts +0 -113
  429. package/apps/studio/test/print-units.test.ts +0 -173
  430. package/apps/studio/test/read-annotations.test.ts +0 -727
  431. package/apps/studio/test/read-only-gate.test.ts +0 -188
  432. package/apps/studio/test/read-only-role.test.ts +0 -95
  433. package/apps/studio/test/readiness.test.ts +0 -337
  434. package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
  435. package/apps/studio/test/reorder-api.test.ts +0 -210
  436. package/apps/studio/test/repo-concurrency.test.ts +0 -146
  437. package/apps/studio/test/repo-lock.test.ts +0 -350
  438. package/apps/studio/test/report-proxy.test.ts +0 -125
  439. package/apps/studio/test/ripple.test.ts +0 -197
  440. package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
  441. package/apps/studio/test/runtime-bundle.test.ts +0 -69
  442. package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
  443. package/apps/studio/test/scaffold-design.test.ts +0 -122
  444. package/apps/studio/test/server-lifecycle.test.ts +0 -38
  445. package/apps/studio/test/session-runtime-state.test.ts +0 -226
  446. package/apps/studio/test/session-scope.test.ts +0 -100
  447. package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
  448. package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
  449. package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
  450. package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
  451. package/apps/studio/test/shared-doc-projection.test.ts +0 -238
  452. package/apps/studio/test/shell-importmap.test.ts +0 -56
  453. package/apps/studio/test/sizing-mode.test.ts +0 -102
  454. package/apps/studio/test/slash-commands.test.ts +0 -117
  455. package/apps/studio/test/snap-distance-pill.test.ts +0 -68
  456. package/apps/studio/test/spacing-handles.test.ts +0 -138
  457. package/apps/studio/test/specimen-select.test.ts +0 -88
  458. package/apps/studio/test/sync-agent.test.ts +0 -660
  459. package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
  460. package/apps/studio/test/sync-asset-push.test.ts +0 -931
  461. package/apps/studio/test/sync-atomic-write.test.ts +0 -62
  462. package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
  463. package/apps/studio/test/sync-autocommit.test.ts +0 -537
  464. package/apps/studio/test/sync-canvas-path.test.ts +0 -200
  465. package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
  466. package/apps/studio/test/sync-codec.test.ts +0 -230
  467. package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
  468. package/apps/studio/test/sync-cold-start.test.ts +0 -304
  469. package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
  470. package/apps/studio/test/sync-connection-state.test.ts +0 -337
  471. package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
  472. package/apps/studio/test/sync-decide-file.test.ts +0 -420
  473. package/apps/studio/test/sync-doc-name.test.ts +0 -281
  474. package/apps/studio/test/sync-echo-guard.test.ts +0 -96
  475. package/apps/studio/test/sync-file-ledger.test.ts +0 -334
  476. package/apps/studio/test/sync-file-membership.test.ts +0 -347
  477. package/apps/studio/test/sync-file-plane.test.ts +0 -976
  478. package/apps/studio/test/sync-file-pull.test.ts +0 -333
  479. package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
  480. package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
  481. package/apps/studio/test/sync-hardening.test.ts +0 -586
  482. package/apps/studio/test/sync-hub-listing.test.ts +0 -46
  483. package/apps/studio/test/sync-hubs-config.test.ts +0 -133
  484. package/apps/studio/test/sync-incident-replay.test.ts +0 -211
  485. package/apps/studio/test/sync-journal.test.ts +0 -176
  486. package/apps/studio/test/sync-meta-codec.test.ts +0 -199
  487. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
  488. package/apps/studio/test/sync-move-retirement.test.ts +0 -231
  489. package/apps/studio/test/sync-panel-surface.test.ts +0 -153
  490. package/apps/studio/test/sync-path-pull.test.ts +0 -531
  491. package/apps/studio/test/sync-presentation.test.ts +0 -262
  492. package/apps/studio/test/sync-pull-budget.test.ts +0 -169
  493. package/apps/studio/test/sync-remote-docs.test.ts +0 -333
  494. package/apps/studio/test/sync-resync-routes.test.ts +0 -168
  495. package/apps/studio/test/sync-runtime.test.ts +0 -1675
  496. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
  497. package/apps/studio/test/sync-settings-routes.test.ts +0 -195
  498. package/apps/studio/test/sync-settings.test.ts +0 -151
  499. package/apps/studio/test/sync-status.test.ts +0 -261
  500. package/apps/studio/test/sync-supervisor.test.ts +0 -262
  501. package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
  502. package/apps/studio/test/sync-trash.test.ts +0 -132
  503. package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
  504. package/apps/studio/test/sync-untrusted.test.ts +0 -103
  505. package/apps/studio/test/system-endpoint.test.ts +0 -144
  506. package/apps/studio/test/text-editability-stamp.test.ts +0 -131
  507. package/apps/studio/test/timeline-comp-target.test.ts +0 -139
  508. package/apps/studio/test/timeline-media-cache.test.ts +0 -41
  509. package/apps/studio/test/timeline-parse.test.ts +0 -184
  510. package/apps/studio/test/timeline-scale.test.ts +0 -65
  511. package/apps/studio/test/timeline-snap.test.ts +0 -85
  512. package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
  513. package/apps/studio/test/tool-palette.test.tsx +0 -55
  514. package/apps/studio/test/tour-overlay.test.tsx +0 -90
  515. package/apps/studio/test/trusted-request-host.test.ts +0 -66
  516. package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
  517. package/apps/studio/test/undo-stack.test.ts +0 -244
  518. package/apps/studio/test/use-agent-presence.test.tsx +0 -117
  519. package/apps/studio/test/use-annotation-resize.test.ts +0 -402
  520. package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
  521. package/apps/studio/test/use-artboard-drag.test.ts +0 -324
  522. package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
  523. package/apps/studio/test/use-collab.test.ts +0 -0
  524. package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
  525. package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
  526. package/apps/studio/test/use-selection-set.test.tsx +0 -166
  527. package/apps/studio/test/use-snap-guides.test.ts +0 -271
  528. package/apps/studio/test/use-tool-mode.test.tsx +0 -186
  529. package/apps/studio/test/use-undo-stack.test.tsx +0 -223
  530. package/apps/studio/test/video-asset.test.ts +0 -163
  531. package/apps/studio/test/video-comp-fixture.test.ts +0 -50
  532. package/apps/studio/test/video-comp.test.ts +0 -270
  533. package/apps/studio/test/video-encode-lib.test.ts +0 -63
  534. package/apps/studio/test/video-render-bridge.test.ts +0 -171
  535. package/apps/studio/test/whats-new.test.ts +0 -113
  536. package/apps/studio/test/whisper-setup.test.ts +0 -97
  537. package/apps/studio/test/workspace-containment.test.ts +0 -382
  538. package/apps/studio/test/workspace-signin.test.ts +0 -292
  539. package/apps/studio/test/ws-handshake.test.ts +0 -33
  540. package/cli/commands/cache.test.mjs +0 -166
  541. package/cli/commands/design-link.test.mjs +0 -419
  542. package/cli/commands/design.test.mjs +0 -240
  543. package/cli/commands/doctor.test.mjs +0 -237
  544. package/cli/commands/hub-workspace.test.mjs +0 -275
  545. package/cli/commands/hub.test.mjs +0 -225
  546. package/cli/commands/kg.test.mjs +0 -142
  547. package/cli/commands/preflight.test.mjs +0 -46
  548. package/cli/lib/argv.test.mjs +0 -46
  549. package/cli/lib/cache.test.mjs +0 -303
  550. package/cli/lib/cell-plan.test.mjs +0 -225
  551. package/cli/lib/config-lint.test.mjs +0 -117
  552. package/cli/lib/ddr-to-kgai.test.mjs +0 -118
  553. package/cli/lib/design-ownership.test.mjs +0 -329
  554. package/cli/lib/fetch-asset.test.mjs +0 -213
  555. package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
  556. package/cli/lib/figma-import-controls.test.mjs +0 -70
  557. package/cli/lib/flow-design-integration.test.mjs +0 -165
  558. package/cli/lib/gitignore-block.test.mjs +0 -136
  559. package/cli/lib/gitignore-drift.test.mjs +0 -156
  560. package/cli/lib/hubs-config.test.mjs +0 -165
  561. package/cli/lib/pkg-root.test.mjs +0 -111
  562. package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
  563. package/cli/lib/plugin-name-namespace.test.mjs +0 -71
  564. package/cli/lib/reconstruct-toolset.test.mjs +0 -194
  565. package/cli/lib/stack-detect.test.mjs +0 -121
  566. package/cli/lib/update-check.test.mjs +0 -32
  567. package/cli/lib/workspace-plan.test.mjs +0 -699
@@ -2,7 +2,7 @@
2
2
  // Returns plain objects; http.ts wraps them in Response.json().
3
3
 
4
4
  import crypto from 'node:crypto';
5
- import type { Dirent } from 'node:fs';
5
+ import { type Dirent, renameSync } from 'node:fs';
6
6
  import {
7
7
  lstat,
8
8
  mkdir,
@@ -17,7 +17,11 @@ import path from 'node:path';
17
17
  import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
18
18
  import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
19
19
  import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
20
+ import { rewriteRelativeImports } from './canvas-imports.ts';
20
21
  import { canvasSlugFromRel } from './canvas-slug.ts';
22
+ import { atomicWrite } from './sync/atomic-write.ts';
23
+ import { dedupeCommentsById } from './sync/comment-identity.ts';
24
+ import { isRuntimeStateRel } from './sync/file-membership.ts';
21
25
 
22
26
  // Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
23
27
  // importing it from api.ts — the actual implementation now lives in
@@ -25,8 +29,19 @@ import { canvasSlugFromRel } from './canvas-slug.ts';
25
29
  // without a cycle back through api.ts.
26
30
  export { canvasSlugFromRel } from './canvas-slug.ts';
27
31
 
32
+ /** Plan T17/L03 — the supporting files the file tree can move, rename and
33
+ * delete: what it previews (notes, styles, data, images, media, fonts), minus
34
+ * the canvas's own sidecars, which only ever travel with their canvas. */
35
+ export function isSupportingFileRel(rel: string): boolean {
36
+ if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
37
+ return /\.(md|css|json|txt|ya?ml|svg|png|jpe?g|gif|webp|avif|mp4|webm|mov|mp3|wav|ogg|m4a|woff2?|ttf|otf)$/i.test(
38
+ rel
39
+ );
40
+ }
41
+
28
42
  import {
29
43
  type AssembleClip,
44
+ type AttributeState,
30
45
  assembleCompSource,
31
46
  CanvasEditError,
32
47
  type ClipInfo,
@@ -60,6 +75,7 @@ import {
60
75
  setArtboardGuides,
61
76
  setArtboardHug,
62
77
  setArtboardKind,
78
+ setArtboardLabel,
63
79
  setArtboardPrint,
64
80
  setArtboardStyle,
65
81
  toggleClipHidden,
@@ -99,6 +115,7 @@ import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
99
115
  import { STICKERS_DIR } from './paths.ts';
100
116
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
101
117
  import { sessionDir } from './session-scope.ts';
118
+ import { describeSourceOp } from './sync/source-ops.ts';
102
119
  import { isWorkspaceMode } from './workspace-mode.ts';
103
120
 
104
121
  // Directories that never hold user-facing canvases. Exported so the
@@ -309,8 +326,27 @@ export type CreateFolderResult =
309
326
 
310
327
  /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
311
328
  export type EditOpResult =
312
- | { ok: true; delta: number; seq?: number }
313
- | { ok: false; status: number; error: string };
329
+ /** `previous` — what a css/attr write replaced (`null` = was unset); absent
330
+ * when it was an expression no literal undo can restore. */
331
+ | { ok: true; delta: number; seq?: number; previous?: string | null }
332
+ /** `conflict` = the target no longer held the caller's expected value. */
333
+ | { ok: false; status: number; error: string; conflict?: true };
334
+
335
+ /**
336
+ * Optional expected-current value for a css/attr write (audit 2026-09-13 P1
337
+ * #5). An absent `expected` key keeps the unconditional legacy behaviour; a
338
+ * present one must be a bounded string or `null` ("currently unset").
339
+ */
340
+ function expectedValueOf(input: {
341
+ expected?: unknown;
342
+ }): { ok: true; expected?: string | null } | { ok: false } {
343
+ if (!Object.hasOwn(input, 'expected') || input.expected === undefined) return { ok: true };
344
+ if (input.expected === null) return { ok: true, expected: null };
345
+ if (typeof input.expected === 'string' && input.expected.length <= 256) {
346
+ return { ok: true, expected: input.expected };
347
+ }
348
+ return { ok: false };
349
+ }
314
350
 
315
351
  /**
316
352
  * Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
@@ -371,7 +407,9 @@ export interface Api {
371
407
  ): Promise<Record<string, unknown> | null>;
372
408
  // Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
373
409
  loadAnnotations(file: string): Promise<string | null>;
374
- saveAnnotations(file: string, svg: string): Promise<boolean>;
410
+ saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
411
+ /** Materialize a document snapshot without publishing it as another user edit. */
412
+ projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
375
413
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
376
414
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
377
415
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
@@ -422,6 +460,10 @@ export interface Api {
422
460
  width?: unknown;
423
461
  height?: unknown;
424
462
  }): Promise<CreateCanvasResult>;
463
+ // Duplicate a canvas beside itself ("<name> copy") — POST /_api/canvas
464
+ // { duplicateOf }. Source, meta and the whiteboard layer; not comments or
465
+ // history, which belong to the original.
466
+ duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult>;
425
467
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
426
468
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
427
469
  // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
@@ -439,6 +481,8 @@ export interface Api {
439
481
  value?: unknown;
440
482
  reset?: unknown;
441
483
  idIndex?: unknown;
484
+ /** Expected current value (string) or `null` = currently unset. Absent = unconditional. */
485
+ expected?: unknown;
442
486
  }): Promise<EditOpResult>;
443
487
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
444
488
  editText(input: {
@@ -456,6 +500,8 @@ export interface Api {
456
500
  id?: unknown;
457
501
  attr?: unknown;
458
502
  value?: unknown;
503
+ reset?: unknown;
504
+ expected?: unknown;
459
505
  }): Promise<EditOpResult>;
460
506
  // Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
461
507
  // only. Moves the element with data-cd-id `id` to `position` relative to
@@ -659,6 +705,12 @@ export interface Api {
659
705
  artboardId?: unknown;
660
706
  kind?: unknown;
661
707
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
708
+ /** Rename an artboard (T25/L08 — double-click its name on the canvas). */
709
+ setArtboardLabelOp(input: {
710
+ canvas?: unknown;
711
+ artboardId?: unknown;
712
+ label?: unknown;
713
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
662
714
  /** Generic layout guides (T5) — replace-whole-prop write. */
663
715
  setArtboardGuidesOp(input: {
664
716
  canvas?: unknown;
@@ -720,9 +772,39 @@ export interface Api {
720
772
  }
721
773
 
722
774
  export interface ApiHooks {
723
- onCommentsChanged: (file: string) => void;
775
+ /**
776
+ * Publish a comments mutation to the SHARED DOC (and the shell's sidebar).
777
+ *
778
+ * `comments` is the post-mutation list, handed over IN MEMORY — issue #111.
779
+ * The hook used to take only `file` and re-read `_comments/<slug>.json`
780
+ * itself, which put a full disk round-trip between the mutation's write and
781
+ * the doc publish. `Room.flush()` fires on ANY doc update (every hub update
782
+ * re-arms it), so on a hub-linked project a flush landed inside that window,
783
+ * projected the PRE-mutation doc back over the file, and the new comment was
784
+ * gone before the hook's own read reached it — "only the first comment
785
+ * stays". Passing the list removes the read, and `publishComments` awaits
786
+ * this hook BEFORE touching disk so the doc is never the stale side.
787
+ */
788
+ /** `base` — the list this mutation started from (a merge hint for the project). */
789
+ onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
724
790
  /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
725
- onAnnotationsChanged?: (file: string, svg: string) => void;
791
+ onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
792
+ /**
793
+ * Accepted-revisions mode (DDR-241): propose a folder operation as ONE
794
+ * project action before touching disk. Absent, or answering `null`, means
795
+ * the project is not in that mode and the local operation is the whole story.
796
+ */
797
+ /**
798
+ * A layout (shared meta) write, with the file text it replaced — accepted
799
+ * revisions propose it with that base instead of inferring one.
800
+ */
801
+ onMetaChanged?: (file: string, text: string, baseText: string | null) => void;
802
+ proposeFolder?: (
803
+ op:
804
+ | { op: 'dir.create'; path: string }
805
+ | { op: 'dir.delete'; path: string }
806
+ | { op: 'dir.move'; from: string; to: string }
807
+ ) => Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
726
808
  /**
727
809
  * feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
728
810
  * (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
@@ -993,7 +1075,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
993
1075
  // stores in its undo record. Ephemeral by design: a server restart drops it
994
1076
  // (undo answers 404 and the canvas stack entry is a no-op, honest failure).
995
1077
  const REORDER_LOG_CAP = 50;
996
- const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
1078
+ const reorderLog = new Map<
1079
+ number,
1080
+ { abs: string; before: string; after: string; undoActionId?: string }
1081
+ >();
997
1082
  let reorderSeq = 0;
998
1083
 
999
1084
  // DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
@@ -1095,7 +1180,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1095
1180
  // Phase 6 — default-fill `author` / `thread` / `mentions` for legacy
1096
1181
  // rows. No write-back here; the on-disk shape stays stable until the
1097
1182
  // next mutation persists the upgraded record.
1098
- return arr.map(backfillComment);
1183
+ //
1184
+ // Deduped by comment identity first (issue #112). This is the read half
1185
+ // of the repair: a project whose `_comments/<slug>.json` was already
1186
+ // doubled by the pre-fix sync lane heals the moment it is loaded, and no
1187
+ // consumer — pins, sidebar, `/_comments`, the /design:edit agent — ever
1188
+ // sees the same comment eight times. First occurrence wins, matching the
1189
+ // rule the codec and the cold-start union use.
1190
+ return dedupeCommentsById(arr).map(backfillComment);
1099
1191
  } catch {
1100
1192
  return [];
1101
1193
  }
@@ -1135,7 +1227,39 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1135
1227
  }
1136
1228
 
1137
1229
  async function saveCommentsForFile(file: string, list: Comment[]) {
1138
- await Bun.write(commentsPath(file), JSON.stringify(list, null, 2));
1230
+ // The write half of the #112 repair, and the reason it belongs HERE: this
1231
+ // is the one choke point every writer passes through — the API mutations,
1232
+ // and the collab room's `persistJson`, which materializes the Y.Array
1233
+ // straight to disk. Deduping at the door means a doc caught mid-convergence
1234
+ // (or a peer still running the old wholesale write) cannot persist a
1235
+ // duplicated list as the canvas's new truth, which is how the doubling
1236
+ // survived restarts and compounded.
1237
+ await Bun.write(commentsPath(file), JSON.stringify(dedupeCommentsById(list), null, 2));
1238
+ }
1239
+
1240
+ /**
1241
+ * Land one comments mutation: SHARED DOC FIRST, disk second (issue #111).
1242
+ *
1243
+ * The order is the fix, not a style choice. Under DDR-064 the room's Y.Doc is
1244
+ * the shared object and `_comments/<slug>.json` is its projection — and
1245
+ * `Room.flush()` projects doc→file on an 800 ms trailing debounce that ANY
1246
+ * doc update re-arms, hub traffic included. So on a linked project a flush is
1247
+ * almost always pending, and any window in which the doc is behind the file
1248
+ * is a window in which that flush silently writes the older list back.
1249
+ *
1250
+ * Publishing to the doc first removes the window instead of narrowing it: a
1251
+ * flush firing at any point from here on carries a doc that already holds the
1252
+ * mutation. Both halves are awaited, so the HTTP response cannot report a
1253
+ * comment the doc never received.
1254
+ *
1255
+ * The list is deduped ONCE here so the doc and the file are handed byte-equal
1256
+ * content — otherwise the file→doc import that follows the write would see a
1257
+ * difference and re-enter the loop.
1258
+ */
1259
+ async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise<void> {
1260
+ const settled = dedupeCommentsById(list);
1261
+ await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined);
1262
+ await saveCommentsForFile(file, settled);
1139
1263
  }
1140
1264
 
1141
1265
  async function loadAllComments(): Promise<Record<string, Comment[]>> {
@@ -1266,6 +1390,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1266
1390
  if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
1267
1391
  if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
1268
1392
  const list = await loadCommentsForFile(payload.file);
1393
+ const base = structuredClone(list);
1269
1394
  const text = String(payload.text).trim().slice(0, 4000);
1270
1395
  const author =
1271
1396
  typeof payload.author === 'string' && payload.author.trim()
@@ -1314,8 +1439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1314
1439
  if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
1315
1440
  }
1316
1441
  list.push(c);
1317
- await saveCommentsForFile(payload.file, list);
1318
- onCommentsChanged(payload.file);
1442
+ await publishComments(payload.file, list, base);
1319
1443
  return c;
1320
1444
  }
1321
1445
 
@@ -1330,6 +1454,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1330
1454
  if (i < 0) continue;
1331
1455
  const entry = list[i];
1332
1456
  if (!entry) continue;
1457
+ const base = structuredClone(list);
1333
1458
  const body = payload.body.trim().slice(0, 4000);
1334
1459
  const author =
1335
1460
  typeof payload.author === 'string' && payload.author.trim()
@@ -1343,31 +1468,39 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1343
1468
  };
1344
1469
  entry.thread = [...entry.thread, reply];
1345
1470
  entry.mentions = mentionsUnion(entry);
1346
- await saveCommentsForFile(file, list);
1347
- onCommentsChanged(file);
1471
+ await publishComments(file, list, base);
1348
1472
  return entry;
1349
1473
  }
1350
1474
  return null;
1351
1475
  }
1352
1476
 
1477
+ // Mutations are ID-TOTAL, not first-match (issue #112). `loadAllComments`
1478
+ // dedupes, so under normal operation there is exactly one entry per id and
1479
+ // these behave as they always did. They stay total as defense in depth: the
1480
+ // reporter's second symptom — "I can't then close or resolve the comments" —
1481
+ // was a `findIndex` + `return` marking copy 1 of 8 resolved while the overlay
1482
+ // (which filters `status !== 'resolved'`) kept drawing the other seven, and a
1483
+ // delete removing one copy per click. A duplicated list can still reach these
1484
+ // from an older peer mid-upgrade; resolve must resolve either way.
1353
1485
  async function commentsPatch(id: string, patch: Partial<Comment>) {
1354
1486
  const all = await loadAllComments();
1355
1487
  for (const [file, list] of Object.entries(all)) {
1356
- const i = list.findIndex((c) => c.id === id);
1357
- if (i < 0) continue;
1358
- const entry = list[i];
1359
- if (!entry) continue;
1360
- if (patch.status === 'resolved' || patch.status === 'open') {
1361
- entry.status = patch.status;
1362
- entry.resolved_at = patch.status === 'resolved' ? new Date().toISOString() : null;
1363
- }
1364
- if (typeof patch.text === 'string' && patch.text.trim()) {
1365
- entry.text = patch.text.trim().slice(0, 4000);
1366
- entry.mentions = mentionsUnion(entry);
1488
+ const matches = list.filter((c) => c.id === id);
1489
+ const first = matches[0];
1490
+ if (!first) continue;
1491
+ const base = structuredClone(list);
1492
+ for (const entry of matches) {
1493
+ if (patch.status === 'resolved' || patch.status === 'open') {
1494
+ entry.status = patch.status;
1495
+ entry.resolved_at = patch.status === 'resolved' ? new Date().toISOString() : null;
1496
+ }
1497
+ if (typeof patch.text === 'string' && patch.text.trim()) {
1498
+ entry.text = patch.text.trim().slice(0, 4000);
1499
+ entry.mentions = mentionsUnion(entry);
1500
+ }
1367
1501
  }
1368
- await saveCommentsForFile(file, list);
1369
- onCommentsChanged(file);
1370
- return entry;
1502
+ await publishComments(file, list, base);
1503
+ return first;
1371
1504
  }
1372
1505
  return null;
1373
1506
  }
@@ -1375,11 +1508,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1375
1508
  async function commentsDelete(id: string): Promise<boolean> {
1376
1509
  const all = await loadAllComments();
1377
1510
  for (const [file, list] of Object.entries(all)) {
1378
- const i = list.findIndex((c) => c.id === id);
1379
- if (i < 0) continue;
1380
- list.splice(i, 1);
1381
- await saveCommentsForFile(file, list);
1382
- onCommentsChanged(file);
1511
+ const remaining = list.filter((c) => c.id !== id);
1512
+ if (remaining.length === list.length) continue;
1513
+ await publishComments(file, remaining, list);
1383
1514
  return true;
1384
1515
  }
1385
1516
  return false;
@@ -1887,7 +2018,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1887
2018
  next.last_modified = new Date().toISOString();
1888
2019
  // Trailing newline — consistent with canvas-create.ts + sync/codec.ts
1889
2020
  // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
1890
- await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
2021
+ const baseText = Object.keys(current).length ? JSON.stringify(current) : null;
2022
+ const nextText = `${JSON.stringify(next, null, 2)}\n`;
2023
+ await Bun.write(metaAbs, nextText);
2024
+ hooks.onMetaChanged?.(file, nextText, baseText);
1891
2025
  // Same reason as the annotations sidecar below, and the same bug: this
1892
2026
  // lane writes the FILE and nothing else, so in a cell — where there is no
1893
2027
  // `fs.watch` — the layout never entered the doc and never reached a peer.
@@ -1927,7 +2061,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1927
2061
  }
1928
2062
  }
1929
2063
 
1930
- async function saveAnnotations(file: string, svg: string): Promise<boolean> {
2064
+ async function saveAnnotations(
2065
+ file: string,
2066
+ svg: string,
2067
+ writeId?: string,
2068
+ base?: string
2069
+ ): Promise<boolean> {
1931
2070
  if (typeof svg !== 'string') return false;
1932
2071
  if (svg.length > 1024 * 1024) return false;
1933
2072
  // Cheap content gate — must look like an <svg> document. Avoids accidental
@@ -1945,8 +2084,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1945
2084
  // (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
1946
2085
  // polyline/text — so stripping executable constructs is zero-regression.
1947
2086
  const clean = sanitizeAnnotationSvg(svg);
2087
+ // The edit's base travels only as a merge hint for the project — bounded
2088
+ // and sanitized like the value itself, never written anywhere.
2089
+ const cleanBase =
2090
+ typeof base === 'string' &&
2091
+ base.length <= 1024 * 1024 &&
2092
+ (base === '' || /^\s*<svg[\s>]/i.test(base))
2093
+ ? base === ''
2094
+ ? ''
2095
+ : sanitizeAnnotationSvg(base)
2096
+ : undefined;
1948
2097
  await Bun.write(annotationsPath(file), clean);
1949
- onAnnotationsChanged?.(file, clean);
2098
+ onAnnotationsChanged?.(file, clean, writeId, cleanBase);
1950
2099
  // Annotations reach OTHER VIEWERS over the collab room, which is why this
1951
2100
  // never needed an `fs:any`. But the file is also a versioned, file-plane
1952
2101
  // sidecar (DDR-115), and the file plane learns about a cell's own writes
@@ -1957,6 +2106,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1957
2106
  return true;
1958
2107
  }
1959
2108
 
2109
+ async function projectAnnotations(
2110
+ file: string,
2111
+ svg: string,
2112
+ isCurrent: () => boolean
2113
+ ): Promise<boolean> {
2114
+ if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
2115
+ return false;
2116
+ if (!isCurrent()) return false;
2117
+ const clean = sanitizeAnnotationSvg(svg);
2118
+ // Runtime scratch stays out of the file plane. The async IO must not touch
2119
+ // the serving file until we recheck the document; another edit may have
2120
+ // arrived while Bun.write was pending. Check + rename have no await gap.
2121
+ const scratch = path.join(paths.designRoot, '_state');
2122
+ await mkdir(scratch, { recursive: true });
2123
+ const temp = path.join(scratch, `annotations-${crypto.randomUUID()}.tmp`);
2124
+ try {
2125
+ await Bun.write(temp, clean);
2126
+ if (!isCurrent()) return false;
2127
+ renameSync(temp, annotationsPath(file));
2128
+ announceWritten(`${fileSlug(file)}.annotations.svg`);
2129
+ return true;
2130
+ } finally {
2131
+ await rm(temp, { force: true });
2132
+ }
2133
+ }
2134
+
1960
2135
  // Phase 23 — content-addressed asset write. Reachable from the (potentially
1961
2136
  // untrusted, DDR-054) canvas origin, so every cap is load-bearing, NOT
1962
2137
  // optional (DDR Task 9):
@@ -2695,6 +2870,91 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2695
2870
  return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
2696
2871
  }
2697
2872
 
2873
+ async function duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult> {
2874
+ const raw = input?.file;
2875
+ if (typeof raw !== 'string' || !raw.trim()) {
2876
+ return { ok: false, status: 400, error: 'file is required' };
2877
+ }
2878
+ let rel = raw.trim();
2879
+ try {
2880
+ rel = decodeURIComponent(rel);
2881
+ } catch {
2882
+ /* leave as-is */
2883
+ }
2884
+ rel = rel.replace(/^\/+/, '');
2885
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
2886
+ if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
2887
+ if (rel.includes('..') || !/\.tsx$/i.test(rel)) {
2888
+ return { ok: false, status: 400, error: 'invalid path' };
2889
+ }
2890
+ const fileAbs = path.resolve(path.join(paths.designRoot, rel));
2891
+ const dirAbs = path.dirname(fileAbs);
2892
+ const groups = cfg.canvasGroups.filter(
2893
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
2894
+ );
2895
+ const inGroup = groups.some((g) => {
2896
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
2897
+ return dirAbs === gAbs || dirAbs.startsWith(`${gAbs}${path.sep}`);
2898
+ });
2899
+ if (!inGroup) {
2900
+ return {
2901
+ ok: false,
2902
+ status: 400,
2903
+ error: 'only canvases under a managed canvas group can be duplicated',
2904
+ };
2905
+ }
2906
+ if (!(await assertRealpathContained(dirAbs))) {
2907
+ return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
2908
+ }
2909
+ if (!(await Bun.file(fileAbs).exists())) {
2910
+ return { ok: false, status: 404, error: 'canvas not found' };
2911
+ }
2912
+ const base = path.basename(rel).replace(/\.tsx$/i, '');
2913
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
2914
+ // "<name> copy", then "<name> copy 2", … — the first free, valid name.
2915
+ let name: string | null = null;
2916
+ for (let n = 1; n < 100 && !name; n++) {
2917
+ const suffix = n === 1 ? ' copy' : ` copy ${n}`;
2918
+ const v = validateCanvasName(`${base.slice(0, 60 - suffix.length)}${suffix}`);
2919
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
2920
+ const candidate = path.posix.join(dir, `${v.name}.tsx`);
2921
+ if (await Bun.file(path.join(paths.designRoot, candidate)).exists()) continue;
2922
+ if (await fileForSlug(fileSlug(candidate))) continue;
2923
+ name = v.name;
2924
+ }
2925
+ if (!name) return { ok: false, status: 409, error: 'too many copies of this canvas' };
2926
+ const toRel = path.posix.join(dir, `${name}.tsx`);
2927
+ const toAbs = path.join(paths.designRoot, toRel);
2928
+ await Bun.write(toAbs, await Bun.file(fileAbs).arrayBuffer());
2929
+ const metaAbs = fileAbs.replace(/\.tsx$/i, '.meta.json');
2930
+ if (await Bun.file(metaAbs).exists()) {
2931
+ let text = await Bun.file(metaAbs).text();
2932
+ try {
2933
+ const meta = JSON.parse(text) as Record<string, unknown>;
2934
+ const now = new Date().toISOString();
2935
+ if (typeof meta.title !== 'string' || meta.title === base) meta.title = name;
2936
+ meta.created = now;
2937
+ meta.last_modified = now;
2938
+ text = `${JSON.stringify(meta, null, 2)}\n`;
2939
+ } catch {
2940
+ /* an unreadable meta is copied verbatim, like the source */
2941
+ }
2942
+ await Bun.write(toAbs.replace(/\.tsx$/i, '.meta.json'), text);
2943
+ }
2944
+ const slug = fileSlug(toRel);
2945
+ // The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
2946
+ const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
2947
+ if (await Bun.file(annotationsAbs).exists()) {
2948
+ await Bun.write(
2949
+ path.join(paths.designRoot, `${slug}.annotations.svg`),
2950
+ await Bun.file(annotationsAbs).arrayBuffer()
2951
+ );
2952
+ }
2953
+ ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
2954
+ ctx.bus.emit('canvas-created', { slug });
2955
+ return { ok: true, file: path.posix.join(paths.designRel, toRel), rel: toRel, slug };
2956
+ }
2957
+
2698
2958
  // Phase 22 — SOFT-delete a canvas (DELETE /_api/canvas). Same trust boundary as
2699
2959
  // createCanvas: main-origin-only, never the untrusted canvas iframe origin
2700
2960
  // (DDR-054). Destructive, so it MOVES the whole sidecar set to
@@ -2718,6 +2978,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2718
2978
 
2719
2979
  // Only a real `.tsx` canvas, no traversal.
2720
2980
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2981
+ if (
2982
+ !/\.tsx$/i.test(rel) &&
2983
+ isSupportingFileRel(rel) &&
2984
+ (await isRegularFile(path.join(paths.designRoot, rel)))
2985
+ ) {
2986
+ return deleteSupportingFile(rel);
2987
+ }
2721
2988
  if (!/\.tsx$/i.test(rel)) {
2722
2989
  // feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
2723
2990
  // a folder delete (dogfood gap: the tree offered no way to remove a
@@ -2827,6 +3094,170 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2827
3094
  // itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
2828
3095
  // is recoverable exactly like a single-canvas delete), then best-effort
2829
3096
  // removes whatever's left (empty subdirs, `.gitkeep` markers).
3097
+ // ── Plan T17/L03 — supporting files ──────────────────────────────────────
3098
+ // Notes, styles, data, images, media and fonts that live in a canvas folder
3099
+ // beside the canvases. They move, rename and delete as themselves. One that a
3100
+ // canvas, stylesheet or whiteboard still names is refused with the name of
3101
+ // the file that uses it: a moved or deleted asset must never leave a broken
3102
+ // image behind silently (L17).
3103
+ async function isRegularFile(abs: string): Promise<boolean> {
3104
+ try {
3105
+ return (await statp(abs)).isFile();
3106
+ } catch {
3107
+ return false;
3108
+ }
3109
+ }
3110
+
3111
+ async function supportingFileGuard(
3112
+ rel: string
3113
+ ): Promise<{ ok: true; abs: string } | { ok: false; status: number; error: string }> {
3114
+ if (rel.includes('..') || rel.startsWith('/') || isRuntimeStateRel(rel)) {
3115
+ return { ok: false, status: 400, error: 'invalid path' };
3116
+ }
3117
+ const abs = path.resolve(path.join(paths.designRoot, rel));
3118
+ const groups = cfg.canvasGroups.filter(
3119
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
3120
+ );
3121
+ const inGroup = groups.some((g) =>
3122
+ abs.startsWith(`${path.resolve(path.join(paths.designRoot, g.path))}${path.sep}`)
3123
+ );
3124
+ if (!inGroup) {
3125
+ return {
3126
+ ok: false,
3127
+ status: 400,
3128
+ error: 'only files inside a canvas folder can be changed here',
3129
+ };
3130
+ }
3131
+ if (!(await assertRealpathContained(path.dirname(abs)))) {
3132
+ return { ok: false, status: 400, error: 'path escapes the design root via a symlink' };
3133
+ }
3134
+ if (!(await isRegularFile(abs))) return { ok: false, status: 404, error: 'file not found' };
3135
+ return { ok: true, abs };
3136
+ }
3137
+
3138
+ /** The first project file (canvas, stylesheet, meta, whiteboard) that names `rel`'s file. */
3139
+ async function firstReferenceTo(rel: string): Promise<string | null> {
3140
+ const base = path.posix.basename(rel);
3141
+ const { readdir, readFile } = await import('node:fs/promises');
3142
+ const walk = async (dirAbs: string, depth: number): Promise<string | null> => {
3143
+ if (depth > 12) return null;
3144
+ let entries: import('node:fs').Dirent[];
3145
+ try {
3146
+ entries = await readdir(dirAbs, { withFileTypes: true });
3147
+ } catch {
3148
+ return null;
3149
+ }
3150
+ for (const e of entries) {
3151
+ if (e.name.startsWith('_') || e.name.startsWith('.') || e.name === 'node_modules') continue;
3152
+ const abs = path.join(dirAbs, e.name);
3153
+ if (e.isDirectory()) {
3154
+ const hit = await walk(abs, depth + 1);
3155
+ if (hit) return hit;
3156
+ } else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
3157
+ const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
3158
+ if (other === rel) continue;
3159
+ const text = await readFile(abs, 'utf8').catch(() => '');
3160
+ if (text.includes(base)) return other;
3161
+ }
3162
+ }
3163
+ return null;
3164
+ };
3165
+ return walk(paths.designRoot, 0);
3166
+ }
3167
+
3168
+ async function moveSupportingFile(
3169
+ rel: string,
3170
+ toDirRaw: unknown,
3171
+ toNameRaw: unknown
3172
+ ): Promise<MoveCanvasResult> {
3173
+ const g = await supportingFileGuard(rel);
3174
+ if (!g.ok) return g;
3175
+ const ext = path.posix.extname(rel);
3176
+ let base = path.posix.basename(rel);
3177
+ if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
3178
+ const stem = String(toNameRaw).replace(new RegExp(`${ext.replace('.', '\\.')}$`, 'i'), '');
3179
+ const v = validateCanvasName(stem);
3180
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3181
+ base = `${v.name}${ext}`;
3182
+ }
3183
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
3184
+ let toDir =
3185
+ typeof toDirRaw === 'string'
3186
+ ? toDirRaw.trim().replace(/^\/+|\/+$/g, '')
3187
+ : path.posix.dirname(rel);
3188
+ if (toDir === drPrefix || toDir === '.') toDir = '';
3189
+ else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
3190
+ const toRel = path.posix.join(toDir, base);
3191
+ if (toRel === rel)
3192
+ return { ok: false, status: 400, error: 'source and destination are the same' };
3193
+ const dest = await supportingFileGuard(toRel).then((d) =>
3194
+ d.ok
3195
+ ? { ok: false as const, status: 409, error: `a file named "${base}" already exists there` }
3196
+ : d
3197
+ );
3198
+ if (!dest.ok && dest.status !== 404) return dest;
3199
+ const toAbs = path.resolve(path.join(paths.designRoot, toRel));
3200
+ if (!(await isRegularFile(path.join(paths.designRoot, rel))))
3201
+ return { ok: false, status: 404, error: 'file not found' };
3202
+ const toDirAbs = path.dirname(toAbs);
3203
+ try {
3204
+ if (!(await statp(toDirAbs)).isDirectory()) throw new Error('not a folder');
3205
+ } catch {
3206
+ return { ok: false, status: 400, error: 'destination folder does not exist' };
3207
+ }
3208
+ const user = await firstReferenceTo(rel);
3209
+ if (user) {
3210
+ return {
3211
+ ok: false,
3212
+ status: 409,
3213
+ error: `${path.posix.basename(rel)} is used by ${user} — change that first`,
3214
+ };
3215
+ }
3216
+ await rename(g.abs, toAbs);
3217
+ ctx.bus.emit('canvas-list-update', {
3218
+ action: 'moved',
3219
+ rel: toRel,
3220
+ slug: fileSlug(toRel),
3221
+ fromRel: rel,
3222
+ fromSlug: fileSlug(rel),
3223
+ });
3224
+ return {
3225
+ ok: true,
3226
+ fromRel: rel,
3227
+ toRel,
3228
+ fromSlug: fileSlug(rel),
3229
+ toSlug: fileSlug(toRel),
3230
+ moved: [toRel],
3231
+ };
3232
+ }
3233
+
3234
+ async function deleteSupportingFile(rel: string): Promise<DeleteCanvasResult> {
3235
+ const g = await supportingFileGuard(rel);
3236
+ if (!g.ok) return g;
3237
+ const user = await firstReferenceTo(rel);
3238
+ if (user) {
3239
+ return {
3240
+ ok: false,
3241
+ status: 409,
3242
+ error: `${path.posix.basename(rel)} is used by ${user} — remove it there first`,
3243
+ };
3244
+ }
3245
+ const slug = fileSlug(rel);
3246
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3247
+ const trashDir = path.join(paths.designRoot, '_trash', `${stamp}__${slug}`);
3248
+ await mkdir(trashDir, { recursive: true });
3249
+ const trashed = path.join(trashDir, path.posix.basename(rel));
3250
+ await rename(g.abs, trashed);
3251
+ ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
3252
+ return {
3253
+ ok: true,
3254
+ rel,
3255
+ slug,
3256
+ trashed: [path.relative(paths.repoRoot, trashed)],
3257
+ trashDir: path.relative(paths.repoRoot, trashDir),
3258
+ };
3259
+ }
3260
+
2830
3261
  async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
2831
3262
  if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2832
3263
  const dirAbs = path.join(paths.designRoot, relDir);
@@ -2883,6 +3314,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2883
3314
  p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
2884
3315
  );
2885
3316
 
3317
+ // ONE project action for the whole folder (accepted-revisions mode): the
3318
+ // hub deletes every canvas under it and the folder entry together, so a
3319
+ // peer never sees half a folder, and history shows one step.
3320
+ const folderAction = await hooks.proposeFolder?.({
3321
+ op: 'dir.delete',
3322
+ path: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
3323
+ });
3324
+ if (folderAction?.status === 'rejected') {
3325
+ return {
3326
+ ok: false,
3327
+ status: 409,
3328
+ error: `the project did not accept deleting this folder (${folderAction.code ?? 'rejected'})`,
3329
+ };
3330
+ }
3331
+
2886
3332
  const trashed: string[] = [];
2887
3333
  let lastTrashDir = '';
2888
3334
  for (const r of canvasRels) {
@@ -2946,6 +3392,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2946
3392
  return realTarget === realRoot || realTarget.startsWith(`${realRoot}${path.sep}`);
2947
3393
  }
2948
3394
 
3395
+ /**
3396
+ * Re-root a moved canvas's relative imports for its new folder depth
3397
+ * (issue #114). `fromDir` / `toDir` are POSIX, design-root-relative.
3398
+ *
3399
+ * Runs AFTER the rename, on the file at its destination. Best-effort by
3400
+ * design: the move has already committed, and failing it here would leave a
3401
+ * half-moved tree — strictly worse than the un-rewritten imports that were
3402
+ * the status quo. A no-op rewrite writes nothing.
3403
+ */
3404
+ async function rewriteCanvasImports(abs: string, fromDir: string, toDir: string): Promise<void> {
3405
+ if (fromDir === toDir) return;
3406
+ try {
3407
+ // NEVER DEREFERENCE A SYMLINK HERE. The move's containment checks cover
3408
+ // the source *directory* (`assertRealpathContained(path.dirname(...))`)
3409
+ // and the destination, but never the source FILE — which was inert while
3410
+ // the move was a bare `rename()`, because renaming a symlink moves the
3411
+ // LINK and no out-of-root byte is ever touched. This function is the
3412
+ // first code on the path that reads and writes the file's contents, so
3413
+ // without this guard a planted `ui/evil.tsx -> /elsewhere/secrets.ts`
3414
+ // would turn an ordinary drag-and-drop into an out-of-root read AND an
3415
+ // out-of-root write. `lstat` does not follow; a link is skipped, not
3416
+ // repaired — its target is not ours to rewrite.
3417
+ const st = await lstat(abs);
3418
+ if (!st.isFile()) return;
3419
+ const source = await readFile(abs, 'utf8');
3420
+ const next = rewriteRelativeImports(source, fromDir, toDir);
3421
+ // atomicWrite, not `writeFile` — this lands on a `.tsx` the fs-mirror is
3422
+ // watching, so a truncate-then-write can surface a partial-content watch
3423
+ // event (the exact hazard atomic-write.ts was built for), and a crash
3424
+ // mid-write would leave a truncated canvas.
3425
+ if (next !== source) atomicWrite(abs, next);
3426
+ } catch (err) {
3427
+ console.warn(
3428
+ `[move] could not rewrite relative imports in ${abs}: ${(err as Error).message}`
3429
+ );
3430
+ }
3431
+ }
3432
+
2949
3433
  // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
2950
3434
  // full artifact set (POST /_api/fs-move). Same main-origin-only trust
2951
3435
  // boundary as createCanvas/deleteCanvas (DDR-054): never reachable from the
@@ -2954,7 +3438,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2954
3438
  // never wrong about where the canvas lives) and every relocation is logged
2955
3439
  // to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
2956
3440
  // (Task 14) for the accepted-limitation writeup.
2957
- async function moveCanvas(input: { file?: unknown; toDir?: unknown }): Promise<MoveCanvasResult> {
3441
+ async function moveCanvas(input: {
3442
+ file?: unknown;
3443
+ toDir?: unknown;
3444
+ toName?: unknown;
3445
+ }): Promise<MoveCanvasResult> {
2958
3446
  const raw = input?.file;
2959
3447
  if (typeof raw !== 'string' || !raw.trim()) {
2960
3448
  return { ok: false, status: 400, error: 'file is required' };
@@ -2970,17 +3458,38 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2970
3458
  if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
2971
3459
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2972
3460
  if (!/\.tsx$/i.test(rel)) {
3461
+ // Plan T17/L03 — a supporting file (notes, styles, images, media in a
3462
+ // canvas folder) moves or renames as itself, never as a "folder".
3463
+ if (isSupportingFileRel(rel) && (await isRegularFile(path.join(paths.designRoot, rel)))) {
3464
+ return moveSupportingFile(rel, input?.toDir, input?.toName);
3465
+ }
2973
3466
  // feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
2974
3467
  // folder move (dragging a folder onto a folder). moveFolder does its own
2975
3468
  // existence + containment validation, so an invalid path still reports
2976
3469
  // the right error from there.
2977
- return moveFolder(rel, input?.toDir);
2978
- }
2979
-
2980
- if (typeof input?.toDir !== 'string') {
3470
+ return moveFolder(rel, input?.toDir, input?.toName);
3471
+ }
3472
+
3473
+ // A rename is a move in place under a new name (`toName`, same allowlist as
3474
+ // a new canvas's name); `toDir` defaults to the canvas's own folder then.
3475
+ let newBase: string | null = null;
3476
+ if (input?.toName !== undefined && input.toName !== null && input.toName !== '') {
3477
+ const v = validateCanvasName(input.toName);
3478
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3479
+ newBase = `${v.name}.tsx`;
3480
+ }
3481
+ const toDirInput =
3482
+ typeof input?.toDir === 'string'
3483
+ ? input.toDir
3484
+ : newBase
3485
+ ? path.posix.dirname(rel) === '.'
3486
+ ? ''
3487
+ : path.posix.dirname(rel)
3488
+ : null;
3489
+ if (typeof toDirInput !== 'string') {
2981
3490
  return { ok: false, status: 400, error: 'toDir is required' };
2982
3491
  }
2983
- let toDir = input.toDir.trim().replace(/^\/+|\/+$/g, '');
3492
+ let toDir = toDirInput.trim().replace(/^\/+|\/+$/g, '');
2984
3493
  try {
2985
3494
  toDir = decodeURIComponent(toDir);
2986
3495
  } catch {
@@ -3048,7 +3557,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3048
3557
  return { ok: false, status: 404, error: 'canvas not found' };
3049
3558
  }
3050
3559
 
3051
- const base = path.basename(rel);
3560
+ const base = newBase ?? path.basename(rel);
3052
3561
  const toRel = path.posix.join(toDir, base);
3053
3562
  if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
3054
3563
  return { ok: false, status: 400, error: 'source and destination are the same' };
@@ -3124,6 +3633,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3124
3633
  return { ok: false, status: 500, error: err instanceof Error ? err.message : 'move failed' };
3125
3634
  }
3126
3635
 
3636
+ // The canvas is at a new depth now, so its relative specifiers point at the
3637
+ // wrong place (issue #114). Bytes used to move without them, and the canvas
3638
+ // 500s on the next build with `Could not resolve`. Best-effort: the move
3639
+ // itself already succeeded, and a rewrite failure must not leave the tree
3640
+ // half-moved — it leaves a build error the user can fix by hand, which is
3641
+ // exactly where they were before this existed.
3642
+ await rewriteCanvasImports(toAbs, path.posix.dirname(rel), path.posix.dirname(toRel));
3643
+
3127
3644
  const moved: string[] = [path.relative(paths.repoRoot, toAbs)];
3128
3645
  for (const artifact of canvasArtifacts({ rel, paths })) {
3129
3646
  if (artifact.kind === 'primary') continue; // already moved above
@@ -3208,7 +3725,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3208
3725
  // nested canvas at once (they live inside the moved directory); only the
3209
3726
  // slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
3210
3727
  // folder structure) need their own per-canvas relocation afterward.
3211
- async function moveFolder(relDir: string, toDirRaw: unknown): Promise<MoveCanvasResult> {
3728
+ async function moveFolder(
3729
+ relDir: string,
3730
+ toDirRaw: unknown,
3731
+ toNameRaw?: unknown
3732
+ ): Promise<MoveCanvasResult> {
3212
3733
  if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
3213
3734
  const dirAbs = path.join(paths.designRoot, relDir);
3214
3735
  const resolvedDesignRoot = path.resolve(paths.designRoot);
@@ -3284,7 +3805,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3284
3805
  return { ok: false, status: 400, error: 'cannot move a folder into itself' };
3285
3806
  }
3286
3807
 
3287
- const base = path.basename(relDir);
3808
+ // A RENAME is a move to a new name in the same (or another) parent — one
3809
+ // manifest action (dir.move), the same as any folder move (plan T17).
3810
+ let base = path.basename(relDir);
3811
+ if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
3812
+ const v = validateFolderName(toNameRaw);
3813
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3814
+ base = v.name;
3815
+ }
3288
3816
  const toRelDir = path.posix.join(toDir, base);
3289
3817
  if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
3290
3818
  return { ok: false, status: 400, error: 'source and destination are the same' };
@@ -3338,6 +3866,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3338
3866
  }
3339
3867
  }
3340
3868
 
3869
+ // ONE project action for the whole folder (accepted-revisions mode) —
3870
+ // canvases, empty sub-folders and all. The per-canvas retire below then
3871
+ // only lets go locally; it proposes nothing of its own.
3872
+ const folderMove = await hooks.proposeFolder?.({
3873
+ op: 'dir.move',
3874
+ from: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
3875
+ to: toRelDir,
3876
+ });
3877
+ if (folderMove?.status === 'rejected') {
3878
+ return {
3879
+ ok: false,
3880
+ status: 409,
3881
+ error: `the project did not accept this move (${folderMove.code ?? 'rejected'})`,
3882
+ };
3883
+ }
3884
+
3341
3885
  // Collab guard for every canvas found, BEFORE any disk mutation. Same
3342
3886
  // coordinated retire as moveCanvas — refuse only when a pinned room could
3343
3887
  // not be retired, and retire even unpinned synced documents so none of
@@ -3371,6 +3915,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3371
3915
  const toRel = toRelDir + fromRel.slice(relDir.length);
3372
3916
  const fromSlug = fileSlug(fromRel);
3373
3917
  const toSlug = fileSlug(toRel);
3918
+ // Same depth change as moveCanvas, once per nested canvas (issue #114).
3919
+ // A folder move relocates every canvas inside it by the same delta, but
3920
+ // each one's own dir differs, so the rewrite is per file, not per folder.
3921
+ await rewriteCanvasImports(
3922
+ path.join(paths.designRoot, toRel),
3923
+ path.posix.dirname(fromRel),
3924
+ path.posix.dirname(toRel)
3925
+ );
3374
3926
  for (const artifact of canvasArtifacts({ rel: fromRel, paths })) {
3375
3927
  if (artifact.kind !== 'slug-keyed') continue; // primary/siblings already moved with the dir
3376
3928
  const dest = relocatedName(artifact, fromRel, toRel, paths);
@@ -3499,6 +4051,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3499
4051
  /* good — doesn't exist yet */
3500
4052
  }
3501
4053
 
4054
+ const folderCreate = await hooks.proposeFolder?.({
4055
+ op: 'dir.create',
4056
+ path: path.posix.join(parent, v.name),
4057
+ });
4058
+ if (folderCreate?.status === 'rejected') {
4059
+ return {
4060
+ ok: false,
4061
+ status: 409,
4062
+ error: `the project did not accept this folder (${folderCreate.code ?? 'rejected'})`,
4063
+ };
4064
+ }
4065
+
3502
4066
  await mkdir(dirAbs, { recursive: true });
3503
4067
  await Bun.write(path.join(dirAbs, '.gitkeep'), '');
3504
4068
 
@@ -3576,10 +4140,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3576
4140
  // suppression BEFORE the write so it catches the debounced fs:any, disarm on
3577
4141
  // a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
3578
4142
  // genuine agent edit's rim.
4143
+ /** T24/T25 — say what a structural edit IS (sync/source-ops), for replay. */
4144
+ function announceOp(
4145
+ abs: string,
4146
+ rel: string,
4147
+ before: string,
4148
+ op: Parameters<typeof describeSourceOp>[2]
4149
+ ): void {
4150
+ const described = describeSourceOp(abs, before, op);
4151
+ if (described) ctx.bus.emit('source-op', { rel, op: described });
4152
+ }
4153
+
3579
4154
  async function suppressedEdit(
3580
4155
  abs: string,
3581
- run: () => Promise<{ delta: number; changed?: boolean }>,
3582
- errLabel: string
4156
+ run: () => Promise<{ delta: number; changed?: boolean; previous?: AttributeState }>,
4157
+ errLabel: string,
4158
+ describe?: Parameters<typeof describeSourceOp>[2]
3583
4159
  ): Promise<EditOpResult> {
3584
4160
  const rel = path.relative(paths.designRoot, abs);
3585
4161
  ctx.bus.emit('activity:suppress', rel);
@@ -3588,18 +4164,33 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3588
4164
  // whole-file undo log (the Timeline replace-src path rides this; other
3589
4165
  // inspector edits get an undoable seq for free).
3590
4166
  const before = await Bun.file(abs).text();
4167
+ // T24 — what this edit IS, so a lost race re-applies it instead of
4168
+ // turning "we both picked a colour" into a conflict.
4169
+ if (describe) {
4170
+ const op = describeSourceOp(abs, before, describe);
4171
+ if (op) ctx.bus.emit('source-op', { rel, op });
4172
+ }
3591
4173
  const res = await run();
4174
+ const previous =
4175
+ res.previous?.kind === 'absent'
4176
+ ? { previous: null }
4177
+ : res.previous?.kind === 'literal'
4178
+ ? { previous: res.previous.value }
4179
+ : {};
3592
4180
  // `changed === false` = the op collapsed to a no-op and nothing hit disk —
3593
4181
  // NOT `delta === 0`, which an equal-length replacement also produces.
3594
4182
  if (res.changed === false) {
3595
4183
  ctx.bus.emit('activity:unsuppress', rel);
3596
- return { ok: true, delta: res.delta };
4184
+ return { ok: true, delta: res.delta, ...previous };
3597
4185
  }
3598
4186
  const after = await Bun.file(abs).text();
3599
4187
  const seq = after !== before ? logUndo(abs, before, after) : undefined;
3600
- return { ok: true, delta: res.delta, seq };
4188
+ return { ok: true, delta: res.delta, seq, ...previous };
3601
4189
  } catch (err) {
3602
4190
  ctx.bus.emit('activity:unsuppress', rel);
4191
+ if (err instanceof CanvasEditError && err.conflict) {
4192
+ return { ok: false, status: 409, error: err.message, conflict: true };
4193
+ }
3603
4194
  return {
3604
4195
  ok: false,
3605
4196
  status: 422,
@@ -3615,9 +4206,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3615
4206
  value?: unknown;
3616
4207
  reset?: unknown;
3617
4208
  idIndex?: unknown;
4209
+ expected?: unknown;
3618
4210
  }): Promise<EditOpResult> {
3619
4211
  const r = resolveCanvasAbs(input.canvas);
3620
4212
  if (!r.ok) return r;
4213
+ const pre = expectedValueOf(input);
4214
+ if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
3621
4215
  const id = typeof input.id === 'string' ? input.id.trim() : '';
3622
4216
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3623
4217
  // Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
@@ -3642,8 +4236,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3642
4236
  if (input.reset === true) {
3643
4237
  return suppressedEdit(
3644
4238
  r.abs,
3645
- () => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
3646
- 'reset failed'
4239
+ () =>
4240
+ removeAttribute(
4241
+ r.abs,
4242
+ id,
4243
+ `style.${camel}`,
4244
+ idIndex,
4245
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
4246
+ ),
4247
+ 'reset failed',
4248
+ {
4249
+ kind: 'remove',
4250
+ id,
4251
+ attr: `style.${camel}`,
4252
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4253
+ }
3647
4254
  );
3648
4255
  }
3649
4256
  const value = typeof input.value === 'string' ? input.value : '';
@@ -3655,8 +4262,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3655
4262
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
3656
4263
  return suppressedEdit(
3657
4264
  r.abs,
3658
- () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
3659
- 'edit failed'
4265
+ () =>
4266
+ editAttribute(
4267
+ r.abs,
4268
+ id,
4269
+ `style.${camel}`,
4270
+ JSON.stringify(value),
4271
+ idIndex,
4272
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
4273
+ ),
4274
+ 'edit failed',
4275
+ {
4276
+ kind: 'set',
4277
+ id,
4278
+ attr: `style.${camel}`,
4279
+ value: JSON.stringify(value),
4280
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4281
+ }
3660
4282
  );
3661
4283
  }
3662
4284
 
@@ -3689,7 +4311,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3689
4311
  return suppressedEdit(
3690
4312
  r.abs,
3691
4313
  () => runEditText(r.abs, id, text, { occurrence, before }),
3692
- 'edit failed'
4314
+ 'edit failed',
4315
+ { kind: 'text', id, text, ...(occurrence !== undefined ? { occurrence } : {}) }
3693
4316
  );
3694
4317
  }
3695
4318
 
@@ -3699,9 +4322,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3699
4322
  attr?: unknown;
3700
4323
  value?: unknown;
3701
4324
  reset?: unknown;
4325
+ expected?: unknown;
3702
4326
  }): Promise<EditOpResult> {
3703
4327
  const r = resolveCanvasAbs(input.canvas);
3704
4328
  if (!r.ok) return r;
4329
+ const pre = expectedValueOf(input);
4330
+ if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
3705
4331
  const id = typeof input.id === 'string' ? input.id.trim() : '';
3706
4332
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3707
4333
  const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
@@ -3719,7 +4345,19 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3719
4345
  }
3720
4346
  // Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
3721
4347
  if (input.reset === true) {
3722
- return suppressedEdit(r.abs, () => removeAttribute(r.abs, id, attr), 'reset failed');
4348
+ return suppressedEdit(
4349
+ r.abs,
4350
+ () =>
4351
+ removeAttribute(
4352
+ r.abs,
4353
+ id,
4354
+ attr,
4355
+ undefined,
4356
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
4357
+ ),
4358
+ 'reset failed',
4359
+ { kind: 'remove', id, attr }
4360
+ );
3723
4361
  }
3724
4362
  const value = typeof input.value === 'string' ? input.value : '';
3725
4363
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
@@ -3740,7 +4378,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3740
4378
  // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
3741
4379
  // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
3742
4380
  // the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
3743
- return suppressedEdit(r.abs, () => editAttribute(r.abs, id, attr, value), 'edit failed');
4381
+ return suppressedEdit(
4382
+ r.abs,
4383
+ () =>
4384
+ editAttribute(
4385
+ r.abs,
4386
+ id,
4387
+ attr,
4388
+ value,
4389
+ undefined,
4390
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
4391
+ ),
4392
+ 'edit failed',
4393
+ { kind: 'set', id, attr, value }
4394
+ );
3744
4395
  }
3745
4396
 
3746
4397
  // Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
@@ -3787,6 +4438,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3787
4438
  ctx.bus.emit('activity:suppress', rel);
3788
4439
  try {
3789
4440
  const before = await Bun.file(r.abs).text();
4441
+ announceOp(r.abs, rel, before, {
4442
+ kind: 'move',
4443
+ id,
4444
+ refId,
4445
+ position: String(position),
4446
+ ...(idIndex !== undefined ? { idIndex } : {}),
4447
+ ...(refIndex !== undefined ? { refIndex } : {}),
4448
+ });
3790
4449
  const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
3791
4450
  const after = await Bun.file(r.abs).text();
3792
4451
  if (after === before) {
@@ -4202,6 +4861,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4202
4861
  ctx.bus.emit('activity:suppress', rel);
4203
4862
  try {
4204
4863
  const before = await Bun.file(r.abs).text();
4864
+ announceOp(r.abs, rel, before, {
4865
+ kind: 'delete',
4866
+ id,
4867
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4868
+ });
4205
4869
  const res = await deleteElement(r.abs, id, idIndex);
4206
4870
  const after = await Bun.file(r.abs).text();
4207
4871
  if (after === before) {
@@ -4667,6 +5331,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4667
5331
  ctx.bus.emit('activity:suppress', rel);
4668
5332
  try {
4669
5333
  const before = await Bun.file(r.abs).text();
5334
+ announceOp(r.abs, rel, before, {
5335
+ kind: 'artboard',
5336
+ fn: 'resize',
5337
+ artboardId,
5338
+ args: [width, height],
5339
+ });
4670
5340
  await resizeArtboard(r.abs, artboardId, width, height);
4671
5341
  const after = await Bun.file(r.abs).text();
4672
5342
  if (after === before) {
@@ -4714,6 +5384,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4714
5384
  ctx.bus.emit('activity:suppress', rel);
4715
5385
  try {
4716
5386
  const before = await Bun.file(r.abs).text();
5387
+ announceOp(r.abs, rel, before, {
5388
+ kind: 'artboard',
5389
+ fn: 'hug',
5390
+ artboardId,
5391
+ args: [fixed, freezeHeight],
5392
+ });
4717
5393
  await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
4718
5394
  const after = await Bun.file(r.abs).text();
4719
5395
  if (after === before) {
@@ -4763,6 +5439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4763
5439
  ctx.bus.emit('activity:suppress', rel);
4764
5440
  try {
4765
5441
  const before = await Bun.file(r.abs).text();
5442
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'kind', artboardId, args: [kind] });
4766
5443
  await setArtboardKind(r.abs, artboardId, kind);
4767
5444
  const after = await Bun.file(r.abs).text();
4768
5445
  if (after === before) {
@@ -4785,6 +5462,50 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4785
5462
  }
4786
5463
  }
4787
5464
 
5465
+ /** Rename an artboard (plan T25/L08 — double-click its name). */
5466
+ async function setArtboardLabelOp(input: {
5467
+ canvas?: unknown;
5468
+ artboardId?: unknown;
5469
+ label?: unknown;
5470
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
5471
+ const r = resolveCanvasAbs(input.canvas);
5472
+ if (!r.ok) return r;
5473
+ if (!takeStructuralToken()) return RATE_LIMITED;
5474
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
5475
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
5476
+ return { ok: false, status: 400, error: 'invalid artboard id' };
5477
+ }
5478
+ if (typeof input.label !== 'string' || !input.label.trim()) {
5479
+ return { ok: false, status: 400, error: 'name is required' };
5480
+ }
5481
+ const label = input.label;
5482
+ const rel = path.relative(paths.designRoot, r.abs);
5483
+ ctx.bus.emit('activity:suppress', rel);
5484
+ try {
5485
+ const before = await Bun.file(r.abs).text();
5486
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'label', artboardId, args: [label] });
5487
+ await setArtboardLabel(r.abs, artboardId, label);
5488
+ const after = await Bun.file(r.abs).text();
5489
+ if (after === before) {
5490
+ ctx.bus.emit('activity:unsuppress', rel);
5491
+ return { ok: true };
5492
+ }
5493
+ try {
5494
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-label');
5495
+ } catch {
5496
+ /* snapshot best-effort */
5497
+ }
5498
+ return { ok: true, seq: logUndo(r.abs, before, after) };
5499
+ } catch (err) {
5500
+ ctx.bus.emit('activity:unsuppress', rel);
5501
+ return {
5502
+ ok: false,
5503
+ status: err instanceof CanvasEditError ? 422 : 500,
5504
+ error: err instanceof Error ? err.message : 'set-artboard-label failed',
5505
+ };
5506
+ }
5507
+ }
5508
+
4788
5509
  const MAX_GUIDES_JSON_BYTES = 4096;
4789
5510
 
4790
5511
  /** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
@@ -4818,6 +5539,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4818
5539
  ctx.bus.emit('activity:suppress', rel);
4819
5540
  try {
4820
5541
  const before = await Bun.file(r.abs).text();
5542
+ announceOp(r.abs, rel, before, {
5543
+ kind: 'artboard',
5544
+ fn: 'guides',
5545
+ artboardId,
5546
+ args: [guides],
5547
+ });
4821
5548
  await setArtboardGuides(r.abs, artboardId, guides);
4822
5549
  const after = await Bun.file(r.abs).text();
4823
5550
  if (after === before) {
@@ -4994,6 +5721,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4994
5721
  ctx.bus.emit('activity:suppress', rel);
4995
5722
  try {
4996
5723
  const before = await Bun.file(r.abs).text();
5724
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'style', artboardId, args: [patch] });
4997
5725
  await setArtboardStyle(r.abs, artboardId, patch);
4998
5726
  const after = await Bun.file(r.abs).text();
4999
5727
  if (after === before) {
@@ -5038,6 +5766,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5038
5766
  ctx.bus.emit('activity:unsuppress', rel);
5039
5767
  return { ok: false, status: 413, error: 'canvas source too large to grow' };
5040
5768
  }
5769
+ announceOp(r.abs, rel, before, {
5770
+ kind: 'duplicate',
5771
+ id,
5772
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
5773
+ });
5041
5774
  const res = await duplicateElement(r.abs, id, idIndex);
5042
5775
  const after = await Bun.file(r.abs).text();
5043
5776
  if (after === before) {
@@ -5363,6 +6096,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5363
6096
  const current = await Bun.file(r.abs).text();
5364
6097
  const expect = dir === 'undo' ? entry.after : entry.before;
5365
6098
  const write = dir === 'undo' ? entry.before : entry.after;
6099
+ // T26 — a project that saves through accepted revisions undoes the
6100
+ // ACTION this edit became: effect-aware, so a teammate's later change
6101
+ // elsewhere in the canvas neither blocks the undo nor gets reverted
6102
+ // with it (a whole-file swap could do only one of those).
6103
+ const runtime = ctx.syncControl?.current?.();
6104
+ if (runtime?.acceptedMode?.()) {
6105
+ const rel = path.relative(paths.repoRoot, r.abs);
6106
+ // Undo targets the edit's action; redo targets the undo that
6107
+ // reverted it (reverting a revert re-applies, effect-aware again).
6108
+ const actionId =
6109
+ dir === 'undo'
6110
+ ? (runtime.acceptedActionForContent?.(rel, entry.after) ?? null)
6111
+ : (entry.undoActionId ?? null);
6112
+ if (actionId) {
6113
+ const res = await runtime.acceptedUndo?.(actionId, dir === 'redo');
6114
+ if (res?.status === 'accepted' || res?.queued) {
6115
+ if (dir === 'undo') entry.undoActionId = res.actionId;
6116
+ else delete entry.undoActionId;
6117
+ return { ok: true, dir };
6118
+ }
6119
+ return {
6120
+ ok: false,
6121
+ status: 409,
6122
+ error:
6123
+ res?.code === 'base-conflict'
6124
+ ? 'someone changed the same thing since — undo skipped'
6125
+ : `undo refused (${res?.code ?? 'unknown'})`,
6126
+ };
6127
+ }
6128
+ }
5366
6129
  if (current !== expect) {
5367
6130
  return {
5368
6131
  ok: false,
@@ -5797,6 +6560,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5797
6560
  patchCanvasMeta,
5798
6561
  loadAnnotations,
5799
6562
  saveAnnotations,
6563
+ projectAnnotations,
5800
6564
  saveAsset,
5801
6565
  listAssets,
5802
6566
  readAssetBytes,
@@ -5808,6 +6572,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5808
6572
  saveChatAttachment,
5809
6573
  resolveChatAttachment,
5810
6574
  createCanvas,
6575
+ duplicateCanvas,
5811
6576
  deleteCanvas,
5812
6577
  moveCanvas,
5813
6578
  createFolder,
@@ -5832,6 +6597,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5832
6597
  setArtboardHugOp,
5833
6598
  setArtboardStyleOp,
5834
6599
  setArtboardKindOp,
6600
+ setArtboardLabelOp,
5835
6601
  setArtboardGuidesOp,
5836
6602
  setArtboardPrintOp,
5837
6603
  deleteArtboardOp,