@1agh/maude 0.28.1 → 0.30.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 (372) hide show
  1. package/README.md +3 -3
  2. package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -2
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +681 -151
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +4537 -0
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/{plugins/design/dev-server → apps/studio}/api.ts +616 -70
  11. package/{plugins/design/dev-server → apps/studio}/artboard-activity-overlay.tsx +1 -1
  12. package/{plugins/design/dev-server → apps/studio}/artboard-marquee.tsx +1 -1
  13. package/apps/studio/bin/_motion-sample-playwright.mjs +129 -0
  14. package/{plugins/design/dev-server → apps/studio}/bin/_png-playwright.mjs +1 -1
  15. package/apps/studio/bin/annotate.mjs +732 -0
  16. package/apps/studio/bin/annotate.sh +17 -0
  17. package/{plugins/design/dev-server → apps/studio}/bin/canvas-edit.sh +1 -1
  18. package/{plugins/design/dev-server → apps/studio}/bin/draw-proof.sh +37 -2
  19. package/{plugins/design/dev-server → apps/studio}/bin/preflight.sh +1 -1
  20. package/apps/studio/bin/read-annotations.mjs +573 -0
  21. package/apps/studio/bin/read-annotations.sh +11 -0
  22. package/{plugins/design/dev-server → apps/studio}/bin/runtime-health.sh +1 -1
  23. package/{plugins/design/dev-server → apps/studio}/bin/screenshot.sh +62 -2
  24. package/{plugins/design/dev-server → apps/studio}/bin/server-up.sh +9 -3
  25. package/apps/studio/bin/to-lottie-verify.html +68 -0
  26. package/apps/studio/bin/to-lottie.sh +128 -0
  27. package/{plugins/design/dev-server → apps/studio}/build.ts +6 -4
  28. package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +79 -10
  29. package/{plugins/design/dev-server → apps/studio}/canvas-comment-mount.tsx +1 -1
  30. package/apps/studio/canvas-create.ts +104 -0
  31. package/{plugins/design/dev-server → apps/studio}/canvas-cursors.ts +48 -12
  32. package/{plugins/design/dev-server → apps/studio}/canvas-edit.ts +257 -7
  33. package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +165 -1
  34. package/{plugins/design/dev-server → apps/studio}/canvas-lib-inline.ts +1 -1
  35. package/{plugins/design/dev-server → apps/studio}/canvas-lib-resolver.ts +1 -1
  36. package/{plugins/design/dev-server → apps/studio}/canvas-lib.tsx +31 -12
  37. package/{plugins/design/dev-server → apps/studio}/canvas-shell.tsx +360 -48
  38. package/apps/studio/client/app.jsx +6742 -0
  39. package/apps/studio/client/canvas-url.js +81 -0
  40. package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
  41. package/apps/studio/client/index.html +16 -0
  42. package/apps/studio/client/styles/1-tokens-maude.css +296 -0
  43. package/apps/studio/client/styles/3-shell-maude.css +1161 -0
  44. package/{plugins/design/dev-server → apps/studio}/client/styles/3-shell.css +129 -0
  45. package/apps/studio/client/styles/4-components-maude.css +74 -0
  46. package/{plugins/design/dev-server → apps/studio}/client/styles/4-components.css +396 -34
  47. package/apps/studio/client/styles/5-maude-overrides.css +137 -0
  48. package/{plugins/design/dev-server → apps/studio}/client/styles/_index.css +9 -0
  49. package/{plugins/design/dev-server → apps/studio}/client/styles.css +1 -1
  50. package/apps/studio/client/tour/overlay.jsx +279 -0
  51. package/apps/studio/client/tour/usage-tour.js +48 -0
  52. package/apps/studio/client/whats-new-seen.js +52 -0
  53. package/apps/studio/client/whats-new.jsx +250 -0
  54. package/{plugins/design/dev-server → apps/studio}/commands/annotation-strokes-command.ts +2 -2
  55. package/{plugins/design/dev-server → apps/studio}/commands/equal-spacing-command.ts +1 -1
  56. package/{plugins/design/dev-server → apps/studio}/commands/move-artboards-command.ts +2 -2
  57. package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +32 -11
  58. package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +5 -4
  59. package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +18 -7
  60. package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +21 -21
  61. package/apps/studio/dist/client.bundle.js +25545 -0
  62. package/apps/studio/dist/comment-mount.js +2046 -0
  63. package/apps/studio/dist/styles.css +8018 -0
  64. package/apps/studio/dom-selection.ts +342 -0
  65. package/apps/studio/draw/animate.ts +333 -0
  66. package/{plugins/design/dev-server → apps/studio}/draw/brush.ts +1 -1
  67. package/{plugins/design/dev-server → apps/studio}/draw/composition.ts +1 -1
  68. package/{plugins/design/dev-server → apps/studio}/draw/geometry.ts +1 -1
  69. package/{plugins/design/dev-server → apps/studio}/draw/index.ts +4 -1
  70. package/{plugins/design/dev-server → apps/studio}/draw/layout.ts +1 -1
  71. package/apps/studio/draw/morph.ts +204 -0
  72. package/{plugins/design/dev-server → apps/studio}/draw/optimize.ts +1 -1
  73. package/{plugins/design/dev-server → apps/studio}/draw/palette.ts +1 -1
  74. package/{plugins/design/dev-server → apps/studio}/draw/primitives.ts +1 -1
  75. package/apps/studio/draw/serialize-animate.ts +324 -0
  76. package/{plugins/design/dev-server → apps/studio}/draw/serialize.ts +9 -3
  77. package/apps/studio/draw/test/animate.test.ts +172 -0
  78. package/apps/studio/draw/test/morph.test.ts +107 -0
  79. package/apps/studio/draw/test/serialize-animate.test.ts +142 -0
  80. package/{plugins/design/dev-server → apps/studio}/equal-spacing-detector.ts +1 -1
  81. package/{plugins/design/dev-server → apps/studio}/equal-spacing-handles.tsx +7 -29
  82. package/{plugins/design/dev-server → apps/studio}/export-dialog.tsx +16 -4
  83. package/{plugins/design/dev-server → apps/studio}/history.ts +20 -3
  84. package/{plugins/design/dev-server → apps/studio}/http.ts +226 -6
  85. package/{plugins/design/dev-server → apps/studio}/input-router.tsx +15 -1
  86. package/{plugins/design/dev-server → apps/studio}/inspect.ts +50 -4
  87. package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
  88. package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +2 -8
  89. package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
  90. package/{plugins/design/dev-server → apps/studio}/server.ts +23 -2
  91. package/{plugins/design/dev-server → apps/studio}/sync/agent.ts +231 -57
  92. package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +45 -0
  93. package/apps/studio/sync/cold-start.ts +158 -0
  94. package/{plugins/design/dev-server → apps/studio}/sync/connection-state.ts +58 -2
  95. package/{plugins/design/dev-server → apps/studio}/sync/index.ts +563 -221
  96. package/apps/studio/sync/journal.ts +190 -0
  97. package/apps/studio/sync/migrate-seed.ts +321 -0
  98. package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +22 -3
  99. package/{plugins/design/dev-server → apps/studio}/sync/status.ts +15 -4
  100. package/{plugins/design/dev-server → apps/studio}/test/activity.test.ts +1 -7
  101. package/apps/studio/test/annotate-write.test.ts +184 -0
  102. package/apps/studio/test/annotations-align.test.ts +88 -0
  103. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  104. package/apps/studio/test/annotations-groups.test.ts +231 -0
  105. package/{plugins/design/dev-server → apps/studio}/test/annotations-layer.test.ts +63 -0
  106. package/{plugins/design/dev-server → apps/studio}/test/annotations-roundtrip.test.ts +97 -0
  107. package/apps/studio/test/annotations-snap.test.ts +79 -0
  108. package/apps/studio/test/annotations-text-format.test.ts +396 -0
  109. package/apps/studio/test/asset-api.test.ts +149 -0
  110. package/apps/studio/test/canvas-create-api.test.ts +361 -0
  111. package/{plugins/design/dev-server → apps/studio}/test/canvas-cursors.test.ts +2 -0
  112. package/apps/studio/test/canvas-edit.test.ts +319 -0
  113. package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-resolver.test.ts +1 -1
  114. package/apps/studio/test/canvas-media-drop.test.ts +158 -0
  115. package/{plugins/design/dev-server → apps/studio}/test/canvas-origin-gate.test.ts +25 -1
  116. package/apps/studio/test/canvas-url.test.ts +99 -0
  117. package/{plugins/design/dev-server → apps/studio}/test/collab-bridge.test.ts +0 -2
  118. package/{plugins/design/dev-server → apps/studio}/test/collab-room.test.ts +2 -7
  119. package/{plugins/design/dev-server → apps/studio}/test/csp-canvas-shell.test.ts +5 -0
  120. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  121. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  122. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  123. package/apps/studio/test/index-data-canvas-ds.test.ts +97 -0
  124. package/apps/studio/test/motion-sample.test.ts +35 -0
  125. package/{plugins/design/dev-server → apps/studio}/test/perf-harness.ts +1 -1
  126. package/{plugins/design/dev-server → apps/studio}/test/phase-3.6-smoke.test.ts +3 -1
  127. package/apps/studio/test/read-annotations.test.ts +409 -0
  128. package/{plugins/design/dev-server → apps/studio}/test/sanitize-annotation-svg.test.ts +108 -2
  129. package/{plugins/design/dev-server → apps/studio}/test/shared-doc-convergence.test.ts +1 -1
  130. package/apps/studio/test/shared-doc-migrate.test.ts +369 -0
  131. package/apps/studio/test/sync-agent.test.ts +575 -0
  132. package/{plugins/design/dev-server → apps/studio}/test/sync-codec.test.ts +65 -0
  133. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  134. package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.test.ts +68 -0
  135. package/{plugins/design/dev-server → apps/studio}/test/sync-hardening.test.ts +0 -8
  136. package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.test.ts +0 -1
  137. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  138. package/apps/studio/test/sync-journal.test.ts +176 -0
  139. package/{plugins/design/dev-server → apps/studio}/test/sync-runtime.test.ts +360 -5
  140. package/{plugins/design/dev-server → apps/studio}/test/sync-status.test.ts +66 -0
  141. package/apps/studio/test/tour-overlay.test.tsx +56 -0
  142. package/apps/studio/test/use-annotation-resize.test.ts +402 -0
  143. package/{plugins/design/dev-server → apps/studio}/test/use-artboard-drag.test.ts +0 -1
  144. package/{plugins/design/dev-server → apps/studio}/test/use-collab.test.ts +0 -0
  145. package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +8 -2
  146. package/apps/studio/test/whats-new.test.ts +101 -0
  147. package/apps/studio/text-imports.d.ts +9 -0
  148. package/{plugins/design/dev-server → apps/studio}/tool-palette.tsx +15 -70
  149. package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -2
  150. package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
  151. package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +5 -3
  152. package/apps/studio/use-annotation-resize.tsx +912 -0
  153. package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +17 -5
  154. package/{plugins/design/dev-server → apps/studio}/use-annotations-visibility.tsx +1 -1
  155. package/{plugins/design/dev-server → apps/studio}/use-artboard-drag.tsx +1 -1
  156. package/{plugins/design/dev-server → apps/studio}/use-canvas-activity.tsx +3 -3
  157. package/apps/studio/use-canvas-media-drop.tsx +343 -0
  158. package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +21 -16
  159. package/{plugins/design/dev-server → apps/studio}/use-cursor-modifiers.tsx +1 -1
  160. package/{plugins/design/dev-server → apps/studio}/use-keyboard-discipline.tsx +5 -2
  161. package/{plugins/design/dev-server → apps/studio}/use-selection-set.tsx +18 -1
  162. package/{plugins/design/dev-server → apps/studio}/use-snap-guides.tsx +1 -1
  163. package/{plugins/design/dev-server → apps/studio}/use-tool-mode.tsx +8 -2
  164. package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
  165. package/apps/studio/whats-new.json +264 -0
  166. package/apps/studio/whats-new.schema.json +86 -0
  167. package/apps/studio/whats-new.ts +87 -0
  168. package/cli/bin/maude.mjs +10 -0
  169. package/cli/commands/design-link.test.mjs +84 -0
  170. package/cli/commands/design.mjs +16 -7
  171. package/cli/commands/design.test.mjs +40 -0
  172. package/cli/commands/help.mjs +8 -2
  173. package/cli/commands/hub.mjs +3 -3
  174. package/cli/commands/hub.test.mjs +1 -1
  175. package/cli/commands/scenario-report.mjs +2 -9
  176. package/cli/lib/design-link.mjs +51 -1
  177. package/cli/lib/flow-design-integration.test.mjs +1 -1
  178. package/cli/lib/gitignore-block.mjs +1 -0
  179. package/cli/lib/plugin-cli-reachability.test.mjs +8 -7
  180. package/cli/lib/preflight.mjs +1 -1
  181. package/package.json +15 -15
  182. package/plugins/design/templates/_shell.html +28 -4
  183. package/plugins/design/templates/brief-board.tsx.template +61 -0
  184. package/plugins/design/templates/canvas.tsx.template +1 -1
  185. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -1
  186. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  187. package/plugins/flow/.claude-plugin/config.schema.json +16 -0
  188. package/plugins/design/dev-server/annotations-layer.tsx +0 -3402
  189. package/plugins/design/dev-server/client/app.jsx +0 -2985
  190. package/plugins/design/dev-server/client/index.html +0 -15
  191. package/plugins/design/dev-server/dist/client.bundle.js +0 -20692
  192. package/plugins/design/dev-server/dist/comment-mount.js +0 -1924
  193. package/plugins/design/dev-server/dist/styles.css +0 -2710
  194. package/plugins/design/dev-server/dom-selection.ts +0 -156
  195. package/plugins/design/dev-server/sync/migrate-seed.ts +0 -163
  196. package/plugins/design/dev-server/test/canvas-edit.test.ts +0 -139
  197. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +0 -159
  198. package/plugins/design/dev-server/test/sync-agent.test.ts +0 -305
  199. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +0 -200
  200. package/plugins/design/dev-server/use-annotation-resize.tsx +0 -449
  201. /package/{plugins/design/dev-server → apps/studio}/activity.ts +0 -0
  202. /package/{plugins/design/dev-server → apps/studio}/bin/_enumerate-artboards-playwright.mjs +0 -0
  203. /package/{plugins/design/dev-server → apps/studio}/bin/_html-playwright.mjs +0 -0
  204. /package/{plugins/design/dev-server → apps/studio}/bin/_pdf-playwright.mjs +0 -0
  205. /package/{plugins/design/dev-server → apps/studio}/bin/_pptx-playwright.mjs +0 -0
  206. /package/{plugins/design/dev-server → apps/studio}/bin/_pw-launch.mjs +0 -0
  207. /package/{plugins/design/dev-server → apps/studio}/bin/_screenshot-playwright.mjs +0 -0
  208. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-optimize.mjs +0 -0
  209. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-playwright.mjs +0 -0
  210. /package/{plugins/design/dev-server → apps/studio}/bin/asset-sweep.sh +0 -0
  211. /package/{plugins/design/dev-server → apps/studio}/bin/bootstrap-check.sh +0 -0
  212. /package/{plugins/design/dev-server → apps/studio}/bin/check-runtime-bundles.sh +0 -0
  213. /package/{plugins/design/dev-server → apps/studio}/bin/draw-build.sh +0 -0
  214. /package/{plugins/design/dev-server → apps/studio}/bin/handoff.sh +0 -0
  215. /package/{plugins/design/dev-server → apps/studio}/bin/prep.sh +0 -0
  216. /package/{plugins/design/dev-server → apps/studio}/bin/scenario-report.mjs +0 -0
  217. /package/{plugins/design/dev-server → apps/studio}/bin/slug.sh +0 -0
  218. /package/{plugins/design/dev-server → apps/studio}/bin/smoke.sh +0 -0
  219. /package/{plugins/design/dev-server → apps/studio}/bin/svg-optimize.sh +0 -0
  220. /package/{plugins/design/dev-server → apps/studio}/bin/visual-sanity.sh +0 -0
  221. /package/{plugins/design/dev-server → apps/studio}/boot-self-heal.ts +0 -0
  222. /package/{plugins/design/dev-server → apps/studio}/canvas-build.ts +0 -0
  223. /package/{plugins/design/dev-server → apps/studio}/canvas-header.ts +0 -0
  224. /package/{plugins/design/dev-server → apps/studio}/canvas-meta.schema.json +0 -0
  225. /package/{plugins/design/dev-server → apps/studio}/canvas-pipeline.ts +0 -0
  226. /package/{plugins/design/dev-server → apps/studio}/client/hmr.mjs +0 -0
  227. /package/{plugins/design/dev-server → apps/studio}/client/iframe-lazy.mjs +0 -0
  228. /package/{plugins/design/dev-server → apps/studio}/client/styles/0-reset.css +0 -0
  229. /package/{plugins/design/dev-server → apps/studio}/client/styles/1-tokens.css +0 -0
  230. /package/{plugins/design/dev-server → apps/studio}/client/styles/2-layout.css +0 -0
  231. /package/{plugins/design/dev-server → apps/studio}/client/styles/5-utilities.css +0 -0
  232. /package/{plugins/design/dev-server → apps/studio}/collab/ai-activity.ts +0 -0
  233. /package/{plugins/design/dev-server → apps/studio}/collab/awareness-bridge.ts +0 -0
  234. /package/{plugins/design/dev-server → apps/studio}/collab/git-lifecycle.ts +0 -0
  235. /package/{plugins/design/dev-server → apps/studio}/collab/index.ts +0 -0
  236. /package/{plugins/design/dev-server → apps/studio}/collab/persistence.ts +0 -0
  237. /package/{plugins/design/dev-server → apps/studio}/collab/protocol.ts +0 -0
  238. /package/{plugins/design/dev-server → apps/studio}/collab/registry.ts +0 -0
  239. /package/{plugins/design/dev-server → apps/studio}/collab/room.ts +0 -0
  240. /package/{plugins/design/dev-server → apps/studio}/config.schema.json +0 -0
  241. /package/{plugins/design/dev-server → apps/studio}/context.ts +0 -0
  242. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/.min-sizes.json +0 -0
  243. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_decoding.js +0 -0
  244. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_encoding.js +0 -0
  245. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion.js +0 -0
  246. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion_react.js +0 -0
  247. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/pixi-js.js +0 -0
  248. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom.js +0 -0
  249. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom_client.js +0 -0
  250. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react.js +0 -0
  251. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-dev-runtime.js +0 -0
  252. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-runtime.js +0 -0
  253. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_awareness.js +0 -0
  254. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_sync.js +0 -0
  255. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/yjs.js +0 -0
  256. /package/{plugins/design/dev-server → apps/studio}/draw/test/brush.test.ts +0 -0
  257. /package/{plugins/design/dev-server → apps/studio}/draw/test/composition.test.ts +0 -0
  258. /package/{plugins/design/dev-server → apps/studio}/draw/test/geometry.test.ts +0 -0
  259. /package/{plugins/design/dev-server → apps/studio}/draw/test/gradient.test.ts +0 -0
  260. /package/{plugins/design/dev-server → apps/studio}/draw/test/layout.test.ts +0 -0
  261. /package/{plugins/design/dev-server → apps/studio}/draw/test/optimize.test.ts +0 -0
  262. /package/{plugins/design/dev-server → apps/studio}/draw/test/palette.test.ts +0 -0
  263. /package/{plugins/design/dev-server → apps/studio}/draw/test/primitives.test.ts +0 -0
  264. /package/{plugins/design/dev-server → apps/studio}/draw/test/serialize.test.ts +0 -0
  265. /package/{plugins/design/dev-server → apps/studio}/examples/README.md +0 -0
  266. /package/{plugins/design/dev-server → apps/studio}/examples/perf-100-artboards.tsx +0 -0
  267. /package/{plugins/design/dev-server → apps/studio}/exporters/_browser-bundles.ts +0 -0
  268. /package/{plugins/design/dev-server → apps/studio}/exporters/canva-handoff-prompt.ts +0 -0
  269. /package/{plugins/design/dev-server → apps/studio}/exporters/canva.ts +0 -0
  270. /package/{plugins/design/dev-server → apps/studio}/exporters/html.ts +0 -0
  271. /package/{plugins/design/dev-server → apps/studio}/exporters/index.ts +0 -0
  272. /package/{plugins/design/dev-server → apps/studio}/exporters/pdf.ts +0 -0
  273. /package/{plugins/design/dev-server → apps/studio}/exporters/png.ts +0 -0
  274. /package/{plugins/design/dev-server → apps/studio}/exporters/pptx.ts +0 -0
  275. /package/{plugins/design/dev-server → apps/studio}/exporters/scope.ts +0 -0
  276. /package/{plugins/design/dev-server → apps/studio}/exporters/svg.ts +0 -0
  277. /package/{plugins/design/dev-server → apps/studio}/exporters/zip.ts +0 -0
  278. /package/{plugins/design/dev-server → apps/studio}/fs-watch.ts +0 -0
  279. /package/{plugins/design/dev-server → apps/studio}/handoff.ts +0 -0
  280. /package/{plugins/design/dev-server → apps/studio}/hmr-broadcast.ts +0 -0
  281. /package/{plugins/design/dev-server → apps/studio}/locator.ts +0 -0
  282. /package/{plugins/design/dev-server → apps/studio}/mem.ts +0 -0
  283. /package/{plugins/design/dev-server → apps/studio}/runtime-bundle.ts +0 -0
  284. /package/{plugins/design/dev-server → apps/studio}/server.mjs +0 -0
  285. /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
  286. /package/{plugins/design/dev-server → apps/studio}/sync/echo-guard.ts +0 -0
  287. /package/{plugins/design/dev-server → apps/studio}/sync/fs-mirror.ts +0 -0
  288. /package/{plugins/design/dev-server → apps/studio}/sync/hubs-config.ts +0 -0
  289. /package/{plugins/design/dev-server → apps/studio}/sync/materialize.ts +0 -0
  290. /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
  291. /package/{plugins/design/dev-server → apps/studio}/sync/untrusted.ts +0 -0
  292. /package/{plugins/design/dev-server → apps/studio}/test/_helpers.ts +0 -0
  293. /package/{plugins/design/dev-server → apps/studio}/test/active-state.test.ts +0 -0
  294. /package/{plugins/design/dev-server → apps/studio}/test/ai-activity.test.ts +0 -0
  295. /package/{plugins/design/dev-server → apps/studio}/test/annotation-strokes-command.test.ts +0 -0
  296. /package/{plugins/design/dev-server → apps/studio}/test/annotations-api.test.ts +0 -0
  297. /package/{plugins/design/dev-server → apps/studio}/test/annotations-draw-modifiers.test.ts +0 -0
  298. /package/{plugins/design/dev-server → apps/studio}/test/artboard-activity-overlay.test.tsx +0 -0
  299. /package/{plugins/design/dev-server → apps/studio}/test/binary-smoke.test.ts +0 -0
  300. /package/{plugins/design/dev-server → apps/studio}/test/boot-self-heal.test.ts +0 -0
  301. /package/{plugins/design/dev-server → apps/studio}/test/bundle-smoke.test.ts +0 -0
  302. /package/{plugins/design/dev-server → apps/studio}/test/canvas-build.test.ts +0 -0
  303. /package/{plugins/design/dev-server → apps/studio}/test/canvas-header.test.ts +0 -0
  304. /package/{plugins/design/dev-server → apps/studio}/test/canvas-hmr-runtime.test.tsx +0 -0
  305. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-inline.test.ts +0 -0
  306. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-motion.test.ts +0 -0
  307. /package/{plugins/design/dev-server → apps/studio}/test/canvas-meta-api.test.ts +0 -0
  308. /package/{plugins/design/dev-server → apps/studio}/test/canvas-pipeline.test.ts +0 -0
  309. /package/{plugins/design/dev-server → apps/studio}/test/canvas-route.test.ts +0 -0
  310. /package/{plugins/design/dev-server → apps/studio}/test/collab-annotations-bridge.test.ts +0 -0
  311. /package/{plugins/design/dev-server → apps/studio}/test/collab-awareness-bridge.test.ts +0 -0
  312. /package/{plugins/design/dev-server → apps/studio}/test/collab-loopback.test.ts +0 -0
  313. /package/{plugins/design/dev-server → apps/studio}/test/collab-protocol.test.ts +0 -0
  314. /package/{plugins/design/dev-server → apps/studio}/test/collab-reseed-guard.test.ts +0 -0
  315. /package/{plugins/design/dev-server → apps/studio}/test/collab-stress.test.ts +0 -0
  316. /package/{plugins/design/dev-server → apps/studio}/test/comment-mount.test.ts +0 -0
  317. /package/{plugins/design/dev-server → apps/studio}/test/comments-api.test.ts +0 -0
  318. /package/{plugins/design/dev-server → apps/studio}/test/compile-entry.test.ts +0 -0
  319. /package/{plugins/design/dev-server → apps/studio}/test/context-resolve-tokens.test.ts +0 -0
  320. /package/{plugins/design/dev-server → apps/studio}/test/equal-spacing-detector.test.ts +0 -0
  321. /package/{plugins/design/dev-server → apps/studio}/test/exporters/canva.test.ts +0 -0
  322. /package/{plugins/design/dev-server → apps/studio}/test/exporters/endpoint.test.ts +0 -0
  323. /package/{plugins/design/dev-server → apps/studio}/test/exporters/history.test.ts +0 -0
  324. /package/{plugins/design/dev-server → apps/studio}/test/exporters/html.test.ts +0 -0
  325. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pdf.test.ts +0 -0
  326. /package/{plugins/design/dev-server → apps/studio}/test/exporters/png.test.ts +0 -0
  327. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx-deck.test.ts +0 -0
  328. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx.test.ts +0 -0
  329. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pw-launch.test.ts +0 -0
  330. /package/{plugins/design/dev-server → apps/studio}/test/exporters/scope.test.ts +0 -0
  331. /package/{plugins/design/dev-server → apps/studio}/test/exporters/svg.test.ts +0 -0
  332. /package/{plugins/design/dev-server → apps/studio}/test/exporters/zip.test.ts +0 -0
  333. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-20-annotations.svg +0 -0
  334. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-21-annotations.svg +0 -0
  335. /package/{plugins/design/dev-server → apps/studio}/test/fs-watch.test.ts +0 -0
  336. /package/{plugins/design/dev-server → apps/studio}/test/git-lifecycle.test.ts +0 -0
  337. /package/{plugins/design/dev-server → apps/studio}/test/handoff-static-frames.test.ts +0 -0
  338. /package/{plugins/design/dev-server → apps/studio}/test/handoff.test.ts +0 -0
  339. /package/{plugins/design/dev-server → apps/studio}/test/history-rollback.test.ts +0 -0
  340. /package/{plugins/design/dev-server → apps/studio}/test/hmr-broadcast.test.ts +0 -0
  341. /package/{plugins/design/dev-server → apps/studio}/test/hmr-classify.test.ts +0 -0
  342. /package/{plugins/design/dev-server → apps/studio}/test/input-router.test.ts +0 -0
  343. /package/{plugins/design/dev-server → apps/studio}/test/locator.test.ts +0 -0
  344. /package/{plugins/design/dev-server → apps/studio}/test/marquee-overlay.test.ts +0 -0
  345. /package/{plugins/design/dev-server → apps/studio}/test/move-artboards-command.test.ts +0 -0
  346. /package/{plugins/design/dev-server → apps/studio}/test/participants-chrome.test.ts +0 -0
  347. /package/{plugins/design/dev-server → apps/studio}/test/paths.test.ts +0 -0
  348. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle-error-mapping.test.ts +0 -0
  349. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle.test.ts +0 -0
  350. /package/{plugins/design/dev-server → apps/studio}/test/server-lifecycle.test.ts +0 -0
  351. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-foundation.test.ts +0 -0
  352. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-projection.test.ts +0 -0
  353. /package/{plugins/design/dev-server → apps/studio}/test/snap-distance-pill.test.ts +0 -0
  354. /package/{plugins/design/dev-server → apps/studio}/test/sync-atomic-write.test.ts +0 -0
  355. /package/{plugins/design/dev-server → apps/studio}/test/sync-echo-guard.test.ts +0 -0
  356. /package/{plugins/design/dev-server → apps/studio}/test/sync-fs-mirror.test.ts +0 -0
  357. /package/{plugins/design/dev-server → apps/studio}/test/sync-meta-codec.test.ts +0 -0
  358. /package/{plugins/design/dev-server → apps/studio}/test/sync-untrusted.test.ts +0 -0
  359. /package/{plugins/design/dev-server → apps/studio}/test/system-endpoint.test.ts +0 -0
  360. /package/{plugins/design/dev-server → apps/studio}/test/tool-palette.test.tsx +0 -0
  361. /package/{plugins/design/dev-server → apps/studio}/test/undo-stack.test.ts +0 -0
  362. /package/{plugins/design/dev-server → apps/studio}/test/use-agent-presence.test.tsx +0 -0
  363. /package/{plugins/design/dev-server → apps/studio}/test/use-annotation-selection.test.tsx +0 -0
  364. /package/{plugins/design/dev-server → apps/studio}/test/use-canvas-activity.test.tsx +0 -0
  365. /package/{plugins/design/dev-server → apps/studio}/test/use-cursor-modifiers.test.ts +0 -0
  366. /package/{plugins/design/dev-server → apps/studio}/test/use-keyboard-discipline.test.ts +0 -0
  367. /package/{plugins/design/dev-server → apps/studio}/test/use-selection-set.test.tsx +0 -0
  368. /package/{plugins/design/dev-server → apps/studio}/test/use-snap-guides.test.ts +0 -0
  369. /package/{plugins/design/dev-server → apps/studio}/test/use-undo-stack.test.tsx +0 -0
  370. /package/{plugins/design/dev-server → apps/studio}/test/ws-handshake.test.ts +0 -0
  371. /package/{plugins/design/dev-server → apps/studio}/tsconfig.json +0 -0
  372. /package/{plugins/design/dev-server → apps/studio}/ws.ts +0 -0
