@agent-native/core 0.85.6 → 0.86.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 (1069) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +130 -1
  3. package/corpus/core/README.md +0 -11
  4. package/corpus/core/docs/content/cloneable-saas.mdx +0 -1
  5. package/corpus/core/docs/content/locales/ar-SA/cloneable-saas.mdx +0 -1
  6. package/corpus/core/docs/content/locales/ar-SA/plan-plugin.mdx +10 -9
  7. package/corpus/core/docs/content/locales/ar-SA/sharing.mdx +1 -2
  8. package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +2 -3
  9. package/corpus/core/docs/content/locales/ar-SA/template-plan.mdx +23 -7
  10. package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +1 -1
  11. package/corpus/core/docs/content/locales/ar-SA/workspace-connections.mdx +11 -11
  12. package/corpus/core/docs/content/locales/de-DE/cloneable-saas.mdx +0 -1
  13. package/corpus/core/docs/content/locales/de-DE/plan-plugin.mdx +10 -9
  14. package/corpus/core/docs/content/locales/de-DE/sharing.mdx +1 -2
  15. package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +2 -3
  16. package/corpus/core/docs/content/locales/de-DE/template-plan.mdx +23 -7
  17. package/corpus/core/docs/content/locales/de-DE/tracking.mdx +1 -1
  18. package/corpus/core/docs/content/locales/de-DE/workspace-connections.mdx +1 -1
  19. package/corpus/core/docs/content/locales/es-ES/cloneable-saas.mdx +0 -1
  20. package/corpus/core/docs/content/locales/es-ES/plan-plugin.mdx +10 -9
  21. package/corpus/core/docs/content/locales/es-ES/sharing.mdx +1 -2
  22. package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +2 -3
  23. package/corpus/core/docs/content/locales/es-ES/template-plan.mdx +23 -7
  24. package/corpus/core/docs/content/locales/es-ES/tracking.mdx +1 -1
  25. package/corpus/core/docs/content/locales/es-ES/workspace-connections.mdx +1 -1
  26. package/corpus/core/docs/content/locales/fr-FR/cloneable-saas.mdx +0 -1
  27. package/corpus/core/docs/content/locales/fr-FR/plan-plugin.mdx +10 -9
  28. package/corpus/core/docs/content/locales/fr-FR/sharing.mdx +1 -2
  29. package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +2 -3
  30. package/corpus/core/docs/content/locales/fr-FR/template-plan.mdx +23 -7
  31. package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +1 -1
  32. package/corpus/core/docs/content/locales/fr-FR/workspace-connections.mdx +1 -1
  33. package/corpus/core/docs/content/locales/hi-IN/cloneable-saas.mdx +0 -1
  34. package/corpus/core/docs/content/locales/hi-IN/plan-plugin.mdx +10 -9
  35. package/corpus/core/docs/content/locales/hi-IN/sharing.mdx +1 -2
  36. package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +2 -3
  37. package/corpus/core/docs/content/locales/hi-IN/template-plan.mdx +23 -7
  38. package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +1 -1
  39. package/corpus/core/docs/content/locales/hi-IN/workspace-connections.mdx +1 -1
  40. package/corpus/core/docs/content/locales/ja-JP/cloneable-saas.mdx +0 -1
  41. package/corpus/core/docs/content/locales/ja-JP/plan-plugin.mdx +10 -9
  42. package/corpus/core/docs/content/locales/ja-JP/sharing.mdx +1 -2
  43. package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +2 -3
  44. package/corpus/core/docs/content/locales/ja-JP/template-plan.mdx +23 -7
  45. package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +1 -1
  46. package/corpus/core/docs/content/locales/ja-JP/workspace-connections.mdx +1 -1
  47. package/corpus/core/docs/content/locales/ko-KR/cloneable-saas.mdx +0 -1
  48. package/corpus/core/docs/content/locales/ko-KR/plan-plugin.mdx +10 -9
  49. package/corpus/core/docs/content/locales/ko-KR/sharing.mdx +1 -2
  50. package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +2 -3
  51. package/corpus/core/docs/content/locales/ko-KR/template-plan.mdx +23 -7
  52. package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +1 -1
  53. package/corpus/core/docs/content/locales/ko-KR/workspace-connections.mdx +1 -1
  54. package/corpus/core/docs/content/locales/pt-BR/cloneable-saas.mdx +0 -1
  55. package/corpus/core/docs/content/locales/pt-BR/plan-plugin.mdx +10 -9
  56. package/corpus/core/docs/content/locales/pt-BR/sharing.mdx +1 -2
  57. package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +2 -3
  58. package/corpus/core/docs/content/locales/pt-BR/template-plan.mdx +23 -7
  59. package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +1 -1
  60. package/corpus/core/docs/content/locales/pt-BR/workspace-connections.mdx +1 -1
  61. package/corpus/core/docs/content/locales/zh-CN/cloneable-saas.mdx +0 -1
  62. package/corpus/core/docs/content/locales/zh-CN/plan-plugin.mdx +10 -9
  63. package/corpus/core/docs/content/locales/zh-CN/sharing.mdx +1 -2
  64. package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +2 -3
  65. package/corpus/core/docs/content/locales/zh-CN/template-plan.mdx +23 -7
  66. package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +1 -1
  67. package/corpus/core/docs/content/locales/zh-CN/workspace-connections.mdx +1 -1
  68. package/corpus/core/docs/content/locales/zh-TW/cloneable-saas.mdx +0 -1
  69. package/corpus/core/docs/content/locales/zh-TW/plan-plugin.mdx +10 -9
  70. package/corpus/core/docs/content/locales/zh-TW/sharing.mdx +1 -2
  71. package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +2 -3
  72. package/corpus/core/docs/content/locales/zh-TW/template-plan.mdx +23 -7
  73. package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +1 -1
  74. package/corpus/core/docs/content/locales/zh-TW/workspace-connections.mdx +1 -1
  75. package/corpus/core/docs/content/plan-plugin.mdx +12 -11
  76. package/corpus/core/docs/content/sharing.mdx +1 -2
  77. package/corpus/core/docs/content/skills-guide.mdx +4 -3
  78. package/corpus/core/docs/content/template-plan.mdx +37 -6
  79. package/corpus/core/docs/content/tracking.mdx +1 -1
  80. package/corpus/core/docs/content/workspace-connections.mdx +1 -1
  81. package/corpus/core/package.json +6 -6
  82. package/corpus/core/src/agent/run-store.ts +1 -1
  83. package/corpus/core/src/chat-threads/store.ts +73 -11
  84. package/corpus/core/src/checkpoints/store.ts +13 -2
  85. package/corpus/core/src/cli/create.ts +3 -4
  86. package/corpus/core/src/cli/design-connect.ts +469 -9
  87. package/corpus/core/src/cli/index.ts +13 -0
  88. package/corpus/core/src/cli/plan-local.ts +281 -24
  89. package/corpus/core/src/cli/skills.ts +177 -21
  90. package/corpus/core/src/cli/templates-meta.ts +0 -13
  91. package/corpus/core/src/cli/visualize-repo.ts +715 -0
  92. package/corpus/core/src/client/AssistantChat.tsx +126 -17
  93. package/corpus/core/src/client/agent-chat-adapter.ts +6 -5
  94. package/corpus/core/src/client/app-providers.tsx +2 -2
  95. package/corpus/core/src/client/collab/index.ts +31 -0
  96. package/corpus/core/src/client/components/LiveCursorOverlay.tsx +16 -1
  97. package/corpus/core/src/client/components/PresenceBar.tsx +14 -1
  98. package/corpus/core/src/client/components/RecentEditHighlights.tsx +219 -0
  99. package/corpus/core/src/client/components/RemoteSelectionRings.tsx +40 -6
  100. package/corpus/core/src/client/index.ts +48 -0
  101. package/corpus/core/src/client/org/OrgSwitcher.tsx +0 -4
  102. package/corpus/core/src/client/rich-markdown-editor/SharedRichEditor.tsx +49 -32
  103. package/corpus/core/src/client/rich-markdown-editor/index.ts +6 -0
  104. package/corpus/core/src/client/rich-markdown-editor/surgical-apply.ts +192 -0
  105. package/corpus/core/src/client/rich-markdown-editor/useCollabReconcile.ts +59 -8
  106. package/corpus/core/src/client/session-replay.ts +181 -15
  107. package/corpus/core/src/client/sse-event-processor.ts +90 -1
  108. package/corpus/core/src/client/use-action.ts +123 -25
  109. package/corpus/core/src/client/use-agent-engine-configured.ts +7 -1
  110. package/corpus/core/src/client/use-chat-threads.ts +18 -7
  111. package/corpus/core/src/client/use-db-sync.ts +104 -19
  112. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  113. package/corpus/core/src/client/visual-style-controls.tsx +475 -0
  114. package/corpus/core/src/collab/agent-presence.ts +191 -62
  115. package/corpus/core/src/collab/awareness.ts +83 -6
  116. package/corpus/core/src/collab/client.ts +89 -146
  117. package/corpus/core/src/collab/index.ts +34 -0
  118. package/corpus/core/src/collab/presence.ts +63 -3
  119. package/corpus/core/src/collab/recent-edits.ts +171 -0
  120. package/corpus/core/src/collab/undo.ts +432 -0
  121. package/corpus/core/src/collab/ydoc-manager.ts +68 -0
  122. package/corpus/core/src/connections/catalog.ts +2 -3
  123. package/corpus/core/src/db/create-get-db.ts +86 -1
  124. package/corpus/core/src/db/ensure-additive-columns.ts +430 -0
  125. package/corpus/core/src/db/index.ts +6 -0
  126. package/corpus/core/src/db/migrations.ts +236 -35
  127. package/corpus/core/src/jobs/scheduler.ts +17 -14
  128. package/corpus/core/src/observability/traces.ts +182 -1
  129. package/corpus/core/src/org/context.ts +79 -2
  130. package/corpus/core/src/server/agent-chat-plugin.ts +10 -6
  131. package/corpus/core/src/server/auth-marketing.ts +0 -11
  132. package/corpus/core/src/server/auth.ts +11 -4
  133. package/corpus/core/src/server/collab-plugin.ts +56 -13
  134. package/corpus/core/src/server/design-token-utils.ts +1 -1
  135. package/corpus/core/src/server/fusion-app.ts +447 -0
  136. package/corpus/core/src/server/index.ts +13 -0
  137. package/corpus/core/src/server/onboarding-html.ts +3 -4
  138. package/corpus/core/src/server/poll-events.ts +14 -2
  139. package/corpus/core/src/server/poll.ts +219 -12
  140. package/corpus/core/src/server/schema-prompt.ts +28 -9
  141. package/corpus/core/src/settings/store.ts +33 -2
  142. package/corpus/core/src/sharing/actions/set-resource-visibility.ts +2 -0
  143. package/corpus/core/src/sharing/actions/share-resource.ts +3 -0
  144. package/corpus/core/src/sharing/actions/unshare-resource.ts +2 -0
  145. package/corpus/core/src/styles/agent-native.css +0 -5
  146. package/corpus/core/src/templates/default/.agents/skills/real-time-collab/SKILL.md +82 -19
  147. package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +15 -0
  148. package/corpus/core/src/templates/default/.agents/skills/storing-data/SKILL.md +8 -0
  149. package/corpus/core/src/templates/default/package.json +3 -3
  150. package/corpus/core/src/templates/workspace-core/.agents/skills/client-methods/SKILL.md +8 -0
  151. package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +22 -0
  152. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +82 -19
  153. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +15 -0
  154. package/corpus/core/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +8 -0
  155. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -0
  156. package/corpus/core/src/tracking/providers.ts +22 -1
  157. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +15 -0
  158. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +20 -5
  159. package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +8 -0
  160. package/corpus/templates/analytics/AGENTS.md +33 -6
  161. package/corpus/templates/analytics/actions/navigate.ts +14 -4
  162. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  163. package/corpus/templates/analytics/actions/view-screen.ts +57 -0
  164. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +23 -1
  165. package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -0
  166. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -0
  167. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +7 -0
  168. package/corpus/templates/analytics/app/i18n/zh-TW.ts +15 -1
  169. package/corpus/templates/analytics/app/i18n-data.ts +151 -10
  170. package/corpus/templates/analytics/app/pages/Agents.tsx +118 -0
  171. package/corpus/templates/analytics/app/pages/DataSources.tsx +11 -1
  172. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +76 -40
  173. package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +5 -0
  174. package/corpus/templates/analytics/app/routes/agents.tsx +10 -0
  175. package/corpus/templates/analytics/changelog/2026-07-03-agent-admin-tools-page.md +6 -0
  176. package/corpus/templates/analytics/changelog/2026-07-03-agent-llm-observability-dashboard.md +6 -0
  177. package/corpus/templates/analytics/changelog/2026-07-03-session-recordings-now-include-response-bodies-for-5xx-error.md +6 -0
  178. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +34 -0
  179. package/corpus/templates/analytics/docs/schemas/tracked-events.md +6 -0
  180. package/corpus/templates/analytics/package.json +3 -3
  181. package/corpus/templates/analytics/seeds/dashboards/agent-observability-llm.json +173 -0
  182. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +23 -5
  183. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +58 -13
  184. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +23 -0
  185. package/corpus/templates/analytics/server/lib/dashboard-seeds.ts +5 -0
  186. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +120 -8
  187. package/corpus/templates/analytics/server/lib/session-replay.ts +3 -2
  188. package/corpus/templates/analytics/server/plugins/db.ts +93 -2
  189. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +45 -1
  190. package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +10 -0
  191. package/corpus/templates/assets/.agents/skills/real-time-collab/SKILL.md +82 -19
  192. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +15 -0
  193. package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +8 -0
  194. package/corpus/templates/assets/package.json +3 -3
  195. package/corpus/templates/assets/server/plugins/db.ts +63 -2
  196. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +15 -0
  197. package/corpus/templates/brain/package.json +3 -3
  198. package/corpus/templates/brain/server/plugins/db.ts +75 -2
  199. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +15 -0
  200. package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +8 -0
  201. package/corpus/templates/calendar/package.json +3 -3
  202. package/corpus/templates/calendar/server/plugins/db.ts +64 -2
  203. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +15 -0
  204. package/corpus/templates/chat/package.json +3 -3
  205. package/corpus/templates/clips/actions/finalize-recording.ts +50 -40
  206. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
  207. package/corpus/templates/clips/app/routes/share.$shareId.tsx +3 -3
  208. package/corpus/templates/clips/changelog/2026-07-03-mobile-recording-pages-keep-videos-in-a-16-9-player-with-act.md +6 -0
  209. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  210. package/corpus/templates/clips/package.json +3 -3
  211. package/corpus/templates/clips/server/lib/recording-upload-state.ts +56 -0
  212. package/corpus/templates/clips/server/plugins/db.ts +68 -1
  213. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +27 -3
  214. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +15 -0
  215. package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +8 -0
  216. package/corpus/templates/content/actions/edit-document.ts +48 -1
  217. package/corpus/templates/content/actions/list-documents.ts +101 -94
  218. package/corpus/templates/content/actions/search-documents.ts +19 -3
  219. package/corpus/templates/content/actions/update-document.ts +54 -1
  220. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -7
  221. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +97 -2
  222. package/corpus/templates/content/changelog/2026-07-03-agent-document-edits-now-appear-as-a-live-collaborator-prese.md +6 -0
  223. package/corpus/templates/content/changelog/2026-07-03-document-search-and-list-views-load-faster-on-large-workspac.md +6 -0
  224. package/corpus/templates/content/package.json +3 -3
  225. package/corpus/templates/content/server/plugins/db.ts +57 -1
  226. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  227. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  228. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  229. package/corpus/templates/design/.agents/skills/full-app-build/SKILL.md +140 -0
  230. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +20 -0
  231. package/corpus/templates/design/AGENTS.md +83 -13
  232. package/corpus/templates/design/actions/add-fusion-screens.ts +91 -0
  233. package/corpus/templates/design/actions/apply-a11y-fix.ts +5 -1
  234. package/corpus/templates/design/actions/apply-component-prop-edit.ts +24 -69
  235. package/corpus/templates/design/actions/apply-fusion-edits.ts +161 -0
  236. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  237. package/corpus/templates/design/actions/apply-visual-edit.ts +38 -3
  238. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  239. package/corpus/templates/design/actions/create-component.ts +5 -1
  240. package/corpus/templates/design/actions/create-fusion-app.ts +202 -0
  241. package/corpus/templates/design/actions/deploy-fusion-app.ts +123 -0
  242. package/corpus/templates/design/actions/edit-design.ts +13 -16
  243. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  244. package/corpus/templates/design/actions/export-zip.ts +10 -1
  245. package/corpus/templates/design/actions/get-fusion-deploy-status.ts +86 -0
  246. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  247. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  248. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  249. package/corpus/templates/design/actions/list-fusion-edits.ts +96 -0
  250. package/corpus/templates/design/actions/list-local-files.ts +133 -0
  251. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  252. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  253. package/corpus/templates/design/actions/push-fusion-app.ts +62 -0
  254. package/corpus/templates/design/actions/queue-fusion-edit.ts +112 -0
  255. package/corpus/templates/design/actions/read-local-file.ts +139 -0
  256. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  257. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  258. package/corpus/templates/design/actions/send-fusion-message.ts +69 -0
  259. package/corpus/templates/design/actions/sync-fusion-app.ts +196 -0
  260. package/corpus/templates/design/actions/write-local-file.ts +162 -36
  261. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +12 -0
  262. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +600 -66
  263. package/corpus/templates/design/app/components/design/EditPanel.tsx +793 -304
  264. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +328 -0
  265. package/corpus/templates/design/app/components/design/LayersPanel.tsx +671 -154
  266. package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +1 -1
  267. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  268. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +2070 -420
  269. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  270. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +7 -98
  271. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  272. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  273. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  274. package/corpus/templates/design/app/components/design/code-workbench/ActivityBar.tsx +85 -0
  275. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +466 -0
  276. package/corpus/templates/design/app/components/design/code-workbench/SideBar.tsx +53 -0
  277. package/corpus/templates/design/app/components/design/code-workbench/commands.ts +339 -0
  278. package/corpus/templates/design/app/components/design/code-workbench/editor/Breadcrumbs.tsx +42 -0
  279. package/corpus/templates/design/app/components/design/code-workbench/editor/BufferSync.tsx +128 -0
  280. package/corpus/templates/design/app/components/design/code-workbench/editor/EditorTabs.tsx +363 -0
  281. package/corpus/templates/design/app/components/design/code-workbench/editor/MonacoHost.tsx +268 -0
  282. package/corpus/templates/design/app/components/design/code-workbench/editor/StatusBar.tsx +208 -0
  283. package/corpus/templates/design/app/components/design/code-workbench/editor/monaco-setup.ts +61 -0
  284. package/corpus/templates/design/app/components/design/code-workbench/editor/monaco-theme-palette.ts +191 -0
  285. package/corpus/templates/design/app/components/design/code-workbench/editor/monaco-theme.ts +29 -0
  286. package/corpus/templates/design/app/components/design/code-workbench/editor/status-bar-lang.ts +21 -0
  287. package/corpus/templates/design/app/components/design/code-workbench/editor/tab-drag.ts +27 -0
  288. package/corpus/templates/design/app/components/design/code-workbench/explorer/ExplorerView.tsx +155 -0
  289. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +551 -0
  290. package/corpus/templates/design/app/components/design/code-workbench/explorer/file-icons.tsx +115 -0
  291. package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open.ts +65 -0
  292. package/corpus/templates/design/app/components/design/code-workbench/format/prettier-format.ts +182 -0
  293. package/corpus/templates/design/app/components/design/code-workbench/model-registry.ts +133 -0
  294. package/corpus/templates/design/app/components/design/code-workbench/quickinput/QuickInput.tsx +563 -0
  295. package/corpus/templates/design/app/components/design/code-workbench/quickinput/fuzzy.ts +170 -0
  296. package/corpus/templates/design/app/components/design/code-workbench/search/SearchView.tsx +508 -0
  297. package/corpus/templates/design/app/components/design/code-workbench/search/search-engine.ts +280 -0
  298. package/corpus/templates/design/app/components/design/code-workbench/store.tsx +734 -0
  299. package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +39 -0
  300. package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +196 -0
  301. package/corpus/templates/design/app/components/design/code-workbench/workspace/localhost-provider.ts +177 -0
  302. package/corpus/templates/design/app/components/design/code-workbench/workspace/tree.ts +110 -0
  303. package/corpus/templates/design/app/components/design/code-workbench/workspace/types.ts +103 -0
  304. package/corpus/templates/design/app/components/design/code-workbench-theme.ts +68 -0
  305. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  306. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +217 -113
  307. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  308. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  309. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +79 -16
  310. package/corpus/templates/design/app/components/design/inspector/index.ts +9 -0
  311. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +67 -2
  312. package/corpus/templates/design/app/components/design/types.ts +16 -0
  313. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +80 -1
  314. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  315. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  316. package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +6 -16
  317. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  318. package/corpus/templates/design/app/i18n/zh-TW.ts +2 -0
  319. package/corpus/templates/design/app/i18n-data.ts +31 -0
  320. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  321. package/corpus/templates/design/app/pages/DesignEditor.tsx +5586 -1112
  322. package/corpus/templates/design/app/pages/Index.tsx +90 -17
  323. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  324. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  325. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  326. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  327. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  328. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  329. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  330. package/corpus/templates/design/changelog/2026-07-03-the-ai-now-edits-like-a-collaborator-on-canvas-its-cursor-gl.md +6 -0
  331. package/corpus/templates/design/changelog/2026-07-03-the-code-panel-is-now-a-full-vs-code-style-editor-with-a-fil.md +6 -0
  332. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  333. package/corpus/templates/design/package.json +4 -3
  334. package/corpus/templates/design/server/db/schema.ts +31 -0
  335. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  336. package/corpus/templates/design/server/lib/fusion-screens.ts +246 -0
  337. package/corpus/templates/design/server/plugins/db.ts +89 -2
  338. package/corpus/templates/design/shared/board-file.ts +25 -5
  339. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  340. package/corpus/templates/design/shared/code-layer.ts +304 -26
  341. package/corpus/templates/design/shared/collab-selection.ts +52 -0
  342. package/corpus/templates/design/shared/color-utils.ts +84 -0
  343. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  344. package/corpus/templates/design/shared/full-app.ts +126 -0
  345. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  346. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  347. package/corpus/templates/design/shared/pen-path.ts +496 -23
  348. package/corpus/templates/dispatch/package.json +3 -3
  349. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +15 -0
  350. package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +8 -0
  351. package/corpus/templates/forms/package.json +3 -3
  352. package/corpus/templates/forms/server/plugins/db.ts +62 -2
  353. package/corpus/templates/macros/package.json +3 -3
  354. package/corpus/templates/macros/server/plugins/db.ts +63 -2
  355. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +15 -0
  356. package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +8 -0
  357. package/corpus/templates/mail/package.json +3 -3
  358. package/corpus/templates/mail/server/lib/contact-frequency.ts +42 -15
  359. package/corpus/templates/mail/server/plugins/db.ts +63 -2
  360. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +15 -0
  361. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +9 -3
  362. package/corpus/templates/plan/.agents/skills/visualize-repo/SKILL.md +92 -0
  363. package/corpus/templates/plan/actions/export-visual-plan.ts +5 -0
  364. package/corpus/templates/plan/actions/get-local-plan-folder.ts +4 -22
  365. package/corpus/templates/plan/actions/get-plan-version.ts +6 -16
  366. package/corpus/templates/plan/actions/list-plan-versions.ts +48 -20
  367. package/corpus/templates/plan/actions/patch-visual-plan-source.ts +41 -1
  368. package/corpus/templates/plan/actions/promote-local-plan-folder.ts +4 -22
  369. package/corpus/templates/plan/actions/update-local-plan-comments.ts +4 -2
  370. package/corpus/templates/plan/actions/update-local-plan-folder.ts +4 -22
  371. package/corpus/templates/plan/actions/update-visual-plan.ts +60 -1
  372. package/corpus/templates/plan/agent-native.app-skill.json +10 -0
  373. package/corpus/templates/plan/app/components/editor/PlanDocumentEditor.tsx +138 -39
  374. package/corpus/templates/plan/app/components/editor/usePlanUndoStack.ts +165 -23
  375. package/corpus/templates/plan/app/components/plan/PlanContentRenderer.tsx +80 -4
  376. package/corpus/templates/plan/app/global.css +11 -9
  377. package/corpus/templates/plan/app/hooks/use-navigation-state.ts +10 -1
  378. package/corpus/templates/plan/app/hooks/use-plan-presence.ts +66 -0
  379. package/corpus/templates/plan/app/pages/PlansPage.tsx +215 -63
  380. package/corpus/templates/plan/changelog/2026-07-03-local-visual-plans-and-recaps-can-save-comments-without-sign.md +6 -0
  381. package/corpus/templates/plan/changelog/2026-07-03-plan-tabs-render-nested-text-instead-of-raw-json.md +6 -0
  382. package/corpus/templates/plan/changelog/2026-07-03-plans-are-now-fully-real-time-collaborative-multiple-people-.md +6 -0
  383. package/corpus/templates/plan/changelog/2026-07-03-plans-open-and-refresh-faster-especially-long-lived-plans-wi.md +6 -0
  384. package/corpus/templates/plan/changelog/2026-07-03-undo-now-survives-agent-edits-cmd-z-keeps-working-on-your-ow.md +6 -0
  385. package/corpus/templates/plan/package.json +5 -3
  386. package/corpus/templates/plan/server/db/schema.ts +12 -0
  387. package/corpus/templates/plan/server/lib/local-plan-kind.ts +25 -0
  388. package/corpus/templates/plan/server/lib/plan-versions.ts +83 -1
  389. package/corpus/templates/plan/server/lib/public-action-paths.ts +0 -2
  390. package/corpus/templates/plan/server/plans.ts +26 -3
  391. package/corpus/templates/plan/server/plugins/db.ts +105 -2
  392. package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +8 -4
  393. package/corpus/templates/slides/AGENTS.md +4 -0
  394. package/corpus/templates/slides/actions/_agent-presence.ts +48 -0
  395. package/corpus/templates/slides/actions/add-slide.ts +16 -18
  396. package/corpus/templates/slides/actions/patch-deck.ts +4 -2
  397. package/corpus/templates/slides/actions/update-slide.ts +114 -77
  398. package/corpus/templates/slides/actions/view-screen.ts +39 -0
  399. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +47 -3
  400. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +32 -35
  401. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +625 -199
  402. package/corpus/templates/slides/app/components/editor/SlideStyleInspector.tsx +342 -0
  403. package/corpus/templates/slides/app/components/editor/TweaksPanel.tsx +46 -96
  404. package/corpus/templates/slides/app/components/visual-editor/SaveStatusIndicator.tsx +8 -1
  405. package/corpus/templates/slides/app/context/DeckContext.tsx +589 -176
  406. package/corpus/templates/slides/app/hooks/use-deck-presence.ts +84 -42
  407. package/corpus/templates/slides/app/i18n/ar-SA.ts +46 -0
  408. package/corpus/templates/slides/app/i18n/de-DE.ts +46 -0
  409. package/corpus/templates/slides/app/i18n/en-US.ts +46 -0
  410. package/corpus/templates/slides/app/i18n/es-ES.ts +46 -0
  411. package/corpus/templates/slides/app/i18n/fr-FR.ts +46 -0
  412. package/corpus/templates/slides/app/i18n/hi-IN.ts +46 -0
  413. package/corpus/templates/slides/app/i18n/ja-JP.ts +46 -0
  414. package/corpus/templates/slides/app/i18n/ko-KR.ts +46 -0
  415. package/corpus/templates/slides/app/i18n/pt-BR.ts +46 -0
  416. package/corpus/templates/slides/app/i18n/zh-CN.ts +46 -0
  417. package/corpus/templates/slides/app/i18n/zh-TW.ts +46 -0
  418. package/corpus/templates/slides/app/pages/DeckEditor.tsx +25 -13
  419. package/corpus/templates/slides/changelog/2026-07-03-figma-style-slide-editing.md +6 -0
  420. package/corpus/templates/slides/changelog/2026-07-03-see-the-ai-working-with-you-when-the-agent-edits-a-slide-it-.md +6 -0
  421. package/corpus/templates/slides/changelog/2026-07-03-undo-redo-is-now-precise-and-safe-with-collaborators-it-only.md +6 -0
  422. package/corpus/templates/slides/package.json +3 -3
  423. package/corpus/templates/slides/server/handlers/decks.ts +31 -3
  424. package/corpus/templates/slides/server/plugins/db.ts +65 -2
  425. package/corpus/templates/videos/netlify.toml +1 -12
  426. package/dist/agent/run-store.js +1 -1
  427. package/dist/agent/run-store.js.map +1 -1
  428. package/dist/chat-threads/store.d.ts +9 -1
  429. package/dist/chat-threads/store.d.ts.map +1 -1
  430. package/dist/chat-threads/store.js +63 -11
  431. package/dist/chat-threads/store.js.map +1 -1
  432. package/dist/checkpoints/store.d.ts.map +1 -1
  433. package/dist/checkpoints/store.js +10 -2
  434. package/dist/checkpoints/store.js.map +1 -1
  435. package/dist/cli/create.d.ts.map +1 -1
  436. package/dist/cli/create.js +3 -5
  437. package/dist/cli/create.js.map +1 -1
  438. package/dist/cli/design-connect.d.ts +49 -1
  439. package/dist/cli/design-connect.d.ts.map +1 -1
  440. package/dist/cli/design-connect.js +414 -9
  441. package/dist/cli/design-connect.js.map +1 -1
  442. package/dist/cli/index.js +12 -0
  443. package/dist/cli/index.js.map +1 -1
  444. package/dist/cli/plan-local.d.ts +24 -0
  445. package/dist/cli/plan-local.d.ts.map +1 -1
  446. package/dist/cli/plan-local.js +188 -20
  447. package/dist/cli/plan-local.js.map +1 -1
  448. package/dist/cli/skills.d.ts +3 -1
  449. package/dist/cli/skills.d.ts.map +1 -1
  450. package/dist/cli/skills.js +173 -21
  451. package/dist/cli/skills.js.map +1 -1
  452. package/dist/cli/templates-meta.d.ts.map +1 -1
  453. package/dist/cli/templates-meta.js +0 -14
  454. package/dist/cli/templates-meta.js.map +1 -1
  455. package/dist/cli/visualize-repo.d.ts +49 -0
  456. package/dist/cli/visualize-repo.d.ts.map +1 -0
  457. package/dist/cli/visualize-repo.js +561 -0
  458. package/dist/cli/visualize-repo.js.map +1 -0
  459. package/dist/client/AssistantChat.d.ts.map +1 -1
  460. package/dist/client/AssistantChat.js +120 -15
  461. package/dist/client/AssistantChat.js.map +1 -1
  462. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  463. package/dist/client/agent-chat-adapter.js +6 -5
  464. package/dist/client/agent-chat-adapter.js.map +1 -1
  465. package/dist/client/app-providers.d.ts +2 -2
  466. package/dist/client/app-providers.js.map +1 -1
  467. package/dist/client/collab/index.d.ts +4 -1
  468. package/dist/client/collab/index.d.ts.map +1 -1
  469. package/dist/client/collab/index.js +3 -0
  470. package/dist/client/collab/index.js.map +1 -1
  471. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
  472. package/dist/client/components/LiveCursorOverlay.js +11 -3
  473. package/dist/client/components/LiveCursorOverlay.js.map +1 -1
  474. package/dist/client/components/PresenceBar.d.ts.map +1 -1
  475. package/dist/client/components/PresenceBar.js +6 -1
  476. package/dist/client/components/PresenceBar.js.map +1 -1
  477. package/dist/client/components/RecentEditHighlights.d.ts +43 -0
  478. package/dist/client/components/RecentEditHighlights.d.ts.map +1 -0
  479. package/dist/client/components/RecentEditHighlights.js +146 -0
  480. package/dist/client/components/RecentEditHighlights.js.map +1 -0
  481. package/dist/client/components/RemoteSelectionRings.d.ts +9 -0
  482. package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -1
  483. package/dist/client/components/RemoteSelectionRings.js +22 -8
  484. package/dist/client/components/RemoteSelectionRings.js.map +1 -1
  485. package/dist/client/index.d.ts +6 -1
  486. package/dist/client/index.d.ts.map +1 -1
  487. package/dist/client/index.js +7 -0
  488. package/dist/client/index.js.map +1 -1
  489. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  490. package/dist/client/org/OrgSwitcher.js +1 -3
  491. package/dist/client/org/OrgSwitcher.js.map +1 -1
  492. package/dist/client/rich-markdown-editor/SharedRichEditor.d.ts +8 -1
  493. package/dist/client/rich-markdown-editor/SharedRichEditor.d.ts.map +1 -1
  494. package/dist/client/rich-markdown-editor/SharedRichEditor.js +8 -2
  495. package/dist/client/rich-markdown-editor/SharedRichEditor.js.map +1 -1
  496. package/dist/client/rich-markdown-editor/index.d.ts +1 -0
  497. package/dist/client/rich-markdown-editor/index.d.ts.map +1 -1
  498. package/dist/client/rich-markdown-editor/index.js +1 -0
  499. package/dist/client/rich-markdown-editor/index.js.map +1 -1
  500. package/dist/client/rich-markdown-editor/surgical-apply.d.ts +66 -0
  501. package/dist/client/rich-markdown-editor/surgical-apply.d.ts.map +1 -0
  502. package/dist/client/rich-markdown-editor/surgical-apply.js +147 -0
  503. package/dist/client/rich-markdown-editor/surgical-apply.js.map +1 -0
  504. package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts +18 -2
  505. package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
  506. package/dist/client/rich-markdown-editor/useCollabReconcile.js +39 -8
  507. package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
  508. package/dist/client/session-replay.d.ts +10 -1
  509. package/dist/client/session-replay.d.ts.map +1 -1
  510. package/dist/client/session-replay.js +134 -7
  511. package/dist/client/session-replay.js.map +1 -1
  512. package/dist/client/sse-event-processor.d.ts.map +1 -1
  513. package/dist/client/sse-event-processor.js +81 -1
  514. package/dist/client/sse-event-processor.js.map +1 -1
  515. package/dist/client/use-action.d.ts +24 -0
  516. package/dist/client/use-action.d.ts.map +1 -1
  517. package/dist/client/use-action.js +109 -27
  518. package/dist/client/use-action.js.map +1 -1
  519. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  520. package/dist/client/use-agent-engine-configured.js +7 -1
  521. package/dist/client/use-agent-engine-configured.js.map +1 -1
  522. package/dist/client/use-chat-threads.d.ts.map +1 -1
  523. package/dist/client/use-chat-threads.js +11 -3
  524. package/dist/client/use-chat-threads.js.map +1 -1
  525. package/dist/client/use-db-sync.d.ts +36 -0
  526. package/dist/client/use-db-sync.d.ts.map +1 -1
  527. package/dist/client/use-db-sync.js +68 -12
  528. package/dist/client/use-db-sync.js.map +1 -1
  529. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  530. package/dist/client/use-pinch-zoom.js +76 -11
  531. package/dist/client/use-pinch-zoom.js.map +1 -1
  532. package/dist/client/visual-style-controls.d.ts +79 -0
  533. package/dist/client/visual-style-controls.d.ts.map +1 -0
  534. package/dist/client/visual-style-controls.js +150 -0
  535. package/dist/client/visual-style-controls.js.map +1 -0
  536. package/dist/collab/agent-presence.d.ts +51 -5
  537. package/dist/collab/agent-presence.d.ts.map +1 -1
  538. package/dist/collab/agent-presence.js +144 -58
  539. package/dist/collab/agent-presence.js.map +1 -1
  540. package/dist/collab/awareness-store.d.ts +32 -0
  541. package/dist/collab/awareness-store.d.ts.map +1 -0
  542. package/dist/collab/awareness-store.js +0 -0
  543. package/dist/collab/awareness-store.js.map +1 -0
  544. package/dist/collab/awareness.d.ts +14 -3
  545. package/dist/collab/awareness.d.ts.map +1 -1
  546. package/dist/collab/awareness.js +53 -5
  547. package/dist/collab/awareness.js.map +1 -1
  548. package/dist/collab/client.d.ts +2 -0
  549. package/dist/collab/client.d.ts.map +1 -1
  550. package/dist/collab/client.js +79 -119
  551. package/dist/collab/client.js.map +1 -1
  552. package/dist/collab/index.d.ts +3 -1
  553. package/dist/collab/index.d.ts.map +1 -1
  554. package/dist/collab/index.js +5 -1
  555. package/dist/collab/index.js.map +1 -1
  556. package/dist/collab/presence.d.ts.map +1 -1
  557. package/dist/collab/presence.js +54 -3
  558. package/dist/collab/presence.js.map +1 -1
  559. package/dist/collab/recent-edits.d.ts +87 -0
  560. package/dist/collab/recent-edits.d.ts.map +1 -0
  561. package/dist/collab/recent-edits.js +116 -0
  562. package/dist/collab/recent-edits.js.map +1 -0
  563. package/dist/collab/undo.d.ts +121 -0
  564. package/dist/collab/undo.d.ts.map +1 -0
  565. package/dist/collab/undo.js +244 -0
  566. package/dist/collab/undo.js.map +1 -0
  567. package/dist/collab/ydoc-manager.d.ts +10 -0
  568. package/dist/collab/ydoc-manager.d.ts.map +1 -1
  569. package/dist/collab/ydoc-manager.js +57 -0
  570. package/dist/collab/ydoc-manager.js.map +1 -1
  571. package/dist/connections/catalog.d.ts +2 -2
  572. package/dist/connections/catalog.d.ts.map +1 -1
  573. package/dist/connections/catalog.js +1 -1
  574. package/dist/connections/catalog.js.map +1 -1
  575. package/dist/db/create-get-db.d.ts +22 -0
  576. package/dist/db/create-get-db.d.ts.map +1 -1
  577. package/dist/db/create-get-db.js +73 -1
  578. package/dist/db/create-get-db.js.map +1 -1
  579. package/dist/db/ensure-additive-columns.d.ts +93 -0
  580. package/dist/db/ensure-additive-columns.d.ts.map +1 -0
  581. package/dist/db/ensure-additive-columns.js +355 -0
  582. package/dist/db/ensure-additive-columns.js.map +1 -0
  583. package/dist/db/index.d.ts +1 -0
  584. package/dist/db/index.d.ts.map +1 -1
  585. package/dist/db/index.js +1 -0
  586. package/dist/db/index.js.map +1 -1
  587. package/dist/db/migrations.d.ts +70 -0
  588. package/dist/db/migrations.d.ts.map +1 -1
  589. package/dist/db/migrations.js +196 -33
  590. package/dist/db/migrations.js.map +1 -1
  591. package/dist/jobs/scheduler.js +17 -10
  592. package/dist/jobs/scheduler.js.map +1 -1
  593. package/dist/notifications/routes.d.ts +3 -3
  594. package/dist/observability/routes.d.ts +5 -5
  595. package/dist/observability/traces.d.ts.map +1 -1
  596. package/dist/observability/traces.js +155 -1
  597. package/dist/observability/traces.js.map +1 -1
  598. package/dist/org/context.d.ts +7 -0
  599. package/dist/org/context.d.ts.map +1 -1
  600. package/dist/org/context.js +54 -2
  601. package/dist/org/context.js.map +1 -1
  602. package/dist/progress/routes.d.ts +1 -1
  603. package/dist/resources/handlers.d.ts +3 -3
  604. package/dist/secrets/routes.d.ts +6 -6
  605. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  606. package/dist/server/agent-chat-plugin.js +10 -6
  607. package/dist/server/agent-chat-plugin.js.map +1 -1
  608. package/dist/server/auth-marketing.d.ts.map +1 -1
  609. package/dist/server/auth-marketing.js +0 -10
  610. package/dist/server/auth-marketing.js.map +1 -1
  611. package/dist/server/auth.d.ts.map +1 -1
  612. package/dist/server/auth.js +6 -4
  613. package/dist/server/auth.js.map +1 -1
  614. package/dist/server/collab-plugin.d.ts.map +1 -1
  615. package/dist/server/collab-plugin.js +45 -13
  616. package/dist/server/collab-plugin.js.map +1 -1
  617. package/dist/server/design-token-utils.d.ts +1 -1
  618. package/dist/server/design-token-utils.js +1 -1
  619. package/dist/server/design-token-utils.js.map +1 -1
  620. package/dist/server/fusion-app.d.ts +107 -0
  621. package/dist/server/fusion-app.d.ts.map +1 -0
  622. package/dist/server/fusion-app.js +371 -0
  623. package/dist/server/fusion-app.js.map +1 -0
  624. package/dist/server/index.d.ts +1 -0
  625. package/dist/server/index.d.ts.map +1 -1
  626. package/dist/server/index.js +1 -0
  627. package/dist/server/index.js.map +1 -1
  628. package/dist/server/onboarding-html.d.ts.map +1 -1
  629. package/dist/server/onboarding-html.js +3 -4
  630. package/dist/server/onboarding-html.js.map +1 -1
  631. package/dist/server/poll-events.d.ts +2 -0
  632. package/dist/server/poll-events.d.ts.map +1 -1
  633. package/dist/server/poll-events.js +7 -2
  634. package/dist/server/poll-events.js.map +1 -1
  635. package/dist/server/poll.d.ts +47 -3
  636. package/dist/server/poll.d.ts.map +1 -1
  637. package/dist/server/poll.js +176 -10
  638. package/dist/server/poll.js.map +1 -1
  639. package/dist/server/schema-prompt.d.ts.map +1 -1
  640. package/dist/server/schema-prompt.js +24 -7
  641. package/dist/server/schema-prompt.js.map +1 -1
  642. package/dist/settings/store.d.ts.map +1 -1
  643. package/dist/settings/store.js +32 -3
  644. package/dist/settings/store.js.map +1 -1
  645. package/dist/sharing/actions/set-resource-visibility.js +2 -0
  646. package/dist/sharing/actions/set-resource-visibility.js.map +1 -1
  647. package/dist/sharing/actions/share-resource.d.ts.map +1 -1
  648. package/dist/sharing/actions/share-resource.js +3 -0
  649. package/dist/sharing/actions/share-resource.js.map +1 -1
  650. package/dist/sharing/actions/unshare-resource.js +2 -0
  651. package/dist/sharing/actions/unshare-resource.js.map +1 -1
  652. package/dist/styles/agent-native.css +0 -5
  653. package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +82 -19
  654. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +15 -0
  655. package/dist/templates/default/.agents/skills/storing-data/SKILL.md +8 -0
  656. package/dist/templates/default/package.json +3 -3
  657. package/dist/templates/workspace-core/.agents/skills/client-methods/SKILL.md +8 -0
  658. package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +22 -0
  659. package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +82 -19
  660. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +15 -0
  661. package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +8 -0
  662. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -0
  663. package/dist/tracking/providers.d.ts.map +1 -1
  664. package/dist/tracking/providers.js +17 -1
  665. package/dist/tracking/providers.js.map +1 -1
  666. package/docs/content/cloneable-saas.mdx +0 -1
  667. package/docs/content/locales/ar-SA/cloneable-saas.mdx +0 -1
  668. package/docs/content/locales/ar-SA/plan-plugin.mdx +10 -9
  669. package/docs/content/locales/ar-SA/sharing.mdx +1 -2
  670. package/docs/content/locales/ar-SA/skills-guide.mdx +2 -3
  671. package/docs/content/locales/ar-SA/template-plan.mdx +23 -7
  672. package/docs/content/locales/ar-SA/tracking.mdx +1 -1
  673. package/docs/content/locales/ar-SA/workspace-connections.mdx +11 -11
  674. package/docs/content/locales/de-DE/cloneable-saas.mdx +0 -1
  675. package/docs/content/locales/de-DE/plan-plugin.mdx +10 -9
  676. package/docs/content/locales/de-DE/sharing.mdx +1 -2
  677. package/docs/content/locales/de-DE/skills-guide.mdx +2 -3
  678. package/docs/content/locales/de-DE/template-plan.mdx +23 -7
  679. package/docs/content/locales/de-DE/tracking.mdx +1 -1
  680. package/docs/content/locales/de-DE/workspace-connections.mdx +1 -1
  681. package/docs/content/locales/es-ES/cloneable-saas.mdx +0 -1
  682. package/docs/content/locales/es-ES/plan-plugin.mdx +10 -9
  683. package/docs/content/locales/es-ES/sharing.mdx +1 -2
  684. package/docs/content/locales/es-ES/skills-guide.mdx +2 -3
  685. package/docs/content/locales/es-ES/template-plan.mdx +23 -7
  686. package/docs/content/locales/es-ES/tracking.mdx +1 -1
  687. package/docs/content/locales/es-ES/workspace-connections.mdx +1 -1
  688. package/docs/content/locales/fr-FR/cloneable-saas.mdx +0 -1
  689. package/docs/content/locales/fr-FR/plan-plugin.mdx +10 -9
  690. package/docs/content/locales/fr-FR/sharing.mdx +1 -2
  691. package/docs/content/locales/fr-FR/skills-guide.mdx +2 -3
  692. package/docs/content/locales/fr-FR/template-plan.mdx +23 -7
  693. package/docs/content/locales/fr-FR/tracking.mdx +1 -1
  694. package/docs/content/locales/fr-FR/workspace-connections.mdx +1 -1
  695. package/docs/content/locales/hi-IN/cloneable-saas.mdx +0 -1
  696. package/docs/content/locales/hi-IN/plan-plugin.mdx +10 -9
  697. package/docs/content/locales/hi-IN/sharing.mdx +1 -2
  698. package/docs/content/locales/hi-IN/skills-guide.mdx +2 -3
  699. package/docs/content/locales/hi-IN/template-plan.mdx +23 -7
  700. package/docs/content/locales/hi-IN/tracking.mdx +1 -1
  701. package/docs/content/locales/hi-IN/workspace-connections.mdx +1 -1
  702. package/docs/content/locales/ja-JP/cloneable-saas.mdx +0 -1
  703. package/docs/content/locales/ja-JP/plan-plugin.mdx +10 -9
  704. package/docs/content/locales/ja-JP/sharing.mdx +1 -2
  705. package/docs/content/locales/ja-JP/skills-guide.mdx +2 -3
  706. package/docs/content/locales/ja-JP/template-plan.mdx +23 -7
  707. package/docs/content/locales/ja-JP/tracking.mdx +1 -1
  708. package/docs/content/locales/ja-JP/workspace-connections.mdx +1 -1
  709. package/docs/content/locales/ko-KR/cloneable-saas.mdx +0 -1
  710. package/docs/content/locales/ko-KR/plan-plugin.mdx +10 -9
  711. package/docs/content/locales/ko-KR/sharing.mdx +1 -2
  712. package/docs/content/locales/ko-KR/skills-guide.mdx +2 -3
  713. package/docs/content/locales/ko-KR/template-plan.mdx +23 -7
  714. package/docs/content/locales/ko-KR/tracking.mdx +1 -1
  715. package/docs/content/locales/ko-KR/workspace-connections.mdx +1 -1
  716. package/docs/content/locales/pt-BR/cloneable-saas.mdx +0 -1
  717. package/docs/content/locales/pt-BR/plan-plugin.mdx +10 -9
  718. package/docs/content/locales/pt-BR/sharing.mdx +1 -2
  719. package/docs/content/locales/pt-BR/skills-guide.mdx +2 -3
  720. package/docs/content/locales/pt-BR/template-plan.mdx +23 -7
  721. package/docs/content/locales/pt-BR/tracking.mdx +1 -1
  722. package/docs/content/locales/pt-BR/workspace-connections.mdx +1 -1
  723. package/docs/content/locales/zh-CN/cloneable-saas.mdx +0 -1
  724. package/docs/content/locales/zh-CN/plan-plugin.mdx +10 -9
  725. package/docs/content/locales/zh-CN/sharing.mdx +1 -2
  726. package/docs/content/locales/zh-CN/skills-guide.mdx +2 -3
  727. package/docs/content/locales/zh-CN/template-plan.mdx +23 -7
  728. package/docs/content/locales/zh-CN/tracking.mdx +1 -1
  729. package/docs/content/locales/zh-CN/workspace-connections.mdx +1 -1
  730. package/docs/content/locales/zh-TW/cloneable-saas.mdx +0 -1
  731. package/docs/content/locales/zh-TW/plan-plugin.mdx +10 -9
  732. package/docs/content/locales/zh-TW/sharing.mdx +1 -2
  733. package/docs/content/locales/zh-TW/skills-guide.mdx +2 -3
  734. package/docs/content/locales/zh-TW/template-plan.mdx +23 -7
  735. package/docs/content/locales/zh-TW/tracking.mdx +1 -1
  736. package/docs/content/locales/zh-TW/workspace-connections.mdx +1 -1
  737. package/docs/content/plan-plugin.mdx +12 -11
  738. package/docs/content/sharing.mdx +1 -2
  739. package/docs/content/skills-guide.mdx +4 -3
  740. package/docs/content/template-plan.mdx +37 -6
  741. package/docs/content/tracking.mdx +1 -1
  742. package/docs/content/workspace-connections.mdx +1 -1
  743. package/package.json +6 -6
  744. package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +82 -19
  745. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +15 -0
  746. package/src/templates/default/.agents/skills/storing-data/SKILL.md +8 -0
  747. package/src/templates/default/package.json +3 -3
  748. package/src/templates/workspace-core/.agents/skills/client-methods/SKILL.md +8 -0
  749. package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +22 -0
  750. package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +82 -19
  751. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +15 -0
  752. package/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +8 -0
  753. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -0
  754. package/corpus/core/docs/content/locales/ar-SA/template-videos.mdx +0 -336
  755. package/corpus/core/docs/content/locales/de-DE/template-videos.mdx +0 -336
  756. package/corpus/core/docs/content/locales/es-ES/template-videos.mdx +0 -336
  757. package/corpus/core/docs/content/locales/fr-FR/template-videos.mdx +0 -336
  758. package/corpus/core/docs/content/locales/hi-IN/template-videos.mdx +0 -336
  759. package/corpus/core/docs/content/locales/ja-JP/template-videos.mdx +0 -336
  760. package/corpus/core/docs/content/locales/ko-KR/template-videos.mdx +0 -336
  761. package/corpus/core/docs/content/locales/pt-BR/template-videos.mdx +0 -336
  762. package/corpus/core/docs/content/locales/zh-CN/template-videos.mdx +0 -334
  763. package/corpus/core/docs/content/locales/zh-TW/template-videos.mdx +0 -334
  764. package/corpus/core/docs/content/template-videos.mdx +0 -332
  765. package/corpus/templates/design/app/components/design/CodeWorkbenchHost.tsx +0 -697
  766. package/corpus/templates/slides/app/components/editor/CodeEditor.tsx +0 -49
  767. package/corpus/templates/videos/.agents/skills/actions/SKILL.md +0 -432
  768. package/corpus/templates/videos/.agents/skills/animation-tracks/SKILL.md +0 -99
  769. package/corpus/templates/videos/.agents/skills/capture-learnings/SKILL.md +0 -76
  770. package/corpus/templates/videos/.agents/skills/composition-management/SKILL.md +0 -106
  771. package/corpus/templates/videos/.agents/skills/create-skill/SKILL.md +0 -221
  772. package/corpus/templates/videos/.agents/skills/delegate-to-agent/SKILL.md +0 -263
  773. package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +0 -112
  774. package/corpus/templates/videos/.agents/skills/security/SKILL.md +0 -263
  775. package/corpus/templates/videos/.agents/skills/self-modifying-code/SKILL.md +0 -88
  776. package/corpus/templates/videos/.agents/skills/shadcn-ui/SKILL.md +0 -95
  777. package/corpus/templates/videos/.builder/ANIMATED_PROPERTIES.md +0 -335
  778. package/corpus/templates/videos/.builder/KEYFRAME_SYNC.md +0 -166
  779. package/corpus/templates/videos/.builder/SEAMLESS_SYNC.md +0 -211
  780. package/corpus/templates/videos/.builder/rules/deploy-app.mdc +0 -22
  781. package/corpus/templates/videos/.builder/rules/organize-ui.mdc +0 -9
  782. package/corpus/templates/videos/.builder/skills/remotion/SKILL.md +0 -29
  783. package/corpus/templates/videos/.builder/skills/remotion/rules/animations.md +0 -27
  784. package/corpus/templates/videos/.builder/skills/remotion/rules/assets.md +0 -43
  785. package/corpus/templates/videos/.builder/skills/remotion/rules/audio.md +0 -58
  786. package/corpus/templates/videos/.builder/skills/remotion/rules/compositions.md +0 -129
  787. package/corpus/templates/videos/.builder/skills/remotion/rules/fonts.md +0 -47
  788. package/corpus/templates/videos/.builder/skills/remotion/rules/images.md +0 -41
  789. package/corpus/templates/videos/.builder/skills/remotion/rules/parameters.md +0 -59
  790. package/corpus/templates/videos/.builder/skills/remotion/rules/sequencing.md +0 -54
  791. package/corpus/templates/videos/.builder/skills/remotion/rules/tailwind.md +0 -11
  792. package/corpus/templates/videos/.builder/skills/remotion/rules/text-animations.md +0 -18
  793. package/corpus/templates/videos/.builder/skills/remotion/rules/timing.md +0 -103
  794. package/corpus/templates/videos/.builder/skills/remotion/rules/transparent-videos.md +0 -35
  795. package/corpus/templates/videos/.builder/skills/remotion/rules/trimming.md +0 -38
  796. package/corpus/templates/videos/.builder/skills/remotion/rules/videos.md +0 -54
  797. package/corpus/templates/videos/.dockerignore +0 -62
  798. package/corpus/templates/videos/.env.example +0 -4
  799. package/corpus/templates/videos/.oxfmtrc.json +0 -8
  800. package/corpus/templates/videos/AGENTS.md +0 -46
  801. package/corpus/templates/videos/CHANGELOG.md +0 -10
  802. package/corpus/templates/videos/DEVELOPING.md +0 -143
  803. package/corpus/templates/videos/README.md +0 -33
  804. package/corpus/templates/videos/actions/analyze-brand-assets.ts +0 -151
  805. package/corpus/templates/videos/actions/apply-design-system.ts +0 -35
  806. package/corpus/templates/videos/actions/create-design-system.ts +0 -84
  807. package/corpus/templates/videos/actions/create-folder.ts +0 -48
  808. package/corpus/templates/videos/actions/db-connect.ts +0 -45
  809. package/corpus/templates/videos/actions/db-health.ts +0 -24
  810. package/corpus/templates/videos/actions/db-status.ts +0 -28
  811. package/corpus/templates/videos/actions/delete-composition.ts +0 -272
  812. package/corpus/templates/videos/actions/delete-folder.ts +0 -27
  813. package/corpus/templates/videos/actions/generate-animated-component.ts +0 -470
  814. package/corpus/templates/videos/actions/get-composition.ts +0 -36
  815. package/corpus/templates/videos/actions/get-design-system.ts +0 -35
  816. package/corpus/templates/videos/actions/import-code.ts +0 -67
  817. package/corpus/templates/videos/actions/import-design-project.ts +0 -42
  818. package/corpus/templates/videos/actions/import-document.ts +0 -125
  819. package/corpus/templates/videos/actions/import-from-url.ts +0 -18
  820. package/corpus/templates/videos/actions/import-github.ts +0 -182
  821. package/corpus/templates/videos/actions/list-compositions.ts +0 -32
  822. package/corpus/templates/videos/actions/list-design-systems.ts +0 -66
  823. package/corpus/templates/videos/actions/list-folders.ts +0 -61
  824. package/corpus/templates/videos/actions/move-composition-to-folder.ts +0 -59
  825. package/corpus/templates/videos/actions/navigate.ts +0 -40
  826. package/corpus/templates/videos/actions/rename-folder.ts +0 -41
  827. package/corpus/templates/videos/actions/run.ts +0 -2
  828. package/corpus/templates/videos/actions/save-composition.ts +0 -114
  829. package/corpus/templates/videos/actions/set-default-design-system.ts +0 -40
  830. package/corpus/templates/videos/actions/update-composition.ts +0 -81
  831. package/corpus/templates/videos/actions/update-design-system.ts +0 -68
  832. package/corpus/templates/videos/actions/validate-compositions.ts +0 -502
  833. package/corpus/templates/videos/actions/view-screen.ts +0 -48
  834. package/corpus/templates/videos/app/components/CameraControls.tsx +0 -442
  835. package/corpus/templates/videos/app/components/CameraToolbar.tsx +0 -437
  836. package/corpus/templates/videos/app/components/CloudUpgrade.tsx +0 -287
  837. package/corpus/templates/videos/app/components/CollabPresenceBar.tsx +0 -81
  838. package/corpus/templates/videos/app/components/CompSettingsEditor.tsx +0 -107
  839. package/corpus/templates/videos/app/components/ComponentLibrarySidebar.tsx +0 -457
  840. package/corpus/templates/videos/app/components/ComponentPropsPanel.tsx +0 -227
  841. package/corpus/templates/videos/app/components/ComponentsSidebar.tsx +0 -70
  842. package/corpus/templates/videos/app/components/CompositionCard.tsx +0 -147
  843. package/corpus/templates/videos/app/components/CurrentElementPanel.tsx +0 -835
  844. package/corpus/templates/videos/app/components/CursorControls.tsx +0 -664
  845. package/corpus/templates/videos/app/components/CursorPositioningOverlay.tsx +0 -238
  846. package/corpus/templates/videos/app/components/LibraryFolderRow.tsx +0 -235
  847. package/corpus/templates/videos/app/components/MotionCurveSelect.tsx +0 -61
  848. package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +0 -192
  849. package/corpus/templates/videos/app/components/PropsEditor.tsx +0 -210
  850. package/corpus/templates/videos/app/components/QuestionFlow.tsx +0 -40
  851. package/corpus/templates/videos/app/components/Sidebar.tsx +0 -609
  852. package/corpus/templates/videos/app/components/StudioHeader.tsx +0 -109
  853. package/corpus/templates/videos/app/components/Timeline.tsx +0 -2067
  854. package/corpus/templates/videos/app/components/TrackPropertiesPanel.tsx +0 -922
  855. package/corpus/templates/videos/app/components/TweaksPanel.tsx +0 -277
  856. package/corpus/templates/videos/app/components/VideoPlayer.tsx +0 -487
  857. package/corpus/templates/videos/app/components/design-system/DesignSystemCard.tsx +0 -134
  858. package/corpus/templates/videos/app/components/design-system/DesignSystemPreview.tsx +0 -176
  859. package/corpus/templates/videos/app/components/design-system/DesignSystemSetup.tsx +0 -707
  860. package/corpus/templates/videos/app/components/keyframes/KeyframeActionButtons.tsx +0 -78
  861. package/corpus/templates/videos/app/components/keyframes/KeyframeNavigation.tsx +0 -59
  862. package/corpus/templates/videos/app/components/keyframes/PropertyNumberInput.tsx +0 -56
  863. package/corpus/templates/videos/app/components/layout/Header.tsx +0 -64
  864. package/corpus/templates/videos/app/components/layout/HeaderActions.tsx +0 -84
  865. package/corpus/templates/videos/app/components/layout/Layout.tsx +0 -132
  866. package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +0 -180
  867. package/corpus/templates/videos/app/components/ui/accordion.tsx +0 -56
  868. package/corpus/templates/videos/app/components/ui/alert-dialog.tsx +0 -139
  869. package/corpus/templates/videos/app/components/ui/alert.tsx +0 -59
  870. package/corpus/templates/videos/app/components/ui/aspect-ratio.tsx +0 -5
  871. package/corpus/templates/videos/app/components/ui/avatar.tsx +0 -48
  872. package/corpus/templates/videos/app/components/ui/badge.tsx +0 -37
  873. package/corpus/templates/videos/app/components/ui/breadcrumb.tsx +0 -115
  874. package/corpus/templates/videos/app/components/ui/button.tsx +0 -57
  875. package/corpus/templates/videos/app/components/ui/calendar.tsx +0 -68
  876. package/corpus/templates/videos/app/components/ui/card.tsx +0 -86
  877. package/corpus/templates/videos/app/components/ui/carousel.tsx +0 -260
  878. package/corpus/templates/videos/app/components/ui/chart.tsx +0 -375
  879. package/corpus/templates/videos/app/components/ui/checkbox.tsx +0 -28
  880. package/corpus/templates/videos/app/components/ui/collapsible.tsx +0 -9
  881. package/corpus/templates/videos/app/components/ui/command.tsx +0 -154
  882. package/corpus/templates/videos/app/components/ui/context-menu.tsx +0 -202
  883. package/corpus/templates/videos/app/components/ui/dialog.tsx +0 -130
  884. package/corpus/templates/videos/app/components/ui/drawer.tsx +0 -116
  885. package/corpus/templates/videos/app/components/ui/dropdown-menu.tsx +0 -212
  886. package/corpus/templates/videos/app/components/ui/form.tsx +0 -177
  887. package/corpus/templates/videos/app/components/ui/hover-card.tsx +0 -27
  888. package/corpus/templates/videos/app/components/ui/input-otp.tsx +0 -69
  889. package/corpus/templates/videos/app/components/ui/input.tsx +0 -22
  890. package/corpus/templates/videos/app/components/ui/label.tsx +0 -24
  891. package/corpus/templates/videos/app/components/ui/menubar.tsx +0 -234
  892. package/corpus/templates/videos/app/components/ui/navigation-menu.tsx +0 -128
  893. package/corpus/templates/videos/app/components/ui/pagination.tsx +0 -121
  894. package/corpus/templates/videos/app/components/ui/popover.tsx +0 -44
  895. package/corpus/templates/videos/app/components/ui/progress.tsx +0 -26
  896. package/corpus/templates/videos/app/components/ui/radio-group.tsx +0 -42
  897. package/corpus/templates/videos/app/components/ui/resizable.tsx +0 -43
  898. package/corpus/templates/videos/app/components/ui/scroll-area.tsx +0 -46
  899. package/corpus/templates/videos/app/components/ui/select.tsx +0 -158
  900. package/corpus/templates/videos/app/components/ui/separator.tsx +0 -29
  901. package/corpus/templates/videos/app/components/ui/sheet.tsx +0 -160
  902. package/corpus/templates/videos/app/components/ui/sidebar.tsx +0 -782
  903. package/corpus/templates/videos/app/components/ui/skeleton.tsx +0 -15
  904. package/corpus/templates/videos/app/components/ui/slider.tsx +0 -26
  905. package/corpus/templates/videos/app/components/ui/sonner.tsx +0 -58
  906. package/corpus/templates/videos/app/components/ui/spinner.tsx +0 -17
  907. package/corpus/templates/videos/app/components/ui/switch.tsx +0 -27
  908. package/corpus/templates/videos/app/components/ui/table.tsx +0 -117
  909. package/corpus/templates/videos/app/components/ui/tabs.tsx +0 -53
  910. package/corpus/templates/videos/app/components/ui/textarea.tsx +0 -23
  911. package/corpus/templates/videos/app/components/ui/toast.tsx +0 -127
  912. package/corpus/templates/videos/app/components/ui/toaster.tsx +0 -33
  913. package/corpus/templates/videos/app/components/ui/toggle-group.tsx +0 -59
  914. package/corpus/templates/videos/app/components/ui/toggle.tsx +0 -43
  915. package/corpus/templates/videos/app/components/ui/tooltip.tsx +0 -30
  916. package/corpus/templates/videos/app/components/ui/use-toast.ts +0 -3
  917. package/corpus/templates/videos/app/config/constants.ts +0 -204
  918. package/corpus/templates/videos/app/contexts/CompositionContext.tsx +0 -437
  919. package/corpus/templates/videos/app/contexts/CurrentElementContext.tsx +0 -247
  920. package/corpus/templates/videos/app/contexts/PlaybackContext.tsx +0 -65
  921. package/corpus/templates/videos/app/contexts/TimelineContext.tsx +0 -433
  922. package/corpus/templates/videos/app/entry.client.tsx +0 -19
  923. package/corpus/templates/videos/app/entry.server.tsx +0 -10
  924. package/corpus/templates/videos/app/global.css +0 -171
  925. package/corpus/templates/videos/app/hooks/use-composition-collab.ts +0 -269
  926. package/corpus/templates/videos/app/hooks/use-composition-design-system.ts +0 -47
  927. package/corpus/templates/videos/app/hooks/use-database-compositions.ts +0 -144
  928. package/corpus/templates/videos/app/hooks/use-db-status.ts +0 -30
  929. package/corpus/templates/videos/app/hooks/use-design-systems.ts +0 -21
  930. package/corpus/templates/videos/app/hooks/use-folders.ts +0 -199
  931. package/corpus/templates/videos/app/hooks/use-mobile.tsx +0 -21
  932. package/corpus/templates/videos/app/hooks/use-navigation-state.ts +0 -52
  933. package/corpus/templates/videos/app/hooks/use-question-flow.ts +0 -14
  934. package/corpus/templates/videos/app/hooks/use-toast.ts +0 -188
  935. package/corpus/templates/videos/app/hooks/useUnsavedChanges.ts +0 -48
  936. package/corpus/templates/videos/app/i18n/ar-SA.ts +0 -446
  937. package/corpus/templates/videos/app/i18n/de-DE.ts +0 -470
  938. package/corpus/templates/videos/app/i18n/en-US.ts +0 -452
  939. package/corpus/templates/videos/app/i18n/es-ES.ts +0 -468
  940. package/corpus/templates/videos/app/i18n/fr-FR.ts +0 -469
  941. package/corpus/templates/videos/app/i18n/hi-IN.ts +0 -446
  942. package/corpus/templates/videos/app/i18n/index.ts +0 -34
  943. package/corpus/templates/videos/app/i18n/ja-JP.ts +0 -460
  944. package/corpus/templates/videos/app/i18n/ko-KR.ts +0 -453
  945. package/corpus/templates/videos/app/i18n/pt-BR.ts +0 -461
  946. package/corpus/templates/videos/app/i18n/zh-CN.ts +0 -437
  947. package/corpus/templates/videos/app/i18n/zh-TW.ts +0 -437
  948. package/corpus/templates/videos/app/lib/database-compositions.ts +0 -67
  949. package/corpus/templates/videos/app/lib/design-systems.ts +0 -234
  950. package/corpus/templates/videos/app/lib/tab-id.ts +0 -1
  951. package/corpus/templates/videos/app/lib/utils.ts +0 -1
  952. package/corpus/templates/videos/app/pages/ComponentLibrary.tsx +0 -134
  953. package/corpus/templates/videos/app/pages/ComponentLibraryView.tsx +0 -362
  954. package/corpus/templates/videos/app/pages/CompositionView.tsx +0 -715
  955. package/corpus/templates/videos/app/pages/DesignSystems.tsx +0 -180
  956. package/corpus/templates/videos/app/pages/Index.tsx +0 -258
  957. package/corpus/templates/videos/app/pages/NewComposition.tsx +0 -95
  958. package/corpus/templates/videos/app/pages/NotFound.tsx +0 -36
  959. package/corpus/templates/videos/app/remotion/CameraHost.tsx +0 -223
  960. package/corpus/templates/videos/app/remotion/animations/useElementAnimations.ts +0 -231
  961. package/corpus/templates/videos/app/remotion/componentRegistry.ts +0 -473
  962. package/corpus/templates/videos/app/remotion/components/AnimatedElement.tsx +0 -436
  963. package/corpus/templates/videos/app/remotion/components/InteractiveElement.tsx +0 -159
  964. package/corpus/templates/videos/app/remotion/components/InteractiveZoneEditor.tsx +0 -273
  965. package/corpus/templates/videos/app/remotion/compositions/BlankComposition.tsx +0 -204
  966. package/corpus/templates/videos/app/remotion/compositions/index.ts +0 -2
  967. package/corpus/templates/videos/app/remotion/createComponentCursorTrack.ts +0 -132
  968. package/corpus/templates/videos/app/remotion/docs/CURSOR_TRACKS.md +0 -285
  969. package/corpus/templates/videos/app/remotion/easingFunctions.ts +0 -255
  970. package/corpus/templates/videos/app/remotion/hooks/createInteractiveComposition.tsx +0 -283
  971. package/corpus/templates/videos/app/remotion/hooks/useCursorHistory.ts +0 -72
  972. package/corpus/templates/videos/app/remotion/hooks/useCursorTypeFromHover.ts +0 -35
  973. package/corpus/templates/videos/app/remotion/hooks/useEditableZones.tsx +0 -267
  974. package/corpus/templates/videos/app/remotion/hooks/useHoverAnimation.ts +0 -90
  975. package/corpus/templates/videos/app/remotion/hooks/useHoverAnimationSmooth.ts +0 -146
  976. package/corpus/templates/videos/app/remotion/hooks/useInteractiveComponent.ts +0 -711
  977. package/corpus/templates/videos/app/remotion/hooks/useRegisterInteractiveElement.ts +0 -56
  978. package/corpus/templates/videos/app/remotion/library-components/Button.tsx +0 -252
  979. package/corpus/templates/videos/app/remotion/library-components/Card.tsx +0 -241
  980. package/corpus/templates/videos/app/remotion/library-components/CodePanel.tsx +0 -1005
  981. package/corpus/templates/videos/app/remotion/library-components/FileItem.tsx +0 -196
  982. package/corpus/templates/videos/app/remotion/library-components/FolderItem.tsx +0 -199
  983. package/corpus/templates/videos/app/remotion/library-components/PrimaryButton.tsx +0 -177
  984. package/corpus/templates/videos/app/remotion/library-components/SecondaryButton.tsx +0 -167
  985. package/corpus/templates/videos/app/remotion/library-components/SectionHeader.tsx +0 -108
  986. package/corpus/templates/videos/app/remotion/library-components/TEMPLATE.tsx +0 -211
  987. package/corpus/templates/videos/app/remotion/library-components/index.ts +0 -10
  988. package/corpus/templates/videos/app/remotion/registry.ts +0 -91
  989. package/corpus/templates/videos/app/remotion/testing/animationTestUtils.ts +0 -366
  990. package/corpus/templates/videos/app/remotion/trackAnimation.ts +0 -179
  991. package/corpus/templates/videos/app/remotion/trackHelpers.ts +0 -110
  992. package/corpus/templates/videos/app/remotion/ui-components/Cursor.tsx +0 -182
  993. package/corpus/templates/videos/app/remotion/ui-components/InteractiveButton.tsx +0 -116
  994. package/corpus/templates/videos/app/remotion/ui-components/InteractiveCard.tsx +0 -126
  995. package/corpus/templates/videos/app/remotion/ui-components/index.ts +0 -16
  996. package/corpus/templates/videos/app/remotion/utils/animationHelpers.ts +0 -444
  997. package/corpus/templates/videos/app/remotion/utils/interactiveElements.ts +0 -235
  998. package/corpus/templates/videos/app/remotion/utils/springAnimation.ts +0 -76
  999. package/corpus/templates/videos/app/root.tsx +0 -165
  1000. package/corpus/templates/videos/app/routes/$.tsx +0 -10
  1001. package/corpus/templates/videos/app/routes/_index.tsx +0 -25
  1002. package/corpus/templates/videos/app/routes/c.$compositionId.tsx +0 -10
  1003. package/corpus/templates/videos/app/routes/components.tsx +0 -10
  1004. package/corpus/templates/videos/app/routes/design-systems.tsx +0 -10
  1005. package/corpus/templates/videos/app/routes/extensions.$id.$slug.tsx +0 -2
  1006. package/corpus/templates/videos/app/routes/extensions.$id.tsx +0 -5
  1007. package/corpus/templates/videos/app/routes/extensions._index.tsx +0 -5
  1008. package/corpus/templates/videos/app/routes/extensions.tsx +0 -5
  1009. package/corpus/templates/videos/app/routes/settings.tsx +0 -88
  1010. package/corpus/templates/videos/app/routes/team.tsx +0 -11
  1011. package/corpus/templates/videos/app/routes.ts +0 -4
  1012. package/corpus/templates/videos/app/state.ts +0 -77
  1013. package/corpus/templates/videos/app/types/elementAnimations.ts +0 -121
  1014. package/corpus/templates/videos/app/types.ts +0 -200
  1015. package/corpus/templates/videos/app/utils/compositionHelpers.ts +0 -445
  1016. package/corpus/templates/videos/app/utils/debug.ts +0 -63
  1017. package/corpus/templates/videos/app/utils/keyframeUtils.ts +0 -331
  1018. package/corpus/templates/videos/app/utils/timelineCoordinates.ts +0 -79
  1019. package/corpus/templates/videos/app/vite-env.d.ts +0 -6
  1020. package/corpus/templates/videos/changelog/2026-06-24-a-new-settings-page-gives-quick-access-to-language-workspace.md +0 -6
  1021. package/corpus/templates/videos/changelog/2026-06-24-added-a-language-picker-and-localized-app-chrome-for-support.md +0 -6
  1022. package/corpus/templates/videos/changelog/2026-06-27-traditional-chinese-copy-uses-taiwan-terminology.md +0 -6
  1023. package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +0 -6
  1024. package/corpus/templates/videos/changelog/2026-06-29-video-layouts-adapt-when-the-agent-sidebar-is-open.md +0 -6
  1025. package/corpus/templates/videos/components.json +0 -20
  1026. package/corpus/templates/videos/data/sync-config.json +0 -3
  1027. package/corpus/templates/videos/docs/examples/create-composition.example.ts +0 -128
  1028. package/corpus/templates/videos/drizzle.config.ts +0 -3
  1029. package/corpus/templates/videos/learnings.defaults.md +0 -5
  1030. package/corpus/templates/videos/package.json +0 -114
  1031. package/corpus/templates/videos/public/agent-native-icon-dark.svg +0 -10
  1032. package/corpus/templates/videos/public/agent-native-icon-light.svg +0 -10
  1033. package/corpus/templates/videos/public/agent-native-logo-dark.svg +0 -21
  1034. package/corpus/templates/videos/public/agent-native-logo-light.svg +0 -21
  1035. package/corpus/templates/videos/public/favicon.svg +0 -1
  1036. package/corpus/templates/videos/public/icon-180.svg +0 -1
  1037. package/corpus/templates/videos/public/icon-192.svg +0 -1
  1038. package/corpus/templates/videos/public/icon-512.svg +0 -1
  1039. package/corpus/templates/videos/public/manifest.json +0 -21
  1040. package/corpus/templates/videos/public/placeholder.svg +0 -1
  1041. package/corpus/templates/videos/public/robots.txt +0 -14
  1042. package/corpus/templates/videos/react-router.config.ts +0 -7
  1043. package/corpus/templates/videos/server/db/index.ts +0 -37
  1044. package/corpus/templates/videos/server/db/schema.ts +0 -55
  1045. package/corpus/templates/videos/server/middleware/auth.ts +0 -15
  1046. package/corpus/templates/videos/server/plugins/agent-chat.ts +0 -29
  1047. package/corpus/templates/videos/server/plugins/auth.ts +0 -14
  1048. package/corpus/templates/videos/server/plugins/collab.ts +0 -11
  1049. package/corpus/templates/videos/server/plugins/core-routes.ts +0 -22
  1050. package/corpus/templates/videos/server/plugins/db.ts +0 -160
  1051. package/corpus/templates/videos/server/routes/[...page].get.ts +0 -5
  1052. package/corpus/templates/videos/server/routes/[...page].head.ts +0 -1
  1053. package/corpus/templates/videos/server/routes/api/save-composition-defaults.post.ts +0 -274
  1054. package/corpus/templates/videos/shared/api.ts +0 -75
  1055. package/corpus/templates/videos/ssr-entry.ts +0 -15
  1056. package/corpus/templates/videos/tsconfig.json +0 -21
  1057. package/corpus/templates/videos/vite.config.ts +0 -19
  1058. package/corpus/templates/videos/vitest.config.ts +0 -16
  1059. package/docs/content/locales/ar-SA/template-videos.mdx +0 -336
  1060. package/docs/content/locales/de-DE/template-videos.mdx +0 -336
  1061. package/docs/content/locales/es-ES/template-videos.mdx +0 -336
  1062. package/docs/content/locales/fr-FR/template-videos.mdx +0 -336
  1063. package/docs/content/locales/hi-IN/template-videos.mdx +0 -336
  1064. package/docs/content/locales/ja-JP/template-videos.mdx +0 -336
  1065. package/docs/content/locales/ko-KR/template-videos.mdx +0 -336
  1066. package/docs/content/locales/pt-BR/template-videos.mdx +0 -336
  1067. package/docs/content/locales/zh-CN/template-videos.mdx +0 -334
  1068. package/docs/content/locales/zh-TW/template-videos.mdx +0 -334
  1069. package/docs/content/template-videos.mdx +0 -332
