@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
@@ -33,6 +33,8 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
33
33
  // above (a type-only SyncStatusSnapshot import that Bun erases).
34
34
  import { syncPresentation } from '../sync/presentation.ts';
35
35
  import { canvasUrl } from './canvas-url.js';
36
+ import { applyEditRequest } from './apply-edit-request.ts';
37
+ import { createIndexLoader } from './index-loader.ts';
36
38
  import {
37
39
  BROWSER_CAPTURE_FORMATS,
38
40
  BROWSER_SERVABLE_FORMATS,
@@ -50,6 +52,8 @@ import GitPanel from './panels/GitPanel.jsx';
50
52
  import SyncConsentDialog from './panels/SyncConsentDialog.jsx';
51
53
  import SyncPanel from './panels/SyncPanel.jsx';
52
54
  import CloudBar from './panels/CloudBar.jsx';
55
+ import { buildShareLinks, normalizeOpenPath, readOpenParam, withOpenParam } from './share-link.js';
56
+ import { ShareDialog, copyShareLink } from './share-dialog.jsx';
53
57
  import IdentityBar from './panels/IdentityBar.jsx';
54
58
  import OnboardingWizard from './panels/OnboardingWizard.jsx';
55
59
  import { ReadinessDialog } from './panels/ReadinessList.jsx';
@@ -119,6 +123,8 @@ import { COLLAB_TOUR } from './tour/collab-tour.js';
119
123
  import { TourOverlay } from './tour/overlay.jsx';
120
124
  import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
121
125
  import { USAGE_TOUR } from './tour/usage-tour.js';
126
+ import { dismissNotice, NotificationHost, notify, notifyCanvasText } from '../notifications.tsx';
127
+ import { acceptCanvasNotice } from '../canvas-notice-message.ts';
122
128
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
123
129
  import { ReportBugDialog } from './report-bug.jsx';
124
130
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
@@ -202,6 +208,7 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels =
202
208
  key={id}
203
209
  type="button"
204
210
  role="tab"
211
+ data-testid={`dock-tab-${id}`}
205
212
  aria-selected={activeId === id}
206
213
  className={'st-docktab' + (activeId === id ? ' is-active' : '')}
207
214
  onClick={() => onPick(id)}
@@ -245,6 +252,18 @@ function pathTestIdSlug(p) {
245
252
  .toLowerCase()
246
253
  .replace(/^-+|-+$/g, '');
247
254
  }
255
+ // A layers tree's identity: its element ids in document order. Two trees with
256
+ // the same signature are the same DOM as far as positional ids go.
257
+ function layersTreeSig(nodes) {
258
+ const ids = [];
259
+ (function walk(list) {
260
+ for (const n of list || []) {
261
+ ids.push(n?.id ?? '');
262
+ walk(n?.children);
263
+ }
264
+ })(nodes);
265
+ return ids.join(',');
266
+ }
248
267
  // Bun's `define` substitutes this at build time (see build.ts); falls back when
249
268
  // the bundle is consumed in a context that hasn't run the build.
250
269
  const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
@@ -331,58 +350,9 @@ function basename(p) {
331
350
  return p.split('/').pop();
332
351
  }
333
352
 
334
- // DDR-150 dogfood — a transient shell toast for timeline/inline-edit op results.
335
- // The clip ops used to console.warn their failures (a 422 "series clip can't
336
- // move" looked like "the button does nothing"). Self-contained DOM (no React
337
- // state), mirrors the canvas-side showCanvasToast.
338
- // `action` (feature-file-tree-drag-drop-folders, Task 10) — optional
339
- // `{ label, onClick }` for an inline undo affordance (the move toast). Absent
340
- // for every other caller, so their toast stays the plain auto-dismissing text
341
- // bubble this always was.
353
+ // Keep call sites (including Undo actions) on the shared notification stack.
342
354
  function shellToast(message, ok = false, action) {
343
- if (typeof document === 'undefined') return;
344
- let el = document.getElementById('st-op-toast');
345
- if (!el) {
346
- el = document.createElement('div');
347
- el.id = 'st-op-toast';
348
- el.setAttribute('role', 'status');
349
- el.style.cssText =
350
- 'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
351
- 'display:flex;align-items:center;gap:10px;max-width:440px;padding:8px 14px;' +
352
- 'border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
353
- 'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
354
- document.body.appendChild(el);
355
- }
356
- el.style.background = ok ? '#1d3524' : '#3a1d1d';
357
- el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
358
- el.textContent = '';
359
- const text = document.createElement('span');
360
- text.textContent = message;
361
- el.appendChild(text);
362
- if (action) {
363
- const btn = document.createElement('button');
364
- btn.type = 'button';
365
- btn.textContent = action.label;
366
- btn.style.cssText =
367
- 'pointer-events:auto;background:none;border:0;text-decoration:underline;' +
368
- 'color:inherit;font:inherit;cursor:pointer;padding:0;';
369
- btn.onclick = () => {
370
- action.onClick();
371
- el.style.opacity = '0';
372
- el.style.pointerEvents = 'none';
373
- };
374
- el.appendChild(btn);
375
- }
376
- el.style.opacity = '1';
377
- el.style.pointerEvents = action ? 'auto' : 'none';
378
- clearTimeout(shellToast._t);
379
- shellToast._t = setTimeout(
380
- () => {
381
- el.style.opacity = '0';
382
- el.style.pointerEvents = 'none';
383
- },
384
- action ? 6000 : 3200
385
- );
355
+ notify({ title: message, kind: ok ? 'success' : 'error', action });
386
356
  }
387
357
 
388
358
  // DDR-223 (issue #93, supersedes DDR-187's boot half) — one-time first-run
@@ -402,48 +372,24 @@ function browseFirstRunHint(readOnly = false) {
402
372
  } catch {
403
373
  return;
404
374
  }
405
- if (document.getElementById('st-browse-hint')) return;
406
- const el = document.createElement('div');
407
- el.id = 'st-browse-hint';
408
- el.setAttribute('role', 'status');
409
- el.style.cssText =
410
- 'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:81;' +
411
- 'display:flex;align-items:center;gap:10px;max-width:520px;padding:10px 14px;border-radius:10px;' +
412
- 'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
413
- 'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
414
- 'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
415
- const kbd =
416
- '<kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
417
- 'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd>';
418
- el.innerHTML =
419
- // Cloud Phase 25 C2 — a viewer selects to inspect, never to edit; the
420
- // hint must not promise an editor the role doesn't have.
421
- (readOnly
422
- ? `<span>Your mock is <strong>live</strong> — click things to try it. Press ${kbd} to select &amp; inspect.</span>`
423
- : '<span>You’re in <strong>Edit</strong> — click selects, like Figma. ' +
424
- 'Switch to <strong>Preview</strong> in the toolbar to use the live mock.</span>') +
425
- '<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
426
- 'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
375
+ if (browseFirstRunHint.active) return;
376
+ browseFirstRunHint.active = true;
427
377
  const dismiss = () => {
428
- try {
429
- localStorage.setItem(MODE_HINT_SEEN, '1');
430
- } catch {
431
- /* private mode */
432
- }
433
- el.style.opacity = '0';
434
- setTimeout(() => el.remove(), 200);
378
+ browseFirstRunHint.active = false;
379
+ try { localStorage.setItem(MODE_HINT_SEEN, '1'); } catch {}
435
380
  document.removeEventListener('keydown', onV, true);
436
381
  };
437
382
  const onV = (e) => {
438
- if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) dismiss();
383
+ if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) {
384
+ dismissNotice('mode-hint');
385
+ dismiss();
386
+ }
439
387
  };
440
- el.querySelector('button')?.addEventListener('click', dismiss);
441
388
  if (readOnly) document.addEventListener('keydown', onV, true);
442
- document.body.appendChild(el);
443
- requestAnimationFrame(() => {
444
- el.style.opacity = '1';
445
- });
446
- setTimeout(dismiss, 9000);
389
+ notify({ id: 'mode-hint', title: readOnly ? 'Your mock is live' : 'You’re in Edit',
390
+ description: readOnly ? 'Click things to try it. Press V to select & inspect.'
391
+ : 'Click selects, like Figma. Switch to Preview in the toolbar to use the live mock.',
392
+ onDismiss: dismiss });
447
393
  }
448
394
 
449
395
  // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
@@ -1218,6 +1164,37 @@ const PDF_DPI_OPTIONS = [
1218
1164
  ];
1219
1165
  const PDF_DPI_DEFAULT = 'auto';
1220
1166
 
1167
+ // issue #116 — how the export treats text. Mirrors export-dialog.tsx's
1168
+ // PDF_TEXT_OPTIONS (same mirror obligation as PDF_DPI_OPTIONS above).
1169
+ //
1170
+ // The list is FIXED regardless of whether Ghostscript is present on this
1171
+ // machine. Hiding "Convert to outlines" when gs is missing would need a new
1172
+ // capability route, and would answer the user's question ("can I send this to
1173
+ // a printer?") by making the answer invisible; a refusal that names the
1174
+ // one-line install is more honest and reaches them at the moment they care.
1175
+ const PDF_TEXT_OPTIONS = [
1176
+ {
1177
+ id: 'keep',
1178
+ label: 'Keep selectable (default)',
1179
+ description:
1180
+ 'Text stays selectable and searchable. You are warned if a font came out unprintable.',
1181
+ },
1182
+ {
1183
+ id: 'embed',
1184
+ label: 'Verify fonts embedded',
1185
+ description:
1186
+ 'Same file, but the export fails instead of shipping a font a print shop would reject.',
1187
+ },
1188
+ {
1189
+ id: 'outline',
1190
+ label: 'Convert to outlines (print-safe)',
1191
+ description:
1192
+ 'Every glyph becomes a vector path — no fonts left to break. Text is no longer selectable ' +
1193
+ 'and the file can grow a lot. Needs Ghostscript installed locally; cloud workspaces have it.',
1194
+ },
1195
+ ];
1196
+ const PDF_TEXT_DEFAULT = 'keep';
1197
+
1221
1198
  // feature-bulk-media-insert — best-effort MIME guess from a filename extension.
1222
1199
  // Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
1223
1200
  // no type) for the destination-toggle's image/video/audio split; the actual