@@ -26,8 +26,15 @@
26
26
  // → echoGuard.consume returns true → event dropped (no infinite loop)
27
27
  //
28
28
  // 800ms quiescence matches the existing Phase 8 collab room flush (DDR-051).
29
+ //
30
+ // Cold-start resolution (DDR-102, supersedes the v1.1 "always hub-wins"):
31
+ // reconcile() feeds the journal-aware decision table in cold-start.ts. A
32
+ // hub-wins overwrite of local disk happens ONLY on a clean fast-forward
33
+ // (local hash == journal hash); genuine divergence snapshots BOTH versions
34
+ // to `_history/<slug>/` and resolves newest-wins (doc syncMeta.bodyEditAt vs
35
+ // local file mtime; unknown/tie → hub). Comments union-merge by id.
29
36
 
30
- import { existsSync, readFileSync } from 'node:fs';
37
+ import { existsSync, readFileSync, statSync } from 'node:fs';
31
38
 
32
39
  import type * as Y from 'yjs';
33
40
 
@@ -39,14 +46,18 @@ import {
39
46
  applyCssToDoc,
40
47
  applyHtmlToDoc,
41
48
  applyMetaToDoc,
49
+ bodyEditAtFromDoc,
42
50
  commentsFromDoc,
43
51
  cssFromDoc,
44
52
  htmlFromDoc,
45
53
  mergeSharedMetaIntoLocal,
46
54
  metaFromDoc,
55
+ stampBodyEdit,
47
56
  Y_SYNC_TYPES,
48
57
  } from './codec.ts';