@@ -4,16 +4,25 @@ import {
4
4
  DEFAULT_ASSIGNED_REGION_HEIGHT,
5
5
  DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
6
6
  DEFAULT_ASSIGNED_REGION_WIDTH,
7
+ DEFAULT_CANVAS_MAX_ZOOM,
8
+ DEFAULT_CANVAS_MIN_ZOOM,
7
9
  DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
8
- appendPolylinePoint,
10
+ computeEqualGapGuides,
9
11
  computeMoveSnap,
10
12
  computeResizeSnap,
13
+ type DistanceGuideBand,
14
+ type EqualGapGuide,
11
15
  getDraftGeometryFromPoints,
12
16
  getFrameGroupBounds,
13
17
  getNudgeDelta,
14
18
  getPanForZoomToCursor,
19
+ getResizeCursorForHandle,
20
+ getRotatedFrameAABB,
15
21
  resizeFrameGroupFromDelta,
16
22
  resizeFrameGroupToBounds,
23
+ resizeRotatedFrameFromDelta,
24
+ rotateFrameGroupAroundCenter,
25
+ rotatedRectIntersects,
17
26
  screenToCanvasPoint,
18
27
  type ArrowNudgeKey,
19
28
  } from "@shared/canvas-math";
@@ -25,9 +34,15 @@ import {
25
34
  createCornerNode,
26
35
  createSmoothNode,
27
36
  getPenPathGeometry,
37
+ hitTestPenAnchor,
38
+ hitTestPenHandle,
28
39
  isPenCloseTarget,
40
+ movePenAnchor,
41
+ movePenHandle,
29
42
  scalePenPathToGeometry,
30
43
  serializePenPath,
44
+ setPenNodeType,
45
+ snapPenAnchorPoint,
31
46
  translatePenPath,
32
47
  type PenNode,
33
48
  type PenPath,
@@ -392,6 +407,14 @@ interface MultiScreenCanvasProps {
392
407
  info?: ElementInfo,
393
408
  details?: { html?: string },
394
409
  ) => void;
410
+ /**
411
+ * Figma-style vector edit mode: when present, renders an interactive
412
+ * overlay (draggable anchors + control handles) over `path` and lets the
413
+ * user reshape it. When null/undefined, nothing new renders and existing
414
+ * pen-draw / selection / drag behavior is unaffected. The parent owns the
415
+ * working PenPath state, entering/exiting edit mode, and persistence.
416
+ */
417
+ vectorEdit?: VectorEditOverlayState | null;
395
418
  }