@@ -1602,6 +1579,7 @@ function ExportDialog({
1602
1579
  const [pdfMarksCrop, setPdfMarksCrop] = useState(false);
1603
1580
  const [pdfMarksRegistration, setPdfMarksRegistration] = useState(false);
1604
1581
  const [pdfDpiId, setPdfDpiId] = useState(PDF_DPI_DEFAULT);
1582
+ const [pdfTextId, setPdfTextId] = useState(PDF_TEXT_DEFAULT);
1605
1583
  // DDR-148 addendum — mp4/webm of a registered video-comp render through
1606
1584
  // renderMediaOnWeb, which produces real audio (Remotion owns the
1607
1585
  // TransitionSeries/volume-closure timeline). gif has no audio track at all
@@ -1714,6 +1692,10 @@ function ExportDialog({
1714
1692
  // itself stays vector regardless of this.
1715
1693
  const pdfDpi = PDF_DPI_OPTIONS.find((d) => d.id === pdfDpiId)?.value;
1716
1694
  if (pdfDpi !== undefined) options.dpi = pdfDpi;
1695
+ // issue #116 — omitted for `keep` rather than sent explicitly: the
1696
+ // adapter's default IS keep, so an untouched dialog produces the exact
1697
+ // request body it did before this feature existed.
1698
+ if (pdfTextId !== 'keep') options.text = pdfTextId;
1717
1699
  }
1718
1700
  // Scope targeting hints (resolveScope reads these): `artboardId` makes
1719
1701
  // "Active artboard" export the right screen instead of `:first-of-type`;
@@ -2022,6 +2004,27 @@ function ExportDialog({
2022
2004
  The page stays vector; this only sets the capture density for raster content on it
2023
2005
  (dropped photos, large-format art).
2024
2006
  </div>
2007
+ <div className="st-dialog-row">
2008
+ <label className="st-dialog-lbl" htmlFor="st-export-pdf-text">
2009
+ Text
2010
+ </label>
2011
+ <select
2012
+ id="st-export-pdf-text"
2013
+ className="st-select"
2014
+ data-testid="export-pdf-text"
2015
+ value={pdfTextId}
2016
+ onChange={(e) => setPdfTextId(e.target.value)}
2017
+ >
2018
+ {PDF_TEXT_OPTIONS.map((t) => (
2019
+ <option key={t.id} value={t.id}>
2020
+ {t.label}
2021
+ </option>
2022
+ ))}
2023
+ </select>
2024
+ </div>
2025
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
2026
+ {PDF_TEXT_OPTIONS.find((t) => t.id === pdfTextId)?.description}
2027
+ </div>
2025
2028
  <div className="st-dialog-row">
2026
2029
  <label
2027
2030
  className="st-dialog-lbl"
@@ -2311,6 +2314,10 @@ function FileRow({
2311
2314
  // feature-file-tree-drag-drop-folders (Task 9) — the KEYBOARD path for
2312
2315
  // "Move to…" (drag-only fails WCAG 2.1.1). Same eligibility as dragging.
2313
2316
  const canMove = draggableRow && typeof menu !== 'undefined';
2317
+ // Plan T17/L03 — a supporting file (notes, styles, images, media) in a
2318
+ // canvas folder gets the same ⋯ menu: rename, move, delete.
2319
+ const supporting = !isCanvas && !!pKind && !sidecar && typeof menu !== 'undefined';
2320
+ const canShare = typeof menu !== 'undefined' && kind !== 'runtime';
2314
2321
  const fileDir = file.path.split('/').slice(0, -1).join('/');
2315
2322
  // Stable hook for the desktop E2E harness (data-testid convention — see the
2316
2323
  // `desktop-e2e` skill): canvas rows only, slug derived from the relative path
@@ -2328,7 +2335,7 @@ function FileRow({
2328
2335
  <button
2329
2336
  type="button"
2330
2337
  role="treeitem"
2331
- data-testid={testId}
2338
+ data-testid={testId ?? (supporting ? `file-row-${pathTestIdSlug(file.path)}` : undefined)}
2332
2339
  aria-selected={isSel}
2333
2340
  aria-disabled={inert ? 'true' : undefined}
2334
2341
  aria-busy={isBusy || undefined}
@@ -2347,7 +2354,17 @@ function FileRow({
2347
2354
  else if (pKind) onPreview?.(file.path);
2348
2355
  }}
2349
2356
  onContextMenu={
2350
- canMove ? (e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir }) : undefined
2357
+ canShare
2358
+ ? (e) =>
2359
+ menu.openAt(e, {
2360
+ kind: 'file',
2361
+ path: file.path,
2362
+ dir: fileDir,
2363
+ canMove: canMove || supporting,
2364
+ supporting,
2365
+ name: file.name,
2366
+ })
2367
+ : undefined
2351
2368
  }
2352
2369
  {...dragHandlers}
2353
2370
  >
@@ -2381,21 +2398,30 @@ function FileRow({
2381
2398
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
2382
2399
  </button>
2383
2400
  );
2384
- if (!canDelete && !canMove) return row;
2401
+ if (!canDelete && !canShare) return row;
2385
2402
  // Sibling menu/delete buttons (can't nest a button in the row button). The
2386
2403
  // wrapper is presentational so the treeitem stays the tree's child for a11y.
2387
2404
  return (
2388
2405
  <div className="st-row-wrap" role="none">
2389
2406
  {row}
2390
- {canMove && (
2407
+ {canShare && (
2391
2408
  <button
2392
2409
  type="button"
2393
2410
  className={'st-row-menu-btn' + (canDelete ? ' has-delete-sibling' : '')}
2394
2411
  data-testid={`tree-row-menu-${pathTestIdSlug(file.path)}`}
2395
- title={`Move ${label}…`}
2412
+ title={`Actions for ${label}`}
2396
2413
  aria-label={`Actions for ${label}`}
2397
2414
  aria-haspopup="menu"
2398
- onClick={(e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir })}
2415
+ onClick={(e) =>
2416
+ menu.openAt(e, {
2417
+ kind: 'file',
2418
+ path: file.path,
2419
+ dir: fileDir,
2420
+ canMove: canMove || supporting,
2421
+ supporting,
2422
+ name: file.name,
2423
+ })
2424
+ }
2399
2425
  >
2400
2426
  <Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
2401
2427
  </button>
@@ -2469,6 +2495,7 @@ function CanvasRow({
2469
2495
  const isDragging = drag?.draggedPath === primary.path;
2470
2496
  const isBusy = drag?.busyPath === primary.path;
2471
2497
  const canMove = draggableRow && typeof menu !== 'undefined';
2498
+ const canShare = typeof menu !== 'undefined' && kind !== 'runtime';
2472
2499
  const primaryDir = primary.path.split('/').slice(0, -1).join('/');
2473
2500
  const row = (
2474
2501
  <button
@@ -2495,7 +2522,7 @@ function CanvasRow({
2495
2522
  onOpen(primary.path);
2496
2523
  }}
2497
2524
  onContextMenu={
2498
- canMove ? (e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir }) : undefined
2525
+ canShare ? (e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir, canMove }) : undefined
2499
2526
  }
2500
2527
  {...dragHandlers}
2501
2528
  >
@@ -2537,17 +2564,17 @@ function CanvasRow({
2537
2564
  );
2538
2565
  return (
2539
2566
  <Fragment>
2540
- {canMove ? (
2567
+ {canShare ? (
2541
2568
  <div className="st-row-wrap" role="none">
2542
2569
  {row}
2543
2570
  <button
2544
2571
  type="button"
2545
2572
  className="st-row-menu-btn"
2546
2573
  data-testid={`tree-row-menu-${pathTestIdSlug(primary.path)}`}
2547
- title={`Move ${displayName(primary.name)}…`}
2574
+ title={`Actions for ${displayName(primary.name)}`}
2548
2575
  aria-label={`Actions for ${displayName(primary.name)}`}
2549
2576
  aria-haspopup="menu"
2550
- onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir })}
2577
+ onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir, canMove })}
2551
2578
  >
2552
2579
  <Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
2553
2580
  </button>
@@ -2560,6 +2587,7 @@ function CanvasRow({
2560
2587
  <FileRow
2561
2588
  key={sc.path}
2562
2589
  file={sc}
2590
+ menu={menu}
2563
2591
  activePath={activePath}
2564
2592
  previewPath={previewPath}
2565
2593
  onOpen={onOpen}
@@ -2666,6 +2694,7 @@ function Tree({
2666
2694
  <FileRow
2667
2695
  key={entry.primary.path}
2668
2696
  file={entry.primary}
2697
+ menu={menu}
2669
2698
  activePath={activePath}
2670
2699
  previewPath={previewPath}
2671
2700
  onOpen={onOpen}
@@ -2680,6 +2709,7 @@ function Tree({
2680
2709
  <FileRow
2681
2710
  key={entry.primary.path}
2682
2711
  file={entry.primary}
2712
+ menu={menu}
2683
2713
  activePath={activePath}
2684
2714
  previewPath={previewPath}
2685
2715
  onOpen={onOpen}
@@ -2738,7 +2768,7 @@ function Tree({
2738
2768
  defaultOpen={true}
2739
2769
  dirPath={childPath}
2740
2770
  drag={drag}
2741
- menu={menu}
2771
+ menu={drag ? menu : undefined}
2742
2772
  >
2743
2773
  {childTree}
2744
2774
  </DirRow>
@@ -2811,12 +2841,20 @@ function Sidebar({
2811
2841
  onMoveCanvas,
2812
2842
  onNewFolder,
2813
2843
  onDeleteFolder,
2844
+ onRenameFolder,
2845
+ onRenameCanvas,
2846
+ onDuplicateCanvas,
2847
+ onDeleteFile,
2814
2848
  // Passed through to CloudBar only — the live `sync:status` payload that makes
2815
2849
  // the connect note follow the link instead of freezing at attach time.
2816
2850
  syncStatus,
2817
2851
  // Passed through to CloudBar only — lifts linkedHub changes to the app shell
2818
2852
  // so the GitPanel's cloud-managed posture (DDR-218) reacts live.
2819
2853
  onLinkedHub,
2854
+ onLocalProject,
2855
+ onOpenLinkedFile,
2856
+ filesReady,
2857
+ onShare,
2820
2858
  // feature-cloud-managed-git-posture — the widened DDR-218 gate, resolved once
2821
2859
  // in App and handed down. Withdraws the drafts switcher: a local branch
2822
2860
  // switch moves a HEAD the cell knows nothing about.
@@ -2852,7 +2890,67 @@ function Sidebar({
2852
2890
  const menuExtra = rowMenu.state?.extra;
2853
2891
  const rowMenuRootItems =
2854
2892
  menuExtra?.kind === 'file'
2855
- ? [{ id: 'move-to', label: 'Move to…', onSelect: () => rowMenu.showMoveTo() }]
2893
+ ? [
2894
+ { id: 'share', label: 'Share…', onSelect: () => { rowMenu.close(); onShare(menuExtra.path); } },
2895
+ ...(onRenameCanvas && /\.tsx$/i.test(menuExtra.path)
2896
+ ? [
2897
+ {
2898
+ id: 'rename-canvas',
2899
+ label: 'Rename…',
2900
+ onSelect: () => {
2901
+ rowMenu.close();
2902
+ const current = displayName(menuExtra.path.split('/').pop());
2903
+ const name = window.prompt('Rename canvas to:', current);
2904
+ if (name?.trim() && name.trim() !== current) onRenameCanvas(menuExtra.path, name.trim());
2905
+ },
2906
+ },
2907
+ ]
2908
+ : []),
2909
+ ...(onDuplicateCanvas && /\.tsx$/i.test(menuExtra.path)
2910
+ ? [
2911
+ {
2912
+ id: 'duplicate-canvas',
2913
+ label: 'Duplicate',
2914
+ onSelect: () => {
2915
+ rowMenu.close();
2916
+ onDuplicateCanvas(menuExtra.path);
2917
+ },
2918
+ },
2919
+ ]
2920
+ : []),
2921
+ // Plan T17/L03 — a supporting file renames as itself (the extension
2922
+ // stays: a rename is not a conversion).
2923
+ ...(menuExtra.supporting && onRenameCanvas
2924
+ ? [
2925
+ {
2926
+ id: 'rename-file',
2927
+ label: 'Rename…',
2928
+ onSelect: () => {
2929
+ rowMenu.close();
2930
+ const n = menuExtra.name ?? menuExtra.path.split('/').pop();
2931
+ const ext = n.includes('.') ? n.slice(n.lastIndexOf('.')) : '';
2932
+ const current = ext ? n.slice(0, -ext.length) : n;
2933
+ const name = window.prompt(`Rename ${n} to:`, current);
2934
+ if (name?.trim() && name.trim() !== current) onRenameCanvas(menuExtra.path, name.trim());
2935
+ },
2936
+ },
2937
+ ]
2938
+ : []),
2939
+ ...(menuExtra.canMove ? [{ id: 'move-to', label: 'Move to…', onSelect: () => rowMenu.showMoveTo() }] : []),
2940
+ ...(menuExtra.supporting && onDeleteFile
2941
+ ? [
2942
+ {
2943
+ id: 'delete-file',
2944
+ label: 'Delete',
2945
+ destructive: true,
2946
+ onSelect: () => {
2947
+ rowMenu.close();
2948
+ onDeleteFile(menuExtra.path, menuExtra.name ?? menuExtra.path.split('/').pop());
2949
+ },
2950
+ },
2951
+ ]
2952
+ : []),
2953
+ ]
2856
2954
  : menuExtra?.kind === 'dir'
2857
2955
  ? [
2858
2956
  {
@@ -2864,6 +2962,16 @@ function Sidebar({
2864
2962
  if (name?.trim()) onNewFolder(menuExtra.dirPath, name.trim());
2865
2963
  },
2866
2964
  },
2965
+ {
2966
+ id: 'rename-folder',
2967
+ label: 'Rename folder',
2968
+ onSelect: () => {
2969
+ rowMenu.close();
2970
+ const current = menuExtra.dirPath.split('/').pop();
2971
+ const name = window.prompt('Rename folder to:', current);
2972
+ if (name?.trim() && name.trim() !== current) onRenameFolder(menuExtra.dirPath, name.trim());
2973
+ },
2974
+ },
2867
2975
  {
2868
2976
  id: 'delete-folder',
2869
2977
  label: 'Delete folder',
@@ -2876,7 +2984,7 @@ function Sidebar({
2876
2984
  ]
2877
2985
  : [];
2878
2986
  const rowMenuDestinations =
2879
- menuExtra?.kind === 'file'
2987
+ menuExtra?.kind === 'file' || menuExtra?.kind === 'supporting'
2880
2988
  ? destinations.filter((d) => d.path !== menuExtra.dir)
2881
2989
  : [];
2882
2990
 
@@ -3168,7 +3276,7 @@ function Sidebar({
3168
3276
  canvasKinds={canvasKinds}
3169
3277
  dirPath={g.fullPath}
3170
3278
  drag={!isDs && g.kind === 'canvas' && !readOnly ? treeDrag : undefined}
3171
- menu={!isDs && g.kind === 'canvas' && !readOnly ? rowMenu : undefined}
3279
+ menu={rowMenu}
3172
3280
  />
3173
3281
  ) : (
3174
3282
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -3196,9 +3304,9 @@ function Sidebar({
3196
3304
  and `/_api/git/checkout` keep exactly their old gates, and a terminal
3197
3305
  `git checkout` still works (and still flushes into Yjs via DDR-051's
3198
3306
  watcher). Absent, not disabled — there is nothing to explain here. */}
3199
- {!savingIsManaged && (
3200
- <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
3201
- )}
3307
+ {/* Plan T22 — withdrawn means the BRANCH half. A managed project still
3308
+ needs the way to another project, so the dock stays, project-only. */}
3309
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} projectOnly={savingIsManaged} />
3202
3310
  {/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
3203
3311
  above the GitHub identity. Dev-server-backed, so it works in the desktop
3204
3312
  shell AND a plain browser. */}
@@ -3207,7 +3315,7 @@ function Sidebar({
3207
3315
  Absent rather than disabled, because unlike the agent chat there is
3208
3316
  nothing here to explain — the capability is not missing, it is
3209
3317
  already satisfied. */}
3210
- {cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} /> : null}
3318
+ {cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} onLocalProject={onLocalProject} onOpenFile={onOpenLinkedFile} filesReady={filesReady} /> : null}
3211
3319
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
3212
3320
  sign in, connected account + New/Pull/Share, sign out. Self-contained
3213
3321
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3916,12 +4024,13 @@ function ToolsDropdown({ onAction, onClose, readOnly = false }) {
3916
4024
 
3917
4025
  // Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
3918
4026
  // shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
3919
- function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
4027
+ function FileDropdown({ onAction, onClose, hasCanvas, hasSharePath, readOnly = false }) {
3920
4028
  // Cloud Phase 25 C2 — a viewer keeps the reads (export, handoff, reload,
3921
4029
  // close); create / assemble / generate / settings are absent.
3922
4030
  const items = readOnly
3923
4031
  ? [
3924
4032
  { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
4033
+ { id: 'share', label: 'Share link…', disabled: !hasSharePath },
3925
4034
  { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
3926
4035
  { sep: true },
3927
4036
  { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
@@ -3934,6 +4043,7 @@ function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3934
4043
  // dropped as reference chips on the active canvas.
3935
4044
  { id: 'assemble', label: 'Assemble dropped clips → video', disabled: !hasCanvas },
3936
4045
  { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
4046
+ { id: 'share', label: 'Share link…', disabled: !hasSharePath },
3937
4047
  { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
3938
4048
  { sep: true },
3939
4049
  // feature-ai-media-generation (DDR-16x) — BYOK generate action + settings.
@@ -3995,6 +4105,8 @@ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3995
4105
 
3996
4106
  function Menubar({
3997
4107
  activePath,
4108
+ sharePath,
4109
+ onShare,
3998
4110
  project,
3999
4111
  /** `{ dashboardUrl?, projectName }` when this is a cloud tab, else null. */
4000
4112
  // Tri-state (see the note where this value is created): `undefined` until
@@ -4333,6 +4445,7 @@ function Menubar({
4333
4445
  type="button"
4334
4446
  className="st-menu"
4335
4447
  role="menuitem"
4448
+ data-testid={`menu-${key}`}
4336
4449
  data-tour={key === 'help' ? 'help' : undefined}
4337
4450
  aria-haspopup={hasDropdown ? 'menu' : undefined}
4338
4451
  aria-expanded={hasDropdown ? open : undefined}
@@ -4352,10 +4465,12 @@ function Menubar({
4352
4465
  <FileDropdown
4353
4466
  readOnly={readOnly}
4354
4467
  hasCanvas={!!activePath}
4468
+ hasSharePath={!!sharePath}
4355
4469
  onAction={(id) => {
4356
4470
  if (id === 'new') onNewCanvas?.();
4357
4471
  else if (id === 'assemble') onAssembleVideo?.();
4358
4472
  else if (id === 'export') onOpenExport?.('export');
4473
+ else if (id === 'share') { document.querySelector('[data-testid="menu-file"]')?.focus(); onShare?.(); }
4359
4474
  else if (id === 'handoff') onOpenExport?.('handoff');
4360
4475
  else if (id === 'generate') onOpenGenerate?.();
4361
4476
  else if (id === 'settings') onOpenSettings?.();
@@ -4499,6 +4614,7 @@ function Menubar({
4499
4614
  <StIcon name="sparkle" size={15} />
4500
4615
  </button>
4501
4616
  )}
4617
+ <button type="button" className="st-mb-icon st-share-btn" data-testid="share-btn" data-tip={sharePath ? 'Share link' : 'Open a canvas to share it'} aria-label="Share link" disabled={!sharePath} onClick={onShare}><StIcon name="share" size={15} /></button>
4502
4618
  {exportCenter && <ExportBadge center={exportCenter} />}
4503
4619
  <button
4504
4620
  type="button"
@@ -4548,6 +4664,10 @@ function Viewport({
4548
4664
  cfg,
4549
4665
  loadingPath,
4550
4666
  onIframeLoad,
4667
+ canvasError,
4668
+ canvasReloadNonce,
4669
+ onRetryCanvasLoad,
4670
+ loadedPath,
4551
4671
  showQuickSetup,
4552
4672
  onStartQuickSetup,
4553
4673
  previewPath,
@@ -4564,8 +4684,21 @@ function Viewport({
4564
4684
  useEffect(() => {
4565
4685
  if (previewPath) previewRef.current?.focus();
4566
4686
  }, [previewPath]);
4687
+ // One observable word for "what is the canvas pane actually doing" — the
4688
+ // top-frame signal the #115 E2E scenario waits on, and the only place these
4689
+ // three states are named together. `ready` is `dgn:'loaded'`-backed, so it
4690
+ // cannot be faked by a timer expiring (which is exactly how the bug used to
4691
+ // look successful: skeleton gone, pane white).
4692
+ const canvasState =
4693
+ canvasError && canvasError.path === activePath
4694
+ ? 'error'
4695
+ : activePath && activePath !== SYSTEM_TAB && loadedPath === activePath
4696
+ ? 'ready'
4697
+ : loadingPath && loadingPath === activePath
4698
+ ? 'loading'
4699
+ : 'idle';
4567
4700
  return (
4568
- <div className="viewport st-stage" data-tour="viewport">
4701
+ <div className="viewport st-stage" data-tour="viewport" data-canvas-state={canvasState}>
4569
4702
  {previewPath && (
4570
4703
  // feature-studio-file-preview — an overlay, not a tab: the canvas
4571
4704
  // iframe (if any) stays mounted underneath so switching back to it
@@ -4638,7 +4771,10 @@ function Viewport({
4638
4771
  }
4639
4772
  return (
4640
4773
  <iframe
4641
- key={t.path}
4774
+ // The nonce is part of the key ONLY so the #115 Retry can force a
4775
+ // remount; it is otherwise constant, so normal switching keys on the
4776
+ // path exactly as before.
4777
+ key={`${t.path}#${canvasReloadNonce}`}
4642
4778
  ref={(el) => registerIframe(t.path, el)}
4643
4779
  src={canvasUrl(t.path, cfg)}
4644
4780
  className={t.path === activePath ? 'active' : ''}
@@ -4669,6 +4805,45 @@ function Viewport({
4669
4805
  </div>
4670
4806
  </div>
4671
4807
  )}
4808
+ {canvasError && canvasError.path === activePath && (
4809
+ // issue #115 — what the blank pane used to be. Names which of the two
4810
+ // failures happened and offers the recovery that used to require
4811
+ // quitting the app (or knowing that switching projects and back
4812
+ // re-reads /_config).
4813
+ <div className="st-canvas-error" role="alert" data-testid="canvas-load-error">
4814
+ <div className="st-canvas-error-card">
4815
+ <div className="st-canvas-error-title">
4816
+ {canvasError.kind === 'server'
4817
+ ? "Maude's server isn't responding"
4818
+ : "This canvas didn't finish loading"}
4819
+ </div>
4820
+ <div className="st-canvas-error-body">
4821
+ {canvasError.kind === 'server' ? (
4822
+ <>
4823
+ The canvas couldn't be reached. This usually means Maude's
4824
+ server restarted underneath this window — your files on disk
4825
+ are untouched.
4826
+ </>
4827
+ ) : (
4828
+ <>
4829
+ The server is up, but{' '}
4830
+ <code>{sanitizeDisplayText(basename(canvasError.path))}</code>{' '}
4831
+ never finished compiling. Its own error, if it has one, is in
4832
+ the canvas frame.
4833
+ </>
4834
+ )}
4835
+ </div>
4836
+ <button
4837
+ type="button"
4838
+ className="btn btn--primary"
4839
+ data-testid="canvas-load-retry"
4840
+ onClick={() => onRetryCanvasLoad?.(canvasError.path)}
4841
+ >
4842
+ Reload canvas
4843
+ </button>
4844
+ </div>
4845
+ </div>
4846
+ )}
4672
4847
  </div>
4673
4848
  );
4674
4849
  }
@@ -5504,6 +5679,11 @@ const CSS_JUSTIFY = [
5504
5679
  'space-evenly',
5505
5680
  ];
5506
5681
  const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
5682
+ /** What an edit route says the write replaced (`previous`, read under the file
5683
+ * lock) — the value an undo must restore. `fallback` only when the route could
5684
+ * not name it (an expression, or an older server). */
5685
+ const replacedValue = (j, fallback) =>
5686
+ j && Object.hasOwn(j, 'previous') ? j.previous : fallback;
5507
5687
  const CSS_FONTS = [
5508
5688
  'inherit',
5509
5689
  'system-ui',
@@ -6396,12 +6576,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6396
6576
  // no longer triggers a reselect that would re-post fresh `authored` values — so
6397
6577
  // the panel must reflect its own commits immediately or it shows the stale
6398
6578
  // pre-edit value until the user re-selects. Each commit/reset writes here;
6399
- // `null` marks a removed key. Cleared when a different element is selected.
6579
+ // `null` marks a removed key. Cleared when a different element is selected —
6580
+ // and whenever the canvas re-posts this selection's maps. A re-post carries
6581
+ // the source as it is NOW (a teammate's change reloads the canvas); keeping
6582
+ // our older commit over it showed a stale value and recorded it as the next
6583
+ // undo's `before`, so Cmd+Z restored a value nobody had on screen.
6400
6584
  const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
6401
- // biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
6585
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clear on a fresh selection payload only.
6402
6586
  useEffect(() => {
6403
6587
  setOverlay({ a: {}, c: {}, t: {} });
6404
- }, [el.id]);
6588
+ }, [el.id, el.authored, el.customStyles, el.attrs]);
6405
6589
  const mergeOverlay = (base, ov) => {
6406
6590
  const out = { ...(base || {}) };
6407
6591
  for (const [k, v] of Object.entries(ov)) {
@@ -6486,10 +6670,27 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6486
6670
  ...s,
6487
6671
  [key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
6488
6672
  }));
6673
+ return res.ok ? j : null;
6489
6674
  } catch (err) {
6490
6675
  setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
6676
+ return null;
6491
6677
  }
6492
6678
  }
6679
+ // Write, THEN record the undo entry from what the write actually replaced
6680
+ // (`previous`, read server-side under the file lock). The panel's own value
6681
+ // can be a teammate's edit old — it is not re-posted when a peer changes the
6682
+ // source — and recording it made Cmd+Z restore a value nobody had on screen.
6683
+ // Serialized so the stack keeps edit order; a failed write records nothing.
6684
+ const writeChainRef = useRef(Promise.resolve());
6685
+ const writeAndRecord = (url, payload, key, op, prop, fallbackBefore, after) => {
6686
+ writeChainRef.current = writeChainRef.current.then(async () => {
6687
+ const j = await post(url, payload, key);
6688
+ if (!j?.ok) return;
6689
+ const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
6690
+ if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
6691
+ record(op, prop, before, after);
6692
+ });
6693
+ };
6493
6694
  // Optimistic preview: nudge the live element so the change shows before the
6494
6695
  // edit → HMR reload lands. `value` null = remove (reset path). No-op when the
6495
6696
  // selection has no stable id (can't be resolved in the canvas).
@@ -6523,8 +6724,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6523
6724
  if (value === (before ?? '').trim()) return; // no-op
6524
6725
  optimistic(property, value);
6525
6726
  setA(property, value); // reflect in the panel immediately (no reload → no reselect)
6526
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
6527
- record('css', property, before, value);
6727
+ writeAndRecord(
6728
+ '/_api/edit-css',
6729
+ { canvas: el.file, id: el.id, property, value },
6730
+ property,
6731
+ 'css',
6732
+ property,
6733
+ before,
6734
+ value
6735
+ );
6528
6736
  };
6529
6737
  // A custom CSS property (Advanced) — same write, but the panel surfaces it from
6530
6738
  // the customStyles map, so overlay THERE.
@@ -6535,8 +6743,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6535
6743
  const before = customStyles[prop] ?? null;
6536
6744
  optimistic(prop, value);
6537
6745
  setC(prop, value);
6538
- post('/_api/edit-css', { canvas: el.file, id: el.id, property: prop, value }, prop);
6539
- record('css', prop, before, value);
6746
+ writeAndRecord(
6747
+ '/_api/edit-css',
6748
+ { canvas: el.file, id: el.id, property: prop, value },
6749
+ prop,
6750
+ 'css',
6751
+ prop,
6752
+ before,
6753
+ value
6754
+ );
6540
6755
  };
6541
6756
  const commitAttr = (attr, raw) => {
6542
6757
  const a = (attr || '').trim();
@@ -6544,8 +6759,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6544
6759
  if (!editable || !a || !value) return;
6545
6760
  const before = attrs[a] ?? null;
6546
6761
  setT(a, value);
6547
- post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
6548
- record('attr', a, before, value);
6762
+ writeAndRecord(
6763
+ '/_api/edit-attr',
6764
+ { canvas: el.file, id: el.id, attr: a, value },
6765
+ `@${a}`,
6766
+ 'attr',
6767
+ a,
6768
+ before,
6769
+ value
6770
+ );
6549
6771
  };
6550
6772
  // Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
6551
6773
  const reset = (property) => {
@@ -6553,23 +6775,44 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6553
6775
  const before = authored[property] ?? null;
6554
6776
  optimistic(property, null);
6555
6777
  setA(property, null);
6556
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
6557
- record('css', property, before, null);
6778
+ writeAndRecord(
6779
+ '/_api/edit-css',
6780
+ { canvas: el.file, id: el.id, property, reset: true },
6781
+ property,
6782
+ 'css',
6783
+ property,
6784
+ before,
6785
+ null
6786
+ );
6558
6787
  };
6559
6788
  const resetCustom = (property) => {
6560
6789
  if (!editable) return;
6561
6790
  const before = customStyles[property] ?? null;
6562
6791
  optimistic(property, null);
6563
6792
  setC(property, null);
6564
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
6565
- record('css', property, before, null);
6793
+ writeAndRecord(
6794
+ '/_api/edit-css',
6795
+ { canvas: el.file, id: el.id, property, reset: true },
6796
+ property,
6797
+ 'css',
6798
+ property,
6799
+ before,
6800
+ null
6801
+ );
6566
6802
  };
6567
6803
  const resetAttr = (attr) => {
6568
6804
  if (!editable) return;
6569
6805
  const before = attrs[attr] ?? null;
6570
6806
  setT(attr, null);
6571
- post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
6572
- record('attr', attr, before, null);
6807
+ writeAndRecord(
6808
+ '/_api/edit-attr',
6809
+ { canvas: el.file, id: el.id, attr, reset: true },
6810
+ `@${attr}`,
6811
+ 'attr',
6812
+ attr,
6813
+ before,
6814
+ null
6815
+ );
6573
6816
  };
6574
6817
  // Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
6575
6818
  // `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
@@ -9598,6 +9841,9 @@ function InspectorPanel({
9598
9841
 
9599
9842
  function App() {
9600
9843
  const [groups, setGroups] = useState([]);
9844
+ const [treeLoaded, setTreeLoaded] = useState(false);
9845
+ const addressMode = useRef('push');
9846
+ const previousAddressPath = useRef(null);
9601
9847
  const [project, setProject] = useState('Design');
9602
9848
  const [tabs, setTabs] = useState([]);
9603
9849
  const [activePath, setActivePath] = useState(null);
@@ -9607,6 +9853,7 @@ function App() {
9607
9853
  // state, or iframe registration that openTab()/openSystem() drive.
9608
9854
  const [previewPath, setPreviewPath] = useState(null);
9609
9855
  const onPreview = useCallback((path) => {
9856
+ addressMode.current = 'push';
9610
9857
  setPreviewPath(path);
9611
9858
  }, []);
9612
9859
  const [selected, setSelected] = useState(null);
@@ -9713,6 +9960,30 @@ function App() {
9713
9960
  // after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
9714
9961
  // so the dgn:'loaded' handler re-selects the moved element by its NEW id.
9715
9962
  const pendingReorderRef = useRef(null);
9963
+ // The last layers tree the canvas posted, and its id signature. A duplicate
9964
+ // or insert renumbers every later positional id, so the copy's NEW id already
9965
+ // names another element (the next sibling) in the pre-write tree: settling
9966
+ // on the first tree that merely contains it selected the wrong node. The
9967
+ // pending entry records the signature at request time and waits for a tree
9968
+ // that differs from it.
9969
+ const lastLayersTreeRef = useRef(null);
9970
+ const settlePendingSelectionRef = useRef(null);
9971
+ settlePendingSelectionRef.current = (tree, sig, artboardId) => {
9972
+ const pend = pendingReorderRef.current;
9973
+ if (!pend || !pend.movedId) return;
9974
+ if (pend.staleSig != null && sig === pend.staleSig) return; // still the pre-write DOM
9975
+ const has = (function find(nodes) {
9976
+ return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
9977
+ })(tree);
9978
+ if (!has) return;
9979
+ pendingReorderRef.current = null;
9980
+ const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
9981
+ if (win) {
9982
+ try {
9983
+ win.postMessage({ dgn: 'select-by-id', id: pend.movedId, artboardId, index: 0 }, '*');
9984
+ } catch {}
9985
+ }
9986
+ };
9716
9987
  // Latest `reorderLayer` (defined far below), read from the stale-closure
9717
9988
  // onMessage handler when the in-canvas grip posts dgn:'reorder-request'
9718
9989
  // (same freshness idiom as selectedRef; avoids a render-time TDZ on the
@@ -9763,10 +10034,52 @@ function App() {
9763
10034
  // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
9764
10035
  // WS message the linked-mode sync runtime emits. null in solo mode.
9765
10036
  const [syncStatus, setSyncStatus] = useState(null);
10037
+ // feature-large-project-seed — the NATIVE half of seed progress.
10038
+ //
10039
+ // A seed against a large project is minutes to hours of work, and the target
10040
+ // user (DDR-177) never opens a terminal. The Sync panel is the detail view;
10041
+ // this is what reaches them when the panel is not on screen: the window
10042
+ // title while it runs, and ONE notification per phase transition.
10043
+ //
10044
+ // Per TRANSITION, never per file. A 2 961-file seed must produce two
10045
+ // notifications, not 2 961 — `send_notification` throttles on the Rust side
10046
+ // too, but a client that leans on that is a client that would spam a plain
10047
+ // browser tab through the Web fallback.
10048
+ const seedPhaseRef = useRef(null);
10049
+ useEffect(() => {
10050
+ const p = syncStatus?.files?.progress;
10051
+ if (!p) return;
10052
+ const prev = seedPhaseRef.current;
10053
+ seedPhaseRef.current = p.phase;
10054
+ if (prev === null || prev === p.phase) return;
10055
+ if (p.phase === 'converged' && (prev === 'seeding' || prev === 'paused')) {
10056
+ notifyDesktop('Project synced', `${p.delivered} files are up to date.`);
10057
+ } else if (p.phase === 'blocked') {
10058
+ const total = (p.blocked ?? []).reduce((n, b) => n + b.count, 0);
10059
+ notifyDesktop(
10060
+ 'Sync needs a decision',
10061
+ `${total} file${total === 1 ? '' : 's'} could not be sent. Open the Sync panel for the reason.`
10062
+ );
10063
+ }
10064
+ }, [syncStatus?.files?.progress]);
10065
+ // The title carries the count while a seed runs, and is restored afterwards —
10066
+ // it is the one surface visible without opening anything.
10067
+ useEffect(() => {
10068
+ if (typeof document === 'undefined') return undefined;
10069
+ const p = syncStatus?.files?.progress;
10070
+ const base = document.title.replace(/\s+—\s+syncing\s+\d+\/\d+$/, '');
10071
+ if (p && (p.phase === 'seeding' || p.phase === 'paused') && p.tracked > 0) {
10072
+ document.title = `${base} — syncing ${p.delivered}/${p.tracked}`;
10073
+ } else {
10074
+ document.title = base;
10075
+ }
10076
+ return undefined;
10077
+ }, [syncStatus?.files?.progress]);
9766
10078
  // DDR-218 — the folder's cloud link ({url, credentialed} | null), lifted from
9767
10079
  // CloudBar (status resolve / attach / detach). Gates the GitPanel's
9768
10080
  // cloud-managed posture; linked-but-uncredentialed keeps the full panel.
9769
10081
  const [cloudLinkedHub, setCloudLinkedHub] = useState(null);
10082
+ const [localProjectName, setLocalProjectName] = useState(null);
9770
10083
  // Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
9771
10084
  // server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
9772
10085
  // `diffTarget` opens the before/after DiffView ({ file, conflict }).
@@ -9790,6 +10103,24 @@ function App() {
9790
10103
  // Canvas-compile skeleton (single-canvas model → one path at a time).
9791
10104
  const [loadingPath, setLoadingPath] = useState(null);
9792
10105
  const loadFallbackTimer = useRef(null);
10106
+ // issue #115 — a canvas that never reported `loaded`. `{ path, kind }`, where
10107
+ // kind is 'server' (the canvas origin did not answer /_health — a dead or
10108
+ // respawned sidecar) or 'compile' (the origin is up, the canvas itself never
10109
+ // mounted). Rendered instead of the blank pane the cap used to leave behind.
10110
+ const [canvasError, setCanvasError] = useState(null);
10111
+ // Bumped by the error panel's Retry. Folded into the iframe `key` so a retry
10112
+ // REMOUNTS the frame — re-setting `src` to the identical URL would not
10113
+ // re-navigate, and after a respawn the URL is identical in every case except
10114
+ // the port that `loadServerConfig()` is about to correct.
10115
+ const [canvasReloadNonce, setCanvasReloadNonce] = useState(0);
10116
+ // The canvas whose SHELL DOCUMENT has actually loaded, per its `dgn:'loaded'`
10117
+ // post. Distinct from `!loadingPath`, which the 2.5 s onLoad fallback and the
10118
+ // 15 s cap also clear — this one only ever means "the shell ran". That makes
10119
+ // it the honest signal for `data-canvas-state` below, and the one the #115 E2E
10120
+ // scenario asserts: the shell's inline script posts it whether or not the
10121
+ // canvas TSX went on to build, so it isolates "the origin was reachable" from
10122
+ // "the canvas compiled" — exactly the axis #115 is about.
10123
+ const [loadedPath, setLoadedPath] = useState(null);
9793
10124
  // Resizable side panels (DS components-resize-panels) + the active drag side.
9794
10125
  const sbSize = usePanelSize('maude-sb-w', { min: 200, max: 420, def: 252 });
9795
10126
  const rpSize = usePanelSize('maude-rp-w', { min: 260, max: 480, def: 304 });
@@ -9825,11 +10156,6 @@ function App() {
9825
10156
  setLoadingPath((p) => (p === path ? null : p));
9826
10157
  }, 2500);
9827
10158
  }, []);
9828
- useEffect(() => {
9829
- if (!loadingPath) return;
9830
- const cap = setTimeout(() => setLoadingPath(null), 15000);
9831
- return () => clearTimeout(cap);
9832
- }, [loadingPath]);
9833
10159
  // Loaded at boot from /_config and re-fetched on the server's
9834
10160
  // `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
9835
10161
  // it mid-session) — informs canvasUrl() so TSX iframes can pass the right
@@ -9846,6 +10172,44 @@ function App() {
9846
10172
  // for one frame, each fired the request its shell exists to refuse, and then
9847
10173
  // unmounted. Nothing was broken and everything looked broken.
9848
10174
  const [cfg, setCfg] = useState({ designRel: '.design', cloud: undefined });
10175
+ // THE CAP MUST NOT CLEAR TO WHITE — issue #115. This used to be a bare
10176
+ // `setLoadingPath(null)`, which is the most useless thing it could do: when
10177
+ // the canvas origin is unreachable the shell HTML never loads, so the shell's
10178
+ // own `#canvas-mount-error` surface (templates/_shell.html) does not exist to
10179
+ // report anything, and dropping the skeleton left a blank pane with no error,
10180
+ // no reason and no way back inside the app. Probe the canvas origin to tell
10181
+ // the two failures apart — a dead server (the #115 stale-origin case, and any
10182
+ // sidecar crash) versus a canvas that genuinely never finished compiling —
10183
+ // and hand the user the recovery in both cases.
10184
+ //
10185
+ // DECLARED AFTER `cfg`, ON PURPOSE — the same rule the block below states for
10186
+ // the git flags, and this effect is what taught us it is not decorative. Its
10187
+ // dependency array reads `cfg?.canvasOrigin`, and A DEPENDENCY ARRAY IS
10188
+ // EVALUATED DURING RENDER: sitting above the `useState` above, it hit `cfg` in
10189
+ // its temporal dead zone, so `App` threw `Cannot access 'cfg' before
10190
+ // initialization` on its very first render and the WHOLE STUDIO mounted
10191
+ // nothing — a white page for every user, not only the desktop shell the fix
10192
+ // was written for. It reached the branch because the PR carried a merge
10193
+ // conflict, which stops GitHub from building a merge ref, which means the
10194
+ // client-boot gate never ran on it (issue #112 close-out).
10195
+ useEffect(() => {
10196
+ if (!loadingPath) return;
10197
+ const path = loadingPath;
10198
+ const cap = setTimeout(async () => {
10199
+ let kind = 'compile';
10200
+ try {
10201
+ const r = await fetch(`${cfg?.canvasOrigin || ''}/_health`, {
10202
+ cache: 'no-store',
10203
+ });
10204
+ if (!r.ok) kind = 'server';
10205
+ } catch {
10206
+ kind = 'server';
10207
+ }
10208
+ setCanvasError({ path, kind });
10209
+ setLoadingPath((p) => (p === path ? null : p));
10210
+ }, 15000);
10211
+ return () => clearTimeout(cap);
10212
+ }, [loadingPath, cfg?.canvasOrigin]);
9849
10213
  // WHO IS SAVING THIS PROJECT — one expression, read by every surface that
9850
10214
  // would otherwise offer to save it, poll for it, or badge it (DDR-218, widened
9851
10215
  // by feature-cloud-managed-git-posture).
@@ -10039,6 +10403,7 @@ function App() {
10039
10403
  const [paletteOpen, setPaletteOpen] = useState(false);
10040
10404
  // T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
10041
10405
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
10406
+ const [shareDialog, setShareDialog] = useState(null);
10042
10407
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
10043
10408
  // feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
10044
10409
  // the AI generate action.
@@ -11381,37 +11746,47 @@ function App() {
11381
11746
  }, []);
11382
11747
 
11383
11748
  // ----- Tree -----
11384
- const loadTree = useCallback(async () => {
11385
- try {
11386
- const r = await fetch('/_index-data');
11387
- const data = await r.json();
11388
- setProject(data.project || 'Design');
11389
- const built = data.groups.map((g) => ({
11390
- ...g,
11391
- tree: buildTree(g.paths, g.stripPrefix, g.dirs),
11392
- }));
11393
- setGroups(built);
11394
- // DDR-093 — fold the server-resolved per-canvas DS map into cfg so
11395
- // canvasUrl() injects each UI canvas's OWN design-system tokens instead of
11396
- // always designSystems[0]. Functional merge to coexist with the /_config
11397
- // fetch (either may land first). `?? {}` keeps older servers (no map) on
11398
- // the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
11399
- // canvas refreshes the map.
11400
- setCfg((prev) => ({
11401
- ...prev,
11402
- canvasDesignSystems: data.canvasDesignSystems ?? {},
11403
- // DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
11404
- // only `reconstructed-experimental`), folded in the same way + for the
11405
- // same reason as canvasDesignSystems above.
11406
- canvasKinds: data.canvasKinds ?? {},
11407
- }));
11408
- } catch (e) {
11409
- console.error('failed to load tree', e);
11410
- }
11411
- }, []);
11412
-
11749
+ const treeLoaderRef = useRef(null);
11750
+ const loadTree = useCallback(() => treeLoaderRef.current?.reload() ?? Promise.resolve(), []);
11413
11751
  useEffect(() => {
11752
+ const loader = createIndexLoader({
11753
+ read: async (signal) => {
11754
+ const r = await fetch('/_index-data', { signal, cache: 'no-store' });
11755
+ if (!r.ok) throw Object.assign(new Error(`Project index request failed: ${r.status}`), { status: r.status });
11756
+ const data = await r.json();
11757
+ const built = data.groups.map((g) => ({
11758
+ ...g,
11759
+ tree: buildTree(g.paths, g.stripPrefix, g.dirs),
11760
+ }));
11761
+ return { data, built };
11762
+ },
11763
+ apply: ({ data, built }) => {
11764
+ setProject(data.project || 'Design');
11765
+ setGroups(built);
11766
+ setTreeLoaded(true);
11767
+ // DDR-093 — fold the server-resolved per-canvas DS map into cfg so
11768
+ // canvasUrl() injects each UI canvas's OWN design-system tokens instead of
11769
+ // always designSystems[0]. Functional merge to coexist with the /_config
11770
+ // fetch (either may land first). `?? {}` keeps older servers (no map) on
11771
+ // the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
11772
+ // canvas refreshes the map.
11773
+ setCfg((prev) => ({
11774
+ ...prev,
11775
+ canvasDesignSystems: data.canvasDesignSystems ?? {},
11776
+ // DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
11777
+ // only `reconstructed-experimental`), folded in the same way + for the
11778
+ // same reason as canvasDesignSystems above.
11779
+ canvasKinds: data.canvasKinds ?? {},
11780
+ }));
11781
+ },
11782
+ onError: (error) => console.error('failed to load tree', error),
11783
+ });
11784
+ treeLoaderRef.current = loader;
11414
11785
  loadTree();
11786
+ return () => {
11787
+ treeLoaderRef.current = null;
11788
+ loader.dispose();
11789
+ };
11415
11790
  }, [loadTree]);
11416
11791
 
11417
11792
  // ----- System data (lazy) -----
@@ -11460,6 +11835,7 @@ function App() {
11460
11835
  }, [loadAllComments]);
11461
11836
 
11462
11837
  // ----- WebSocket -----
11838
+ const canvasListChangeRef = useRef(() => {});
11463
11839
  useEffect(() => {
11464
11840
  // KEEPALIVE. The inspector feed only pushes on events (a comment, a
11465
11841
  // selection, sync:status), so an idle designer's socket exchanges nothing
@@ -11471,12 +11847,30 @@ function App() {
11471
11847
  // 25 s keeps the socket active (Bun resets the idle timer on any frame,
11472
11848
  // inbound OR outbound); the server ignores an unknown message type.
11473
11849
  let pingTimer = null;
11850
+ // A RECONNECT MEANS THE PEER MAY BE A DIFFERENT PROCESS — issue #115.
11851
+ // The desktop supervisor respawns the sidecar when it dies (DDR-235's Bun
11852
+ // fault is a recurring one), and everything this page cached from `/_config`
11853
+ // was published by the process that just went away. `canvasOrigin` is the
11854
+ // one that bites: it is an OS-assigned ephemeral port (server.ts's
11855
+ // `startCanvasServer(0)`), so it is DIFFERENT in every process, while the
11856
+ // main origin walks a deterministic ladder from 4399 and usually reclaims
11857
+ // its old port. The result was a half-live page — socket back up, status bar
11858
+ // `live`, tree and panels fine — whose every canvas iframe navigated to a
11859
+ // dead port. Since a canvas switch mounts a FRESH iframe (single-canvas
11860
+ // model, keyed by path), the already-open canvas kept rendering and only
11861
+ // switching went white, silently and permanently.
11862
+ //
11863
+ // So: re-derive the config from whoever is answering now. Only on a
11864
+ // RE-connect — the boot effect above already did the first fetch.
11865
+ let everConnected = false;
11474
11866
  function connect() {
11475
11867
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
11476
11868
  const ws = new WebSocket(proto + '//' + location.host + '/_ws');
11477
11869
  wsRef.current = ws;
11478
11870
  ws.addEventListener('open', () => {
11479
11871
  setWsConnected(true);
11872
+ if (everConnected) loadServerConfig();
11873
+ everConnected = true;
11480
11874
  clearInterval(pingTimer);
11481
11875
  pingTimer = setInterval(() => {
11482
11876
  if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}');
@@ -11553,26 +11947,8 @@ function App() {
11553
11947
  // Phase 9 Task 8 — hub connection state for the offline banner.
11554
11948
  setSyncStatus(m.payload);
11555
11949
  } else if (m.type === 'canvas-list-update') {
11556
- // Phase 30 — a canvas was created/deleted on THIS dev-server; re-read
11557
- // the branch-scoped tree so other open tabs reflect it without a
11558
- // reload. Cross-machine peers get a new canvas via git "Get latest".
11559
11950
  loadTree();
11560
- // feature-file-tree-drag-drop-folders (Task 10) — a canvas moved.
11561
- // moveCanvasReq already retargets the INITIATING tab locally (no
11562
- // need to wait for this broadcast to round-trip); this branch is
11563
- // for every OTHER open tab on the same dev-server, so a canvas
11564
- // that was open there doesn't go dead pointing at a path that no
11565
- // longer exists.
11566
- if (m.payload?.action === 'moved' && m.payload.fromRel && m.payload.rel) {
11567
- const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
11568
- /^\/+|\/+$/g,
11569
- ''
11570
- );
11571
- const fromFile = `${designRel}/${m.payload.fromRel}`;
11572
- const toFile = `${designRel}/${m.payload.rel}`;
11573
- setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
11574
- setActivePath((prev) => (prev === fromFile ? toFile : prev));
11575
- }
11951
+ canvasListChangeRef.current(m.payload);
11576
11952
  } else if (m.type === 'config-updated') {
11577
11953
  // Server hot-reloaded .design/config.json (/design:setup-ds rewrote
11578
11954
  // it) — refetch /_config so designSystems / tokensCssRel / groups
@@ -11775,6 +12151,39 @@ function App() {
11775
12151
  }
11776
12152
  }, []);
11777
12153
 
12154
+ // ACCEPTED REVISIONS — the project's own history (DDR-241, T27). When the
12155
+ // project saves through accepted revisions the History tab lists logical
12156
+ // actions (who did what, when), not Git commits; a row previews as `r<rev>`
12157
+ // through the same version preview. `'legacy'` means "use Git history".
12158
+ const [projectHistoryOn, setProjectHistoryOn] = useState(false);
12159
+ const loadAcceptedLog = useCallback(async (path) => {
12160
+ try {
12161
+ const qs =
12162
+ '/_api/project/history?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
12163
+ const r = await fetch(qs);
12164
+ if (!r.ok) return 'legacy';
12165
+ const data = await r.json();
12166
+ if (!data?.ok) return data?.reason === 'legacy' ? 'legacy' : null;
12167
+ return (data.history || []).map((a) => {
12168
+ const where = path ? '' : (a.canvases || []).map((c) => c.split('/').pop()).join(', ');
12169
+ return {
12170
+ sha: `r${a.revision}`,
12171
+ message: [a.label || a.kind, where].filter(Boolean).join(' · '),
12172
+ author: a.actor,
12173
+ date: new Date(a.committedAt).toISOString(),
12174
+ accepted: {
12175
+ revision: a.revision,
12176
+ actionId: a.actionId,
12177
+ mine: !!a.mine,
12178
+ undo: a.kind === 'undo' || a.kind === 'redo',
12179
+ },
12180
+ };
12181
+ });
12182
+ } catch {
12183
+ return null;
12184
+ }
12185
+ }, []);
12186
+
11778
12187
  // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
11779
12188
  // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
11780
12189
  // live with the WS broadcast.
@@ -11818,18 +12227,99 @@ function App() {
11818
12227
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
11819
12228
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
11820
12229
  const openTab = useCallback((path) => {
12230
+ addressMode.current = 'push';
12231
+ setFocusedCommentId(null);
12232
+ setPreviewPath(null);
12233
+ // The same path keeps its mounted iframe. It will not emit a new loaded
12234
+ // event, so keep its current loading/error state until an actual retry.
12235
+ if (path === activePath) return;
11821
12236
  setTabs((prev) => {
11822
12237
  // Drop the previously-open iframe so we don't leak DOM nodes.
11823
12238
  for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
11824
12239
  return [{ path }];
11825
12240
  });
11826
12241
  setActivePath(path);
11827
- setFocusedCommentId(null);
11828
- setPreviewPath(null);
12242
+ setCanvasError(null);
12243
+ setLoadedPath(null);
11829
12244
  // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
11830
12245
  // the onLoad fallback timer (legacy .html), or a hard 15s cap.
11831
12246
  if (path !== SYSTEM_TAB) setLoadingPath(path);
11832
- }, []);
12247
+ }, [activePath]);
12248
+
12249
+ // Resolve URL identities against the loaded tree, including non-canvas previews.
12250
+ const openLinkedFile = useCallback((rel, mode = 'push') => {
12251
+ const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
12252
+ if (path && CANVAS_EXT_RE.test(path)) openTab(path);
12253
+ else if (path && previewKind(basename(path))) onPreview(path);
12254
+ else {
12255
+ setTabs([]);
12256
+ setActivePath(null);
12257
+ setPreviewPath(null);
12258
+ notify({ title: 'Not here yet', description: `${rel} is not in this project (not synced yet?)`, kind: 'info' });
12259
+ }
12260
+ addressMode.current = mode;
12261
+ }, [groups, cfg.designRel, openTab, onPreview]);
12262
+
12263
+ const addressBooted = useRef(false);
12264
+ useEffect(() => {
12265
+ if (!treeLoaded || cfg.cloud === undefined || addressBooted.current) return;
12266
+ addressBooted.current = true;
12267
+ const rel = readOpenParam(location, cfg.designRel);
12268
+ if (rel) openLinkedFile(rel, 'none');
12269
+ }, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
12270
+
12271
+ useEffect(() => {
12272
+ if (!treeLoaded || cfg.cloud === undefined) return;
12273
+ const navigate = () => {
12274
+ const rel = readOpenParam(location, cfg.designRel);
12275
+ if (rel) openLinkedFile(rel, 'none');
12276
+ else {
12277
+ addressMode.current = 'none';
12278
+ setTabs([]);
12279
+ setActivePath(null);
12280
+ setPreviewPath(null);
12281
+ }
12282
+ };
12283
+ window.addEventListener('popstate', navigate);
12284
+ return () => window.removeEventListener('popstate', navigate);
12285
+ }, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
12286
+
12287
+ useEffect(() => {
12288
+ const visible = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
12289
+ if (visible === previousAddressPath.current) return;
12290
+ previousAddressPath.current = visible;
12291
+ const rel = normalizeOpenPath(visible, cfg.designRel);
12292
+ if (addressMode.current !== 'none' && readOpenParam(location, cfg.designRel) !== rel) {
12293
+ history[rel ? 'pushState' : 'replaceState'](rel ? { open: rel } : null, '', withOpenParam(location, rel));
12294
+ }
12295
+ addressMode.current = 'push';
12296
+ }, [activePath, previewPath, cfg.designRel]);
12297
+
12298
+ const sharePath = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
12299
+ const shareShell = cfg.cloud ? 'cloud' : isNativeApp() ? 'native' : 'local';
12300
+ const shareLinksFor = (path) => buildShareLinks({
12301
+ rel: normalizeOpenPath(path, cfg.designRel), shell: shareShell, location,
12302
+ linkedHubUrl: cloudLinkedHub?.url, project: cfg.cloud ? project : localProjectName,
12303
+ localUrl: location.origin,
12304
+ });
12305
+ const showShare = (path = sharePath) => {
12306
+ const rel = normalizeOpenPath(path, cfg.designRel);
12307
+ if (rel) setShareDialog({ path, rel, label: basename(path) });
12308
+ };
12309
+
12310
+ // Retry from the #115 error panel: re-read /_config FIRST (the stale
12311
+ // `canvasOrigin` is the likeliest reason we are here at all), then remount the
12312
+ // iframe via the nonce so it navigates against the corrected origin.
12313
+ const retryCanvasLoad = useCallback(
12314
+ (path) => {
12315
+ setCanvasError(null);
12316
+ setLoadedPath(null);
12317
+ loadServerConfig();
12318
+ setCanvasReloadNonce((n) => n + 1);
12319
+ if (path && path !== SYSTEM_TAB) setLoadingPath(path);
12320
+ },
12321
+ [loadServerConfig]
12322
+ );
11833
12323
 
11834
12324
  /**
11835
12325
  * First open lands on a rendered canvas — Cloud Phase 27 C3.
@@ -11847,6 +12337,7 @@ function App() {
11847
12337
  useEffect(() => {
11848
12338
  if (autoOpened.current) return;
11849
12339
  if (!cfg.cloud) return; // desktop / unknown-yet
12340
+ if (new URLSearchParams(location.search).has('open')) { autoOpened.current = true; return; }
11850
12341
  if (tabs.length > 0) return;
11851
12342
  if (!groups.length) return; // tree not loaded
11852
12343
  // The first canvas that is somebody's WORK — a design-system specimen is a
@@ -11907,6 +12398,23 @@ function App() {
11907
12398
  [activePath]
11908
12399
  );
11909
12400
 
12401
+ // The socket stays connected while this ref follows the current config and
12402
+ // active tab. Structural events apply to local and remote users alike.
12403
+ canvasListChangeRef.current = (change) => {
12404
+ if (!change?.rel) return;
12405
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
12406
+ const file = `${designRel}/${change.rel}`;
12407
+ if (change.action === 'removed') {
12408
+ closeTab(file);
12409
+ } else if (change.action === 'moved' && change.fromRel) {
12410
+ const fromFile = `${designRel}/${change.fromRel}`;
12411
+ setTabs((prev) => prev.map((t) => t.path === fromFile ? { ...t, path: file } : t));
12412
+ setActivePath((prev) => prev === fromFile ? file : prev);
12413
+ setLoadingPath((prev) => prev === fromFile ? null : prev);
12414
+ iframesRef.current.delete(fromFile);
12415
+ }
12416
+ };
12417
+
11910
12418
  const reloadActive = useCallback(() => {
11911
12419
  if (!activePath || activePath === SYSTEM_TAB) {
11912
12420
  if (activePath === SYSTEM_TAB) loadSystemData();
@@ -12291,6 +12799,100 @@ function App() {
12291
12799
  [loadTree]
12292
12800
  );
12293
12801
 
12802
+ // Plan T17 — rename a folder: a move to a new name in the same parent (one
12803
+ // dir.move action in accepted mode, carrying every canvas inside).
12804
+ const renameFolderReq = useCallback(
12805
+ async (dirPath, name) => {
12806
+ const parent = dirPath.split('/').slice(0, -1).join('/');
12807
+ try {
12808
+ const r = await fetch('/_api/fs-move', {
12809
+ method: 'POST',
12810
+ headers: { 'Content-Type': 'application/json' },
12811
+ body: JSON.stringify({ file: dirPath, toDir: parent, toName: name }),
12812
+ });
12813
+ const j = await r.json().catch(() => ({}));
12814
+ if (!r.ok || !j.ok) {
12815
+ shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
12816
+ return;
12817
+ }
12818
+ await loadTree();
12819
+ } catch (e) {
12820
+ shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12821
+ }
12822
+ },
12823
+ [loadTree]
12824
+ );
12825
+
12826
+ // Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
12827
+ // follows the new path) and duplicate one beside itself.
12828
+ const renameCanvasReq = useCallback(
12829
+ async (filePath, name) => {
12830
+ try {
12831
+ const r = await fetch('/_api/fs-move', {
12832
+ method: 'POST',
12833
+ headers: { 'Content-Type': 'application/json' },
12834
+ body: JSON.stringify({ file: filePath, toName: name }),
12835
+ });
12836
+ const j = await r.json().catch(() => ({}));
12837
+ if (!r.ok || !j.ok) {
12838
+ shellToast(`Could not rename: ${j.error || `error ${r.status}`}`);
12839
+ return;
12840
+ }
12841
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
12842
+ /^\/+|\/+$/g,
12843
+ ''
12844
+ );
12845
+ const fromFile = `${designRel}/${j.fromRel}`;
12846
+ const toFile = `${designRel}/${j.toRel}`;
12847
+ await loadTree();
12848
+ setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
12849
+ setActivePath((prev) => (prev === fromFile ? toFile : prev));
12850
+ } catch (e) {
12851
+ shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12852
+ }
12853
+ },
12854
+ [loadTree, cfg]
12855
+ );
12856
+ const duplicateCanvasReq = useCallback(
12857
+ async (filePath) => {
12858
+ try {
12859
+ const r = await fetch('/_api/canvas', {
12860
+ method: 'POST',
12861
+ headers: { 'Content-Type': 'application/json' },
12862
+ body: JSON.stringify({ duplicateOf: filePath }),
12863
+ });
12864
+ const j = await r.json().catch(() => ({}));
12865
+ if (!r.ok || !j.ok) {
12866
+ shellToast(`Could not duplicate: ${j.error || `error ${r.status}`}`);
12867
+ return;
12868
+ }
12869
+ await loadTree();
12870
+ shellToast(`Duplicated as ${j.rel.split('/').pop().replace(/\.tsx$/i, '')}`, true);
12871
+ } catch (e) {
12872
+ shellToast(`Duplicate failed: ${e instanceof Error ? e.message : 'network error'}`);
12873
+ }
12874
+ },
12875
+ [loadTree]
12876
+ );
12877
+
12878
+ const deleteFileReq = useCallback(
12879
+ async (filePath, name) => {
12880
+ if (!window.confirm(`Move “${name}” to trash?\n\nYou can restore it from .design/_trash/.`)) return;
12881
+ try {
12882
+ const r = await fetch(`/_api/canvas?file=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
12883
+ const j = await r.json().catch(() => ({}));
12884
+ if (!r.ok || !j.ok) {
12885
+ shellToast(`Could not delete: ${j.error || `error ${r.status}`}`);
12886
+ return;
12887
+ }
12888
+ await loadTree();
12889
+ } catch (e) {
12890
+ shellToast(`Delete failed: ${e instanceof Error ? e.message : 'network error'}`);
12891
+ }
12892
+ },
12893
+ [loadTree]
12894
+ );
12895
+
12294
12896
  const clearSelected = useCallback(() => {
12295
12897
  wsSend({ type: 'clear-select' });
12296
12898
  setSelected(null);
@@ -12340,6 +12942,13 @@ function App() {
12340
12942
  if (e.origin !== expectedOrigin) return;
12341
12943
  const m = e.data;
12342
12944
  if (!m || typeof m !== 'object' || !m.dgn) return;
12945
+ if (m.dgn === 'canvas-notice') {
12946
+ const activeWin = activePath && activePath !== SYSTEM_TAB
12947
+ ? iframesRef.current.get(activePath)?.contentWindow : null;
12948
+ const notice = acceptCanvasNotice(e, expectedOrigin, activeWin);
12949
+ if (notice) notifyCanvasText(notice.title, notice.kind);
12950
+ return;
12951
+ }
12343
12952
  if (m.dgn === 'tool-cursor') {
12344
12953
  // Phase 24 — show the active canvas tool's cursor across the WHOLE app
12345
12954
  // shell (sidebar, top bar, everything) so the custom cursor is visible
@@ -12481,55 +13090,48 @@ function App() {
12481
13090
  // Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
12482
13091
  // `edit-source` command can't call the main-origin-only `/_api/edit-*`
12483
13092
  // routes (DDR-054), so it asks us to re-apply the before/after value.
12484
- // `value` null = reset (remove the inline prop / attr). For CSS we also
12485
- // optimistically repaint so the revert shows before the HMR reload.
12486
- const op = m.op;
13093
+ // `value` null = reset (remove the inline prop / attr). The css/attr
13094
+ // request carries the command's expected current value, so an undo can
13095
+ // never overwrite a teammate's newer value (audit 2026-09-13 P1 #5);
13096
+ // the outcome goes back to the canvas so a refused undo/redo does not
13097
+ // advance its stack.
13098
+ const req = applyEditRequest(m);
13099
+ if (!req) return;
13100
+ const replyTo = e.source;
13101
+ const reply = (result) => {
13102
+ if (typeof m.requestId !== 'string') return;
13103
+ try {
13104
+ replyTo?.postMessage({ dgn: 'apply-edit-result', requestId: m.requestId, ...result }, '*');
13105
+ } catch {}
13106
+ };
12487
13107
  const value = typeof m.value === 'string' ? m.value : null;
12488
- let url;
12489
- let body;
12490
- if (op === 'css') {
12491
- url = '/_api/edit-css';
12492
- body =
12493
- value == null
12494
- ? { canvas: m.canvas, id: m.id, property: m.key, reset: true }
12495
- : { canvas: m.canvas, id: m.id, property: m.key, value };
12496
- applyOptimisticStyle({ id: m.id, prop: m.key, value });
12497
- } else if (op === 'attr') {
12498
- url = '/_api/edit-attr';
12499
- body =
12500
- value == null
12501
- ? { canvas: m.canvas, id: m.id, attr: m.key, reset: true }
12502
- : { canvas: m.canvas, id: m.id, attr: m.key, value };
12503
- } else {
12504
- url = '/_api/edit-text';
12505
- body = { canvas: m.canvas, id: m.id, text: value ?? '' };
12506
- // Undo/redo of a `{variable}` text edit needs to re-target the source
12507
- // string: the occurrence + the value currently on disk (`from` — the
12508
- // side we're replacing FROM). Harmless for literal text.
12509
- if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
12510
- if (typeof m.from === 'string') body.before = m.from;
12511
- }
12512
13108
  editApplyChainRef.current = editApplyChainRef.current
12513
13109
  .catch(() => {})
12514
13110
  .then(() =>
12515
- fetch(url, {
13111
+ fetch(req.url, {
12516
13112
  method: 'POST',
12517
13113
  headers: { 'content-type': 'application/json' },
12518
- body: JSON.stringify(body),
13114
+ body: JSON.stringify(req.body),
12519
13115
  })
12520
13116
  .then((r) => r.json().catch(() => ({})))
12521
13117
  .then((j) => {
12522
- // Undo/redo lane (Cmd+Z/Cmd+Shift+Z re-application). The primary
12523
- // edit-text lane above owns the user-facing revert + toast; a
12524
- // refusal here is logged. (DDR-150 P1 kept this a warn after the
12525
- // setStatus-scope fix — setStatus is not in App's scope.)
12526
- if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
13118
+ if (j.ok) {
13119
+ // Repaint only once the source accepted it: a refused undo
13120
+ // must not show a value the file does not hold.
13121
+ if (req.op === 'css') applyOptimisticStyle({ id: m.id, prop: m.key, value });
13122
+ reply({ ok: true });
13123
+ return;
13124
+ }
13125
+ console.warn('[apply-edit]', req.op, j.error || 'failed');
13126
+ reply({ ok: false, error: j.error || 'failed', conflict: j.conflict === true });
12527
13127
  })
12528
- .catch(() => {})
13128
+ .catch(() => reply({ ok: false, error: 'network error' }))
12529
13129
  );
12530
13130
  } else if (m.dgn === 'layers-tree') {
12531
13131
  // Phase 12 Task 4 — browsable layers tree for the active artboard.
12532
13132
  setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
13133
+ const sig = layersTreeSig(m.tree);
13134
+ lastLayersTreeRef.current = { tree: m.tree, sig, artboardId: m.artboardId ?? null };
12533
13135
  // fresh tree (correct ids) landed — drags OK again
12534
13136
  layersBusyRef.current = false;
12535
13137
  if (layersBusyTimerRef.current) {
@@ -12545,24 +13147,7 @@ function App() {
12545
13147
  // actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
12546
13148
  // first (old ids, pre-write); consuming then would re-select nothing and
12547
13149
  // burn the pending ref before the real post-HMR tree lands.
12548
- const pend = pendingReorderRef.current;
12549
- if (pend && pend.movedId) {
12550
- const has = (function find(nodes) {
12551
- return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
12552
- })(m.tree);
12553
- if (has) {
12554
- pendingReorderRef.current = null;
12555
- const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12556
- if (win) {
12557
- try {
12558
- win.postMessage(
12559
- { dgn: 'select-by-id', id: pend.movedId, artboardId: m.artboardId ?? null, index: 0 },
12560
- '*'
12561
- );
12562
- } catch {}
12563
- }
12564
- }
12565
- }
13150
+ settlePendingSelectionRef.current?.(m.tree, sig, m.artboardId ?? null);
12566
13151
  } else if (m.dgn === 'reorder-revert') {
12567
13152
  // Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
12568
13153
  // stack (untrusted iframe) can't reach the main-origin-only
@@ -12886,6 +13471,23 @@ function App() {
12886
13471
  if (e.source === activeWin && typeof m.artboardId === 'string') {
12887
13472
  deleteArtboardShellRef.current?.(m.artboardId);
12888
13473
  }
13474
+ } else if (m.dgn === 'rename-artboard-request') {
13475
+ // Plan T25/L08 — double-click an artboard's name to rename it. Same
13476
+ // confused-deputy guard: the canvas asks, the shell writes the active
13477
+ // canvas only.
13478
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
13479
+ if (
13480
+ e.source === activeWin &&
13481
+ typeof m.artboardId === 'string' &&
13482
+ typeof m.label === 'string' &&
13483
+ m.label.trim()
13484
+ ) {
13485
+ structuralWriteRef.current?.(
13486
+ '/_api/set-artboard-label',
13487
+ { artboardId: m.artboardId, label: m.label.slice(0, 80) },
13488
+ { label: 'rename artboard' }
13489
+ );
13490
+ }
12889
13491
  } else if (m.dgn === 'set-artboard-kind-request') {
12890
13492
  // feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
12891
13493
  // kind" submenu (inside the untrusted iframe). `kind: null` clears
@@ -13132,6 +13734,10 @@ function App() {
13132
13734
  // iframe finished loading — drop the compile skeleton, push current
13133
13735
  // comments + carry over focused pin if any
13134
13736
  setLoadingPath((p) => (p === m.file ? null : p));
13737
+ setLoadedPath(m.file);
13738
+ // …and retire any #115 error panel for this canvas: it just proved it
13739
+ // can load (a late load, or a successful Retry).
13740
+ setCanvasError((e) => (e && e.path === m.file ? null : e));
13135
13741
  // Presentation Mode suppresses comment pins (same gate as the push
13136
13742
  // effect above), so a canvas opened while presenting starts pin-free.
13137
13743
  const list = presentMode ? [] : commentsByFile[m.file] || [];
@@ -13528,6 +14134,7 @@ function App() {
13528
14134
  // fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
13529
14135
  applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
13530
14136
  applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
14137
+ let j1ref = null;
13531
14138
  const writeProp = (property, value) =>
13532
14139
  fetch('/_api/edit-css', {
13533
14140
  method: 'POST',
@@ -13539,6 +14146,7 @@ function App() {
13539
14146
  .then(() => writeProp('left', left))
13540
14147
  .then((j1) => {
13541
14148
  if (!j1.ok) throw new Error(j1.error || 'left write failed');
14149
+ j1ref = j1;
13542
14150
  return writeProp('top', top);
13543
14151
  })
13544
14152
  .then((j2) => {
@@ -13550,7 +14158,7 @@ function App() {
13550
14158
  canvas,
13551
14159
  id,
13552
14160
  key: 'left',
13553
- before: `${beforeLeft}px`,
14161
+ before: replacedValue(j1ref, `${beforeLeft}px`),
13554
14162
  after: `${left}px`,
13555
14163
  });
13556
14164
  recordSourceEdit({
@@ -13558,7 +14166,7 @@ function App() {
13558
14166
  canvas,
13559
14167
  id,
13560
14168
  key: 'top',
13561
- before: `${beforeTop}px`,
14169
+ before: replacedValue(j2, `${beforeTop}px`),
13562
14170
  after: `${top}px`,
13563
14171
  });
13564
14172
  })
@@ -13646,11 +14254,15 @@ function App() {
13646
14254
  headers: { 'content-type': 'application/json' },
13647
14255
  body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
13648
14256
  }).then((r) => r.json().catch(() => ({})));
14257
+ const replies = {};
13649
14258
  let chain = editApplyChainRef.current.catch(() => {});
13650
14259
  for (const p of props) {
13651
14260
  chain = chain.then((prev) => {
13652
14261
  if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
13653
- return writeProp(p, patch[p]);
14262
+ return writeProp(p, patch[p]).then((j) => {
14263
+ replies[p] = j;
14264
+ return j;
14265
+ });
13654
14266
  });
13655
14267
  }
13656
14268
  editApplyChainRef.current = chain
@@ -13658,7 +14270,14 @@ function App() {
13658
14270
  if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
13659
14271
  // Record one undo entry per written property (each Cmd+Z reverts one).
13660
14272
  for (const p of props) {
13661
- recordSourceEdit({ op: 'css', canvas, id, key: p, before: b[p] ?? null, after: patch[p] });
14273
+ recordSourceEdit({
14274
+ op: 'css',
14275
+ canvas,
14276
+ id,
14277
+ key: p,
14278
+ before: replacedValue(replies[p], b[p] ?? null),
14279
+ after: patch[p],
14280
+ });
13662
14281
  }
13663
14282
  // Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
13664
14283
  // structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
@@ -13690,9 +14309,18 @@ function App() {
13690
14309
  // inverse descriptor goes stale (same reason reorder uses the server seq log).
13691
14310
  const structuralWriteRef = useRef(null);
13692
14311
  const structuralWrite = useCallback(
13693
- (route, body, { label, onOk, onFail } = {}) => {
14312
+ (route, body, { label, onOk, onFail: failed } = {}) => {
13694
14313
  if (!activePath) return;
13695
14314
  const canvas = activePath;
14315
+ // A refused structural edit used to vanish into the console: the
14316
+ // designer pressed Delete and nothing happened. Say so in the canvas.
14317
+ const onFail =
14318
+ failed ??
14319
+ ((j) =>
14320
+ postToActiveCanvas({
14321
+ dgn: 'op-toast',
14322
+ message: `Couldn't ${label || 'apply the edit'}${j?.error ? ` — ${j.error}` : ''}.`,
14323
+ }));
13696
14324
  editApplyChainRef.current = editApplyChainRef.current
13697
14325
  .catch(() => {})
13698
14326
  .then(() =>
@@ -13739,6 +14367,7 @@ function App() {
13739
14367
 
13740
14368
  const insertElementShell = useCallback(
13741
14369
  (refId, position, kind, opts = {}) => {
14370
+ const staleSig = lastLayersTreeRef.current?.sig ?? null;
13742
14371
  structuralWrite(
13743
14372
  '/_api/insert-element',
13744
14373
  {
@@ -13754,7 +14383,10 @@ function App() {
13754
14383
  // Select the new element once the HMR reload lands (its id is stamped
13755
14384
  // on transpile — best-effort, like reorder's pendingReorderRef).
13756
14385
  onOk: (j, canvas) => {
13757
- if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
14386
+ if (!j.newId) return;
14387
+ pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
14388
+ const last = lastLayersTreeRef.current;
14389
+ if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
13758
14390
  },
13759
14391
  }
13760
14392
  );
@@ -13818,14 +14450,19 @@ function App() {
13818
14450
 
13819
14451
  const duplicateElementShell = useCallback(
13820
14452
  (id, idIndex) => {
14453
+ const staleSig = lastLayersTreeRef.current?.sig ?? null;
13821
14454
  structuralWrite(
13822
14455
  '/_api/duplicate-element',
13823
14456
  { id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
13824
14457
  {
13825
14458
  label: 'duplicate element',
13826
- // Select the copy once the HMR reload lands (best-effort, like insert).
14459
+ // Select the copy once the post-write tree lands — not the pre-write
14460
+ // one, where the copy's id still names the next sibling.
13827
14461
  onOk: (j, canvas) => {
13828
- if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
14462
+ if (!j.newId) return;
14463
+ pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
14464
+ const last = lastLayersTreeRef.current;
14465
+ if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
13829
14466
  },
13830
14467
  }
13831
14468
  );
@@ -13895,7 +14532,14 @@ function App() {
13895
14532
  .then((r) => r.json().catch(() => ({})))
13896
14533
  .then((j) => {
13897
14534
  if (j.ok)
13898
- recordSourceEdit({ op: 'css', canvas, id, key: property, before: null, after: value });
14535
+ recordSourceEdit({
14536
+ op: 'css',
14537
+ canvas,
14538
+ id,
14539
+ key: property,
14540
+ before: replacedValue(j, null),
14541
+ after: value,
14542
+ });
13899
14543
  })
13900
14544
  .catch(() => {})
13901
14545
  );
@@ -14235,7 +14879,7 @@ function App() {
14235
14879
  canvas,
14236
14880
  id: req.id,
14237
14881
  key: 'src',
14238
- before: req.before ?? null,
14882
+ before: replacedValue(j, req.before ?? null),
14239
14883
  after: pickedPath,
14240
14884
  });
14241
14885
  } else {
@@ -14699,11 +15343,18 @@ function App() {
14699
15343
  kbd: '⇧⌘E',
14700
15344
  run: () => setExportDialog({ mode: 'export' }),
14701
15345
  },
15346
+ {
15347
+ id: 'share-link',
15348
+ group: 'Canvas',
15349
+ label: 'Copy share link',
15350
+ icon: 'link',
15351
+ run: () => { const links = shareLinksFor(sharePath); const link = links.web ?? links.app ?? links.local; if (link) copyShareLink(link); },
15352
+ },
14702
15353
  {
14703
15354
  id: 'handoff',
14704
15355
  group: 'Canvas',
14705
15356
  label: 'Handoff to production',
14706
- icon: 'share',
15357
+ icon: 'external',
14707
15358
  kbd: '⇧⌘H',
14708
15359
  run: () => setExportDialog({ mode: 'handoff' }),
14709
15360
  },
@@ -14808,7 +15459,7 @@ function App() {
14808
15459
  run: () => setReportBugOpen(true),
14809
15460
  },
14810
15461
  ],
14811
- [openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
15462
+ [openSystem, toggleTheme, reloadActive, whatsNew, createVideo, sharePath, shareShell, cloudLinkedHub, localProjectName, cfg.designRel]
14812
15463
  );
14813
15464
 
14814
15465
  // feature-configurable-panel-docking — resolve, for each slot, the panels
@@ -14887,6 +15538,9 @@ function App() {
14887
15538
  activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
14888
15539
  onOpen={openTab}
14889
15540
  onPreview={onPreview}
15541
+ onOpenLinkedFile={openLinkedFile}
15542
+ filesReady={treeLoaded}
15543
+ onShare={showShare}
14890
15544
  onOpenSystem={openSystem}
14891
15545
  wsConnected={wsConnected}
14892
15546
  search={search}
@@ -14900,6 +15554,10 @@ function App() {
14900
15554
  onMoveCanvas={moveCanvasReq}
14901
15555
  onNewFolder={newFolderReq}
14902
15556
  onDeleteFolder={deleteFolderReq}
15557
+ onRenameFolder={renameFolderReq}
15558
+ onRenameCanvas={renameCanvasReq}
15559
+ onDuplicateCanvas={duplicateCanvasReq}
15560
+ onDeleteFile={deleteFileReq}
14903
15561
  onRefresh={refreshTree}
14904
15562
  refreshing={treeRefreshing}
14905
15563
  collapsed={false}
@@ -14913,6 +15571,7 @@ function App() {
14913
15571
  canvasKinds={cfg?.canvasKinds}
14914
15572
  syncStatus={syncStatus}
14915
15573
  onLinkedHub={setCloudLinkedHub}
15574
+ onLocalProject={setLocalProjectName}
14916
15575
  savingIsManaged={savingIsManaged}
14917
15576
  />
14918
15577
  );
@@ -14953,8 +15612,35 @@ function App() {
14953
15612
  // WHICH HISTORY, decided at the ONE place the posture is named — not
14954
15613
  // inside the panel, which would be a second derivation of the rule
14955
15614
  // `cloud-managed-save-surfaces.test.ts` exists to keep singular.
14956
- loadLog={cloudManaged ? gitLoadCloudLog : gitLoadLog}
14957
- historySource={cloudManaged ? 'cloud' : 'local'}
15615
+ loadLog={async (path) => {
15616
+ // The project's accepted history when it has one; Git otherwise.
15617
+ const accepted = await loadAcceptedLog(path);
15618
+ setProjectHistoryOn(accepted !== 'legacy');
15619
+ if (accepted !== 'legacy') return accepted;
15620
+ return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
15621
+ }}
15622
+ historySource={projectHistoryOn ? 'project' : cloudManaged ? 'cloud' : 'local'}
15623
+ onRestoreVersion={async (revision) => {
15624
+ const r = await fetch('/_api/project/restore', {
15625
+ method: 'POST',
15626
+ headers: { 'Content-Type': 'application/json' },
15627
+ body: JSON.stringify({ path: activePath, revision }),
15628
+ }).catch(() => null);
15629
+ const j = r ? await r.json().catch(() => null) : null;
15630
+ return !!j?.ok;
15631
+ }}
15632
+ // Not through a cell's door: every browser editor proposes under the
15633
+ // studio's one credential there, so "your own action" is not
15634
+ // knowable and the hub refuses the route (studio-manifest.mjs).
15635
+ onUndoAction={cellManaged ? undefined : async (actionId) => {
15636
+ const r = await fetch('/_api/project/undo', {
15637
+ method: 'POST',
15638
+ headers: { 'Content-Type': 'application/json' },
15639
+ body: JSON.stringify({ actionId }),
15640
+ }).catch(() => null);
15641
+ const j = r ? await r.json().catch(() => null) : null;
15642
+ return !!j?.ok;
15643
+ }}
14958
15644
  // What the cloud half of the header names. The cell reports its own
14959
15645
  // project and branch with the log; the hub host is the fallback,
14960
15646
  // because a header that names the LOCAL folder while listing the
@@ -15071,8 +15757,10 @@ function App() {
15071
15757
  only appears if you happen to open the Sync panel is not consent.
15072
15758
  Skipped during first-run onboarding so two flows don't stack. */}
15073
15759
  {!firstRun && <SyncConsentDialog status={syncStatus} cloud={cfg.cloud} />}
15074
- {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
15075
- {!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
15760
+ <NotificationHost paused={!!usageNudge || !!tourSteps}
15761
+ hiddenGroups={exportCenter.panelOpen ? ['exports'] : []} />
15762
+ <WhatsNewToast wn={whatsNew} />
15763
+ <ExportToast center={exportCenter} />
15076
15764
  {gitLifecycle && (
15077
15765
  <div role="status" aria-live="polite" className="st-banner st-banner--info">
15078
15766
  <span className="st-banner-dot" aria-hidden="true" />
@@ -15095,6 +15783,8 @@ function App() {
15095
15783
  )}
15096
15784
  <div className="st-shell">
15097
15785
  <Menubar
15786
+ sharePath={sharePath}
15787
+ onShare={() => showShare()}
15098
15788
  readOnly={viewerMode}
15099
15789
  // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
15100
15790
  // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
@@ -15259,6 +15949,10 @@ function App() {
15259
15949
  cfg={cfg}
15260
15950
  loadingPath={loadingPath}
15261
15951
  onIframeLoad={onIframeLoad}
15952
+ canvasError={canvasError}
15953
+ canvasReloadNonce={canvasReloadNonce}
15954
+ onRetryCanvasLoad={retryCanvasLoad}
15955
+ loadedPath={loadedPath}
15262
15956
  showQuickSetup={
15263
15957
  isNativeApp() && !viewerMode && !!setupReadiness && !setupReadiness.ready
15264
15958
  }
@@ -16103,6 +16797,7 @@ function App() {
16103
16797
  </div>
16104
16798
  </div>
16105
16799
  )}
16800
+ {shareDialog && <ShareDialog target={shareDialog} links={shareLinksFor(shareDialog.path)} shell={shareShell} onClose={() => setShareDialog(null)} Icon={StIcon} />}
16106
16801
  {exportDialog && (
16107
16802
  <ExportDialog
16108
16803
  mode={exportDialog.mode}