58
+ import { decideColdStart, unionCommentsById } from './cold-start.ts';
49
59
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
60
+ import type { SyncJournal } from './journal.ts';
50
61
 
51
62
  export const DOC_FLUSH_MS = 800;
52
63
 
@@ -80,13 +91,37 @@ export interface CanvasSyncAgentOptions {
80
91
  /** Injected for tests — defaults to atomicWrite. */
81
92
  writer?: (path: string, bytes: string | Uint8Array) => void;
82
93
  /**
83
- * Called when a non-adopt reconcile (cold-start / post-git-pull) overwrites
84
- * differing, non-empty local content with hub state i.e. the local peer
85
- * had divergent work that hub-wins discarded. Lets the runtime surface a
86
- * "hub overwrote your local changes" notification (Phase 9 Task 8). v1.1
87
- * resolution is always hub-wins; the interactive 3-way prompt is deferred.
94
+ * Per-machine sync journal (DDR-102) the divergence detector. Every
95
+ * successful disk↔doc traversal checkpoints the content hash here; the
96
+ * cold-start decision allows a hub-wins overwrite ONLY when local matches
97
+ * the journal (clean fast-forward). Optional older test constructions
98
+ * without it simply degrade to the conservative conflict path.
99
+ */
100
+ journal?: SyncJournal;
101
+ /**
102
+ * Snapshot writer (DDR-102 conflict protocol) — persists a body version to
103
+ * `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
104
+ * failure). The runtime wires this to history.ts `writeSnapshot`. Optional —
105
+ * without it the conflict path still resolves newest-wins, just without the
106
+ * recovery snapshots (test-only constructions).
107
+ */
108
+ snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
109
+ /**
110
+ * Called when a non-adopt reconcile (cold-start / post-git-pull) found
111
+ * divergent non-empty content on both sides (DDR-102). `winner` is the side
112
+ * newest-wins kept; `snapshots` carries the `_history/` ISO timestamps of
113
+ * the pre-resolution versions (when the snapshot writer is wired). The
114
+ * legacy `cold-start-hub-wins` kind stays in the union for old readers.
88
115
  */
89
- onConflict?: (info: { slug: string; kind: 'cold-start-hub-wins' }) => void;
116
+ onConflict?: (info: {
117
+ slug: string;
118
+ kind: 'cold-start-hub-wins' | 'cold-start-diverged';
119
+ winner?: 'local' | 'hub';
120
+ snapshots?: { local?: string; hub?: string };
121
+ /** DDR-102 fail-closed (F1): the local snapshot didn't land, so the
122
+ * hub-wins overwrite was refused and local kept instead. */
123
+ snapshotFailed?: boolean;
124
+ }) => void;
90
125
  }