396
419
 
397
420
  /**
@@ -406,6 +429,9 @@ const SCREEN_GAP = 56;
406
429
  export const SURFACE_PADDING = 240;
407
430
  const DUPLICATE_DRAG_THRESHOLD = 6;
408
431
  const DRAG_THRESHOLD = 3;
432
+ /** How close two gaps must be (in screen px, converted to canvas px at the
433
+ * live zoom) to count as "equal" for the smart-spacing guides (CV11). */
434
+ const EQUAL_GAP_TOLERANCE_SCREEN_PX = 2;
409
435
  const FRAME_LABEL_HEIGHT = 28;
410
436
  const FRAME_HEADER_BUTTON_OUTSIDE_WIDTH = 260;
411
437
  const FRAME_HEADER_BUTTON_RESERVE = 116;
@@ -414,8 +440,21 @@ const TRANSFORM_BADGE_EDGE_PADDING = 8;
414
440
  const TRANSFORM_BADGE_HEIGHT = 28;
415
441
  const TRANSFORM_BADGE_MIN_WIDTH = 64;
416
442
  const TRANSFORM_BADGE_MAX_WIDTH = 180;
417
- const MIN_ZOOM = 2;
418
- const MAX_ZOOM = 800;
443
+ // Additive zIndex boost for the current "top" screen (selected, else active,
444
+ // else the first frame — see topScreenId). Screens are keyed by screen.id in
445
+ // stable DOM order (see PF16): reordering the top screen's key to the end of
446
+ // the array to win the paint stacking order forced React to move that
447
+ // iframe's DOM node, which reloads its document (a visible white flash).
448
+ // zIndex alone can express "renders above its siblings" without touching DOM
449
+ // order, as long as the boost is large enough to beat any real geometry.z
450
+ // (frame z-order is a small per-design integer) while staying well under the
451
+ // reserved resize-handle stacking range (999_999+).
452
+ const TOP_SCREEN_Z_BOOST = 100_000;
453
+ // Shared with canvas-math.ts (DEFAULT_CANVAS_MIN_ZOOM/DEFAULT_CANVAS_MAX_ZOOM)
454
+ // so this surface's zoom clamp lives in one place instead of being
455
+ // redeclared locally and drifting from the shared constant.
456
+ const MIN_ZOOM = DEFAULT_CANVAS_MIN_ZOOM;
457
+ const MAX_ZOOM = DEFAULT_CANVAS_MAX_ZOOM;
419
458
  const ZOOM_SENSITIVITY = 0.01;
420
459
  const MAX_WHEEL_ZOOM_DELTA = 120;
421
460
  const MAX_WHEEL_PAN_DELTA = 140;
@@ -722,8 +761,14 @@ const DRAFT_TEXT_WIDTH = 180;
722
761
  const DRAFT_TEXT_HEIGHT = 48;
723
762
  const DRAFT_LINE_WIDTH = 160;
724
763
  const DRAFT_PATH_MIN_SIZE = 12;
725
- const PEN_SAMPLE_DISTANCE_SCREEN_PX = 5;
726
764
  const PEN_CLOSE_HIT_RADIUS_SCREEN_PX = 10;
765
+ /** Screen-space hit radius for vector-edit anchor/handle pointer targets,
766
+ * independent of PEN_CLOSE_HIT_RADIUS_SCREEN_PX (that one gates the pen
767
+ * tool's close-path affordance while drawing, not editing an existing
768
+ * path). Converted to canvas px via `screenPxToCanvasPx` before being
769
+ * passed to hitTestPenAnchor/hitTestPenHandle, which operate in canvas
770
+ * space. */
771
+ const VECTOR_EDIT_HIT_RADIUS_SCREEN_PX = 8;
727
772
 
728
773
  interface ResolvedScreenMetadata {
729
774
  source: ScreenSourceType;
@@ -766,6 +811,56 @@ export interface Point {
766
811
  y: number;
767
812
  }
768
813
 
814
+ /**
815
+ * Interactive vector-edit overlay state, supplied by the parent
816
+ * (DesignEditor) whenever the user is editing an existing path's anchors and
817
+ * control handles on the canvas. `path` is expressed in path-local
818
+ * coordinates (the same space `pen-path.ts` helpers operate in); `originCanvas`
819
+ * is where that path's local origin (0,0) sits in canvas space, so a given
820
+ * local point's canvas position is simply `originCanvas + localPoint`
821
+ * (see `vectorEditLocalToCanvasPoint`). The parent owns the working PenPath
822
+ * state, entering/exiting edit mode, and persistence — this component only
823
+ * renders the overlay and reports pointer interaction back through
824
+ * `onChange`/`onExit`.
825
+ */
826
+ export interface VectorEditOverlayState {
827
+ path: PenPath;
828
+ originCanvas: Point;
829
+ onChange: (nextPath: PenPath, phase: "preview" | "commit") => void;
830
+ onExit: () => void;
831
+ }
832
+
833
+ /** Path-local point -> canvas-space point, given the path's canvas origin. */
834
+ export function vectorEditLocalToCanvasPoint(
835
+ local: Point,
836
+ originCanvas: Point,
837
+ ): Point {
838
+ return { x: originCanvas.x + local.x, y: originCanvas.y + local.y };
839
+ }
840
+
841
+ /** Canvas-space point -> path-local point, given the path's canvas origin.
842
+ * Inverse of `vectorEditLocalToCanvasPoint`. */
843
+ export function vectorEditCanvasToLocalPoint(
844
+ canvasPoint: Point,
845
+ originCanvas: Point,
846
+ ): Point {
847
+ return {
848
+ x: canvasPoint.x - originCanvas.x,
849
+ y: canvasPoint.y - originCanvas.y,
850
+ };
851
+ }
852
+
853
+ /** Converts a constant on-screen pixel radius (e.g. a hit-test tolerance
854
+ * that should feel the same size regardless of zoom) into canvas px, the
855
+ * space hitTestPenAnchor/hitTestPenHandle and PenPath geometry operate in.
856
+ * `zoom` is the same 0-based-at-100 percentage used throughout this file
857
+ * (100 = 1:1). Mirrors the `screenPx / (zoom / 100)` conversion already
858
+ * used by the pen tool's close-hit-target radius. */
859
+ export function screenPxToCanvasPx(screenPx: number, zoom: number): number {
860
+ const scale = zoom / 100;
861
+ return scale > 0 ? screenPx / scale : screenPx;
862
+ }
863
+
769
864
  export type CrossScreenDropPlacement = "before" | "after" | "inside";
770
865
  export type CrossScreenDropAxis = "x" | "y";
771
866
  export type CrossScreenDropMode = "flow-insert" | "absolute-container";
@@ -851,6 +946,59 @@ function isCrossScreenHitTestAnchorRect(
851
946
  );
852
947
  }
853
948
 