91
126
 
92
127
  export interface CanvasSyncAgent {
@@ -179,6 +214,9 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
179
214
  echoGuard.record(paths.html, hash);
180
215
  writer(paths.html, next);
181
216
  lastHtml = next;
217
+ // DDR-102 — a successful doc→disk body flush is a journal checkpoint:
218
+ // disk now holds exactly what the hub holds.
219
+ opts.journal?.record(slug, { bodyHash: hash });
182
220
  }
183
221
 
184
222
  function writeCommentsIfChanged(): void {
@@ -234,6 +272,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
234
272
  const hash = hashBytes(next);
235
273
  echoGuard.record(paths.css, hash);
236
274
  writer(paths.css, next);
275
+ opts.journal?.record(slug, { cssHash: hash }); // DDR-102 checkpoint
237
276
  }
238
277
 
239
278
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
@@ -243,8 +282,18 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
243
282
 
244
283
  const str = bytesToString(evt.bytes);
245
284
  if (evt.path === paths.html) {
246
- const changed = applyHtmlToDoc(doc, str, origin);
247
- if (changed) lastHtml = htmlFromDoc(doc);
285
+ // Body apply + syncMeta stamp in ONE transaction (same origin) so peers
286
+ // receive a single update and the newest-wins stamp rides the body edit.
287
+ let changed = false;
288
+ doc.transact(() => {
289
+ changed = applyHtmlToDoc(doc, str, origin);
290
+ if (changed) stampBodyEdit(doc, origin);
291
+ }, origin);
292
+ if (changed) {
293
+ lastHtml = htmlFromDoc(doc);
294
+ // DDR-102 — a successful disk→doc body apply is a journal checkpoint.
295
+ opts.journal?.record(slug, { bodyHash: evt.hash });
296
+ }
248
297
  return changed;
249
298
  }
250
299
  if (evt.path === paths.comments) {
@@ -268,7 +317,10 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
268
317
  }
269
318
  if (paths.css && evt.path === paths.css) {
270
319
  const changed = applyCssToDoc(doc, str, origin);
271
- if (changed) lastCss = str;
320
+ if (changed) {
321
+ lastCss = str;
322
+ opts.journal?.record(slug, { cssHash: evt.hash }); // DDR-102 checkpoint
323
+ }
272
324
  return changed;
273
325
  }
274
326
  return false;
@@ -293,7 +345,11 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
293
345
  if (adopt) {
294
346
  // Push local up: doc takes its values from disk. Hub becomes our
295
347
  // canonical view of this canvas. One-shot.
296
- if (localHtml !== null) applyHtmlToDoc(doc, localHtml, origin);
348
+ if (localHtml !== null) {
349
+ doc.transact(() => {
350
+ if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
351
+ }, origin);
352
+ }
297
353
  if (localComments !== null) {
298
354
  const parsed = tryParseJsonArray(localComments);
299
355
  if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
@@ -307,55 +363,170 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
307
363
  lastMeta = metaFromDoc(doc);
308
364
  lastCss = cssFromDoc(doc);
309
365
  adopt = false;
366
+ // DDR-102 — adopt is a disk→doc traversal: checkpoint it so the next
367
+ // boot fast-forwards instead of re-entering the conflict path.
368
+ if (localHtml !== null) {
369
+ opts.journal?.record(slug, {
370
+ bodyHash: hashBytes(localHtml),
371
+ ...(localCss !== null ? { cssHash: hashBytes(localCss) } : {}),
372
+ });
373
+ }
310
374
  return;
311
375
  }
312
376
 
313
- // Hub-wins (default): overwrite disk from doc when they differ.
314
- lastHtml = docHtml;
315
- lastComments = docCommentsStr;
316
- lastAnnotations = docAnnotations;
317
- lastMeta = docMeta;
318
- lastCss = docCss;
319
- if (localHtml !== docHtml) {
320
- // DATA-LOSS GUARD: an EMPTY hub doc at cold-start means the hub holds no
321
- // body for this slug yet (fresh / never-seeded hub) — NOT an authoritative
322
- // "this canvas is blank". Overwriting a non-empty local body with it would
323
- // silently destroy the canvas (observed in the wild: a fresh hub emptied
324
- // every local .tsx on first connect). The comments/annotations/meta/css
325
- // branches below already skip empty-doc writes, and the shared-doc
326
- // projection path documents the same "never clobber non-empty local with
327
- // an empty doc value" rule — the HTML body was the one branch missing it.
328
- // Resolution: seed the doc FROM local instead, so the body survives AND
329
- // the hub gets our content (local→doc, exactly like a first-sync seed).
330
- if (docHtml === '' && localHtml !== null && localHtml.trim() !== '') {
331
- applyHtmlToDoc(doc, localHtml, origin);
332
- lastHtml = localHtml;
333
- } else {
334
- // Local had divergent, non-empty content that hub-wins is discarding —
335
- // notify so the user knows their local edits were overwritten (Task 8).
336
- // An absent/empty local file is a clean first-sync, not a conflict.
337
- if (localHtml !== null && localHtml.trim() !== '') {
338
- opts.onConflict?.({ slug, kind: 'cold-start-hub-wins' });
377
+ // ---- body: cold-start decision table (DDR-102; replaces v1.1 hub-wins) --
378
+ const decision = decideColdStart({
379
+ localBody: localHtml,
380
+ docBody: docHtml,
381
+ journalHash: opts.journal?.get(slug)?.bodyHash ?? null,
382
+ localMtimeMs: localMtimeMs(paths.html),
383
+ docBodyEditAtMs: bodyEditAtFromDoc(doc),
384
+ });
385
+
386
+ // Which side owns the visually-coupled lanes (annotations/css) below.
387
+ let bodyWinner: 'local' | 'hub' = 'hub';
388
+
389
+ const writeBodyFromDoc = (): void => {
390
+ const hash = hashBytes(docHtml);
391
+ echoGuard.record(paths.html, hash);
392
+ writer(paths.html, docHtml);
393
+ lastHtml = docHtml;
394
+ opts.journal?.record(slug, { bodyHash: hash });
395
+ };
396
+ const seedBodyUp = (body: string): void => {
397
+ doc.transact(() => {
398
+ if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
399
+ }, origin);
400
+ lastHtml = body;
401
+ opts.journal?.record(slug, { bodyHash: hashBytes(body) });
402
+ };
403
+
404
+ switch (decision.action) {
405
+ case 'noop':
406
+ lastHtml = docHtml;
407
+ // Identical non-empty sides: checkpoint so the next boot fast-forwards.
408
+ if (localHtml !== null && localHtml === docHtml && docHtml !== '') {
409
+ opts.journal?.record(slug, { bodyHash: hashBytes(docHtml) });
339
410
  }
340
- const hash = hashBytes(docHtml);
341
- echoGuard.record(paths.html, hash);
342
- writer(paths.html, docHtml);
411
+ break;
412
+ case 'materialize-hub':
413
+ case 'fast-forward-hub':
414
+ writeBodyFromDoc();
415
+ break;
416
+ case 'seed-local-up':
417
+ // The DDR-064 empty-hub guard as a named decision row: an empty hub doc
418
+ // means the hub holds no body for this slug yet — NOT an authoritative
419
+ // "this canvas is blank". Seed the doc FROM local so the body survives
420
+ // AND the hub gets our content.
421
+ seedBodyUp(localHtml as string);
422
+ bodyWinner = 'local';
423
+ break;
424
+ case 'conflict': {
425
+ // Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
426
+ // write, then apply the newest-wins winner. Even a wrong pick costs
427
+ // one /design:rollback (the incident's class of loss is closed).
428
+ const snapshots: { local?: string; hub?: string } = {};
429
+ let snapshotAttempted = false;
430
+ if (opts.snapshot) {
431
+ snapshotAttempted = true;
432
+ try {
433
+ const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
434
+ if (localTs) snapshots.local = localTs;
435
+ const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
436
+ if (hubTs) snapshots.hub = hubTs;
437
+ } catch {
438
+ /* swallowed below — the missing snapshot ref drives the fail-closed guard */
439
+ }
440
+ }
441
+ // DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
442
+ // recoverable from _history". A hub-wins resolution OVERWRITES local — so
443
+ // if we asked for a snapshot but the local one did NOT land (full disk,
444
+ // read-only `_history/`, a Bun.write error), refuse the destructive
445
+ // overwrite. Keep local on disk and seed it UP instead, so nothing is
446
+ // lost on either side (local survives; the hub still gets our content).
447
+ // `snapshotAttempted` gates this to production wiring — a test/standalone
448
+ // agent with no snapshot fn keeps the plain newest-wins behavior.
449
+ const localSnapshotMissing = snapshotAttempted && !snapshots.local;
450
+ let winner = decision.winner;
451
+ if (winner === 'hub' && localSnapshotMissing) {
452
+ winner = 'local';
453
+ console.error(
454
+ `[sync/${slug}] cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Keeping local + pushing it up; resolve the _history/ write failure (disk full / read-only?) to restore newest-wins.`
455
+ );
456
+ }
457
+ if (winner === 'local') {
458
+ seedBodyUp(localHtml as string);
459
+ bodyWinner = 'local';
460
+ } else {
461
+ writeBodyFromDoc();
462
+ }
463
+ console.warn(`[sync/${slug}] cold-start divergence — ${decision.reason}`);
464
+ opts.onConflict?.({
465
+ slug,
466
+ kind: 'cold-start-diverged',
467
+ winner,
468
+ ...(snapshots.local || snapshots.hub ? { snapshots } : {}),
469
+ ...(localSnapshotMissing ? { snapshotFailed: true } : {}),
470
+ });
471
+ break;
343
472
  }
344
473
  }
345
- if (docCommentsStr !== '' && localComments !== docCommentsStr) {
346
- const hash = hashBytes(docCommentsStr);
347
- echoGuard.record(paths.comments, hash);
348
- writer(paths.comments, docCommentsStr);
474
+
475
+ // ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
476
+ const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
477
+ if (localParsedComments !== null && localParsedComments.length > 0) {
478
+ const merged = unionCommentsById(docComments, localParsedComments);
479
+ const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
480
+ applyCommentsToDoc(doc, merged, origin); // no-ops when equal
481
+ if (mergedStr !== '' && mergedStr !== localComments) {
482
+ const hash = hashBytes(mergedStr);
483
+ echoGuard.record(paths.comments, hash);
484
+ writer(paths.comments, mergedStr);
485
+ }
486
+ lastComments = mergedStr;
487
+ } else {
488
+ // No (parseable) local comments — hub state materializes as before.
489
+ lastComments = docCommentsStr;
490
+ if (docCommentsStr !== '' && localComments !== docCommentsStr) {
491
+ const hash = hashBytes(docCommentsStr);
492
+ echoGuard.record(paths.comments, hash);
493
+ writer(paths.comments, docCommentsStr);
494
+ }
349
495
  }
350
- if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
351
- const hash = hashBytes(docAnnotations);
352
- echoGuard.record(paths.annotations, hash);
353
- writer(paths.annotations, docAnnotations);
496
+
497
+ // ---- annotations + css: follow the body winner (visually coupled) ------
498
+ if (bodyWinner === 'local') {
499
+ if (localAnnotations !== null && localAnnotations !== docAnnotations) {
500
+ applyAnnotationsToDoc(doc, localAnnotations, origin);
501
+ lastAnnotations = localAnnotations;
502
+ } else {
503
+ lastAnnotations = docAnnotations;
504
+ }
505
+ if (paths.css && localCss !== null && localCss !== docCss) {
506
+ applyCssToDoc(doc, localCss, origin);
507
+ lastCss = localCss;
508
+ opts.journal?.record(slug, { cssHash: hashBytes(localCss) });
509
+ } else {
510
+ lastCss = docCss;
511
+ }
512
+ } else {
513
+ lastAnnotations = docAnnotations;
514
+ if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
515
+ const hash = hashBytes(docAnnotations);
516
+ echoGuard.record(paths.annotations, hash);
517
+ writer(paths.annotations, docAnnotations);
518
+ }
519
+ lastCss = docCss;
520
+ if (paths.css && docCss !== null && localCss !== docCss) {
521
+ const hash = hashBytes(docCss);
522
+ echoGuard.record(paths.css, hash);
523
+ writer(paths.css, docCss);
524
+ opts.journal?.record(slug, { cssHash: hash });
525
+ }
354
526
  }
355
- // Meta: merge the doc's shared subset (layout/artboards) into local,
356
- // preserving this machine's viewport + syncable. Only writes when the merge
357
- // actually changes the file (a fresh peer with no local viewport, or an
358
- // artboard layout the hub carries that local lacks).
527
+
528
+ // ---- meta: shared-subset merge, unchanged in all cases -----------------
529
+ lastMeta = docMeta;
359
530
  if (paths.meta && docMeta !== null) {
360
531
  const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
361
532
  if (merged !== null && merged !== localMeta) {
@@ -364,11 +535,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
364
535
  writer(paths.meta, merged);
365
536
  }
366
537
  }
367
- if (paths.css && docCss !== null && localCss !== docCss) {
368
- const hash = hashBytes(docCss);
369
- echoGuard.record(paths.css, hash);
370
- writer(paths.css, docCss);
371
- }
372
538
  }
373
539
 
374
540
  return {
@@ -395,6 +561,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
395
561
 
396
562
  /* ---------------------------------------------------------------- helpers */
397
563
 
564
+ function localMtimeMs(p: string): number | null {
565
+ try {
566
+ return statSync(p).mtimeMs;
567
+ } catch {
568
+ return null;
569
+ }
570
+ }
571
+
398
572
  function readLocal(p: string): string | null {
399
573
  if (!existsSync(p)) return null;
400
574
  try {
@@ -21,6 +21,8 @@
21
21
  // insert-all`, and crucially preserves cursor positions other peers may have
22
22
  // in the unchanged regions.
23
23
 
24
+ import { hostname } from 'node:os';
25
+
24
26
  import type * as Y from 'yjs';
25
27
 
26
28
  import { Y_TYPES } from '../collab/persistence.ts';
@@ -43,6 +45,13 @@ export const Y_SYNC_TYPES = {
43
45
  * security opt-in (syncable) are stripped before sync — see META_LOCAL_KEYS
44
46
  * + sharedMetaCanonical. Phase 9.1 Gap 2. */
45
47
  meta: 'meta',
48
+ /** Doc-side sync bookkeeping (Y.Map, DDR-102): `bodyEditAt` ms-epoch stamp
49
+ * written by every peer that applies a LOCAL body into the doc, + `by` (a
50
+ * short peer label). The cold-start newest-wins decision compares it to the
51
+ * local file mtime. Deliberately a dedicated lane: `.meta.json`'s
52
+ * `last_modified` is in META_LOCAL_KEYS (per-machine, never syncs), so the
53
+ * meta codec CANNOT carry this. Never materialized to disk. */
54
+ syncMeta: 'syncMeta',
46
55
  } as const;
47
56
 
48
57
  /**
@@ -293,6 +302,42 @@ export function mergeSharedMetaIntoLocal(
293
302
  return `${JSON.stringify(merged, null, 2)}\n`;
294
303
  }
295
304
 
305
+ /* ---------------------------------------------------------------- syncMeta */
306
+
307
+ /** Short peer label for `syncMeta.by` — os.hostname() truncated. Informational
308
+ * only (status surfaces); never trusted for decisions. */
309
+ const PEER_LABEL_MAX = 32;
310
+ function peerLabel(): string {
311
+ try {
312
+ return hostname().slice(0, PEER_LABEL_MAX);
313
+ } catch {
314
+ return 'unknown';
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Stamp `syncMeta.bodyEditAt` (+ `by`) on the doc. Call this in the SAME
320
+ * transaction + origin as every local→doc body apply (agent applyFromFs html
321
+ * branch, reconcile seed-up, migrate-seed adopt, projection file→doc body
322
+ * import) so peers receive ONE update and origin-filtering stays intact.
323
+ * yjs nests transactions — wrapping `applyHtmlToDoc` + `stampBodyEdit` in an
324
+ * outer `doc.transact(fn, origin)` produces a single update.
325
+ */
326
+ export function stampBodyEdit(doc: Y.Doc, origin?: unknown, nowMs?: number): void {
327
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
328
+ doc.transact(() => {
329
+ map.set('bodyEditAt', nowMs ?? Date.now());
330
+ map.set('by', peerLabel());
331
+ }, origin);
332
+ }
333
+
334
+ /** The doc-side body-edit stamp, or null when no peer ever stamped (older
335
+ * peers don't write syncMeta → callers fall back to hub-wins, interop-safe). */
336
+ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
337
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('bodyEditAt');
338
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
339
+ }
340
+
296
341
  /* ---------------------------------------------------------------- css */
297
342
 
298
343
  /** The synced canvas CSS string held in the doc, or null when unset/empty. */
@@ -0,0 +1,158 @@
1
+ // Cold-start body resolution — the pure decision core of the conflict
2
+ // protocol (DDR-102; supersedes the v1.1 "hub-wins always" scoping).
3
+ //
4
+ // Both sync paths (agent.ts reconcile, migrate-seed.ts for the shared-doc
5
+ // cutover) feed their observed state into `decideColdStart` and execute the
6
+ // returned action. Keeping the table pure makes the full matrix unit-testable
7
+ // without Y.Docs or disk (mirrors codec.ts's pure-function style).
8
+ //
9
+ // The journal (journal.ts) is what separates a CLEAN catch-up from divergence:
10
+ // journalHash == hash(local) means every local byte was already reconciled
11
+ // through this machine — the hub being different just means it moved ahead, so
12
+ // overwriting disk is a fast-forward, not data loss. Any other combination of
13
+ // "both sides non-empty and different" is a conflict: the caller snapshots
14
+ // BOTH versions to `_history/<slug>/` and applies the newest-wins winner.
15
+ //
16
+ // Newest-wins compares the doc-side `syncMeta.bodyEditAt` stamp (codec.ts —
17
+ // written by every peer that applies a local body into the doc) against the
18
+ // local file mtime. Either side unknown (older peer never stamped; mtime
19
+ // unavailable) or equal → hub wins, exactly the v1.1 default — but now both
20
+ // sides are snapshotted first, so even a wrong pick costs one /design:rollback.
21
+
22
+ import { hashBytes } from './echo-guard.ts';
23
+
24
+ export type ColdStartAction =
25
+ | 'noop'
26
+ | 'materialize-hub'
27
+ | 'seed-local-up'
28
+ | 'fast-forward-hub'
29
+ | 'conflict';
30
+
31
+ export interface ColdStartInput {
32
+ /** Local body file content, or null when the file doesn't exist. */
33
+ localBody: string | null;
34
+ /** Body currently held by the doc (hub state after first sync). */
35
+ docBody: string;
36
+ /** Journal entry's bodyHash for this slug, or null when never checkpointed. */
37
+ journalHash: string | null;
38
+ /** Local body file mtime (ms epoch), or null when unavailable. */
39
+ localMtimeMs: number | null;
40
+ /** Doc-side syncMeta.bodyEditAt stamp (ms epoch), or null when no peer ever
41
+ * stamped (older peer interop) — falls back to hub-wins. */
42
+ docBodyEditAtMs: number | null;
43
+ }
44
+
45
+ export interface ColdStartDecision {
46
+ action: ColdStartAction;
47
+ /** Set only for `conflict`. */
48
+ winner?: 'local' | 'hub';
49
+ /** Human-readable, logged + recorded in the conflict entry. */
50
+ reason: string;
51
+ }
52
+
53
+ /** Trim-only-whitespace bodies count as empty (mirrors the agent's
54
+ * `localHtml.trim() !== ''` guard). */
55
+ function isEmptyBody(body: string | null): boolean {
56
+ return body === null || body.trim() === '';
57
+ }
58
+
59
+ function commentId(c: unknown): string | null {
60
+ if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
61
+ return (c as { id: string }).id;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Union-merge two comment snapshots by stable `id` (DDR-102): doc order first,
68
+ * local-only entries appended. Comments carry stable ids, so union loses
69
+ * nothing and needs no winner — same-id entries keep the doc's version.
70
+ * Id-less entries dedupe by JSON identity (conservative). Shared by both
71
+ * cold-start paths (agent reconcile + migrate-seed).
72
+ */
73
+ export function unionCommentsById(docList: unknown[], localList: unknown[]): unknown[] {
74
+ const out = [...docList];
75
+ const seenIds = new Set<string>();
76
+ const seenJson = new Set<string>();
77
+ for (const c of docList) {
78
+ const id = commentId(c);
79
+ if (id !== null) seenIds.add(id);
80
+ else seenJson.add(JSON.stringify(c));
81
+ }
82
+ for (const c of localList) {
83
+ const id = commentId(c);
84
+ const dup = id !== null ? seenIds.has(id) : seenJson.has(JSON.stringify(c));
85
+ if (dup) continue;
86
+ out.push(c);
87
+ if (id !== null) seenIds.add(id);
88
+ else seenJson.add(JSON.stringify(c));
89
+ }
90
+ return out;
91
+ }
92
+
93
+ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
94
+ const localEmpty = isEmptyBody(input.localBody);
95
+ const docEmpty = isEmptyBody(input.docBody);
96
+
97
+ if (localEmpty && docEmpty) {
98
+ return { action: 'noop', reason: 'both sides empty — nothing to reconcile' };
99
+ }
100
+
101
+ if (localEmpty) {
102
+ // Clean first sync: nothing local to lose.
103
+ return { action: 'materialize-hub', reason: 'no local body — materializing hub state' };
104
+ }
105
+
106
+ if (docEmpty) {
107
+ // DDR-064 empty-hub guard, now a named case: an empty hub doc means the hub
108
+ // holds no body for this slug yet — NOT an authoritative "blank canvas".
109
+ return {
110
+ action: 'seed-local-up',
111
+ reason: 'hub doc empty — seeding local body up (DDR-064 guard)',
112
+ };
113
+ }
114
+
115
+ if (input.localBody === input.docBody) {
116
+ // Caller records the journal so the NEXT boot sees a clean checkpoint.
117
+ return { action: 'noop', reason: 'local and hub identical' };
118
+ }
119
+
120
+ // Hash via hashBytes ONLY — the journal recorded its hashes through the same
121
+ // fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
122
+ if (
123
+ input.journalHash !== null &&
124
+ input.localBody !== null &&
125
+ input.journalHash === hashBytes(input.localBody)
126
+ ) {
127
+ // Everything local was already reconciled through this machine; the hub is
128
+ // simply ahead. Overwrite WITHOUT snapshot/conflict — a clean fast-forward.
129
+ return {
130
+ action: 'fast-forward-hub',
131
+ reason: 'local matches last-synced journal hash — hub is ahead, fast-forwarding',
132
+ };
133
+ }
134
+
135
+ // Divergence: both sides non-empty, different, and local carries bytes this
136
+ // machine never reconciled (journal stale or absent). Newest wins; unknown
137
+ // or tied timestamps fall back to hub (v1.1 default, now recoverable).
138
+ const local = input.localMtimeMs;
139
+ const doc = input.docBodyEditAtMs;
140
+ if (local !== null && doc !== null && local !== doc) {
141
+ const winner = local > doc ? 'local' : 'hub';
142
+ return {
143
+ action: 'conflict',
144
+ winner,
145
+ reason: `diverged — newest wins: local mtime ${new Date(local).toISOString()} ${
146
+ winner === 'local' ? '>' : '<'
147
+ } doc bodyEditAt ${new Date(doc).toISOString()}`,
148
+ };
149
+ }
150
+ return {
151
+ action: 'conflict',
152
+ winner: 'hub',
153
+ reason:
154
+ local === null || doc === null
155
+ ? 'diverged — timestamp unknown on one side, falling back to hub-wins (recoverable: both sides snapshotted)'
156
+ : 'diverged — timestamps tied, falling back to hub-wins (recoverable: both sides snapshotted)',
157
+ };
158
+ }