949
+ // Snap/guide recompute runs every rAF-coalesced mousemove during a drag and
950
+ // always returns a freshly-allocated array, so referential-equality bail in
951
+ // setState never fires even when the guides are unchanged frame-to-frame
952
+ // (e.g. holding steady mid-drag, or dragging along an axis with no new
953
+ // alignment). Shallow field-compare avoids the wasted re-render (PF15).
954
+ function alignmentGuidesEqual(a: AlignmentGuide[], b: AlignmentGuide[]) {
955
+ if (a === b) return true;
956
+ if (a.length !== b.length) return false;
957
+ for (let i = 0; i < a.length; i++) {
958
+ const x = a[i];
959
+ const y = b[i];
960
+ if (
961
+ x.orientation !== y.orientation ||
962
+ x.position !== y.position ||
963
+ x.start !== y.start ||
964
+ x.end !== y.end
965
+ ) {
966
+ return false;
967
+ }
968
+ }
969
+ return true;
970
+ }
971
+
972
+ function distanceGuideBandEqual(
973
+ a: DistanceGuideBand,
974
+ b: DistanceGuideBand,
975
+ ): boolean {
976
+ return (
977
+ a.gapStart === b.gapStart &&
978
+ a.gapEnd === b.gapEnd &&
979
+ a.crossStart === b.crossStart &&
980
+ a.crossEnd === b.crossEnd
981
+ );
982
+ }
983
+
984
+ function equalGapGuidesEqual(a: EqualGapGuide[], b: EqualGapGuide[]) {
985
+ if (a === b) return true;
986
+ if (a.length !== b.length) return false;
987
+ for (let i = 0; i < a.length; i++) {
988
+ const x = a[i];
989
+ const y = b[i];
990
+ if (
991
+ x.orientation !== y.orientation ||
992
+ x.gap !== y.gap ||
993
+ !distanceGuideBandEqual(x.bands[0], y.bands[0]) ||
994
+ !distanceGuideBandEqual(x.bands[1], y.bands[1])
995
+ ) {
996
+ return false;
997
+ }
998
+ }
999
+ return true;
1000
+ }
1001
+
854
1002
  export function getCrossScreenDropGuideForHitTest(args: {
855
1003
  hit: CrossScreenHitTestResult;
856
1004
  targetGeometry: FrameGeometry;
@@ -963,13 +1111,21 @@ interface DraftPrimitive {
963
1111
 
964
1112
  type DraftPrimitiveById = Record<string, DraftPrimitive>;
965
1113
 
1114
+ /** Live keyboard modifiers for shape-drawing tools: shift constrains rect/
1115
+ * ellipse to a square/circle (and lines/arrows to 45deg increments); alt
1116
+ * draws outward from the start point as the shape's center. */
1117
+ interface DraftGeometryModifiers {
1118
+ shiftKey?: boolean;
1119
+ altKey?: boolean;
1120
+ }
1121
+
966
1122
  interface DraftPrimitiveInput {
967
1123
  tool: DraftCreationTool;
968
1124
  start: Point;
969
1125
  end: Point;
970
- points?: Point[];
971
1126
  moved: boolean;
972
1127
  toolProps?: CanvasToolProps;
1128
+ modifiers?: DraftGeometryModifiers;
973
1129
  }
974
1130
 
975
1131
  interface MarqueeRect {
@@ -1017,6 +1173,21 @@ interface RotateDragState {
1017
1173
  hasMoved: boolean;
1018
1174
  }
1019
1175
 
1176
+ /** Multi-selection rotate (CV14): rotates every selected frame together
1177
+ * around the group's own center, using rotateFrameGroupAroundCenter. Kept
1178
+ * as a separate drag-state type from the single-frame RotateDragState above
1179
+ * (rather than extending it to optionally hold multiple ids) so the
1180
+ * existing, already-correct single-frame rotate path is never touched. */
1181
+ interface GroupRotateDragState {
1182
+ type: "group-rotate";
1183
+ originClient: Point;
1184
+ originFrames: FrameGeometryById;
1185
+ targetIds: string[];
1186
+ groupCenter: Point;
1187
+ originPointerAngle: number;
1188
+ hasMoved: boolean;
1189
+ }
1190
+
1020
1191
  interface MarqueeDragState {
1021
1192
  type: "marquee";
1022
1193
  originClient: Point;
@@ -1068,6 +1239,40 @@ interface PenNodeDragState {
1068
1239
  anchor: Point;
1069
1240
  pathBefore: PenPath | null;
1070
1241
  hasMoved: boolean;
1242
+ /**
1243
+ * True when this drag started on the close-hit-target (the path's first
1244
+ * anchor) rather than adding a new node. Figma defers the close commit
1245
+ * until mouseup so a drag on the closing click can shape the closing
1246
+ * segment's curve (the first anchor's handleIn) instead of the click
1247
+ * being an instant, undraggable straight-line close.
1248
+ */
1249
+ closing?: boolean;
1250
+ }
1251
+
1252
+ /** Dragging an anchor square of a `vectorEdit` overlay path (P-VE1). Anchor
1253
+ * drags move the whole node (point + handles, via movePenAnchor's default
1254
+ * moveHandlesWithAnchor:true) rather than reshaping a single handle. */
1255
+ interface VectorEditAnchorDragState {
1256
+ type: "vector-anchor";
1257
+ originClient: Point;
1258
+ nodeIndex: number;
1259
+ /** Path snapshot (local coords) from just before this drag began, restored
1260
+ * on cancel. */
1261
+ pathBefore: PenPath;
1262
+ hasMoved: boolean;
1263
+ }
1264
+
1265
+ /** Dragging a control-handle circle of a `vectorEdit` overlay path (P-VE1).
1266
+ * Alt/Option held during the drag breaks handle symmetry into a cusp
1267
+ * (movePenHandle's breakSymmetry), matching the pen tool's own alt
1268
+ * behavior while placing a fresh anchor. */
1269
+ interface VectorEditHandleDragState {
1270
+ type: "vector-handle";
1271
+ originClient: Point;
1272
+ nodeIndex: number;
1273
+ which: "in" | "out";
1274
+ pathBefore: PenPath;
1275
+ hasMoved: boolean;
1071
1276
  }
1072
1277
 
1073
1278
  interface DraftCreationPreview {
@@ -1080,12 +1285,15 @@ type DragState =
1080
1285
  | MoveDragState
1081
1286
  | ResizeDragState
1082
1287
  | RotateDragState
1288
+ | GroupRotateDragState
1083
1289
  | MarqueeDragState
1084
1290
  | PanDragState
1085
1291
  | DraftMoveDragState
1086
1292
  | DraftResizeDragState
1087
1293
  | DraftCreateDragState
1088
- | PenNodeDragState;
1294
+ | PenNodeDragState
1295
+ | VectorEditAnchorDragState
1296
+ | VectorEditHandleDragState;
1089
1297
 
1090
1298
  type PendingWheelGesture =
1091
1299
  | {
@@ -1101,7 +1309,14 @@ type PendingWheelGesture =
1101
1309
  deltaY: number;
1102
1310
  };
1103
1311
 
1104
- export function MultiScreenCanvas({
1312
+ // PF8: DesignEditor re-renders on nearly every interaction (drag, hover,
1313
+ // zoom, selection). All call-site props are stabilized in DesignEditor.tsx
1314
+ // (useMemo/useCallback hoists, cached getLayerSelectorsForFile, hoisted
1315
+ // renderScreenContent, hoisted board-surface callbacks — see the "PF8"
1316
+ // comments near those definitions), so a default shallow comparator is
1317
+ // sufficient and preferred: it will correctly re-render whenever a real
1318
+ // data/callback identity changes instead of silently masking one.
1319
+ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1105
1320
  screens,
1106
1321
  zoom,
1107
1322
  activeId,
@@ -1164,6 +1379,7 @@ export function MultiScreenCanvas({
1164
1379
  onBoardVisualStyleChange,
1165
1380
  onBoardVisualDuplicateChange,
1166
1381
  onBoardTextContentChange,
1382
+ vectorEdit,
1167
1383
  }: MultiScreenCanvasProps) {
1168
1384
  const t = useT();
1169
1385
  const surfaceRef = useRef<HTMLDivElement>(null);
@@ -1189,6 +1405,16 @@ export function MultiScreenCanvas({
1189
1405
  );
1190
1406
  const [penPointer, setPenPointer] = useState<Point | null>(null);
1191
1407
  const [penCloseHover, setPenCloseHover] = useState(false);
1408
+ // Last raw client point the pen ghost/close-hover preview was computed
1409
+ // from (P18). A wheel pan/zoom gesture mutates pan/zoom every animation
1410
+ // frame via applyViewToDom without the mouse itself moving, so the
1411
+ // screen->canvas mapping used for the ghost segment goes stale unless we
1412
+ // re-derive it from this remembered client point after each such change.
1413
+ const lastPenClientPointRef = useRef<{
1414
+ clientX: number;
1415
+ clientY: number;
1416
+ shiftKey: boolean;
1417
+ } | null>(null);
1192
1418
  const [localActiveTool, setLocalActiveTool] =
1193
1419
  useState<MultiScreenCanvasTool>("move");
1194
1420
  const [selectedIds, setSelectedIds] = useState<string[]>(
@@ -1203,7 +1429,25 @@ export function MultiScreenCanvas({
1203
1429
  const [isDragging, setIsDragging] = useState(false);
1204
1430
  const [isPanning, setIsPanning] = useState(false);
1205
1431
  const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
1206
- const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
1432
+ const marqueeRef = useRef<MarqueeRect | null>(marquee);
1433
+ const [alignmentGuides, setAlignmentGuidesRaw] = useState<AlignmentGuide[]>(
1434
+ [],
1435
+ );
1436
+ const [equalGapGuides, setEqualGapGuidesRaw] = useState<EqualGapGuide[]>([]);
1437
+ // Guides are recomputed into a brand-new array on every rAF-coalesced
1438
+ // mousemove during a drag; without a value-equality bail, React commits a
1439
+ // state update (and a re-render) every frame even when the guides drawn on
1440
+ // screen haven't actually changed (PF15). Bail before calling setState.
1441
+ const setAlignmentGuides = useCallback((next: AlignmentGuide[]) => {
1442
+ setAlignmentGuidesRaw((current) =>
1443
+ alignmentGuidesEqual(current, next) ? current : next,
1444
+ );
1445
+ }, []);
1446
+ const setEqualGapGuides = useCallback((next: EqualGapGuide[]) => {
1447
+ setEqualGapGuidesRaw((current) =>
1448
+ equalGapGuidesEqual(current, next) ? current : next,
1449
+ );
1450
+ }, []);
1207
1451
  const [duplicatePreview, setDuplicatePreview] =
1208
1452
  useState<DuplicatePreview | null>(null);
1209
1453
  const [transformBadge, setTransformBadge] = useState<TransformBadge | null>(
@@ -1214,6 +1458,12 @@ export function MultiScreenCanvas({
1214
1458
  useState<PrimitiveDropTarget | null>(null);
1215
1459
  const primitiveDropTargetRef = useRef<PrimitiveDropTarget | null>(null);
1216
1460
  const onPrimitiveReparentRef = useRef(onPrimitiveReparent);
1461
+ // Mirrors the `vectorEdit` prop so long-lived mousemove/mouseup closures
1462
+ // created at drag-start (beginVectorAnchorDrag/beginVectorHandleDrag) always
1463
+ // read the current path/onChange even if the prop identity changes mid-drag
1464
+ // (e.g. a re-render from the preview onChange itself), rather than closing
1465
+ // over a snapshot from the moment the drag began.
1466
+ const vectorEditRef = useRef(vectorEdit);
1217
1467
 
1218
1468
  // Cross-screen element drag state — driven by postMessage from the source iframe.
1219
1469
  interface CrossScreenDragGhost {
@@ -1254,17 +1504,47 @@ export function MultiScreenCanvas({
1254
1504
  const crossScreenLastBoardPointRef = useRef<{ x: number; y: number } | null>(
1255
1505
  null,
1256
1506
  );
1507
+ /** rAF handle for throttling drop-guide hit-tests during the parent-window
1508
+ * mousemove fallback drag (see activateParentDrag) — a hit-test is a
1509
+ * postMessage round-trip to the target iframe, so firing one per raw
1510
+ * mousemove event (which can be dozens per frame) floods the message
1511
+ * channel for no visual benefit beyond one update per animation frame. */
1512
+ const crossScreenMoveRafRef = useRef<number | null>(null);
1513
+ const crossScreenPendingMoveRef = useRef<{
1514
+ boardPoint: Point;
1515
+ sourceScreenId: string;
1516
+ } | null>(null);
1517
+ /** Last successful hit-test result per target screen id, so a timed-out
1518
+ * request (bridge script briefly busy, iframe still loading, etc.) can
1519
+ * fall back to the previous guide instead of resolving empty and making
1520
+ * the drop guide flicker away every time a single hit-test is slow. */
1521
+ const crossScreenLastHitResultRef = useRef<
1522
+ Map<string, CrossScreenHitTestResult>
1523
+ >(new Map());
1257
1524
  const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
1258
1525
  const onBoardDrawPrimitiveRef = useRef(onBoardDrawPrimitive);
1259
1526
  // Ref wrapper for finishDrag so callbacks declared before finishDrag can
1260
1527
  // reference it via the ref without hitting the const TDZ.
1261
1528
  const finishDragRef = useRef<() => void>(() => {});
1529
+ // Ref wrappers for applyViewToDom/scheduleViewCommit (defined later, near
1530
+ // the wheel/pinch gesture path) so beginPan — declared earlier — can reuse
1531
+ // the same imperative-transform-during-gesture pattern without a TDZ error.
1532
+ const applyViewToDomRef = useRef<() => void>(() => {});
1533
+ const scheduleViewCommitRef = useRef<
1534
+ (options?: { settleChrome?: boolean }) => void
1535
+ >(() => {});
1536
+ // Ref wrapper for recomputePenPointerForViewChange (P18, defined later
1537
+ // near updatePenPointer) so the external `zoom` prop sync effect —
1538
+ // declared earlier — can resync the pen ghost preview after an
1539
+ // externally-driven (toolbar/keyboard) zoom change without a TDZ error.
1540
+ const recomputePenPointerForViewChangeRef = useRef<() => void>(() => {});
1262
1541
  const suppressNextPick = useRef(false);
1263
1542
  const feedbackTimerRef = useRef<number | null>(null);
1264
1543
  const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
1265
1544
  const wheelGestureFrameRef = useRef<number | null>(null);
1266
1545
  const worldRef = useRef<HTMLDivElement>(null);
1267
1546
  const pixelGridRef = useRef<HTMLDivElement>(null);
1547
+ const marqueeOverlayRef = useRef<HTMLSpanElement>(null);
1268
1548
  const viewCommitTimerRef = useRef<number | null>(null);
1269
1549
  const pendingChromeSettleRef = useRef(false);
1270
1550
  const chromeSettleTimerRef = useRef<number | null>(null);
@@ -1309,6 +1589,10 @@ export function MultiScreenCanvas({
1309
1589
  onPrimitiveReparentRef.current = onPrimitiveReparent;
1310
1590
  }, [onPrimitiveReparent]);
1311
1591
 
1592
+ useEffect(() => {
1593
+ vectorEditRef.current = vectorEdit;
1594
+ }, [vectorEdit]);
1595
+
1312
1596
  useEffect(() => {
1313
1597
  onCrossScreenElementDropRef.current = onCrossScreenElementDrop;
1314
1598
  }, [onCrossScreenElementDrop]);
@@ -1327,12 +1611,18 @@ export function MultiScreenCanvas({
1327
1611
 
1328
1612
  const updateFrameGeometry = useCallback(
1329
1613
  (updater: (current: FrameGeometryById) => FrameGeometryById) => {
1330
- setFrameGeometry((current) => {
1331
- const next = updater(current);
1332
- frameGeometryRef.current = next;
1333
- onGeometryChangeRef.current?.(next);
1334
- return next;
1335
- });
1614
+ // Compute the next value from the ref (kept in sync below and by the
1615
+ // frameGeometry-mirroring effect) and call the onGeometryChange side
1616
+ // effect *after* setFrameGeometry, not inside the updater passed to
1617
+ // it. React (especially StrictMode, which double-invokes state
1618
+ // updaters to surface impure updates) may call an updater function
1619
+ // more than once per commit — doing the ref write + external callback
1620
+ // inside it would double-fire onGeometryChange for a single logical
1621
+ // geometry change.
1622
+ const next = updater(frameGeometryRef.current);
1623
+ frameGeometryRef.current = next;
1624
+ setFrameGeometry(next);
1625
+ onGeometryChangeRef.current?.(next);
1336
1626
  },
1337
1627
  [],
1338
1628
  );
@@ -1387,6 +1677,10 @@ export function MultiScreenCanvas({
1387
1677
  panRef.current = pan;
1388
1678
  }, [pan]);
1389
1679
 
1680
+ useEffect(() => {
1681
+ marqueeRef.current = marquee;
1682
+ }, [marquee]);
1683
+
1390
1684
  useEffect(() => {
1391
1685
  zoomRef.current = canvasZoom;
1392
1686
  }, [canvasZoom]);
@@ -1436,8 +1730,39 @@ export function MultiScreenCanvas({
1436
1730
  }, [selectedDraftIds]);
1437
1731
 
1438
1732
  useEffect(() => {
1733
+ // zoomRef.current is the canvas's own last-known zoom, kept in sync
1734
+ // synchronously by every internal zoom path (wheel/pinch commitView,
1735
+ // fit-to-screen) *before* those paths call onZoomChange. So if it
1736
+ // already matches the incoming prop, this change originated from our own
1737
+ // gesture round-tripping back through a controlled `zoom` prop — that
1738
+ // path already applied its own (cursor-anchored) pan compensation, and
1739
+ // redoing it here with a surface-center anchor would double-shift pan.
1740
+ // Only compensate when this is a genuinely external change (toolbar
1741
+ // buttons, keyboard shortcuts) that never touched zoomRef/panRef.
1742
+ const previousZoom = zoomRef.current;
1743
+ if (zoom === previousZoom) return;
1744
+ // External zoom changes otherwise anchor at world origin (0,0) since
1745
+ // only canvasZoom is updated here — content visibly jumps diagonally
1746
+ // instead of zooming in place. Mirror the wheel/pinch cursor-anchored
1747
+ // compensation using the surface's own center as the anchor, since
1748
+ // there's no cursor position for a toolbar/keyboard-driven zoom change.
1749
+ const rect = surfaceRef.current?.getBoundingClientRect();
1750
+ const cursor = rect
1751
+ ? { x: rect.width / 2, y: rect.height / 2 }
1752
+ : { x: 0, y: 0 };
1753
+ const nextPan = getPanForZoomToCursor({
1754
+ pan: panRef.current,
1755
+ cursor,
1756
+ oldZoom: previousZoom,
1757
+ nextZoom: zoom,
1758
+ });
1759
+ panRef.current = nextPan;
1760
+ setPan(nextPan);
1439
1761
  setCanvasZoom(zoom);
1440
1762
  zoomRef.current = zoom;
1763
+ // P18: an externally-driven zoom change (toolbar/keyboard) also moves
1764
+ // the canvas-space mapping the pen ghost preview was computed from.
1765
+ recomputePenPointerForViewChangeRef.current();
1441
1766
  }, [zoom]);
1442
1767
 
1443
1768
  useEffect(() => {
@@ -1703,8 +2028,12 @@ export function MultiScreenCanvas({
1703
2028
 
1704
2029
  const postHitTestPreviewClear = (targetId: string | null | undefined) => {
1705
2030
  if (!targetId) return;
2031
+ const targetScreen = screensRef.current.find((s) => s.id === targetId);
2032
+ const iframeId = targetScreen
2033
+ ? getActiveScreenIframeId(targetScreen)
2034
+ : targetId;
1706
2035
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1707
- `[data-screen-iframe-id="${CSS.escape(targetId)}"]`,
2036
+ `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
1708
2037
  );
1709
2038
  targetIframe?.contentWindow?.postMessage(
1710
2039
  { type: "agent-native:hit-test-preview-clear" },
@@ -1736,6 +2065,9 @@ export function MultiScreenCanvas({
1736
2065
  clearCrossScreenDropGuide();
1737
2066
  crossScreenTargetRef.current = null;
1738
2067
  crossScreenDragMsgRef.current = null;
2068
+ // Drop the timeout-fallback cache so a future, unrelated drag session
2069
+ // never shows a guide left over from this one.
2070
+ crossScreenLastHitResultRef.current.clear();
1739
2071
  };
1740
2072
 
1741
2073
  const runHitTest = (
@@ -1748,8 +2080,9 @@ export function MultiScreenCanvas({
1748
2080
  );
1749
2081
  if (!targetScreen) return Promise.resolve({});
1750
2082
  const targetGeometry = candidate.geometry;
2083
+ const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1751
2084
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1752
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
2085
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1753
2086
  );
1754
2087
  const targetContentWindow = targetIframe?.contentWindow;
1755
2088
  if (!targetContentWindow) return Promise.resolve({});
@@ -1769,20 +2102,28 @@ export function MultiScreenCanvas({
1769
2102
  return new Promise((resolve) => {
1770
2103
  const timer = window.setTimeout(() => {
1771
2104
  window.removeEventListener("message", hitListener);
1772
- resolve({});
1773
- }, 50);
2105
+ // Fall back to the last successful result for this target instead
2106
+ // of resolving empty — a single slow reply (bridge script briefly
2107
+ // busy, iframe mid-navigation, etc.) shouldn't make the drop guide
2108
+ // flicker away and immediately reappear on the next hit-test.
2109
+ resolve(crossScreenLastHitResultRef.current.get(candidate.id) ?? {});
2110
+ }, 250);
1774
2111
 
1775
2112
  const hitListener = (ev: MessageEvent) => {
1776
2113
  if (
1777
2114
  !ev.data ||
1778
2115
  ev.data.type !== "agent-native:hit-test-result" ||
1779
- ev.data.correlationId !== correlationId
2116
+ ev.data.correlationId !== correlationId ||
2117
+ // Require the reply to actually come from the iframe we asked,
2118
+ // not just any window that happens to observe/guess the
2119
+ // correlationId and reply with a matching payload shape.
2120
+ ev.source !== targetContentWindow
1780
2121
  ) {
1781
2122
  return;
1782
2123
  }
1783
2124
  window.clearTimeout(timer);
1784
2125
  window.removeEventListener("message", hitListener);
1785
- resolve({
2126
+ const result: CrossScreenHitTestResult = {
1786
2127
  anchorNodeId:
1787
2128
  typeof ev.data.anchorNodeId === "string"
1788
2129
  ? ev.data.anchorNodeId
@@ -1799,7 +2140,9 @@ export function MultiScreenCanvas({
1799
2140
  anchorRect: isCrossScreenHitTestAnchorRect(ev.data.anchorRect)
1800
2141
  ? ev.data.anchorRect
1801
2142
  : undefined,
1802
- });
2143
+ };
2144
+ crossScreenLastHitResultRef.current.set(candidate.id, result);
2145
+ resolve(result);
1803
2146
  };
1804
2147
  window.addEventListener("message", hitListener);
1805
2148
 
@@ -1828,8 +2171,9 @@ export function MultiScreenCanvas({
1828
2171
  (s) => s.id === candidate.id,
1829
2172
  );
1830
2173
  if (!targetScreen) return null;
2174
+ const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1831
2175
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1832
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
2176
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1833
2177
  );
1834
2178
  const targetViewportWidth =
1835
2179
  targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width;
@@ -1978,6 +2322,31 @@ export function MultiScreenCanvas({
1978
2322
  if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
1979
2323
  return;
1980
2324
  }
2325
+ // Resolve which of our own embedded design-preview iframes actually
2326
+ // posted this message — postMessage's origin/source aren't otherwise
2327
+ // checked here, so without this any window (including a spoofed one)
2328
+ // could claim to be a screen. Same pattern as
2329
+ // handleEmbeddedWheelMessage below. Bind to the matched iframe element
2330
+ // itself rather than trusting the payload: a compromised/owned preview
2331
+ // iframe can put any `screenId` it wants in the message body, so the
2332
+ // sender's true screen identity must come from the DOM, not the data.
2333
+ const surfaceForSourceCheck = surfaceRef.current;
2334
+ const sourcePreviewIframe = surfaceForSourceCheck
2335
+ ? Array.from(
2336
+ surfaceForSourceCheck.querySelectorAll<HTMLIFrameElement>(
2337
+ "iframe[data-design-preview-iframe]",
2338
+ ),
2339
+ ).find((iframe) => iframe.contentWindow === event.source)
2340
+ : undefined;
2341
+ if (!sourcePreviewIframe) return;
2342
+ // The board's DesignCanvas renders with `boardSurface` set, which
2343
+ // deliberately omits `data-screen-iframe-id` (see DesignCanvas.tsx), so
2344
+ // an unset attribute here means the message came from the board
2345
+ // surface iframe rather than a per-screen one.
2346
+ const domScreenId =
2347
+ sourcePreviewIframe.getAttribute("data-screen-iframe-id") ??
2348
+ boardFileId ??
2349
+ undefined;
1981
2350
  const msg = event.data as {
1982
2351
  type: string;
1983
2352
  phase: "start" | "move" | "end" | "cancel";
@@ -2012,12 +2381,17 @@ export function MultiScreenCanvas({
2012
2381
  return;
2013
2382
  }
2014
2383
 
2015
- // Prefer the iframe-supplied source id. In overview, activeId can point
2016
- // at a different screen than the layer currently being dragged.
2384
+ // Attribute the drag to the DOM-verified source iframe's own screen id,
2385
+ // never the payload's claimed `msg.screenId` the message body is
2386
+ // authored by the (sandboxed but same-origin-capable) preview iframe
2387
+ // content and must not be trusted to identify itself. Fall back to
2388
+ // activeId only when the matched iframe unexpectedly has no resolvable
2389
+ // screen id (e.g. a stale/unknown screen), matching prior behavior for
2390
+ // that edge case.
2017
2391
  const sourceScreenId =
2018
- msg.screenId &&
2019
- (msg.screenId === boardFileId || frameGeometryRef.current[msg.screenId])
2020
- ? msg.screenId
2392
+ domScreenId &&
2393
+ (domScreenId === boardFileId || frameGeometryRef.current[domScreenId])
2394
+ ? domScreenId
2021
2395
  : activeId;
2022
2396
  if (!sourceScreenId) {
2023
2397
  // Always clear visual state (ghost + highlight) when we have no active
@@ -2042,6 +2416,23 @@ export function MultiScreenCanvas({
2042
2416
  surfaceRef.current,
2043
2417
  );
2044
2418
  let didCleanup = false;
2419
+ const cancelPendingParentDrag = () => {
2420
+ if (crossScreenMoveRafRef.current !== null) {
2421
+ window.cancelAnimationFrame(crossScreenMoveRafRef.current);
2422
+ crossScreenMoveRafRef.current = null;
2423
+ }
2424
+ crossScreenPendingMoveRef.current = null;
2425
+ };
2426
+ const flushPendingParentDrag = () => {
2427
+ crossScreenMoveRafRef.current = null;
2428
+ const pending = crossScreenPendingMoveRef.current;
2429
+ crossScreenPendingMoveRef.current = null;
2430
+ if (!pending) return;
2431
+ updateCrossScreenTargetFromBoardPoint(
2432
+ pending.boardPoint,
2433
+ pending.sourceScreenId,
2434
+ );
2435
+ };
2045
2436
  const activateParentDrag = (ev: MouseEvent) => {
2046
2437
  ev.preventDefault();
2047
2438
  updateCrossScreenTargetFromBoardPoint(
@@ -2050,9 +2441,26 @@ export function MultiScreenCanvas({
2050
2441
  );
2051
2442
  };
2052
2443
  const handleParentMouseMove = (ev: MouseEvent) => {
2053
- activateParentDrag(ev);
2444
+ ev.preventDefault();
2445
+ // Each drop-guide update is a postMessage round-trip to the target
2446
+ // iframe (see requestCrossScreenDropGuide/runHitTest). Coalesce
2447
+ // rapid mousemove events down to one hit-test per animation frame
2448
+ // instead of firing one per raw event.
2449
+ crossScreenPendingMoveRef.current = {
2450
+ boardPoint: getCanvasPoint(ev.clientX, ev.clientY),
2451
+ sourceScreenId,
2452
+ };
2453
+ if (crossScreenMoveRafRef.current === null) {
2454
+ crossScreenMoveRafRef.current = window.requestAnimationFrame(
2455
+ flushPendingParentDrag,
2456
+ );
2457
+ }
2054
2458
  };
2055
2459
  const handleParentMouseUp = (ev: MouseEvent) => {
2460
+ // Flush synchronously with the true final pointer position on
2461
+ // release — don't wait for a throttled rAF that may not run
2462
+ // before finalizeCrossScreenDrop reads crossScreenTargetRef.
2463
+ cancelPendingParentDrag();
2056
2464
  activateParentDrag(ev);
2057
2465
  const candidate = crossScreenTargetRef.current;
2058
2466
  const payload = crossScreenDragMsgRef.current ?? {
@@ -2071,11 +2479,13 @@ export function MultiScreenCanvas({
2071
2479
  );
2072
2480
  };
2073
2481
  const handleParentWindowBlur = () => {
2482
+ cancelPendingParentDrag();
2074
2483
  clearCrossScreenDrag();
2075
2484
  };
2076
2485
  const cleanup = () => {
2077
2486
  if (didCleanup) return;
2078
2487
  didCleanup = true;
2488
+ cancelPendingParentDrag();
2079
2489
  window.removeEventListener("mousemove", handleParentMouseMove, true);
2080
2490
  window.removeEventListener("mouseup", handleParentMouseUp, true);
2081
2491
  window.removeEventListener("blur", handleParentWindowBlur, true);
@@ -2265,24 +2675,57 @@ export function MultiScreenCanvas({
2265
2675
  surfaceRef.current,
2266
2676
  );
2267
2677
  let lastMouseEvent: MouseEvent | null = null;
2678
+ // rAF-coalesce raw mousemove: a drag/pan/marquee gesture can fire many
2679
+ // mousemove events per frame, but the handler recomputes snap/geometry
2680
+ // and commits React state — doing that per-event (rather than per
2681
+ // frame) is the dominant cost during a drag (see PF15 in perf report).
2682
+ // We keep only the latest event and flush it once per animation frame
2683
+ // (latest-wins). Flushing is forced synchronously before mouseup/blur
2684
+ // so the gesture always ends on the true final pointer position.
2685
+ let pendingMoveFrame: number | null = null;
2686
+ const flushPendingMove = () => {
2687
+ if (pendingMoveFrame !== null) {
2688
+ window.cancelAnimationFrame(pendingMoveFrame);
2689
+ pendingMoveFrame = null;
2690
+ }
2691
+ if (lastMouseEvent) {
2692
+ handleMouseMove(lastMouseEvent);
2693
+ }
2694
+ };
2268
2695
  const move = (ev: MouseEvent) => {
2269
2696
  lastMouseEvent = ev;
2270
2697
  ev.preventDefault();
2271
- handleMouseMove(ev);
2698
+ if (pendingMoveFrame !== null) return;
2699
+ pendingMoveFrame = window.requestAnimationFrame(() => {
2700
+ pendingMoveFrame = null;
2701
+ if (lastMouseEvent) handleMouseMove(lastMouseEvent);
2702
+ });
2272
2703
  };
2273
2704
  const up = (ev: MouseEvent) => {
2274
2705
  lastMouseEvent = ev;
2275
2706
  ev.preventDefault();
2707
+ // Flush any coalesced move first so the final drop position reflects
2708
+ // this exact event, then run the up handler with it.
2709
+ flushPendingMove();
2276
2710
  handleMouseUp(ev);
2277
2711
  };
2278
2712
  const cleanupOnBlur = () => {
2279
2713
  if (handleCancel) {
2714
+ if (pendingMoveFrame !== null) {
2715
+ window.cancelAnimationFrame(pendingMoveFrame);
2716
+ pendingMoveFrame = null;
2717
+ }
2280
2718
  handleCancel();
2281
2719
  return;
2282
2720
  }
2721
+ flushPendingMove();
2283
2722
  handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
2284
2723
  };
2285
2724
  dragCleanup.current = () => {
2725
+ if (pendingMoveFrame !== null) {
2726
+ window.cancelAnimationFrame(pendingMoveFrame);
2727
+ pendingMoveFrame = null;
2728
+ }
2286
2729
  window.removeEventListener("mousemove", move);
2287
2730
  window.removeEventListener("mouseup", up);
2288
2731
  window.removeEventListener("blur", cleanupOnBlur);
@@ -2298,8 +2741,12 @@ export function MultiScreenCanvas({
2298
2741
 
2299
2742
  const requestSelectableElementInfos = useCallback(
2300
2743
  (screenId: string): Promise<ElementInfo[]> => {
2744
+ const targetScreen = screensRef.current.find((s) => s.id === screenId);
2745
+ const iframeId = targetScreen
2746
+ ? getActiveScreenIframeId(targetScreen)
2747
+ : screenId;
2301
2748
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2302
- `[data-screen-iframe-id="${CSS.escape(screenId)}"]`,
2749
+ `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
2303
2750
  );
2304
2751
  const targetContentWindow = targetIframe?.contentWindow;
2305
2752
  if (!targetContentWindow) return Promise.resolve([]);
@@ -2315,7 +2762,11 @@ export function MultiScreenCanvas({
2315
2762
  if (
2316
2763
  !event.data ||
2317
2764
  event.data.type !== "agent-native:selectable-rects-result" ||
2318
- event.data.correlationId !== correlationId
2765
+ event.data.correlationId !== correlationId ||
2766
+ // Require the reply to actually come from the iframe we asked,
2767
+ // not just any window that happens to observe/guess the
2768
+ // correlationId and reply with a matching payload shape.
2769
+ event.source !== targetContentWindow
2319
2770
  ) {
2320
2771
  return;
2321
2772
  }
@@ -2347,58 +2798,76 @@ export function MultiScreenCanvas({
2347
2798
  [],
2348
2799
  );
2349
2800
 
2350
- const collectLayerMarqueeCandidates = useCallback(async () => {
2351
- const frameCandidates = await Promise.all(
2352
- getCurrentFrameEntries().map(async (entry) => {
2353
- const screen = screensRef.current.find((item) => item.id === entry.id);
2354
- if (!screen) return [] as CanvasLayerMarqueeCandidate[];
2355
- const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2356
- `[data-screen-iframe-id="${CSS.escape(entry.id)}"]`,
2357
- );
2358
- const metadata = getResolvedMetadata(screen);
2359
- const viewportWidth = iframe?.clientWidth || metadata.width;
2360
- const viewportHeight = iframe?.clientHeight || metadata.height;
2361
- const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
2362
- const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
2363
- const infos = await requestSelectableElementInfos(entry.id);
2364
- return infos.map((info) => ({
2365
- screenId: entry.id,
2366
- info,
2367
- geometry: {
2368
- x: entry.geometry.x + info.boundingRect.x * scaleX,
2369
- y: entry.geometry.y + info.boundingRect.y * scaleY,
2370
- width: info.boundingRect.width * scaleX,
2371
- height: info.boundingRect.height * scaleY,
2372
- },
2373
- frameGeometry: entry.geometry,
2374
- }));
2375
- }),
2376
- );
2377
- const boardCandidates =
2378
- boardFileId && boardFrameGeometry
2379
- ? await (async () => {
2380
- const infos = await requestSelectableElementInfos(boardFileId);
2381
- return infos.map((info) => ({
2382
- screenId: boardFileId,
2383
- info,
2384
- geometry: {
2385
- x: boardFrameGeometry.x + info.boundingRect.x,
2386
- y: boardFrameGeometry.y + info.boundingRect.y,
2387
- width: info.boundingRect.width,
2388
- height: info.boundingRect.height,
2389
- },
2390
- frameGeometry: boardFrameGeometry,
2391
- }));
2392
- })()
2393
- : [];
2394
- return [...frameCandidates.flat(), ...boardCandidates];
2395
- }, [
2396
- boardFileId,
2397
- boardFrameGeometry,
2398
- getCurrentFrameEntries,
2399
- getResolvedMetadata,
2400
- requestSelectableElementInfos,
2401
- ]);
2801
+ /** Collects marquee-selectable layer candidates. Each screen requires an
2802
+ * async postMessage round-trip into its iframe (requestSelectableElementInfos),
2803
+ * so collecting for every screen on the board unconditionally at marquee
2804
+ * mousedown (PF20) is expensive for boards with many screens — most of
2805
+ * which the marquee rect will never touch. `screenIds`, when given, scopes
2806
+ * collection to just those frame entries (plus the board, which spans the
2807
+ * whole surface so it's included whenever explicitly requested); omit it
2808
+ * to collect every screen. */
2809
+ const collectLayerMarqueeCandidates = useCallback(
2810
+ async (screenIds?: Set<string>) => {
2811
+ const frameEntries = getCurrentFrameEntries().filter(
2812
+ (entry) => !screenIds || screenIds.has(entry.id),
2813
+ );
2814
+ const frameCandidates = await Promise.all(
2815
+ frameEntries.map(async (entry) => {
2816
+ const screen = screensRef.current.find(
2817
+ (item) => item.id === entry.id,
2818
+ );
2819
+ if (!screen) return [] as CanvasLayerMarqueeCandidate[];
2820
+ const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2821
+ `[data-screen-iframe-id="${CSS.escape(getActiveScreenIframeId(screen))}"]`,
2822
+ );
2823
+ const metadata = getResolvedMetadata(screen);
2824
+ const viewportWidth = iframe?.clientWidth || metadata.width;
2825
+ const viewportHeight = iframe?.clientHeight || metadata.height;
2826
+ const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
2827
+ const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
2828
+ const infos = await requestSelectableElementInfos(entry.id);
2829
+ return infos.map((info) => ({
2830
+ screenId: entry.id,
2831
+ info,
2832
+ geometry: {
2833
+ x: entry.geometry.x + info.boundingRect.x * scaleX,
2834
+ y: entry.geometry.y + info.boundingRect.y * scaleY,
2835
+ width: info.boundingRect.width * scaleX,
2836
+ height: info.boundingRect.height * scaleY,
2837
+ },
2838
+ frameGeometry: entry.geometry,
2839
+ }));
2840
+ }),
2841
+ );
2842
+ const boardCandidates =
2843
+ boardFileId &&
2844
+ boardFrameGeometry &&
2845
+ (!screenIds || screenIds.has(boardFileId))
2846
+ ? await (async () => {
2847
+ const infos = await requestSelectableElementInfos(boardFileId);
2848
+ return infos.map((info) => ({
2849
+ screenId: boardFileId,
2850
+ info,
2851
+ geometry: {
2852
+ x: boardFrameGeometry.x + info.boundingRect.x,
2853
+ y: boardFrameGeometry.y + info.boundingRect.y,
2854
+ width: info.boundingRect.width,
2855
+ height: info.boundingRect.height,
2856
+ },
2857
+ frameGeometry: boardFrameGeometry,
2858
+ }));
2859
+ })()
2860
+ : [];
2861
+ return [...frameCandidates.flat(), ...boardCandidates];
2862
+ },
2863
+ [
2864
+ boardFileId,
2865
+ boardFrameGeometry,
2866
+ getCurrentFrameEntries,
2867
+ getResolvedMetadata,
2868
+ requestSelectableElementInfos,
2869
+ ],
2870
+ );
2402
2871
 
2403
2872
  const scheduleFeedbackClear = useCallback(() => {
2404
2873
  if (feedbackTimerRef.current !== null) {
@@ -2437,11 +2906,20 @@ export function MultiScreenCanvas({
2437
2906
  window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
2438
2907
  ? clientY + TRANSFORM_BADGE_OFFSET
2439
2908
  : clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
2440
- setTransformBadge({
2441
- text,
2442
- x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
2443
- y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
2444
- });
2909
+ const nextX = clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX);
2910
+ const nextY = clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY);
2911
+ // Equality-bail: called from the rAF-coalesced mousemove handler, so
2912
+ // this runs at most once per frame already, but a steady drag (e.g.
2913
+ // pinned against a snap axis) can still repeat the identical badge
2914
+ // text/position — skip the setState in that case (PF15).
2915
+ setTransformBadge((current) =>
2916
+ current &&
2917
+ current.text === text &&
2918
+ current.x === nextX &&
2919
+ current.y === nextY
2920
+ ? current
2921
+ : { text, x: nextX, y: nextY },
2922
+ );
2445
2923
  },
2446
2924
  [],
2447
2925
  );
@@ -2529,6 +3007,7 @@ export function MultiScreenCanvas({
2529
3007
  setMarquee(null);
2530
3008
  setCreationPreview(null);
2531
3009
  setAlignmentGuides([]);
3010
+ setEqualGapGuides([]);
2532
3011
  setTransformBadge(null);
2533
3012
  setDragCursor(null);
2534
3013
  primitiveDropTargetRef.current = null;
@@ -2546,7 +3025,11 @@ export function MultiScreenCanvas({
2546
3025
 
2547
3026
  if (state) {
2548
3027
  cancelled = true;
2549
- if (state.type === "move" || state.type === "resize") {
3028
+ if (
3029
+ state.type === "move" ||
3030
+ state.type === "resize" ||
3031
+ state.type === "group-rotate"
3032
+ ) {
2550
3033
  updateFrameGeometry((current) =>
2551
3034
  frameGeometryWithOverrides(current, state.originFrames),
2552
3035
  );
@@ -2574,6 +3057,14 @@ export function MultiScreenCanvas({
2574
3057
  setPenGesturePreview(null);
2575
3058
  setPenPointer(null);
2576
3059
  setPenCloseHover(false);
3060
+ } else if (
3061
+ state.type === "vector-anchor" ||
3062
+ state.type === "vector-handle"
3063
+ ) {
3064
+ // vectorEdit's path is parent-owned (unlike activePenPath above),
3065
+ // so reverting on cancel means reporting the pre-drag snapshot back
3066
+ // as a commit rather than mutating any local state here.
3067
+ vectorEdit?.onChange(clonePenPath(state.pathBefore), "commit");
2577
3068
  }
2578
3069
  }
2579
3070
 
@@ -2593,22 +3084,33 @@ export function MultiScreenCanvas({
2593
3084
  updateFrameGeometry,
2594
3085
  updateSelectedDraftIds,
2595
3086
  updateSelectedIds,
3087
+ vectorEdit,
2596
3088
  ]);
2597
3089
 
2598
3090
  useEffect(() => {
2599
3091
  const handleKeyDown = (event: KeyboardEvent) => {
2600
3092
  if (event.key !== "Escape") return;
2601
- if (!cancelActiveDrag()) return;
2602
- event.preventDefault();
2603
- event.stopPropagation();
2604
- event.stopImmediatePropagation();
3093
+ if (cancelActiveDrag()) {
3094
+ event.preventDefault();
3095
+ event.stopPropagation();
3096
+ event.stopImmediatePropagation();
3097
+ return;
3098
+ }
3099
+ // No in-flight drag to cancel: Escape while in vector edit mode exits
3100
+ // the mode entirely (matches Figma), rather than being a no-op.
3101
+ if (vectorEdit) {
3102
+ vectorEdit.onExit();
3103
+ event.preventDefault();
3104
+ event.stopPropagation();
3105
+ event.stopImmediatePropagation();
3106
+ }
2605
3107
  };
2606
3108
 
2607
3109
  window.addEventListener("keydown", handleKeyDown, true);
2608
3110
  return () => {
2609
3111
  window.removeEventListener("keydown", handleKeyDown, true);
2610
3112
  };
2611
- }, [cancelActiveDrag]);
3113
+ }, [cancelActiveDrag, vectorEdit]);
2612
3114
 
2613
3115
  const beginPan = useCallback(
2614
3116
  (e: React.MouseEvent) => {
@@ -2629,10 +3131,25 @@ export function MultiScreenCanvas({
2629
3131
  y: state.originPan.y + ev.clientY - state.originClient.y,
2630
3132
  };
2631
3133
  panRef.current = nextPan;
2632
- setPan(nextPan);
3134
+ // Mirror the wheel/pinch path (applyViewToDom): mutate the transform
3135
+ // directly during the gesture and only reconcile React state once the
3136
+ // gesture settles, so a mouse-pan produces zero re-renders per move.
3137
+ applyViewToDomRef.current();
3138
+ scheduleViewCommitRef.current();
2633
3139
  };
2634
3140
 
2635
- installDragListeners(handleMouseMove, finishDrag);
3141
+ const handlePanEnd = () => {
3142
+ // Ensure React state reflects the true final pan immediately on
3143
+ // release rather than waiting for the debounced commit timer.
3144
+ setPan(panRef.current);
3145
+ // P18: a middle-mouse-button pan while a pen path is active also
3146
+ // moves the canvas-space mapping the ghost preview was computed
3147
+ // from — resync it now that pan has settled.
3148
+ recomputePenPointerForViewChangeRef.current();
3149
+ finishDrag();
3150
+ };
3151
+
3152
+ installDragListeners(handleMouseMove, handlePanEnd);
2636
3153
  },
2637
3154
  [finishDrag, installDragListeners],
2638
3155
  );
@@ -2644,14 +3161,25 @@ export function MultiScreenCanvas({
2644
3161
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
2645
3162
  let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
2646
3163
  let layerCandidates: CanvasLayerMarqueeCandidate[] = [];
3164
+ // PF20: collecting selectable layer info for every screen on the board
3165
+ // requires one async postMessage round-trip per iframe. Doing that
3166
+ // eagerly for the whole board on marquee mousedown is wasted work for
3167
+ // any screen the marquee rect never reaches. Instead, lazily collect
3168
+ // only screens the rect currently intersects, growing the collected
3169
+ // set incrementally as the drag expands the rect. `collectingScreenIds`
3170
+ // guards against re-requesting a screen whose collection is already
3171
+ // in flight or done.
3172
+ const collectedScreenIds = new Set<string>();
3173
+ const collectingScreenIds = new Set<string>();
2647
3174
  const reportLayerSelection = (rect: MarqueeRect) => {
2648
3175
  const state = dragState.current;
2649
3176
  if (!state || state.type !== "marquee") return;
3177
+ const chromeScale = chromeScaleFromZoom(zoomRef.current);
2650
3178
  const selection = layerCandidates
2651
3179
  .filter((candidate) =>
2652
3180
  rotatedRectIntersects(
2653
3181
  rect,
2654
- getSelectableBounds(candidate.geometry),
3182
+ getSelectableBounds(candidate.geometry, chromeScale),
2655
3183
  getFrameCenter(candidate.frameGeometry),
2656
3184
  candidate.frameGeometry.rotation ?? 0,
2657
3185
  ),
@@ -2666,6 +3194,34 @@ export function MultiScreenCanvas({
2666
3194
  shiftKey: state.additive,
2667
3195
  });
2668
3196
  };
3197
+ const collectForIntersectedScreens = (hitIds: string[]) => {
3198
+ const newIds = hitIds.filter(
3199
+ (id) => !collectedScreenIds.has(id) && !collectingScreenIds.has(id),
3200
+ );
3201
+ // The board spans the whole surface, so include it once anything
3202
+ // intersects (or immediately, for the initial zero-size rect) rather
3203
+ // than trying to hit-test its own — usually oversized — geometry.
3204
+ if (
3205
+ boardFileId &&
3206
+ boardFrameGeometry &&
3207
+ !collectedScreenIds.has(boardFileId) &&
3208
+ !collectingScreenIds.has(boardFileId)
3209
+ ) {
3210
+ newIds.push(boardFileId);
3211
+ }
3212
+ if (newIds.length === 0) return;
3213
+ const requestIds = new Set(newIds);
3214
+ newIds.forEach((id) => collectingScreenIds.add(id));
3215
+ void collectLayerMarqueeCandidates(requestIds).then((candidates) => {
3216
+ newIds.forEach((id) => {
3217
+ collectingScreenIds.delete(id);
3218
+ collectedScreenIds.add(id);
3219
+ });
3220
+ if (dragState.current?.type !== "marquee") return;
3221
+ layerCandidates = [...layerCandidates, ...candidates];
3222
+ reportLayerSelection(latestRect);
3223
+ });
3224
+ };
2669
3225
  dragState.current = {
2670
3226
  type: "marquee",
2671
3227
  originClient: { x: e.clientX, y: e.clientY },
@@ -2686,11 +3242,10 @@ export function MultiScreenCanvas({
2686
3242
  });
2687
3243
  }
2688
3244
  setIsDragging(true);
2689
- void collectLayerMarqueeCandidates().then((candidates) => {
2690
- if (dragState.current?.type !== "marquee") return;
2691
- layerCandidates = candidates;
2692
- reportLayerSelection(latestRect);
2693
- });
3245
+ // Seed collection with whatever the zero-size origin rect already
3246
+ // touches (typically just the board, if present) so a click-without-
3247
+ // drag still reports a correct (likely empty) selection on mouseup.
3248
+ collectForIntersectedScreens([]);
2694
3249
 
2695
3250
  const handleMouseMove = (ev: MouseEvent) => {
2696
3251
  const state = dragState.current;
@@ -2709,11 +3264,12 @@ export function MultiScreenCanvas({
2709
3264
  }
2710
3265
  setMarquee(rect);
2711
3266
 
3267
+ const chromeScale = chromeScaleFromZoom(zoomRef.current);
2712
3268
  const hitIds = getCurrentFrameEntries()
2713
3269
  .filter((entry) =>
2714
3270
  rotatedRectIntersects(
2715
3271
  rect,
2716
- getSelectableBounds(entry.geometry),
3272
+ getSelectableBounds(entry.geometry, chromeScale),
2717
3273
  getFrameCenter(entry.geometry),
2718
3274
  entry.geometry.rotation ?? 0,
2719
3275
  ),
@@ -2723,21 +3279,23 @@ export function MultiScreenCanvas({
2723
3279
  .filter((entry) =>
2724
3280
  rotatedRectIntersects(
2725
3281
  rect,
2726
- getSelectableBounds(entry.geometry),
3282
+ getSelectableBounds(entry.geometry, chromeScale),
2727
3283
  getFrameCenter(entry.geometry),
2728
3284
  entry.geometry.rotation ?? 0,
2729
3285
  ),
2730
3286
  )
2731
3287
  .map((entry) => entry.id);
2732
3288
 
3289
+ collectForIntersectedScreens(hitIds);
3290
+
2733
3291
  updateSelectedIds(() =>
2734
3292
  state.additive
2735
- ? dedupeIds([...state.baseSelectedIds, ...hitIds])
3293
+ ? xorMarqueeSelection(state.baseSelectedIds, hitIds)
2736
3294
  : hitIds,
2737
3295
  );
2738
3296
  updateSelectedDraftIds(() =>
2739
3297
  state.additive
2740
- ? dedupeIds([...state.baseSelectedDraftIds, ...hitDraftIds])
3298
+ ? xorMarqueeSelection(state.baseSelectedDraftIds, hitDraftIds)
2741
3299
  : hitDraftIds,
2742
3300
  );
2743
3301
  reportLayerSelection(rect);
@@ -2760,6 +3318,8 @@ export function MultiScreenCanvas({
2760
3318
  installDragListeners(handleMouseMove, handleMouseUp);
2761
3319
  },
2762
3320
  [
3321
+ boardFileId,
3322
+ boardFrameGeometry,
2763
3323
  collectLayerMarqueeCandidates,
2764
3324
  finishDrag,
2765
3325
  getCanvasPoint,
@@ -3029,15 +3589,24 @@ export function MultiScreenCanvas({
3029
3589
  ) => {
3030
3590
  const rawPoint = getCanvasPoint(clientX, clientY);
3031
3591
  const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
3032
- return shiftKey && lastAnchor
3033
- ? constrainPointTo45Degrees(lastAnchor, rawPoint)
3034
- : rawPoint;
3592
+ const constrainedPoint =
3593
+ shiftKey && lastAnchor
3594
+ ? constrainPointTo45Degrees(lastAnchor, rawPoint)
3595
+ : rawPoint;
3596
+ // Light anchor snapping (P15): snap onto an existing anchor of the
3597
+ // path being drawn (so you can precisely re-hit a prior point), else
3598
+ // round to integer canvas px once zoomed to 100% or more.
3599
+ return snapPenAnchorPoint(constrainedPoint, path, {
3600
+ hitRadius: PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
3601
+ zoom: zoomRef.current,
3602
+ });
3035
3603
  },
3036
3604
  [getCanvasPoint],
3037
3605
  );
3038
3606
 
3039
3607
  const updatePenPointer = useCallback(
3040
3608
  (clientX: number, clientY: number, shiftKey: boolean) => {
3609
+ lastPenClientPointRef.current = { clientX, clientY, shiftKey };
3041
3610
  const path = activePenPathRef.current;
3042
3611
  if (!path || path.closed) {
3043
3612
  setPenPointer(null);
@@ -3061,35 +3630,54 @@ export function MultiScreenCanvas({
3061
3630
  [getCanvasPoint, getPenAnchorPoint],
3062
3631
  );
3063
3632
 
3633
+ // P18: a wheel pan/zoom gesture moves pan/zoom every animation frame
3634
+ // (applyViewToDom, mutated imperatively — see its comment) without any
3635
+ // mousemove event firing, so the pen ghost/ close-hover preview — derived
3636
+ // from screen->canvas conversion of the last known client point — goes
3637
+ // stale and visibly detaches from the cursor mid-gesture. Recompute it
3638
+ // from the remembered client point whenever pan/zoom changes.
3639
+ const recomputePenPointerForViewChange = useCallback(() => {
3640
+ const last = lastPenClientPointRef.current;
3641
+ if (!last || !activePenPathRef.current) return;
3642
+ updatePenPointer(last.clientX, last.clientY, last.shiftKey);
3643
+ }, [updatePenPointer]);
3644
+ recomputePenPointerForViewChangeRef.current =
3645
+ recomputePenPointerForViewChange;
3646
+
3064
3647
  const beginPenNodeCreation = useCallback(
3065
3648
  (e: React.MouseEvent) => {
3066
3649
  if (e.button !== 0) return;
3067
3650
  e.preventDefault();
3068
3651
  e.stopPropagation();
3652
+ // Double (or further) click ends the path instead of adding another
3653
+ // duplicate coincident anchor at the same point (P7).
3654
+ if (e.detail > 1) {
3655
+ finishPenPath();
3656
+ return;
3657
+ }
3069
3658
  suppressNextPick.current = true;
3070
3659
 
3071
3660
  const pathBefore = activePenPathRef.current?.closed
3072
3661
  ? null
3073
3662
  : activePenPathRef.current;
3074
3663
  const rawPoint = getCanvasPoint(e.clientX, e.clientY);
3075
- if (
3664
+ const closing = Boolean(
3076
3665
  pathBefore &&
3077
3666
  isPenCloseTarget(
3078
3667
  pathBefore,
3079
3668
  rawPoint,
3080
3669
  PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
3081
- )
3082
- ) {
3083
- finishPenPath(closePenPath(pathBefore));
3084
- return;
3085
- }
3086
-
3087
- const anchor = getPenAnchorPoint(
3088
- e.clientX,
3089
- e.clientY,
3090
- e.shiftKey,
3091
- pathBefore,
3670
+ ),
3092
3671
  );
3672
+
3673
+ // Figma defers the close commit to mouseup rather than closing
3674
+ // instantly on mousedown, so a drag on the closing click can shape
3675
+ // the closing segment's curve. Anchor the drag at the path's first
3676
+ // point (rather than the raw cursor position) so a click-only close
3677
+ // (no drag) still closes exactly on the start anchor.
3678
+ const anchor = closing
3679
+ ? pathBefore!.nodes[0].point
3680
+ : getPenAnchorPoint(e.clientX, e.clientY, e.shiftKey, pathBefore);
3093
3681
  const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
3094
3682
  dragState.current = {
3095
3683
  type: "pen-node",
@@ -3097,13 +3685,18 @@ export function MultiScreenCanvas({
3097
3685
  anchor,
3098
3686
  pathBefore: pathSnapshot,
3099
3687
  hasMoved: false,
3688
+ closing,
3100
3689
  };
3101
- const initialPath = appendPenNode(pathSnapshot, createCornerNode(anchor));
3690
+ const initialPath = closing
3691
+ ? (pathSnapshot as PenPath)
3692
+ : appendPenNode(pathSnapshot, createCornerNode(anchor));
3102
3693
  activePenPathRef.current = initialPath;
3103
3694
  setActivePenPath(initialPath);
3104
- setPenGesturePreview(initialPath);
3695
+ setPenGesturePreview(
3696
+ closing ? closePenPath(pathSnapshot as PenPath) : initialPath,
3697
+ );
3105
3698
  setPenPointer(null);
3106
- setPenCloseHover(false);
3699
+ setPenCloseHover(closing);
3107
3700
  setIsDragging(true);
3108
3701
  setDragCursor("crosshair");
3109
3702
 
@@ -3121,13 +3714,31 @@ export function MultiScreenCanvas({
3121
3714
  }
3122
3715
 
3123
3716
  const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
3717
+ const handleOut = ev.shiftKey
3718
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
3719
+ : handlePoint;
3720
+
3721
+ if (state.closing) {
3722
+ // Shape the closing segment: drag the first anchor's handleIn
3723
+ // (mirrored across the anchor from the drag point, matching how
3724
+ // every other smooth anchor's handles work) without appending a
3725
+ // new node — the path is still just previewed as closed.
3726
+ const closedPreviewPath = shapeClosingHandles(
3727
+ state.pathBefore as PenPath,
3728
+ state.hasMoved ? handleOut : null,
3729
+ );
3730
+ setPenGesturePreview(closedPreviewPath);
3731
+ return;
3732
+ }
3733
+
3124
3734
  const node = state.hasMoved
3125
- ? createSmoothNode(
3126
- state.anchor,
3127
- ev.shiftKey
3128
- ? constrainPointTo45Degrees(state.anchor, handlePoint)
3129
- : handlePoint,
3130
- )
3735
+ ? createSmoothNode(state.anchor, handleOut, {
3736
+ // Alt/Option while dragging a new anchor's handle breaks
3737
+ // symmetry into a cusp (P8): read the live event's altKey on
3738
+ // every move so toggling Alt mid-drag updates immediately,
3739
+ // rather than latching whatever it was when the drag started.
3740
+ breakSymmetry: ev.altKey,
3741
+ })
3131
3742
  : createCornerNode(state.anchor);
3132
3743
  const nextPath = appendPenNode(state.pathBefore, node);
3133
3744
  activePenPathRef.current = nextPath;
@@ -3143,13 +3754,27 @@ export function MultiScreenCanvas({
3143
3754
  }
3144
3755
 
3145
3756
  const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
3757
+ const handleOut = ev.shiftKey
3758
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
3759
+ : handlePoint;
3760
+
3761
+ if (state.closing) {
3762
+ const closedPath = shapeClosingHandles(
3763
+ state.pathBefore as PenPath,
3764
+ state.hasMoved ? handleOut : null,
3765
+ );
3766
+ setPenGesturePreview(null);
3767
+ setPenPointer(null);
3768
+ setPenCloseHover(false);
3769
+ finishPenPath(closedPath);
3770
+ finishDrag();
3771
+ return;
3772
+ }
3773
+
3146
3774
  const node: PenNode = state.hasMoved
3147
- ? createSmoothNode(
3148
- state.anchor,
3149
- ev.shiftKey
3150
- ? constrainPointTo45Degrees(state.anchor, handlePoint)
3151
- : handlePoint,
3152
- )
3775
+ ? createSmoothNode(state.anchor, handleOut, {
3776
+ breakSymmetry: ev.altKey,
3777
+ })
3153
3778
  : createCornerNode(state.anchor);
3154
3779
  const nextPath = appendPenNode(state.pathBefore, node);
3155
3780
  activePenPathRef.current = nextPath;
@@ -3185,6 +3810,220 @@ export function MultiScreenCanvas({
3185
3810
  ],
3186
3811
  );
3187
3812
 
3813
+ // ── Vector edit mode (P-VE1): drag an existing path's anchors/handles ────
3814
+ // (see VectorEditOverlayState / VectorEditOverlay). The overlay itself
3815
+ // resolves hit-tests and starts these; both gestures follow the same
3816
+ // installDragListeners/dragState pattern as every other drag above, with
3817
+ // the parent-owned `vectorEdit.path` (not local React state) as the
3818
+ // source of truth: every move reports an updated path via
3819
+ // `vectorEdit.onChange(next, "preview" | "commit")` instead of setting
3820
+ // local state.
3821
+ const beginVectorAnchorDrag = useCallback(
3822
+ (nodeIndex: number, e: React.MouseEvent) => {
3823
+ if (!vectorEdit || e.button !== 0) return;
3824
+ e.preventDefault();
3825
+ e.stopPropagation();
3826
+ claimKeyboardFocus();
3827
+
3828
+ const pathBefore = clonePenPath(vectorEdit.path);
3829
+ dragState.current = {
3830
+ type: "vector-anchor",
3831
+ originClient: { x: e.clientX, y: e.clientY },
3832
+ nodeIndex,
3833
+ pathBefore,
3834
+ hasMoved: false,
3835
+ };
3836
+ setIsDragging(true);
3837
+ setDragCursor("move");
3838
+
3839
+ const handleMouseMove = (ev: MouseEvent) => {
3840
+ const state = dragState.current;
3841
+ if (!state || state.type !== "vector-anchor") return;
3842
+ const active = vectorEditRef.current;
3843
+ if (!active) return;
3844
+ if (
3845
+ !state.hasMoved &&
3846
+ Math.hypot(
3847
+ ev.clientX - state.originClient.x,
3848
+ ev.clientY - state.originClient.y,
3849
+ ) >= DRAG_THRESHOLD
3850
+ ) {
3851
+ state.hasMoved = true;
3852
+ }
3853
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
3854
+ const localPoint = vectorEditCanvasToLocalPoint(
3855
+ canvasPoint,
3856
+ active.originCanvas,
3857
+ );
3858
+ const nextPath = movePenAnchor(
3859
+ state.pathBefore,
3860
+ state.nodeIndex,
3861
+ localPoint,
3862
+ );
3863
+ active.onChange(nextPath, "preview");
3864
+ };
3865
+
3866
+ const handleMouseUp = (ev: MouseEvent) => {
3867
+ const state = dragState.current;
3868
+ if (!state || state.type !== "vector-anchor") {
3869
+ finishDrag();
3870
+ return;
3871
+ }
3872
+ const active = vectorEditRef.current;
3873
+ if (!active) {
3874
+ finishDrag();
3875
+ return;
3876
+ }
3877
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
3878
+ const localPoint = vectorEditCanvasToLocalPoint(
3879
+ canvasPoint,
3880
+ active.originCanvas,
3881
+ );
3882
+ const nextPath = state.hasMoved
3883
+ ? movePenAnchor(state.pathBefore, state.nodeIndex, localPoint)
3884
+ : state.pathBefore;
3885
+ active.onChange(nextPath, "commit");
3886
+ finishDrag();
3887
+ };
3888
+
3889
+ const cancelGesture = () => {
3890
+ const state = dragState.current;
3891
+ const active = vectorEditRef.current;
3892
+ if (state?.type === "vector-anchor" && active) {
3893
+ active.onChange(clonePenPath(state.pathBefore), "commit");
3894
+ }
3895
+ finishDrag();
3896
+ };
3897
+
3898
+ installDragListeners(handleMouseMove, handleMouseUp, cancelGesture);
3899
+ },
3900
+ [
3901
+ claimKeyboardFocus,
3902
+ finishDrag,
3903
+ getCanvasPoint,
3904
+ installDragListeners,
3905
+ vectorEdit,
3906
+ ],
3907
+ );
3908
+
3909
+ const beginVectorHandleDrag = useCallback(
3910
+ (nodeIndex: number, which: "in" | "out", e: React.MouseEvent) => {
3911
+ if (!vectorEdit || e.button !== 0) return;
3912
+ e.preventDefault();
3913
+ e.stopPropagation();
3914
+ claimKeyboardFocus();
3915
+
3916
+ const pathBefore = clonePenPath(vectorEdit.path);
3917
+ dragState.current = {
3918
+ type: "vector-handle",
3919
+ originClient: { x: e.clientX, y: e.clientY },
3920
+ nodeIndex,
3921
+ which,
3922
+ pathBefore,
3923
+ hasMoved: false,
3924
+ };
3925
+ setIsDragging(true);
3926
+ setDragCursor("crosshair");
3927
+
3928
+ const handleMouseMove = (ev: MouseEvent) => {
3929
+ const state = dragState.current;
3930
+ if (!state || state.type !== "vector-handle") return;
3931
+ const active = vectorEditRef.current;
3932
+ if (!active) return;
3933
+ if (
3934
+ !state.hasMoved &&
3935
+ Math.hypot(
3936
+ ev.clientX - state.originClient.x,
3937
+ ev.clientY - state.originClient.y,
3938
+ ) >= DRAG_THRESHOLD
3939
+ ) {
3940
+ state.hasMoved = true;
3941
+ }
3942
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
3943
+ const localPoint = vectorEditCanvasToLocalPoint(
3944
+ canvasPoint,
3945
+ active.originCanvas,
3946
+ );
3947
+ // Alt/Option held mid-drag breaks handle symmetry into a cusp,
3948
+ // matching the pen tool's own alt behavior (read live on every move
3949
+ // so toggling Alt mid-drag updates immediately).
3950
+ const nextPath = movePenHandle(
3951
+ state.pathBefore,
3952
+ state.nodeIndex,
3953
+ state.which,
3954
+ localPoint,
3955
+ { breakSymmetry: ev.altKey },
3956
+ );
3957
+ active.onChange(nextPath, "preview");
3958
+ };
3959
+
3960
+ const handleMouseUp = (ev: MouseEvent) => {
3961
+ const state = dragState.current;
3962
+ if (!state || state.type !== "vector-handle") {
3963
+ finishDrag();
3964
+ return;
3965
+ }
3966
+ const active = vectorEditRef.current;
3967
+ if (!active) {
3968
+ finishDrag();
3969
+ return;
3970
+ }
3971
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
3972
+ const localPoint = vectorEditCanvasToLocalPoint(
3973
+ canvasPoint,
3974
+ active.originCanvas,
3975
+ );
3976
+ const nextPath = state.hasMoved
3977
+ ? movePenHandle(
3978
+ state.pathBefore,
3979
+ state.nodeIndex,
3980
+ state.which,
3981
+ localPoint,
3982
+ { breakSymmetry: ev.altKey },
3983
+ )
3984
+ : state.pathBefore;
3985
+ active.onChange(nextPath, "commit");
3986
+ finishDrag();
3987
+ };
3988
+
3989
+ const cancelGesture = () => {
3990
+ const state = dragState.current;
3991
+ const active = vectorEditRef.current;
3992
+ if (state?.type === "vector-handle" && active) {
3993
+ active.onChange(clonePenPath(state.pathBefore), "commit");
3994
+ }
3995
+ finishDrag();
3996
+ };
3997
+
3998
+ installDragListeners(handleMouseMove, handleMouseUp, cancelGesture);
3999
+ },
4000
+ [
4001
+ claimKeyboardFocus,
4002
+ finishDrag,
4003
+ getCanvasPoint,
4004
+ installDragListeners,
4005
+ vectorEdit,
4006
+ ],
4007
+ );
4008
+
4009
+ /** Toggle corner<->smooth on double-click of an anchor (P-VE1). Always
4010
+ * commits immediately (no preview phase — there's no drag to preview). */
4011
+ const toggleVectorNodeType = useCallback(
4012
+ (nodeIndex: number) => {
4013
+ if (!vectorEdit) return;
4014
+ const node = vectorEdit.path.nodes[nodeIndex];
4015
+ if (!node) return;
4016
+ const isCorner = !node.handleIn && !node.handleOut;
4017
+ const nextPath = setPenNodeType(
4018
+ vectorEdit.path,
4019
+ nodeIndex,
4020
+ isCorner ? "smooth" : "corner",
4021
+ );
4022
+ vectorEdit.onChange(nextPath, "commit");
4023
+ },
4024
+ [vectorEdit],
4025
+ );
4026
+
3188
4027
  const beginDraftCreation = useCallback(
3189
4028
  (tool: DraftCreationTool, e: React.MouseEvent) => {
3190
4029
  if (e.button !== 0) return;
@@ -3200,14 +4039,12 @@ export function MultiScreenCanvas({
3200
4039
  false,
3201
4040
  );
3202
4041
  const initialPoints =
3203
- tool === "pen"
3204
- ? [originCanvas]
3205
- : tool === "line" || tool === "arrow"
3206
- ? [
3207
- originCanvas,
3208
- { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
3209
- ]
3210
- : undefined;
4042
+ tool === "line" || tool === "arrow"
4043
+ ? [
4044
+ originCanvas,
4045
+ { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
4046
+ ]
4047
+ : undefined;
3211
4048
  dragState.current = {
3212
4049
  type: "draft-create",
3213
4050
  tool,
@@ -3223,7 +4060,7 @@ export function MultiScreenCanvas({
3223
4060
  points: initialPoints,
3224
4061
  });
3225
4062
  setIsDragging(true);
3226
- setDragCursor(tool === "pen" ? "crosshair" : "crosshair");
4063
+ setDragCursor("crosshair");
3227
4064
 
3228
4065
  const handleMouseMove = (ev: MouseEvent) => {
3229
4066
  const state = dragState.current;
@@ -3239,22 +4076,15 @@ export function MultiScreenCanvas({
3239
4076
  state.hasMoved = true;
3240
4077
  }
3241
4078
 
3242
- if (state.tool === "pen") {
3243
- const minDistance =
3244
- PEN_SAMPLE_DISTANCE_SCREEN_PX / (zoomRef.current / 100);
3245
- state.points = appendPolylinePoint(
3246
- state.points,
3247
- nextCanvas,
3248
- minDistance,
3249
- );
3250
- setCreationPreview({
3251
- tool,
3252
- geometry: getPathGeometry(state.points),
3253
- points: state.points,
3254
- });
3255
- return;
3256
- }
3257
-
4079
+ const modifiers: DraftGeometryModifiers = {
4080
+ shiftKey: ev.shiftKey,
4081
+ altKey: ev.altKey,
4082
+ };
4083
+ const isLineTool = state.tool === "line" || state.tool === "arrow";
4084
+ const previewEnd =
4085
+ isLineTool && ev.shiftKey
4086
+ ? constrainPointTo45Degrees(state.originCanvas, nextCanvas)
4087
+ : nextCanvas;
3258
4088
  setCreationPreview({
3259
4089
  tool,
3260
4090
  geometry: getDraftPreviewGeometryForTool(
@@ -3262,11 +4092,9 @@ export function MultiScreenCanvas({
3262
4092
  state.originCanvas,
3263
4093
  nextCanvas,
3264
4094
  state.hasMoved,
4095
+ modifiers,
3265
4096
  ),
3266
- points:
3267
- state.tool === "line" || state.tool === "arrow"
3268
- ? [state.originCanvas, nextCanvas]
3269
- : undefined,
4097
+ points: isLineTool ? [state.originCanvas, previewEnd] : undefined,
3270
4098
  });
3271
4099
  };
3272
4100
 
@@ -3277,7 +4105,7 @@ export function MultiScreenCanvas({
3277
4105
  return;
3278
4106
  }
3279
4107
 
3280
- let endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
4108
+ const endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
3281
4109
  const canvasMoved =
3282
4110
  Math.hypot(
3283
4111
  endCanvas.x - state.originCanvas.x,
@@ -3291,18 +4119,18 @@ export function MultiScreenCanvas({
3291
4119
  ) >= DRAG_THRESHOLD ||
3292
4120
  canvasMoved;
3293
4121
  state.hasMoved = releaseMoved;
3294
- let points = state.tool === "pen" ? state.points : undefined;
3295
- if (state.tool === "pen") {
3296
- points = appendPolylinePoint(state.points, endCanvas, 0);
3297
- if (!releaseMoved || points.length < 2) {
3298
- finishDrag();
3299
- return;
3300
- }
3301
- endCanvas = points[points.length - 1] ?? endCanvas;
3302
- }
4122
+ const modifiers: DraftGeometryModifiers = {
4123
+ shiftKey: ev.shiftKey,
4124
+ altKey: ev.altKey,
4125
+ };
3303
4126
  if (state.tool === "frame" && onCreateScreenFrame) {
3304
4127
  onCreateScreenFrame(
3305
- getDraftGeometryForTool(state.tool, state.originCanvas, endCanvas),
4128
+ getDraftGeometryForTool(
4129
+ state.tool,
4130
+ state.originCanvas,
4131
+ endCanvas,
4132
+ modifiers,
4133
+ ),
3306
4134
  );
3307
4135
  if (activeTool === undefined) {
3308
4136
  setLocalActiveTool("move");
@@ -3315,22 +4143,15 @@ export function MultiScreenCanvas({
3315
4143
  tool: state.tool,
3316
4144
  start: state.originCanvas,
3317
4145
  end: endCanvas,
3318
- points,
3319
4146
  moved: releaseMoved,
3320
4147
  toolProps,
4148
+ modifiers,
3321
4149
  });
3322
4150
  commitDraftPrimitive(nextDraft, state.originFrameId);
3323
4151
  if (activeTool === undefined) {
3324
4152
  setLocalActiveTool("move");
3325
4153
  }
3326
- if (state.tool === "pen") {
3327
- if (activeTool === undefined) {
3328
- setLocalActiveTool("pen");
3329
- }
3330
- onActiveToolChange?.("pen");
3331
- } else {
3332
- onActiveToolChange?.("move");
3333
- }
4154
+ onActiveToolChange?.("move");
3334
4155
  finishDrag();
3335
4156
  };
3336
4157
 
@@ -3726,8 +4547,8 @@ export function MultiScreenCanvas({
3726
4547
  const state = dragState.current;
3727
4548
  if (!state || state.type !== "move") return;
3728
4549
  const scale = zoomRef.current / 100;
3729
- const dx = (ev.clientX - state.originClient.x) / scale;
3730
- const dy = (ev.clientY - state.originClient.y) / scale;
4550
+ let dx = (ev.clientX - state.originClient.x) / scale;
4551
+ let dy = (ev.clientY - state.originClient.y) / scale;
3731
4552
  if (
3732
4553
  !state.hasMoved &&
3733
4554
  Math.hypot(
@@ -3738,6 +4559,26 @@ export function MultiScreenCanvas({
3738
4559
  state.hasMoved = true;
3739
4560
  }
3740
4561
 
4562
+ // Never commit a live transform before the drag threshold is crossed:
4563
+ // otherwise 1-2px of click jitter nudges the frame, that nudge is
4564
+ // never reverted (mouseup below only restores origin when the whole
4565
+ // gesture never moved past threshold — see below), and the next drag
4566
+ // reads its origin from the already-nudged geometry.
4567
+ if (!state.hasMoved) return;
4568
+
4569
+ // Shift held mid-move (not at mousedown — that path is shift-click
4570
+ // multi-select and never reaches here, see the guard above) locks
4571
+ // movement to a single axis, matching Figma. Zero the smaller-
4572
+ // magnitude axis before snapping so snap candidates on the locked
4573
+ // axis can't reintroduce drift on it.
4574
+ if (ev.shiftKey) {
4575
+ if (Math.abs(dx) >= Math.abs(dy)) {
4576
+ dy = 0;
4577
+ } else {
4578
+ dx = 0;
4579
+ }
4580
+ }
4581
+
3741
4582
  const movingEntries = state.targetIds.map((targetId) => ({
3742
4583
  id: targetId,
3743
4584
  geometry: {
@@ -3769,6 +4610,51 @@ export function MultiScreenCanvas({
3769
4610
  });
3770
4611
  setAlignmentGuides(snap.guides);
3771
4612
 
4613
+ // Smart-spacing guides (CV11) — only meaningful for a single moving
4614
+ // frame (matches Figma, which only shows equal-gap guides while
4615
+ // dragging one object, not a multi-select group).
4616
+ if (state.targetIds.length === 1) {
4617
+ const primaryId = state.targetIds[0];
4618
+ const movedGeometry = movingEntries.find(
4619
+ (entry) => entry.id === primaryId,
4620
+ )?.geometry;
4621
+ if (movedGeometry) {
4622
+ // Same screen-px-to-canvas-px conversion computeMoveSnap uses
4623
+ // for its own threshold, so the equal-gap tolerance also stays
4624
+ // a constant few screen pixels regardless of zoom level.
4625
+ const tolerance =
4626
+ EQUAL_GAP_TOLERANCE_SCREEN_PX /
4627
+ Math.max(0.01, zoomRef.current / 100);
4628
+ setEqualGapGuides(
4629
+ computeEqualGapGuides(
4630
+ {
4631
+ ...movedGeometry,
4632
+ x: movedGeometry.x + snap.dx,
4633
+ y: movedGeometry.y + snap.dy,
4634
+ },
4635
+ stationaryEntries,
4636
+ { toleranceCanvasPx: tolerance },
4637
+ ),
4638
+ );
4639
+ }
4640
+ } else {
4641
+ setEqualGapGuides([]);
4642
+ }
4643
+
4644
+ // Resize shows a W x H badge and rotate shows a degrees badge — move
4645
+ // was the one transform with no live feedback at all. Show the
4646
+ // primary frame's new (rounded) position, matching resize/rotate's
4647
+ // convention of displaying the current absolute value rather than a
4648
+ // delta.
4649
+ const primaryOrigin = state.originFrames[state.primaryId];
4650
+ if (primaryOrigin) {
4651
+ showTransformFeedback(
4652
+ `${Math.round(primaryOrigin.x + dx + snap.dx)}, ${Math.round(primaryOrigin.y + dy + snap.dy)}`,
4653
+ ev.clientX,
4654
+ ev.clientY,
4655
+ );
4656
+ }
4657
+
3772
4658
  // When all dragged ids are committed primitive nodeIds (not screen
3773
4659
  // frames), check for a container primitive drop target to highlight.
3774
4660
  const currentFrameIds = Object.keys(frameGeometryRef.current);
@@ -3786,6 +4672,15 @@ export function MultiScreenCanvas({
3786
4672
  const handleMouseUp = () => {
3787
4673
  const state = dragState.current;
3788
4674
  const dropTarget = primitiveDropTargetRef.current;
4675
+ if (state?.type === "move" && !state.hasMoved) {
4676
+ // Belt-and-braces: the live transform above already skips committing
4677
+ // until hasMoved, but restore origin here too in case any geometry
4678
+ // slipped through (e.g. a future code path that writes frameGeometry
4679
+ // directly) so a below-threshold click never leaves a phantom nudge.
4680
+ updateFrameGeometry((current) =>
4681
+ frameGeometryWithOverrides(current, state.originFrames),
4682
+ );
4683
+ }
3789
4684
  if (state?.type === "move" && state.hasMoved) {
3790
4685
  // If all dragged ids are committed primitive nodeIds (not screen
3791
4686
  // frames), attempt a primitive reparent on drop.
@@ -3831,6 +4726,7 @@ export function MultiScreenCanvas({
3831
4726
  installDragListeners,
3832
4727
  onPick,
3833
4728
  resolvePrimitiveScreenId,
4729
+ showTransformFeedback,
3834
4730
  updateFrameGeometry,
3835
4731
  updatePrimitiveDropTarget,
3836
4732
  updateSelectedDraftIds,
@@ -3872,7 +4768,19 @@ export function MultiScreenCanvas({
3872
4768
  hasMoved: false,
3873
4769
  };
3874
4770
  setIsDragging(true);
3875
- setDragCursor(getResizeCursor(handle));
4771
+ // Rotation-aware cursor: a static per-handle cursor is only correct
4772
+ // when the frame isn't rotated. For a single selected (possibly
4773
+ // rotated) frame, quantize the handle's rotated visual angle to the
4774
+ // nearest 45deg to pick the matching cursor; group resizes keep the
4775
+ // unrotated cursor, matching the group's own unrotated resize math.
4776
+ setDragCursor(
4777
+ originEntries.length === 1
4778
+ ? getResizeCursorForHandle(
4779
+ handle,
4780
+ originEntries[0].geometry.rotation ?? 0,
4781
+ )
4782
+ : getResizeCursor(handle),
4783
+ );
3876
4784
 
3877
4785
  const handleMouseMove = (ev: MouseEvent) => {
3878
4786
  const state = dragState.current;
@@ -3890,10 +4798,58 @@ export function MultiScreenCanvas({
3890
4798
  state.hasMoved = true;
3891
4799
  }
3892
4800
 
4801
+ // Skip committing any transform until the drag threshold is crossed —
4802
+ // see the matching comment in beginFrameDrag's move handler.
4803
+ if (!state.hasMoved) return;
4804
+
3893
4805
  const originEntries = state.targetIds.map((targetId) => ({
3894
4806
  id: targetId,
3895
4807
  geometry: state.originFrames[targetId],
3896
4808
  }));
4809
+
4810
+ // A single rotated frame needs rotation-aware resize math: the handle
4811
+ // follows the frame's own rotated axes (matching how the handles
4812
+ // render, rotated with the frame) and the opposite anchor edge/corner
4813
+ // stays fixed in WORLD space, not just in the unrotated local frame.
4814
+ // Multi-select group resize with rotated members keeps the prior
4815
+ // (unrotated-bounds) behavior — extending this to groups would need
4816
+ // per-member rotation handling around a shared group anchor, which is
4817
+ // a larger change than this fix covers.
4818
+ const singleRotatedFrame =
4819
+ originEntries.length === 1 &&
4820
+ (originEntries[0].geometry.rotation ?? 0)
4821
+ ? originEntries[0]
4822
+ : null;
4823
+
4824
+ if (singleRotatedFrame) {
4825
+ const resizedGeometry = resizeRotatedFrameFromDelta(
4826
+ singleRotatedFrame.geometry,
4827
+ state.handle,
4828
+ dx,
4829
+ dy,
4830
+ {
4831
+ preserveAspectRatio: ev.shiftKey,
4832
+ resizeFromCenter: ev.altKey,
4833
+ minWidth: 1,
4834
+ minHeight: 1,
4835
+ },
4836
+ );
4837
+ updateFrameGeometry((current) => ({
4838
+ ...current,
4839
+ [singleRotatedFrame.id]: {
4840
+ ...state.originFrames[singleRotatedFrame.id],
4841
+ ...resizedGeometry,
4842
+ },
4843
+ }));
4844
+ setAlignmentGuides([]);
4845
+ showTransformFeedback(
4846
+ `${Math.round(resizedGeometry.width)} x ${Math.round(resizedGeometry.height)}`,
4847
+ ev.clientX,
4848
+ ev.clientY,
4849
+ );
4850
+ return;
4851
+ }
4852
+
3897
4853
  const resized = resizeFrameGroupFromDelta(
3898
4854
  originEntries,
3899
4855
  state.originBounds,
@@ -3917,6 +4873,11 @@ export function MultiScreenCanvas({
3917
4873
  thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
3918
4874
  zoom: zoomRef.current,
3919
4875
  bypass: ev.metaKey || ev.ctrlKey,
4876
+ // Snapping x and y independently can each pull toward a
4877
+ // different sibling edge, which would distort a shift-held
4878
+ // (aspect-locked) resize away from its ratio — see
4879
+ // computeAspectPreservingResizeSnap in canvas-math.ts.
4880
+ preserveAspectRatio: ev.shiftKey,
3920
4881
  },
3921
4882
  );
3922
4883
  const resizedEntries = resizeFrameGroupToBounds(
@@ -3944,6 +4905,12 @@ export function MultiScreenCanvas({
3944
4905
 
3945
4906
  const handleMouseUp = () => {
3946
4907
  const state = dragState.current;
4908
+ if (state?.type === "resize" && !state.hasMoved) {
4909
+ // Belt-and-braces restore, matching the move handler.
4910
+ updateFrameGeometry((current) =>
4911
+ frameGeometryWithOverrides(current, state.originFrames),
4912
+ );
4913
+ }
3947
4914
  if (state?.type === "resize" && state.hasMoved) {
3948
4915
  const after = cloneFrameGeometryById(frameGeometryRef.current);
3949
4916
  onGeometryCommitRef.current?.(
@@ -4020,6 +4987,10 @@ export function MultiScreenCanvas({
4020
4987
  ) {
4021
4988
  state.hasMoved = true;
4022
4989
  }
4990
+ // Skip committing any transform until the drag threshold is crossed —
4991
+ // see the matching comment in beginFrameDrag's move handler.
4992
+ if (!state.hasMoved) return;
4993
+
4023
4994
  const pointer = getCanvasPoint(ev.clientX, ev.clientY);
4024
4995
  const center = getFrameCenter(state.originFrame);
4025
4996
  const raw =
@@ -4043,12 +5014,135 @@ export function MultiScreenCanvas({
4043
5014
 
4044
5015
  const handleMouseUp = () => {
4045
5016
  const state = dragState.current;
5017
+ if (state?.type === "rotate" && !state.hasMoved) {
5018
+ // Belt-and-braces restore, matching the move handler.
5019
+ updateFrameGeometry((current) => ({
5020
+ ...current,
5021
+ [state.frameId]: { ...state.originFrame },
5022
+ }));
5023
+ }
4046
5024
  if (state?.type === "rotate" && state.hasMoved) {
4047
5025
  const after = cloneFrameGeometryById(frameGeometryRef.current);
4048
5026
  onGeometryCommitRef.current?.(
4049
- frameGeometryWithOverrides(after, {
4050
- [state.frameId]: state.originFrame,
4051
- }),
5027
+ frameGeometryWithOverrides(after, {
5028
+ [state.frameId]: state.originFrame,
5029
+ }),
5030
+ after,
5031
+ );
5032
+ }
5033
+ suppressNextPick.current = true;
5034
+ finishDrag();
5035
+ };
5036
+
5037
+ installDragListeners(handleMouseMove, handleMouseUp);
5038
+ },
5039
+ [
5040
+ activeId,
5041
+ finishDrag,
5042
+ getCanvasPoint,
5043
+ getCurrentFrameEntries,
5044
+ installDragListeners,
5045
+ onPick,
5046
+ showTransformFeedback,
5047
+ updateFrameGeometry,
5048
+ updateSelectedIds,
5049
+ ],
5050
+ );
5051
+
5052
+ // Multi-selection rotate (CV14): rotates every currently-selected frame
5053
+ // together around the group's own center. Kept entirely separate from
5054
+ // beginRotate above — single-frame rotate is unaffected.
5055
+ const beginGroupRotate = useCallback(
5056
+ (e: React.MouseEvent) => {
5057
+ if (e.button !== 0) return;
5058
+ e.preventDefault();
5059
+ e.stopPropagation();
5060
+ suppressNextPick.current = true;
5061
+
5062
+ const targetIds = selectedIdsRef.current;
5063
+ if (targetIds.length < 2) return;
5064
+ const originEntries = getCurrentFrameEntries().filter((entry) =>
5065
+ targetIds.includes(entry.id),
5066
+ );
5067
+ if (originEntries.length < 2) return;
5068
+ const groupBounds = getFrameGroupBounds(originEntries);
5069
+ if (!groupBounds) return;
5070
+ const groupCenter = { x: groupBounds.centerX, y: groupBounds.centerY };
5071
+
5072
+ const pointer = getCanvasPoint(e.clientX, e.clientY);
5073
+ dragState.current = {
5074
+ type: "group-rotate",
5075
+ originClient: { x: e.clientX, y: e.clientY },
5076
+ originFrames: Object.fromEntries(
5077
+ originEntries.map((entry) => [entry.id, entry.geometry]),
5078
+ ) as FrameGeometryById,
5079
+ targetIds: originEntries.map((entry) => entry.id),
5080
+ groupCenter,
5081
+ originPointerAngle: angleBetween(groupCenter, pointer),
5082
+ hasMoved: false,
5083
+ };
5084
+ setIsDragging(true);
5085
+ setDragCursor("grabbing");
5086
+
5087
+ const handleMouseMove = (ev: MouseEvent) => {
5088
+ const state = dragState.current;
5089
+ if (!state || state.type !== "group-rotate") return;
5090
+ if (
5091
+ !state.hasMoved &&
5092
+ Math.hypot(
5093
+ ev.clientX - state.originClient.x,
5094
+ ev.clientY - state.originClient.y,
5095
+ ) >= DRAG_THRESHOLD
5096
+ ) {
5097
+ state.hasMoved = true;
5098
+ }
5099
+ // Skip committing any transform until the drag threshold is crossed —
5100
+ // see the matching comment in beginFrameDrag's move handler.
5101
+ if (!state.hasMoved) return;
5102
+
5103
+ const pointer = getCanvasPoint(ev.clientX, ev.clientY);
5104
+ const currentAngle = angleBetween(state.groupCenter, pointer);
5105
+ const rawDelta = currentAngle - state.originPointerAngle;
5106
+ const delta = ev.shiftKey ? Math.round(rawDelta / 15) * 15 : rawDelta;
5107
+
5108
+ const originEntriesForRotate = state.targetIds.map((targetId) => ({
5109
+ id: targetId,
5110
+ geometry: state.originFrames[targetId],
5111
+ }));
5112
+ const rotated = rotateFrameGroupAroundCenter(
5113
+ originEntriesForRotate,
5114
+ state.groupCenter,
5115
+ delta,
5116
+ );
5117
+ updateFrameGeometry((current) => {
5118
+ const next = { ...current };
5119
+ rotated.forEach((entry) => {
5120
+ next[entry.id] = {
5121
+ ...state.originFrames[entry.id],
5122
+ ...entry.geometry,
5123
+ };
5124
+ });
5125
+ return next;
5126
+ });
5127
+ showTransformFeedback(
5128
+ `${Math.round(delta)}deg`,
5129
+ ev.clientX,
5130
+ ev.clientY,
5131
+ );
5132
+ };
5133
+
5134
+ const handleMouseUp = () => {
5135
+ const state = dragState.current;
5136
+ if (state?.type === "group-rotate" && !state.hasMoved) {
5137
+ // Belt-and-braces restore, matching the move handler.
5138
+ updateFrameGeometry((current) =>
5139
+ frameGeometryWithOverrides(current, state.originFrames),
5140
+ );
5141
+ }
5142
+ if (state?.type === "group-rotate" && state.hasMoved) {
5143
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
5144
+ onGeometryCommitRef.current?.(
5145
+ frameGeometryWithOverrides(after, state.originFrames),
4052
5146
  after,
4053
5147
  );
4054
5148
  }
@@ -4059,15 +5153,12 @@ export function MultiScreenCanvas({
4059
5153
  installDragListeners(handleMouseMove, handleMouseUp);
4060
5154
  },
4061
5155
  [
4062
- activeId,
4063
5156
  finishDrag,
4064
5157
  getCanvasPoint,
4065
5158
  getCurrentFrameEntries,
4066
5159
  installDragListeners,
4067
- onPick,
4068
5160
  showTransformFeedback,
4069
5161
  updateFrameGeometry,
4070
- updateSelectedIds,
4071
5162
  ],
4072
5163
  );
4073
5164
 
@@ -4153,6 +5244,11 @@ export function MultiScreenCanvas({
4153
5244
  canDuplicate: !!onDuplicate,
4154
5245
  moved: false,
4155
5246
  });
5247
+ // Mount the interaction shield and mute preview-iframe pointer events for
5248
+ // the duration of the gesture, same as every other drag — otherwise the
5249
+ // pointer freezes crossing a live embedded iframe and a release over a
5250
+ // screen never reaches handleMouseUp.
5251
+ setIsDragging(true);
4156
5252
 
4157
5253
  const handleMouseMove = (ev: MouseEvent) => {
4158
5254
  const dx = ev.clientX - origin.x;
@@ -4165,22 +5261,24 @@ export function MultiScreenCanvas({
4165
5261
  y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
4166
5262
  width: previewWidth,
4167
5263
  height: previewHeight,
4168
- canDuplicate: !!onDuplicate,
5264
+ // Live alt state, not just capability: if the user releases alt
5265
+ // mid-drag the preview should visibly fall back to its "not armed"
5266
+ // dashed/preview styling, matching that mouseup will then cancel
5267
+ // the duplicate instead of creating one (see handleMouseUp below).
5268
+ canDuplicate: !!onDuplicate && ev.altKey,
4169
5269
  moved,
4170
5270
  });
4171
5271
  };
4172
5272
 
4173
5273
  const cleanupDuplicateGesture = () => {
4174
5274
  setDuplicatePreview(null);
4175
- window.removeEventListener("mousemove", handleMouseMove);
4176
- window.removeEventListener("mouseup", handleMouseUp);
4177
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
4178
- window.removeEventListener("blur", handleBlur);
4179
5275
  duplicateCleanup.current = null;
4180
- };
4181
-
4182
- const handleBlur = () => {
4183
- cleanupDuplicateGesture();
5276
+ // finishDrag clears isDragging, unmounts the shield, and — critically —
5277
+ // runs dragCleanup.current() to detach the window listeners installed
5278
+ // by installDragListeners and restore preview-iframe pointer events.
5279
+ // dragState.current was never set for this gesture, so finishDrag's
5280
+ // other resets (marquee/creation-preview/etc.) are no-ops here.
5281
+ finishDrag();
4184
5282
  };
4185
5283
 
4186
5284
  const handleMouseUp = (ev: MouseEvent) => {
@@ -4188,22 +5286,27 @@ export function MultiScreenCanvas({
4188
5286
  Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
4189
5287
  DUPLICATE_DRAG_THRESHOLD;
4190
5288
  const mode = moved ? "alt-drag" : "alt-click";
4191
-
4192
- if (onDuplicate) {
5289
+ // Figma semantics: a plain alt-click (no drag) never duplicates —
5290
+ // only an actual alt-drag does. And alt is evaluated live: releasing
5291
+ // it before mouseup cancels the pending duplicate rather than
5292
+ // creating one anyway (this gesture never moves the original frame
5293
+ // during the drag — it only shows a floating ghost preview — so
5294
+ // "cancel" here means no-op, not handing off to a live move).
5295
+ const shouldDuplicate = moved && ev.altKey;
5296
+
5297
+ if (onDuplicate && shouldDuplicate) {
4193
5298
  const dropCanvasPosition = canvasPointFromClient(
4194
5299
  ev.clientX,
4195
5300
  ev.clientY,
4196
5301
  );
4197
- const canvasPosition =
4198
- moved || !sourceFrame
4199
- ? {
4200
- x: dropCanvasPosition.x - pointerOffset.x,
4201
- y: dropCanvasPosition.y - pointerOffset.y,
4202
- }
4203
- : {
4204
- x: sourceFrame.geometry.x + SCREEN_GAP / 2,
4205
- y: sourceFrame.geometry.y + SCREEN_GAP / 2,
4206
- };
5302
+ // shouldDuplicate implies moved, so the drop position is always
5303
+ // relative to the pointer's offset into the source frame (the
5304
+ // "snap next to source" placement only applied to the old
5305
+ // zero-move alt-click case, which no longer duplicates at all).
5306
+ const canvasPosition = {
5307
+ x: dropCanvasPosition.x - pointerOffset.x,
5308
+ y: dropCanvasPosition.y - pointerOffset.y,
5309
+ };
4207
5310
  onDuplicate(screen.id, {
4208
5311
  mode,
4209
5312
  screen,
@@ -4219,11 +5322,20 @@ export function MultiScreenCanvas({
4219
5322
  };
4220
5323
 
4221
5324
  duplicateCleanup.current = cleanupDuplicateGesture;
4222
- window.addEventListener("mousemove", handleMouseMove);
4223
- window.addEventListener("mouseup", handleMouseUp);
4224
- window.addEventListener("blur", handleBlur);
5325
+ installDragListeners(
5326
+ handleMouseMove,
5327
+ handleMouseUp,
5328
+ cleanupDuplicateGesture,
5329
+ );
4225
5330
  },
4226
- [canvasPointFromClient, getCurrentFrameEntries, onDuplicate, onPick],
5331
+ [
5332
+ canvasPointFromClient,
5333
+ finishDrag,
5334
+ getCurrentFrameEntries,
5335
+ installDragListeners,
5336
+ onDuplicate,
5337
+ onPick,
5338
+ ],
4227
5339
  );
4228
5340
 
4229
5341
  const handleMouseDown = useCallback(
@@ -4236,6 +5348,52 @@ export function MultiScreenCanvas({
4236
5348
  const target = e.target as HTMLElement;
4237
5349
  const onFrame = !!target.closest("[data-frame-shell]");
4238
5350
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
5351
+ if (vectorEdit) {
5352
+ if (e.button !== 0) return;
5353
+ e.preventDefault();
5354
+ // Hit-test the click directly against the path's anchors/handles
5355
+ // (rather than relying on per-element DOM handlers), reusing the
5356
+ // same pure hitTestPenAnchor/hitTestPenHandle helpers pen-path.ts
5357
+ // exports. A screen-space radius keeps the hit target a constant
5358
+ // physical size regardless of zoom, matching PEN_CLOSE_HIT_RADIUS
5359
+ // above. Handles take priority over anchors when both are in range
5360
+ // (checked first, below).
5361
+ const canvasPoint = getCanvasPoint(e.clientX, e.clientY);
5362
+ const localPoint = vectorEditCanvasToLocalPoint(
5363
+ canvasPoint,
5364
+ vectorEdit.originCanvas,
5365
+ );
5366
+ const hitRadius = screenPxToCanvasPx(
5367
+ VECTOR_EDIT_HIT_RADIUS_SCREEN_PX,
5368
+ zoomRef.current,
5369
+ );
5370
+ const handleHit = hitTestPenHandle(
5371
+ vectorEdit.path,
5372
+ localPoint,
5373
+ hitRadius,
5374
+ );
5375
+ if (handleHit) {
5376
+ beginVectorHandleDrag(handleHit.nodeIndex, handleHit.which, e);
5377
+ return;
5378
+ }
5379
+ const anchorHit = hitTestPenAnchor(
5380
+ vectorEdit.path,
5381
+ localPoint,
5382
+ hitRadius,
5383
+ );
5384
+ if (anchorHit) {
5385
+ if (e.detail > 1) {
5386
+ toggleVectorNodeType(anchorHit.nodeIndex);
5387
+ return;
5388
+ }
5389
+ beginVectorAnchorDrag(anchorHit.nodeIndex, e);
5390
+ return;
5391
+ }
5392
+ // Missed everything: an empty-canvas click while in vector edit mode
5393
+ // exits the mode, matching Figma.
5394
+ vectorEdit.onExit();
5395
+ return;
5396
+ }
4239
5397
  if (e.button === 1) {
4240
5398
  beginPan(e);
4241
5399
  return;
@@ -4263,8 +5421,13 @@ export function MultiScreenCanvas({
4263
5421
  beginMarquee,
4264
5422
  beginPan,
4265
5423
  beginPenNodeCreation,
5424
+ beginVectorAnchorDrag,
5425
+ beginVectorHandleDrag,
4266
5426
  claimKeyboardFocus,
5427
+ getCanvasPoint,
4267
5428
  localActiveTool,
5429
+ toggleVectorNodeType,
5430
+ vectorEdit,
4268
5431
  ],
4269
5432
  );
4270
5433
 
@@ -4298,6 +5461,20 @@ export function MultiScreenCanvas({
4298
5461
  grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
4299
5462
  grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
4300
5463
  }
5464
+ // A marquee-select drag can run concurrently with a wheel/trackpad pan
5465
+ // gesture (e.g. two-finger scroll while left-mouse-dragging a marquee).
5466
+ // Without this, the marquee overlay's position/size — computed from
5467
+ // React `pan`/`scale` in its inline style — would only catch up once the
5468
+ // gesture settles and scheduleViewCommit() re-renders, visibly lagging
5469
+ // behind the frames it's supposed to be selecting against.
5470
+ const marqueeOverlay = marqueeOverlayRef.current;
5471
+ const activeMarquee = marqueeRef.current;
5472
+ if (marqueeOverlay && activeMarquee) {
5473
+ marqueeOverlay.style.left = `${p.x + (SURFACE_PADDING + activeMarquee.x) * nextScale}px`;
5474
+ marqueeOverlay.style.top = `${p.y + (SURFACE_PADDING + activeMarquee.y) * nextScale}px`;
5475
+ marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`;
5476
+ marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`;
5477
+ }
4301
5478
  }, []);
4302
5479
 
4303
5480
  const startChromeSettle = useCallback(() => {
@@ -4319,7 +5496,11 @@ export function MultiScreenCanvas({
4319
5496
  setCanvasZoom(zoomRef.current);
4320
5497
  setPan(panRef.current);
4321
5498
  onZoomChange?.(zoomRef.current);
4322
- }, [onZoomChange, startChromeSettle]);
5499
+ // P18: the wheel/pinch gesture just settled (pan/zoom state is
5500
+ // reconciled into React here) — resync the pen ghost preview from the
5501
+ // last known cursor position now that the canvas-space mapping changed.
5502
+ recomputePenPointerForViewChange();
5503
+ }, [onZoomChange, recomputePenPointerForViewChange, startChromeSettle]);
4323
5504
 
4324
5505
  // Debounced: only commit to React state once the gesture has been idle for a
4325
5506
  // beat, so a continuous pinch produces zero re-renders until the user pauses.
@@ -4335,6 +5516,8 @@ export function MultiScreenCanvas({
4335
5516
  },
4336
5517
  [commitView],
4337
5518
  );
5519
+ applyViewToDomRef.current = applyViewToDom;
5520
+ scheduleViewCommitRef.current = scheduleViewCommit;
4338
5521
 
4339
5522
  const flushPendingWheelGesture = useCallback(() => {
4340
5523
  wheelGestureFrameRef.current = null;
@@ -4462,6 +5645,15 @@ export function MultiScreenCanvas({
4462
5645
 
4463
5646
  const handleWheelEvent = useCallback(
4464
5647
  (event: WheelEvent) => {
5648
+ // DesignCanvas re-dispatches embedded-canvas-wheel messages as a
5649
+ // synthetic (non-isTrusted) WheelEvent on its own iframe element so its
5650
+ // own listeners can reuse one code path. That synthetic event bubbles
5651
+ // up through this surface's capture listener too, so without this guard
5652
+ // every embedded-screen wheel gesture gets processed twice: once via
5653
+ // the postMessage handler below, and again here from the re-dispatch.
5654
+ // Real user wheel input is always isTrusted, so this only filters out
5655
+ // the synthetic replay.
5656
+ if (!event.isTrusted) return;
4465
5657
  event.preventDefault();
4466
5658
  event.stopPropagation();
4467
5659
  enqueueWheelGestureFromClient({
@@ -4663,6 +5855,19 @@ export function MultiScreenCanvas({
4663
5855
 
4664
5856
  const primaryKey = event.metaKey || event.ctrlKey;
4665
5857
  if (primaryKey && !event.shiftKey && event.key.toLowerCase() === "z") {
5858
+ // While a pen-node drag is actively in progress (mouse down, still
5859
+ // dragging the handle for the anchor being placed), the path in
5860
+ // activePenPathRef already includes that in-progress node — undoing
5861
+ // here would pop it out from under the live drag, then the drag's
5862
+ // own mousemove/mouseup handlers would immediately re-add it from
5863
+ // their closed-over `state`, producing a no-op flicker. Ignore the
5864
+ // shortcut until the drag settles (mouseup/cancel).
5865
+ if (dragState.current?.type === "pen-node") {
5866
+ event.preventDefault();
5867
+ event.stopPropagation();
5868
+ event.stopImmediatePropagation();
5869
+ return;
5870
+ }
4666
5871
  event.preventDefault();
4667
5872
  event.stopPropagation();
4668
5873
  event.stopImmediatePropagation();
@@ -4684,7 +5889,12 @@ export function MultiScreenCanvas({
4684
5889
  event.preventDefault();
4685
5890
  event.stopPropagation();
4686
5891
  event.stopImmediatePropagation();
4687
- clearActivePenPath();
5892
+ // Figma: Escape ends the path in progress and keeps what's drawn so
5893
+ // far (no data loss), rather than discarding the whole path.
5894
+ // finishPenPath already falls back to a discard for a path with
5895
+ // fewer than 2 nodes (P16), where there's nothing meaningful to
5896
+ // commit.
5897
+ finishPenPath(path);
4688
5898
  return;
4689
5899
  }
4690
5900
 
@@ -4700,15 +5910,28 @@ export function MultiScreenCanvas({
4700
5910
  return () => {
4701
5911
  window.removeEventListener("keydown", handleKeyDown, true);
4702
5912
  };
4703
- }, [clearActivePenPath, finishPenPath, undoActivePenPathSegment]);
5913
+ }, [finishPenPath, undoActivePenPathSegment]);
4704
5914
 
4705
5915
  useEffect(() => {
4706
5916
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
4707
- if (tool !== "pen") clearActivePenPath();
4708
- }, [activeTool, clearActivePenPath, localActiveTool]);
4709
-
4710
- // Cmd+D / Ctrl+D: duplicate the selected frame with a visible offset so the
4711
- // copy doesn't land exactly on top of the original (Figma-style behaviour).
5917
+ // Switching tools mid-path (toolbar click or a single-letter shortcut
5918
+ // reaching the editor's handlers) used to silently discard the path in
5919
+ // progress. Figma commits it instead — finishPenPath already discards
5920
+ // for a sub-2-node path (P16), so this only ever loses genuinely empty
5921
+ // in-progress state.
5922
+ if (tool !== "pen") finishPenPath();
5923
+ }, [activeTool, finishPenPath, localActiveTool]);
5924
+
5925
+ // Cmd+D / Ctrl+D: duplicate every selected frame (not just the first) with
5926
+ // a visible offset so each copy doesn't land exactly on top of its
5927
+ // original (Figma-style behaviour). `onDuplicate` is fire-and-forget
5928
+ // (`(id, request) => void`) and the actual new file id is only known
5929
+ // asynchronously by the caller (DesignEditor's createFileMutation
5930
+ // onSuccess), so this component has no id to add to its own selection
5931
+ // state. Known limitation: after a multi-duplicate, selection isn't
5932
+ // reprogrammed to the new copies (the caller instead makes its own last
5933
+ // duplicate the active file) — promoting the duplicates to the new
5934
+ // selection would require `onDuplicate` to return/callback the created id.
4712
5935
  useEffect(() => {
4713
5936
  if (!onDuplicate) return;
4714
5937
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -4720,31 +5943,39 @@ export function MultiScreenCanvas({
4720
5943
  ) {
4721
5944
  return;
4722
5945
  }
5946
+ // Always suppress the browser default (bookmark dialog) — but leave
5947
+ // propagation intact until we know a frame is duplicable here, so the
5948
+ // global hotkey hook can still duplicate non-frame layer selections.
5949
+ event.preventDefault();
4723
5950
  // Only act on frame IDs — filter out canvas primitives (sub-elements).
4724
5951
  const frameIds = selectedIdsRef.current.filter(
4725
5952
  (id) => frameGeometryRef.current[id],
4726
5953
  );
4727
- const targetId = frameIds[0];
4728
- if (!targetId) return;
4729
- const screen = screens.find((s) => s.id === targetId);
4730
- if (!screen) return;
4731
- const sourceGeometry = frameGeometryRef.current[targetId];
4732
- if (!sourceGeometry) return;
4733
- event.preventDefault();
5954
+ if (frameIds.length === 0) return;
4734
5955
  event.stopPropagation();
4735
5956
  event.stopImmediatePropagation();
4736
- // Offset the duplicate by one grid gap to the right (and slightly down)
4737
- // so it is visually distinct from the original, mirroring Figma's behaviour.
4738
- const canvasPosition = {
4739
- x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
4740
- y: sourceGeometry.y,
4741
- };
4742
- onDuplicate(targetId, {
4743
- mode: "alt-click",
4744
- screen,
4745
- canvasPosition,
4746
- dropCanvasPosition: canvasPosition,
4747
- });
5957
+ // Duplicate every selected frame, not just the first each duplicate
5958
+ // is offset relative to its OWN source geometry (not chained off the
5959
+ // previous duplicate), mirroring how a multi-select alt-drag would
5960
+ // offset each frame independently.
5961
+ for (const targetId of frameIds) {
5962
+ const screen = screens.find((s) => s.id === targetId);
5963
+ if (!screen) continue;
5964
+ const sourceGeometry = frameGeometryRef.current[targetId];
5965
+ if (!sourceGeometry) continue;
5966
+ // Offset the duplicate by one grid gap to the right (and slightly down)
5967
+ // so it is visually distinct from the original, mirroring Figma's behaviour.
5968
+ const canvasPosition = {
5969
+ x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
5970
+ y: sourceGeometry.y,
5971
+ };
5972
+ onDuplicate(targetId, {
5973
+ mode: "alt-click",
5974
+ screen,
5975
+ canvasPosition,
5976
+ dropCanvasPosition: canvasPosition,
5977
+ });
5978
+ }
4748
5979
  };
4749
5980
 
4750
5981
  window.addEventListener("keydown", handleKeyDown, true);
@@ -4785,29 +6016,43 @@ export function MultiScreenCanvas({
4785
6016
  : effectiveTool === "hand"
4786
6017
  ? "grab"
4787
6018
  : "default";
4788
- const canvasFrames = screens.map((screen, index) => {
4789
- const metadata = getResolvedMetadata(screen);
4790
- return {
4791
- screen,
4792
- metadata,
4793
- geometry:
4794
- frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata),
4795
- };
4796
- });
4797
- const topScreenId =
4798
- selectedIds.find((id) =>
4799
- canvasFrames.some(({ screen }) => screen.id === id),
4800
- ) ??
4801
- (activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
4802
- ? activeId
4803
- : canvasFrames[0]?.screen.id);
4804
- const renderCanvasFrames =
4805
- topScreenId && canvasFrames.some(({ screen }) => screen.id === topScreenId)
4806
- ? [
4807
- ...canvasFrames.filter(({ screen }) => screen.id !== topScreenId),
4808
- ...canvasFrames.filter(({ screen }) => screen.id === topScreenId),
4809
- ]
4810
- : canvasFrames;
6019
+ // PF19: canvasFrames (and everything derived from it below) used to be
6020
+ // plain per-render recomputation over `screens`/`frameGeometry`, which are
6021
+ // large arrays/maps for boards with many screens. Memoize so a render that
6022
+ // doesn't touch screens/geometry (e.g. a hover-only or unrelated state
6023
+ // update) doesn't re-walk and re-allocate the whole frame list.
6024
+ const canvasFrames = useMemo(
6025
+ () =>
6026
+ screens.map((screen, index) => {
6027
+ const metadata = getResolvedMetadata(screen);
6028
+ return {
6029
+ screen,
6030
+ metadata,
6031
+ geometry:
6032
+ frameGeometry[screen.id] ??
6033
+ getInitialFrameGeometry(index, metadata),
6034
+ };
6035
+ }),
6036
+ [frameGeometry, getResolvedMetadata, screens],
6037
+ );
6038
+ const topScreenId = useMemo(
6039
+ () =>
6040
+ selectedIds.find((id) =>
6041
+ canvasFrames.some(({ screen }) => screen.id === id),
6042
+ ) ??
6043
+ (activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
6044
+ ? activeId
6045
+ : canvasFrames[0]?.screen.id),
6046
+ [activeId, canvasFrames, selectedIds],
6047
+ );
6048
+ // PF16: previously this reordered canvasFrames so the top screen's keyed
6049
+ // entry moved to the end of the array. Since screens are keyed by
6050
+ // screen.id, moving a key's position in a mapped list makes React move
6051
+ // that DOM node (iframe) to match — which reloads the iframe's document,
6052
+ // producing a visible white flash every time selection changes. zIndex
6053
+ // (boosted for the top screen in Screen's root style, see
6054
+ // TOP_SCREEN_Z_BOOST) already expresses "paint above its siblings" without
6055
+ // needing to touch DOM order, so render canvasFrames in stable order.
4811
6056
  const screenContentById = useMemo(() => {
4812
6057
  if (!renderScreenContent) return new Map<string, ReactNode>();
4813
6058
  return new Map(
@@ -4822,21 +6067,41 @@ export function MultiScreenCanvas({
4822
6067
  }),
4823
6068
  );
4824
6069
  }, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
4825
- const selectedFrameEntries = canvasFrames
4826
- .filter(({ screen }) => selectedIdSet.has(screen.id))
4827
- .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
4828
- const selectedGroupBounds =
4829
- selectedFrameEntries.length > 1
4830
- ? getFrameGroupBounds(selectedFrameEntries)
4831
- : null;
6070
+ // PF19: filters/maps over canvasFrames + a getFrameGroupBounds pass — cheap
6071
+ // for a handful of screens, but this runs on every render (hover, hint
6072
+ // text, unrelated state), not just selection changes. Memoize keyed on the
6073
+ // actual selection + frame list so unrelated renders reuse the prior arrays
6074
+ // (and downstream consumers like SelectionBox/GroupSelectionBox, which take
6075
+ // these by reference, skip re-rendering too).
6076
+ const selectedFrameEntries = useMemo(
6077
+ () =>
6078
+ canvasFrames
6079
+ .filter(({ screen }) => selectedIdSet.has(screen.id))
6080
+ .map(({ screen, geometry }) => ({ id: screen.id, geometry })),
6081
+ [canvasFrames, selectedIdSet],
6082
+ );
6083
+ const selectedGroupBounds = useMemo(
6084
+ () =>
6085
+ selectedFrameEntries.length > 1
6086
+ ? getFrameGroupBounds(selectedFrameEntries)
6087
+ : null,
6088
+ [selectedFrameEntries],
6089
+ );
4832
6090
  const hasGroupSelection = !!selectedGroupBounds;
4833
- const selectedDraftEntries = draftPrimitives
4834
- .filter((draft) => selectedDraftIdSet.has(draft.id))
4835
- .map((draft) => ({ id: draft.id, geometry: draft.geometry }));
4836
- const selectedDraftGroupBounds =
4837
- selectedDraftEntries.length > 1
4838
- ? getFrameGroupBounds(selectedDraftEntries)
4839
- : null;
6091
+ const selectedDraftEntries = useMemo(
6092
+ () =>
6093
+ draftPrimitives
6094
+ .filter((draft) => selectedDraftIdSet.has(draft.id))
6095
+ .map((draft) => ({ id: draft.id, geometry: draft.geometry })),
6096
+ [draftPrimitives, selectedDraftIdSet],
6097
+ );
6098
+ const selectedDraftGroupBounds = useMemo(
6099
+ () =>
6100
+ selectedDraftEntries.length > 1
6101
+ ? getFrameGroupBounds(selectedDraftEntries)
6102
+ : null,
6103
+ [selectedDraftEntries],
6104
+ );
4840
6105
  const singleSelectedFrame =
4841
6106
  selectedFrameEntries.length === 1 && !selectedGroupBounds
4842
6107
  ? selectedFrameEntries[0]
@@ -4993,7 +6258,7 @@ export function MultiScreenCanvas({
4993
6258
  );
4994
6259
  })()}
4995
6260
 
4996
- {renderCanvasFrames.map(({ screen, metadata, geometry }) => {
6261
+ {canvasFrames.map(({ screen, metadata, geometry }) => {
4997
6262
  return (
4998
6263
  <Screen
4999
6264
  key={screen.id}
@@ -5002,6 +6267,7 @@ export function MultiScreenCanvas({
5002
6267
  geometry={geometry}
5003
6268
  screenContent={screenContentById.get(screen.id)}
5004
6269
  isActive={screen.id === activeId}
6270
+ isTopScreen={screen.id === topScreenId}
5005
6271
  isSelected={selectedIdSet.has(screen.id)}
5006
6272
  showFullView={fullViewIdSet.has(screen.id)}
5007
6273
  isDirectlyHovered={screen.id === directlyHoveredScreenId}
@@ -5022,16 +6288,13 @@ export function MultiScreenCanvas({
5022
6288
  onStartResize={beginResize}
5023
6289
  onStartRotate={beginRotate}
5024
6290
  onStartDuplicateGesture={beginDuplicateGesture}
5025
- onAddBreakpoint={
5026
- onAddBreakpoint
5027
- ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
5028
- : undefined
5029
- }
5030
- onActiveBreakpointChange={
5031
- onActiveBreakpointChange
5032
- ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
5033
- : undefined
5034
- }
6291
+ // Pass the id-first callbacks straight through (PF18): Screen
6292
+ // itself binds screen.id when it calls these, so every screen
6293
+ // instance gets the exact same stable function reference here
6294
+ // instead of a fresh per-screen closure allocated on every
6295
+ // MultiScreenCanvas render, which used to defeat memo(Screen).
6296
+ onAddBreakpoint={onAddBreakpoint}
6297
+ onActiveBreakpointChange={onActiveBreakpointChange}
5035
6298
  />
5036
6299
  );
5037
6300
  })}
@@ -5089,7 +6352,18 @@ export function MultiScreenCanvas({
5089
6352
  : null}
5090
6353
 
5091
6354
  {displayedPenPath ? (
5092
- <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
6355
+ <PenPathOverlay
6356
+ path={displayedPenPath}
6357
+ closeHover={penCloseHover}
6358
+ chromeScale={chromeScale}
6359
+ />
6360
+ ) : null}
6361
+
6362
+ {vectorEdit ? (
6363
+ <VectorEditOverlay
6364
+ vectorEdit={vectorEdit}
6365
+ chromeScale={chromeScale}
6366
+ />
5093
6367
  ) : null}
5094
6368
 
5095
6369
  {singleSelectedFrame ? (
@@ -5126,6 +6400,7 @@ export function MultiScreenCanvas({
5126
6400
  chromeScale={chromeScale}
5127
6401
  chromeSettling={chromeSettling}
5128
6402
  onStartResize={beginGroupResize}
6403
+ onStartRotate={beginGroupRotate}
5129
6404
  />
5130
6405
  ) : null}
5131
6406
 
@@ -5159,8 +6434,63 @@ export function MultiScreenCanvas({
5159
6434
  }
5160
6435
  />
5161
6436
  ))}
6437
+
6438
+ {/* Smart-spacing guides (CV11): highlight both equal-sized gaps
6439
+ around the moving frame, with one label showing the shared
6440
+ distance. */}
6441
+ {equalGapGuides.map((guide, index) =>
6442
+ guide.bands.map((band, bandIndex) => (
6443
+ <span
6444
+ key={`equal-gap-${guide.orientation}-${index}-${bandIndex}`}
6445
+ className="pointer-events-none absolute z-30 bg-[var(--design-editor-accent-color)]/25"
6446
+ style={
6447
+ guide.orientation === "vertical"
6448
+ ? {
6449
+ left: SURFACE_PADDING + band.gapStart,
6450
+ top: SURFACE_PADDING + band.crossStart,
6451
+ width: Math.max(1, band.gapEnd - band.gapStart),
6452
+ height: Math.max(1, band.crossEnd - band.crossStart),
6453
+ }
6454
+ : {
6455
+ left: SURFACE_PADDING + band.crossStart,
6456
+ top: SURFACE_PADDING + band.gapStart,
6457
+ width: Math.max(1, band.crossEnd - band.crossStart),
6458
+ height: Math.max(1, band.gapEnd - band.gapStart),
6459
+ }
6460
+ }
6461
+ />
6462
+ )),
6463
+ )}
5162
6464
  </div>
5163
6465
 
6466
+ {/* Equal-gap distance labels render outside the pan/scale-transformed
6467
+ world container (same reasoning as the marquee/duplicate-preview
6468
+ overlays above it) so they need the explicit
6469
+ pan + (SURFACE_PADDING + canvasCoord) * scale conversion instead of
6470
+ the raw canvas coordinates the bands above use inside that
6471
+ container. */}
6472
+ {equalGapGuides.map((guide, index) => {
6473
+ const band = guide.bands[0];
6474
+ const crossMid = (band.crossStart + band.crossEnd) / 2;
6475
+ const gapMid = (band.gapStart + band.gapEnd) / 2;
6476
+ const labelCanvasPoint =
6477
+ guide.orientation === "vertical"
6478
+ ? { x: gapMid, y: crossMid }
6479
+ : { x: crossMid, y: gapMid };
6480
+ return (
6481
+ <span
6482
+ key={`equal-gap-label-${guide.orientation}-${index}`}
6483
+ className="pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2 rounded bg-[var(--design-editor-accent-color)] px-1 py-0.5 text-[10px] font-medium leading-none text-[var(--design-editor-accent-contrast-color)] shadow-sm"
6484
+ style={{
6485
+ left: pan.x + (SURFACE_PADDING + labelCanvasPoint.x) * scale,
6486
+ top: pan.y + (SURFACE_PADDING + labelCanvasPoint.y) * scale,
6487
+ }}
6488
+ >
6489
+ {Math.round(guide.gap)}
6490
+ </span>
6491
+ );
6492
+ })}
6493
+
5164
6494
  {penActive || creationToolActive ? (
5165
6495
  <div
5166
6496
  data-canvas-creation-shield
@@ -5171,11 +6501,18 @@ export function MultiScreenCanvas({
5171
6501
 
5172
6502
  {marquee ? (
5173
6503
  <span
6504
+ ref={marqueeOverlayRef}
5174
6505
  className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
5175
6506
  style={{
5176
6507
  // Convert canvas-space marquee to surface-space so this overlay
5177
6508
  // is never clipped or hidden by the canvas transform container.
5178
6509
  // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
6510
+ // NOTE: this uses React `pan`/`scale`, which only update on the
6511
+ // debounced view-commit — during an active wheel/pinch gesture
6512
+ // (e.g. two-finger pan while marquee-dragging) applyViewToDom
6513
+ // keeps this element's position in sync imperatively via
6514
+ // marqueeOverlayRef, the same way it already does for the world
6515
+ // transform and pixel grid.
5179
6516
  left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
5180
6517
  top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
5181
6518
  width: Math.max(1, marquee.width * scale),
@@ -5288,7 +6625,7 @@ export function MultiScreenCanvas({
5288
6625
  ) : null}
5289
6626
  </div>
5290
6627
  );
5291
- }
6628
+ });
5292
6629
 
5293
6630
  function DraftPrimitiveLayer({
5294
6631
  draft,
@@ -5374,6 +6711,7 @@ function DraftPrimitiveLayer({
5374
6711
  showRotate={false}
5375
6712
  chromeScale={chromeScale}
5376
6713
  chromeSettling={chromeSettling}
6714
+ rotationDeg={draft.geometry.rotation ?? 0}
5377
6715
  onStartResize={(handle, event) =>
5378
6716
  onStartResize(draft.id, handle, event)
5379
6717
  }
@@ -5512,12 +6850,27 @@ function DraftPrimitiveContent({
5512
6850
  function PenPathOverlay({
5513
6851
  path,
5514
6852
  closeHover,
6853
+ chromeScale,
5515
6854
  }: {
5516
6855
  path: PenPath;
5517
6856
  closeHover: boolean;
6857
+ chromeScale: number;
5518
6858
  }) {
5519
6859
  const geometry = getPenPathGeometry(path);
5520
6860
  const pathData = serializePenPath(path);
6861
+ // PenPathOverlay lives inside the pan/zoom-scaled world container, so raw
6862
+ // px sizes here would shrink to specks at low zoom and blow up into blobs
6863
+ // at high zoom. Scale every screen-space size (anchor/handle boxes,
6864
+ // stroke widths) by chromeScale (= 1 / zoomScale) the same way
6865
+ // SelectionBox's resize/rotate handles do, so they stay a constant size
6866
+ // on screen regardless of canvas zoom.
6867
+ const anchorSize = 8 * chromeScale;
6868
+ const handleSize = 6 * chromeScale;
6869
+ const anchorBorderWidth = Math.max(1, chromeScale);
6870
+ const handleBorderWidth = Math.max(1, chromeScale);
6871
+ const outlineStrokeWidth = 5 * chromeScale;
6872
+ const strokeWidth = 2 * chromeScale;
6873
+ const handleLineStrokeWidth = Math.max(1, chromeScale);
5521
6874
  return (
5522
6875
  <div
5523
6876
  data-pen-path-overlay
@@ -5543,7 +6896,7 @@ function PenPathOverlay({
5543
6896
  y2={node.handleIn.y}
5544
6897
  stroke="var(--design-editor-accent-color)"
5545
6898
  strokeDasharray="3 3"
5546
- strokeWidth={1}
6899
+ strokeWidth={handleLineStrokeWidth}
5547
6900
  />
5548
6901
  ) : null}
5549
6902
  {node.handleOut ? (
@@ -5554,7 +6907,7 @@ function PenPathOverlay({
5554
6907
  y2={node.handleOut.y}
5555
6908
  stroke="var(--design-editor-accent-color)"
5556
6909
  strokeDasharray="3 3"
5557
- strokeWidth={1}
6910
+ strokeWidth={handleLineStrokeWidth}
5558
6911
  />
5559
6912
  ) : null}
5560
6913
  </g>
@@ -5565,7 +6918,7 @@ function PenPathOverlay({
5565
6918
  stroke="rgba(255,255,255,0.95)"
5566
6919
  strokeLinecap="round"
5567
6920
  strokeLinejoin="round"
5568
- strokeWidth={5}
6921
+ strokeWidth={outlineStrokeWidth}
5569
6922
  />
5570
6923
  <path
5571
6924
  d={pathData}
@@ -5573,7 +6926,7 @@ function PenPathOverlay({
5573
6926
  stroke="var(--design-editor-accent-color)"
5574
6927
  strokeLinecap="round"
5575
6928
  strokeLinejoin="round"
5576
- strokeWidth={2}
6929
+ strokeWidth={strokeWidth}
5577
6930
  />
5578
6931
  </svg>
5579
6932
  {path.nodes.map((node, index) => (
@@ -5581,14 +6934,17 @@ function PenPathOverlay({
5581
6934
  key={`anchor-${index}`}
5582
6935
  data-pen-anchor
5583
6936
  className={cn(
5584
- "absolute size-2 rounded-[2px] border shadow-sm",
6937
+ "absolute rounded-[2px] border shadow-sm",
5585
6938
  index === 0 && closeHover
5586
6939
  ? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
5587
6940
  : "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
5588
6941
  )}
5589
6942
  style={{
5590
- left: node.point.x - geometry.x - 4,
5591
- top: node.point.y - geometry.y - 4,
6943
+ left: node.point.x - geometry.x - anchorSize / 2,
6944
+ top: node.point.y - geometry.y - anchorSize / 2,
6945
+ width: anchorSize,
6946
+ height: anchorSize,
6947
+ borderWidth: anchorBorderWidth,
5592
6948
  }}
5593
6949
  />
5594
6950
  ))}
@@ -5599,10 +6955,13 @@ function PenPathOverlay({
5599
6955
  <span
5600
6956
  key={`handle-${index}-${handleIndex}`}
5601
6957
  data-pen-handle
5602
- className="absolute size-1.5 rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
6958
+ className="absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
5603
6959
  style={{
5604
- left: handle.x - geometry.x - 3,
5605
- top: handle.y - geometry.y - 3,
6960
+ left: handle.x - geometry.x - handleSize / 2,
6961
+ top: handle.y - geometry.y - handleSize / 2,
6962
+ width: handleSize,
6963
+ height: handleSize,
6964
+ borderWidth: handleBorderWidth,
5606
6965
  }}
5607
6966
  />
5608
6967
  )),
@@ -5615,6 +6974,154 @@ function isPoint(point: Point | undefined): point is Point {
5615
6974
  return !!point;
5616
6975
  }
5617
6976
 
6977
+ /**
6978
+ * Interactive counterpart of `PenPathOverlay` (P-VE1): renders the same
6979
+ * anchor-square / handle-circle / dashed-connector visual language, sized by
6980
+ * `chromeScale` so it stays a constant screen size at any zoom — like
6981
+ * `SelectionBox`'s resize handles, not like `PenPathOverlay`'s fixed-size
6982
+ * (non-interactive) chrome. Lives inside the pan/zoom-scaled `world`
6983
+ * container alongside `PenPathOverlay`/`SelectionBox`, positioned in canvas
6984
+ * space via `originCanvas + local point` (`vectorEditLocalToCanvasPoint`).
6985
+ *
6986
+ * Purely visual: pointer interaction (hit-testing + drag) is owned entirely
6987
+ * by the parent's `handleMouseDown`, which runs on the capture phase and
6988
+ * resolves hitTestPenHandle/hitTestPenAnchor itself against the raw click
6989
+ * point (handles take priority over anchors when both are in range) — see
6990
+ * the `vectorEdit` branch there. This mirrors how `PenPathOverlay` is a pure
6991
+ * render of `activePenPath`/`penGesturePreview` state owned by the pen-tool
6992
+ * gesture handlers rather than an independently-interactive component.
6993
+ */
6994
+ function VectorEditOverlay({
6995
+ vectorEdit,
6996
+ chromeScale,
6997
+ }: {
6998
+ vectorEdit: VectorEditOverlayState;
6999
+ chromeScale: number;
7000
+ }) {
7001
+ const { path, originCanvas } = vectorEdit;
7002
+ // Render in canvas space: every local point is offset by the path's
7003
+ // canvas origin before being laid out, so the overlay's own geometry/
7004
+ // pathData stay in the same canvas coordinate frame PenPathOverlay uses.
7005
+ const canvasPath = useMemo<PenPath>(
7006
+ () => translatePenPath(path, originCanvas.x, originCanvas.y),
7007
+ [path, originCanvas.x, originCanvas.y],
7008
+ );
7009
+ const geometry = getPenPathGeometry(canvasPath);
7010
+ const pathData = serializePenPath(canvasPath);
7011
+
7012
+ const anchorSize = 9 * chromeScale;
7013
+ const handleSize = 7 * chromeScale;
7014
+ const anchorBorderWidth = Math.max(1, 1.5 * chromeScale);
7015
+ const handleBorderWidth = Math.max(1, chromeScale);
7016
+ const outlineStrokeWidth = 5 * chromeScale;
7017
+ const strokeWidth = 2 * chromeScale;
7018
+ const handleLineStrokeWidth = Math.max(1, chromeScale);
7019
+
7020
+ return (
7021
+ <div
7022
+ data-vector-edit-overlay
7023
+ // pointer-events:auto on the overlay's own bounding box (not each
7024
+ // anchor/handle) so a click anywhere within it is captured by the
7025
+ // surface's onMouseDownCapture handler for hit-testing, while empty
7026
+ // space *outside* this box still reaches the surface as a background
7027
+ // click (which exits vector edit mode) via CSS containment rather than
7028
+ // per-element listeners.
7029
+ className="pointer-events-auto absolute z-[95]"
7030
+ style={{
7031
+ left: SURFACE_PADDING + geometry.x,
7032
+ top: SURFACE_PADDING + geometry.y,
7033
+ width: geometry.width,
7034
+ height: geometry.height,
7035
+ }}
7036
+ >
7037
+ <svg
7038
+ className="pointer-events-none absolute inset-0 size-full overflow-visible"
7039
+ viewBox={`${geometry.x} ${geometry.y} ${geometry.width} ${geometry.height}`}
7040
+ >
7041
+ {canvasPath.nodes.map((node, index) => (
7042
+ <g key={`vector-handle-lines-${index}`}>
7043
+ {node.handleIn ? (
7044
+ <line
7045
+ x1={node.point.x}
7046
+ y1={node.point.y}
7047
+ x2={node.handleIn.x}
7048
+ y2={node.handleIn.y}
7049
+ stroke="var(--design-editor-accent-color)"
7050
+ strokeDasharray="3 3"
7051
+ strokeWidth={handleLineStrokeWidth}
7052
+ />
7053
+ ) : null}
7054
+ {node.handleOut ? (
7055
+ <line
7056
+ x1={node.point.x}
7057
+ y1={node.point.y}
7058
+ x2={node.handleOut.x}
7059
+ y2={node.handleOut.y}
7060
+ stroke="var(--design-editor-accent-color)"
7061
+ strokeDasharray="3 3"
7062
+ strokeWidth={handleLineStrokeWidth}
7063
+ />
7064
+ ) : null}
7065
+ </g>
7066
+ ))}
7067
+ <path
7068
+ d={pathData}
7069
+ fill="none"
7070
+ stroke="rgba(255,255,255,0.95)"
7071
+ strokeLinecap="round"
7072
+ strokeLinejoin="round"
7073
+ strokeWidth={outlineStrokeWidth}
7074
+ />
7075
+ <path
7076
+ d={pathData}
7077
+ fill="none"
7078
+ stroke="var(--design-editor-accent-color)"
7079
+ strokeLinecap="round"
7080
+ strokeLinejoin="round"
7081
+ strokeWidth={strokeWidth}
7082
+ />
7083
+ </svg>
7084
+ {canvasPath.nodes.map((node, index) => (
7085
+ <span
7086
+ key={`vector-anchor-${index}`}
7087
+ data-vector-anchor
7088
+ className="pointer-events-none absolute rounded-[2px] border shadow-sm border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]"
7089
+ style={{
7090
+ left: node.point.x - geometry.x - anchorSize / 2,
7091
+ top: node.point.y - geometry.y - anchorSize / 2,
7092
+ width: anchorSize,
7093
+ height: anchorSize,
7094
+ borderWidth: anchorBorderWidth,
7095
+ }}
7096
+ />
7097
+ ))}
7098
+ {canvasPath.nodes.flatMap((node, index) =>
7099
+ (
7100
+ [
7101
+ ["in", node.handleIn] as const,
7102
+ ["out", node.handleOut] as const,
7103
+ ] as const
7104
+ )
7105
+ .filter((entry): entry is ["in" | "out", Point] => isPoint(entry[1]))
7106
+ .map(([which, handle]) => (
7107
+ <span
7108
+ key={`vector-handle-${index}-${which}`}
7109
+ data-vector-handle
7110
+ className="pointer-events-none absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
7111
+ style={{
7112
+ left: handle.x - geometry.x - handleSize / 2,
7113
+ top: handle.y - geometry.y - handleSize / 2,
7114
+ width: handleSize,
7115
+ height: handleSize,
7116
+ borderWidth: handleBorderWidth,
7117
+ }}
7118
+ />
7119
+ )),
7120
+ )}
7121
+ </div>
7122
+ );
7123
+ }
7124
+
5618
7125
  /** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
5619
7126
  const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
5620
7127
 
@@ -5630,12 +7137,57 @@ function nextBreakpointWidth(existing: number[]): number | undefined {
5630
7137
  return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
5631
7138
  }
5632
7139
 
7140
+ /**
7141
+ * The `data-screen-iframe-id` DOM attribute value for a screen's PRIMARY
7142
+ * iframe. Breakpoint sub-frames (see BreakpointPreviewRow) get their own
7143
+ * distinct suffixed id via `getBreakpointIframeId` so `querySelector`
7144
+ * (which always returns the first DOM match) can't silently collide two
7145
+ * different iframes onto the same id.
7146
+ */
7147
+ export function getPrimaryIframeId(screenId: string): string {
7148
+ return screenId;
7149
+ }
7150
+
7151
+ /** The `data-screen-iframe-id` value for one specific breakpoint sub-frame. */
7152
+ export function getBreakpointIframeId(
7153
+ screenId: string,
7154
+ widthPx: number,
7155
+ ): string {
7156
+ return `${screenId}::bp-${widthPx}`;
7157
+ }
7158
+
7159
+ /**
7160
+ * Resolves which iframe DOM id actually represents `screen` right now for
7161
+ * hit-test / drag / wheel bridge lookups: the currently active breakpoint
7162
+ * sub-frame if one is selected, otherwise the primary iframe. Every
7163
+ * `querySelector('[data-screen-iframe-id="…"]')` call site that resolves a
7164
+ * screen id to a live iframe must go through this so an active breakpoint
7165
+ * scope actually targets the frame the user is looking at, instead of
7166
+ * always silently resolving to the primary frame regardless of which one is
7167
+ * selected.
7168
+ */
7169
+ export function getActiveScreenIframeId(screen: {
7170
+ id: string;
7171
+ activeBreakpointWidth?: number;
7172
+ breakpointWidths?: number[];
7173
+ }): string {
7174
+ const activeWidth = screen.activeBreakpointWidth;
7175
+ if (
7176
+ activeWidth !== undefined &&
7177
+ screen.breakpointWidths?.includes(activeWidth)
7178
+ ) {
7179
+ return getBreakpointIframeId(screen.id, activeWidth);
7180
+ }
7181
+ return getPrimaryIframeId(screen.id);
7182
+ }
7183
+
5633
7184
  interface ScreenProps {
5634
7185
  screen: ScreenFile;
5635
7186
  metadata: ResolvedScreenMetadata;
5636
7187
  geometry: FrameGeometry;
5637
7188
  isActive: boolean;
5638
7189
  isSelected: boolean;
7190
+ isTopScreen: boolean;
5639
7191
  showFullView: boolean;
5640
7192
  isDirectlyHovered: boolean;
5641
7193
  hasHoveredChild: boolean;
@@ -5661,8 +7213,17 @@ interface ScreenProps {
5661
7213
  display: string,
5662
7214
  e: React.MouseEvent<HTMLElement>,
5663
7215
  ) => void;
5664
- onAddBreakpoint?: (widthPx: number) => void;
5665
- onActiveBreakpointChange?: (widthPx: number | undefined) => void;
7216
+ // Id-first (screenId, widthPx) shape, same as MultiScreenCanvas's own
7217
+ // onAddBreakpoint/onActiveBreakpointChange props (PF18): Screen binds
7218
+ // screen.id itself when calling these, so the parent can pass the same
7219
+ // stable function reference for every screen instead of allocating a new
7220
+ // per-screen `(widthPx) => onAddBreakpoint(screen.id, widthPx)` closure on
7221
+ // every render, which defeated memo(Screen) for every screen every time.
7222
+ onAddBreakpoint?: (screenId: string, widthPx: number) => void;
7223
+ onActiveBreakpointChange?: (
7224
+ screenId: string,
7225
+ widthPx: number | undefined,
7226
+ ) => void;
5666
7227
  }
5667
7228
 
5668
7229
  const Screen = memo(function Screen({
@@ -5671,6 +7232,7 @@ const Screen = memo(function Screen({
5671
7232
  geometry,
5672
7233
  isActive,
5673
7234
  isSelected,
7235
+ isTopScreen,
5674
7236
  showFullView,
5675
7237
  isDirectlyHovered,
5676
7238
  hasHoveredChild,
@@ -5760,7 +7322,9 @@ const Screen = memo(function Screen({
5760
7322
  ? `rotate(${geometry.rotation}deg)`
5761
7323
  : undefined,
5762
7324
  transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
5763
- zIndex: geometry.z,
7325
+ zIndex: isTopScreen
7326
+ ? (geometry.z ?? 0) + TOP_SCREEN_Z_BOOST
7327
+ : geometry.z,
5764
7328
  }}
5765
7329
  >
5766
7330
  <div
@@ -5784,6 +7348,11 @@ const Screen = memo(function Screen({
5784
7348
  if (e.button !== 0) return;
5785
7349
  if (penActive || creationToolActive) return;
5786
7350
  if (e.altKey) {
7351
+ // Matches the data-screen-card mousedown handler below: without
7352
+ // this, a trailing click after the alt-drag/duplicate gesture
7353
+ // ends falls through to this row's onClick and steals selection
7354
+ // away from the newly created duplicate.
7355
+ suppressNextClick.current = true;
5787
7356
  onStartDuplicateGesture(screen, display, e);
5788
7357
  return;
5789
7358
  }
@@ -5827,6 +7396,19 @@ const Screen = memo(function Screen({
5827
7396
  >
5828
7397
  {display}
5829
7398
  </span>
7399
+ {metadata.source === "fusion" ? (
7400
+ <span
7401
+ data-frame-source-badge="fusion"
7402
+ className="shrink-0 rounded-sm bg-muted-foreground/15 px-1 !text-[9px] font-medium uppercase tracking-wide text-muted-foreground"
7403
+ title={
7404
+ "Backed by a running app" /* i18n-ignore short frame badge, mirrors other frame-chrome literals in this file */
7405
+ }
7406
+ >
7407
+ {
7408
+ "App" /* i18n-ignore short frame badge, mirrors other frame-chrome literals in this file */
7409
+ }
7410
+ </span>
7411
+ ) : null}
5830
7412
  </div>
5831
7413
  <button
5832
7414
  type="button"
@@ -6008,6 +7590,7 @@ const Screen = memo(function Screen({
6008
7590
  showRotate
6009
7591
  chromeScale={chromeScale}
6010
7592
  chromeSettling={chromeSettling}
7593
+ rotationDeg={geometry.rotation ?? 0}
6011
7594
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
6012
7595
  onStartRotate={(e) => onStartRotate(screen.id, e)}
6013
7596
  />
@@ -6028,8 +7611,16 @@ const Screen = memo(function Screen({
6028
7611
  penActive={penActive}
6029
7612
  creationToolActive={creationToolActive}
6030
7613
  chromeScale={chromeScale}
6031
- onActiveBreakpointChange={onActiveBreakpointChange}
6032
- onAddBreakpoint={onAddBreakpoint}
7614
+ onActiveBreakpointChange={
7615
+ onActiveBreakpointChange
7616
+ ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
7617
+ : undefined
7618
+ }
7619
+ onAddBreakpoint={
7620
+ onAddBreakpoint
7621
+ ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
7622
+ : undefined
7623
+ }
6033
7624
  />
6034
7625
  ) : null}
6035
7626
  </div>
@@ -6044,6 +7635,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
6044
7635
  sameFrameGeometry(prev.geometry, next.geometry) &&
6045
7636
  prev.isActive === next.isActive &&
6046
7637
  prev.isSelected === next.isSelected &&
7638
+ prev.isTopScreen === next.isTopScreen &&
6047
7639
  prev.showFullView === next.showFullView &&
6048
7640
  prev.isDirectlyHovered === next.isDirectlyHovered &&
6049
7641
  prev.hasHoveredChild === next.hasHoveredChild &&
@@ -6059,7 +7651,13 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
6059
7651
  prev.onStartFrameDrag === next.onStartFrameDrag &&
6060
7652
  prev.onStartResize === next.onStartResize &&
6061
7653
  prev.onStartRotate === next.onStartRotate &&
6062
- prev.onStartDuplicateGesture === next.onStartDuplicateGesture
7654
+ prev.onStartDuplicateGesture === next.onStartDuplicateGesture &&
7655
+ // Now id-first (screenId, widthPx) callbacks passed straight through
7656
+ // from MultiScreenCanvas's own props (PF18) instead of a fresh
7657
+ // per-screen arrow allocated in the render loop, so these are expected
7658
+ // to be referentially stable across renders and are safe to compare.
7659
+ prev.onAddBreakpoint === next.onAddBreakpoint &&
7660
+ prev.onActiveBreakpointChange === next.onActiveBreakpointChange
6063
7661
  );
6064
7662
  }
6065
7663
 
@@ -6087,7 +7685,9 @@ function BreakpointPreviewRow({
6087
7685
  * The primary screen's srcdoc with the lightweight hit-test responder already
6088
7686
  * injected (memoised in the parent Screen component). Passed down so
6089
7687
  * breakpoint sub-iframes carry the same responder and can be found via
6090
- * [data-screen-iframe-id] by the cross-screen drop-into-container handler.
7688
+ * their own distinct [data-screen-iframe-id] (see getBreakpointIframeId)
7689
+ * by the cross-screen drop-into-container handler when that breakpoint is
7690
+ * the active edit scope (see getActiveScreenIframeId).
6091
7691
  */
6092
7692
  srcdocWithHitTest: string;
6093
7693
  activeBreakpointWidth: number | undefined;
@@ -6178,7 +7778,19 @@ function BreakpointPreviewRow({
6178
7778
  style={{ width: frameWidth, height: frameHeight }}
6179
7779
  >
6180
7780
  <iframe
6181
- data-screen-iframe-id={screen.id}
7781
+ // Distinct id per breakpoint sub-frame — the primary iframe
7782
+ // above uses the bare screen id, so without a suffix here
7783
+ // every breakpoint sub-frame's iframe would share the exact
7784
+ // same [data-screen-iframe-id] as the primary, and
7785
+ // querySelector (which returns only the first DOM match)
7786
+ // would always resolve hit-test/drag/wheel bridge lookups to
7787
+ // the primary frame regardless of which breakpoint is
7788
+ // active. getActiveScreenIframeId resolves the correct one
7789
+ // to query at lookup time.
7790
+ data-screen-iframe-id={getBreakpointIframeId(
7791
+ screen.id,
7792
+ widthPx,
7793
+ )}
6182
7794
  src={previewUrl}
6183
7795
  srcDoc={previewUrl ? undefined : srcdocWithHitTest}
6184
7796
  sandbox="allow-scripts"
@@ -6245,11 +7857,16 @@ function GroupSelectionBox({
6245
7857
  chromeScale,
6246
7858
  chromeSettling,
6247
7859
  onStartResize,
7860
+ onStartRotate,
6248
7861
  }: {
6249
7862
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
6250
7863
  chromeScale: number;
6251
7864
  chromeSettling: boolean;
6252
7865
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
7866
+ /** Multi-selection rotate (CV14). Omit to keep the previous behavior (no
7867
+ * rotate handle shown) — used by callers whose selection kind doesn't
7868
+ * support group rotate yet (e.g. draft primitives). */
7869
+ onStartRotate?: (e: React.MouseEvent) => void;
6253
7870
  }) {
6254
7871
  return (
6255
7872
  <SelectionBox
@@ -6261,10 +7878,10 @@ function GroupSelectionBox({
6261
7878
  }}
6262
7879
  chromeScale={chromeScale}
6263
7880
  chromeSettling={chromeSettling}
6264
- showRotate={false}
7881
+ showRotate={!!onStartRotate}
6265
7882
  filled
6266
7883
  onStartResize={onStartResize}
6267
- onStartRotate={() => {}}
7884
+ onStartRotate={onStartRotate ?? (() => {})}
6268
7885
  />
6269
7886
  );
6270
7887
  }
@@ -6360,6 +7977,7 @@ function SelectionBox({
6360
7977
  showRotate={showRotate}
6361
7978
  chromeScale={chromeScale}
6362
7979
  chromeSettling={chromeSettling}
7980
+ rotationDeg={geometry.rotation ?? 0}
6363
7981
  onStartResize={onStartResize}
6364
7982
  onStartRotate={onStartRotate}
6365
7983
  />
@@ -6374,6 +7992,7 @@ function ResizeHandles({
6374
7992
  showRotate = true,
6375
7993
  chromeScale = 1,
6376
7994
  chromeSettling = false,
7995
+ rotationDeg = 0,
6377
7996
  onStartResize,
6378
7997
  onStartRotate,
6379
7998
  }: {
@@ -6383,6 +8002,10 @@ function ResizeHandles({
6383
8002
  showRotate?: boolean;
6384
8003
  chromeScale?: number;
6385
8004
  chromeSettling?: boolean;
8005
+ /** The frame's own rotation, so hover cursors match the handle's rotated
8006
+ * visual direction instead of a static unrotated cursor (CSS `cursor` is
8007
+ * never itself rotated by a transform on the element). */
8008
+ rotationDeg?: number;
6386
8009
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
6387
8010
  onStartRotate: (e: React.MouseEvent) => void;
6388
8011
  }) {
@@ -6410,7 +8033,7 @@ function ResizeHandles({
6410
8033
  className={edgeHandleClass}
6411
8034
  style={edgeHandleStyle(
6412
8035
  config.handle,
6413
- config.cursor,
8036
+ getResizeCursorForHandle(config.handle, rotationDeg),
6414
8037
  chromeScale,
6415
8038
  chromeSettling,
6416
8039
  )}
@@ -6424,7 +8047,7 @@ function ResizeHandles({
6424
8047
  className={visibleHandleClass}
6425
8048
  style={cornerHandleStyle(
6426
8049
  config.handle,
6427
- config.cursor,
8050
+ getResizeCursorForHandle(config.handle, rotationDeg),
6428
8051
  chromeScale,
6429
8052
  chromeSettling,
6430
8053
  )}
@@ -6660,6 +8283,29 @@ function dedupeIds(ids: string[]) {
6660
8283
  return [...new Set(ids)];
6661
8284
  }
6662
8285
 
8286
+ /** Matches the render-time `chromeScale = scale > 0 ? 1 / scale : 1` used to
8287
+ * keep on-screen chrome (labels, handles) a constant pixel size regardless
8288
+ * of canvas zoom. Callbacks that hit-test against the rendered label band
8289
+ * (e.g. marquee selection) must use the same conversion from the live zoom
8290
+ * ref instead of a fixed constant. */
8291
+ function chromeScaleFromZoom(zoom: number) {
8292
+ const scale = zoom / 100;
8293
+ return scale > 0 ? 1 / scale : 1;
8294
+ }
8295
+
8296
+ /** Shift-marquee selection combine, matching Figma: items currently swept by
8297
+ * the marquee toggle relative to the selection the gesture started with —
8298
+ * already-selected items under the marquee are deselected, not re-added.
8299
+ * Items outside the base selection AND not currently under the marquee are
8300
+ * left untouched. A plain union (the previous behavior) can only ever grow
8301
+ * the selection, so it never lets a shift-marquee deselect anything. */
8302
+ function xorMarqueeSelection(baseIds: string[], hitIds: string[]) {
8303
+ const hitSet = new Set(hitIds);
8304
+ const kept = baseIds.filter((id) => !hitSet.has(id));
8305
+ const added = hitIds.filter((id) => !baseIds.includes(id));
8306
+ return dedupeIds([...kept, ...added]);
8307
+ }
8308
+
6663
8309
  function sameIds(a: string[], b: string[]) {
6664
8310
  return a.length === b.length && a.every((id, index) => id === b[index]);
6665
8311
  }
@@ -6759,17 +8405,6 @@ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
6759
8405
  };
6760
8406
  }
6761
8407
 
6762
- function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
6763
- const right = rect.x + rect.width;
6764
- const bottom = rect.y + rect.height;
6765
- return (
6766
- rect.x <= bounds.right &&
6767
- right >= bounds.left &&
6768
- rect.y <= bounds.bottom &&
6769
- bottom >= bounds.top
6770
- );
6771
- }
6772
-
6773
8408
  function rectContainsPoint(bounds: BoundsRect, point: Point) {
6774
8409
  return (
6775
8410
  point.x >= bounds.left &&
@@ -6800,63 +8435,6 @@ function rotatePointAroundCenter(
6800
8435
  };
6801
8436
  }
6802
8437
 
6803
- /** Test whether a marquee rect (in canvas space) intersects an OBB described by
6804
- * `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
6805
- * We map the four marquee corners into the frame's local space and check
6806
- * whether any corner is inside the bounds; we also check the reverse (any
6807
- * frame corner inside the marquee) to handle the case where the marquee is
6808
- * entirely contained within the rotated frame. */
6809
- function rotatedRectIntersects(
6810
- rect: MarqueeRect,
6811
- bounds: BoundsRect,
6812
- center: Point,
6813
- degrees: number,
6814
- ): boolean {
6815
- if (!degrees) {
6816
- return rectIntersects(rect, bounds);
6817
- }
6818
- // Four corners of the marquee rect in canvas space.
6819
- const marqueeCorners: Point[] = [
6820
- { x: rect.x, y: rect.y },
6821
- { x: rect.x + rect.width, y: rect.y },
6822
- { x: rect.x + rect.width, y: rect.y + rect.height },
6823
- { x: rect.x, y: rect.y + rect.height },
6824
- ];
6825
- // Test: any marquee corner inside the unrotated frame bounds?
6826
- for (const corner of marqueeCorners) {
6827
- const local = rotatePointAroundCenter(corner, center, degrees);
6828
- if (rectContainsPoint(bounds, local)) return true;
6829
- }
6830
- // Four corners of the unrotated frame in canvas space (rotate them outward).
6831
- const frameCorners: Point[] = [
6832
- { x: bounds.left, y: bounds.top },
6833
- { x: bounds.right, y: bounds.top },
6834
- { x: bounds.right, y: bounds.bottom },
6835
- { x: bounds.left, y: bounds.bottom },
6836
- ];
6837
- const rad = (degrees * Math.PI) / 180;
6838
- const cos = Math.cos(rad);
6839
- const sin = Math.sin(rad);
6840
- const marqueeRight = rect.x + rect.width;
6841
- const marqueeBottom = rect.y + rect.height;
6842
- // Test: any rotated frame corner inside the marquee rect?
6843
- for (const fc of frameCorners) {
6844
- const dx = fc.x - center.x;
6845
- const dy = fc.y - center.y;
6846
- const wx = center.x + dx * cos - dy * sin;
6847
- const wy = center.y + dx * sin + dy * cos;
6848
- if (
6849
- wx >= rect.x &&
6850
- wx <= marqueeRight &&
6851
- wy >= rect.y &&
6852
- wy <= marqueeBottom
6853
- ) {
6854
- return true;
6855
- }
6856
- }
6857
- return false;
6858
- }
6859
-
6860
8438
  function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
6861
8439
  return (
6862
8440
  a.x === b.x &&
@@ -6912,25 +8490,36 @@ export function getDraftPreviewGeometryForTool(
6912
8490
  start: Point,
6913
8491
  end: Point,
6914
8492
  hasMoved: boolean,
8493
+ modifiers?: DraftGeometryModifiers,
6915
8494
  ): FrameGeometry {
6916
- if (tool === "pen" || tool === "line" || tool === "arrow") {
6917
- return getDraftGeometryForTool(tool, start, end);
8495
+ // Note: "pen" is a valid DraftCreationTool value for board-surface/cursor
8496
+ // purposes (see getDraftCreationTool), but the pen tool never actually
8497
+ // drives this creation-drag geometry path — beginPenNodeCreation handles
8498
+ // pen gestures entirely separately via activePenPath/PenNodeDragState.
8499
+ if (tool === "line" || tool === "arrow") {
8500
+ return getDraftGeometryForTool(tool, start, end, modifiers);
6918
8501
  }
6919
8502
 
6920
8503
  if (!hasMoved) {
6921
8504
  return { x: start.x, y: start.y, width: 0, height: 0 };
6922
8505
  }
6923
8506
 
6924
- return getDraftGeometryForTool(tool, start, end);
8507
+ return getDraftGeometryForTool(tool, start, end, modifiers);
6925
8508
  }
6926
8509
 
6927
8510
  function getDraftGeometryForTool(
6928
8511
  tool: DraftCreationTool,
6929
8512
  start: Point,
6930
8513
  end: Point,
8514
+ modifiers?: DraftGeometryModifiers,
6931
8515
  ): FrameGeometry {
6932
- if (tool === "pen" || tool === "line" || tool === "arrow") {
6933
- return getPathGeometry([start, end]);
8516
+ if (tool === "line" || tool === "arrow") {
8517
+ // Shift constrains the line/arrow to 45deg increments, reusing the same
8518
+ // helper the pen tool already uses for constrained segments.
8519
+ const effectiveEnd = modifiers?.shiftKey
8520
+ ? constrainPointTo45Degrees(start, end)
8521
+ : end;
8522
+ return getPathGeometry([start, effectiveEnd]);
6934
8523
  }
6935
8524
  const options =
6936
8525
  tool === "frame"
@@ -6953,7 +8542,11 @@ function getDraftGeometryForTool(
6953
8542
  defaultWidth: DRAFT_RECT_WIDTH,
6954
8543
  defaultHeight: DRAFT_RECT_HEIGHT,
6955
8544
  };
6956
- return getDraftGeometryFromPoints(start, end, options);
8545
+ return getDraftGeometryFromPoints(start, end, {
8546
+ ...options,
8547
+ square: modifiers?.shiftKey,
8548
+ fromCenter: modifiers?.altKey,
8549
+ });
6957
8550
  }
6958
8551
 
6959
8552
  function getPathGeometry(points: readonly Point[]): FrameGeometry {
@@ -6983,35 +8576,19 @@ function createDraftPrimitive({
6983
8576
  tool,
6984
8577
  start,
6985
8578
  end,
6986
- points,
6987
8579
  moved,
6988
8580
  toolProps,
8581
+ modifiers,
6989
8582
  }: DraftPrimitiveInput): DraftPrimitive {
6990
8583
  const id = createDraftId(tool);
6991
8584
  const geometry = moved
6992
- ? getDraftGeometryForTool(tool, start, end)
6993
- : getDraftGeometryForTool(tool, start, start);
6994
- if (tool === "pen") {
6995
- const pathPoints =
6996
- points && points.length > 1
6997
- ? points
6998
- : [
6999
- start,
7000
- { x: start.x + 64, y: start.y + 24 },
7001
- { x: start.x + 128, y: start.y },
7002
- ];
7003
- return createPenDraftPrimitive(
7004
- {
7005
- nodes: pathPoints.map(createCornerNode),
7006
- closed: false,
7007
- },
7008
- {
7009
- id,
7010
- stroke: toolProps?.stroke,
7011
- strokeWidth: toolProps?.strokeWidth,
7012
- },
7013
- );
7014
- }
8585
+ ? getDraftGeometryForTool(tool, start, end, modifiers)
8586
+ : getDraftGeometryForTool(tool, start, start, modifiers);
8587
+ // Note: pen never reaches createDraftPrimitive — beginPenNodeCreation
8588
+ // commits pen paths directly via createPenDraftPrimitive (see
8589
+ // finishPenPath). The old fallback here (a hardcoded 3-point zigzag
8590
+ // placeholder path used when no freehand samples existed) was unreachable
8591
+ // dead code and has been removed.
7015
8592
  if (tool === "text") {
7016
8593
  return {
7017
8594
  id,
@@ -7024,8 +8601,11 @@ function createDraftPrimitive({
7024
8601
  };
7025
8602
  }
7026
8603
  if (tool === "line" || tool === "arrow") {
8604
+ const effectiveEnd = modifiers?.shiftKey
8605
+ ? constrainPointTo45Degrees(start, end)
8606
+ : end;
7027
8607
  const pathPoints = moved
7028
- ? [start, end]
8608
+ ? [start, effectiveEnd]
7029
8609
  : [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
7030
8610
  return {
7031
8611
  id,
@@ -7051,6 +8631,31 @@ function createDraftPrimitive({
7051
8631
  };
7052
8632
  }
7053
8633
 
8634
+ /**
8635
+ * Builds the closed preview/commit path for a click-or-drag close gesture
8636
+ * (P6). Closing on the first anchor is deferred to mouseup so a drag can
8637
+ * shape the closing segment's curve: dragging sets the first anchor's
8638
+ * handleIn (mirrored across that anchor, like every other smooth-node
8639
+ * handle) so the curve eases into the start point instead of always
8640
+ * closing with a hard straight/corner segment.
8641
+ */
8642
+ function shapeClosingHandles(
8643
+ pathBefore: PenPath,
8644
+ dragPoint: Point | null,
8645
+ ): PenPath {
8646
+ const closed = closePenPath(pathBefore);
8647
+ if (!dragPoint || closed.nodes.length === 0) return closed;
8648
+
8649
+ const first = closed.nodes[0];
8650
+ const handleIn = {
8651
+ x: first.point.x - (dragPoint.x - first.point.x),
8652
+ y: first.point.y - (dragPoint.y - first.point.y),
8653
+ };
8654
+ const nodes = closed.nodes.slice();
8655
+ nodes[0] = { ...first, handleIn };
8656
+ return { nodes, closed: true };
8657
+ }
8658
+
7054
8659
  function createPenDraftPrimitive(
7055
8660
  path: PenPath,
7056
8661
  {
@@ -7301,10 +8906,19 @@ function angleBetween(center: Point, point: Point) {
7301
8906
  return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
7302
8907
  }
7303
8908
 
7304
- function getSelectableBounds(geometry: FrameGeometry): BoundsRect {
8909
+ function getSelectableBounds(
8910
+ geometry: FrameGeometry,
8911
+ chromeScale = 1,
8912
+ ): BoundsRect {
7305
8913
  return {
7306
8914
  left: geometry.x,
7307
- top: geometry.y - FRAME_LABEL_HEIGHT,
8915
+ // The rendered frame-label band is FRAME_LABEL_HEIGHT * chromeScale (see
8916
+ // frameLabelHeight in the Screen renderer) — chromeScale grows as you
8917
+ // zoom out (chromeScale = 1 / scale) so the label stays a constant
8918
+ // on-screen size. Marquee/layer hit-testing must match that rendered
8919
+ // band, not a fixed canvas-space height, or the clickable label area
8920
+ // drifts out of sync with what's drawn at any zoom other than 100%.
8921
+ top: geometry.y - FRAME_LABEL_HEIGHT * chromeScale,
7308
8922
  right: geometry.x + geometry.width,
7309
8923
  bottom: geometry.y + geometry.height,
7310
8924
  };
@@ -7510,12 +9124,44 @@ function hashString(s: string): string {
7510
9124
  export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
7511
9125
  const PRIMITIVE_PARSE_CACHE_MAX = 64;
7512
9126
 
9127
+ /** First-level identity cache: most callers (e.g. drag/marquee mousemove
9128
+ * handlers) call parsePrimitivesFromScreen repeatedly per frame against the
9129
+ * *same* ScreenFile object — `screen.content` hasn't changed at all between
9130
+ * calls. Hashing the full content string on every call to build the
9131
+ * second-level cache key is wasted work in that case (PF17). Keep one most-
9132
+ * recent `{content, result}` pair per screen id and skip straight past the
9133
+ * hash when the content reference is unchanged. Only falls through to the
9134
+ * hash-keyed Map (and a real re-parse) when content has actually changed. */
9135
+ const primitiveParseIdentityCache = new Map<
9136
+ string,
9137
+ { content: string; result: ParsedScreenPrimitive[] }
9138
+ >();
9139
+
9140
+ /** Test-only escape hatch: clears both cache levels. Production code never
9141
+ * needs to call this — the identity cache self-invalidates whenever
9142
+ * `screen.content` changes, and the hash-keyed cache is bounded/LRU'd. */
9143
+ export function __clearPrimitiveParseCachesForTests() {
9144
+ primitiveParseCache.clear();
9145
+ primitiveParseIdentityCache.clear();
9146
+ }
9147
+
7513
9148
  export function parsePrimitivesFromScreen(
7514
9149
  screen: ScreenFile,
7515
9150
  ): ParsedScreenPrimitive[] {
9151
+ const identityEntry = primitiveParseIdentityCache.get(screen.id);
9152
+ if (identityEntry && identityEntry.content === screen.content) {
9153
+ return identityEntry.result;
9154
+ }
9155
+
7516
9156
  const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
7517
9157
  const cached = primitiveParseCache.get(cacheKey);
7518
- if (cached) return cached;
9158
+ if (cached) {
9159
+ primitiveParseIdentityCache.set(screen.id, {
9160
+ content: screen.content,
9161
+ result: cached,
9162
+ });
9163
+ return cached;
9164
+ }
7519
9165
 
7520
9166
  const result: ParsedScreenPrimitive[] = [];
7521
9167
  if (typeof DOMParser === "undefined" || !screen.content) {
@@ -7589,6 +9235,10 @@ export function parsePrimitivesFromScreen(
7589
9235
  if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
7590
9236
  }
7591
9237
  primitiveParseCache.set(cacheKey, result);
9238
+ primitiveParseIdentityCache.set(screen.id, {
9239
+ content: screen.content,
9240
+ result,
9241
+ });
7592
9242
  return result;
7593
9243
  }
7594
9244