@agent-native/core 0.92.9 → 0.92.11

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 (345) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +29 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +181 -15
  5. package/corpus/core/src/agent/run-manager.ts +31 -4
  6. package/corpus/core/src/agent/run-store.ts +80 -0
  7. package/corpus/core/src/cli/design-connect.ts +433 -151
  8. package/corpus/core/src/cli/skills.ts +14 -0
  9. package/corpus/core/src/client/AssistantChat.tsx +25 -0
  10. package/corpus/core/src/client/MultiTabAssistantChat.tsx +40 -6
  11. package/corpus/core/src/client/agent-chat.ts +165 -1
  12. package/corpus/core/src/client/chat/index.ts +9 -1
  13. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +38 -2
  14. package/corpus/core/src/client/index.ts +5 -0
  15. package/corpus/core/src/client/rich-markdown-editor/useCollabReconcile.ts +18 -7
  16. package/corpus/core/src/collab/awareness-store.ts +23 -12
  17. package/corpus/core/src/collab/client.ts +16 -5
  18. package/corpus/core/src/collab/index.ts +4 -0
  19. package/corpus/core/src/collab/recent-edits.ts +65 -2
  20. package/corpus/core/src/deploy/build.ts +133 -5
  21. package/corpus/core/src/index.browser.ts +1 -0
  22. package/corpus/core/src/server/agent-chat-plugin.ts +98 -11
  23. package/corpus/core/src/server/builder-design-systems.ts +30 -4
  24. package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +1 -1
  25. package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +12 -6
  26. package/corpus/templates/clips/AGENTS.md +4 -0
  27. package/corpus/templates/clips/actions/create-recording.ts +2 -0
  28. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +5 -3
  29. package/corpus/templates/clips/actions/request-transcript.ts +102 -33
  30. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -0
  31. package/corpus/templates/clips/app/components/player/share-dialog.tsx +59 -19
  32. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +48 -4
  33. package/corpus/templates/clips/app/components/player/video-player.tsx +17 -12
  34. package/corpus/templates/clips/app/i18n/ar-SA.ts +1 -0
  35. package/corpus/templates/clips/app/i18n/de-DE.ts +1 -0
  36. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  37. package/corpus/templates/clips/app/i18n/es-ES.ts +1 -0
  38. package/corpus/templates/clips/app/i18n/fr-FR.ts +1 -0
  39. package/corpus/templates/clips/app/i18n/hi-IN.ts +1 -0
  40. package/corpus/templates/clips/app/i18n/ja-JP.ts +1 -0
  41. package/corpus/templates/clips/app/i18n/ko-KR.ts +1 -0
  42. package/corpus/templates/clips/app/i18n/pt-BR.ts +1 -0
  43. package/corpus/templates/clips/app/i18n/zh-CN.ts +1 -0
  44. package/corpus/templates/clips/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +70 -37
  46. package/corpus/templates/clips/app/routes/record.tsx +17 -4
  47. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  48. package/corpus/templates/clips/changelog/2026-07-09-desktop-popover-sits-closer-to-the-menu-bar.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-09-desktop-recording-optimization-progress-stays-visible-until-the-clip-opens.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-09-fixed-large-video-file-uploads-failing-before-their-resumabl.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-09-fixed-new-video-uploads-defaulting-to-private.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-09-long-recordings-now-get-enough-time-to-download-their-media-.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-09-meeting-notes-no-longer-lowers-your-microphone-volume-for-ot.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-09-on-mobile-tapping-a-playing-clip-reveals-playback-controls-w.md +6 -0
  55. package/corpus/templates/clips/changelog/2026-07-09-regenerate-any-clip-transcript-from-its-saved-recording-or-r.md +6 -0
  56. package/corpus/templates/clips/changelog/2026-07-09-slack-previews-now-show-the-clip-length-alongside-its-descri.md +6 -0
  57. package/corpus/templates/clips/desktop/src/app.tsx +12 -2
  58. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +8 -1
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +8 -5
  60. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +5 -5
  61. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +3 -0
  62. package/corpus/templates/clips/desktop/src/styles.css +6 -3
  63. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +29 -11
  64. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1 -1
  65. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +103 -21
  66. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +117 -39
  67. package/corpus/templates/clips/server/lib/slack-unfurls.ts +20 -1
  68. package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +10 -4
  69. package/corpus/templates/clips/shared/recording-core.ts +12 -0
  70. package/corpus/templates/content/AGENTS.md +7 -6
  71. package/corpus/templates/content/actions/_document-flush.ts +132 -17
  72. package/corpus/templates/content/actions/_notion-action-utils.ts +15 -0
  73. package/corpus/templates/content/actions/create-and-link-notion-page.ts +2 -0
  74. package/corpus/templates/content/actions/link-notion-page.ts +2 -0
  75. package/corpus/templates/content/actions/pull-document.ts +5 -7
  76. package/corpus/templates/content/actions/pull-notion-page.ts +2 -0
  77. package/corpus/templates/content/actions/push-notion-page.ts +10 -0
  78. package/corpus/templates/content/actions/resolve-notion-sync-conflict.ts +2 -0
  79. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +88 -23
  80. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +32 -2
  81. package/corpus/templates/content/app/hooks/use-notion.ts +6 -6
  82. package/corpus/templates/content/app/i18n/zh-TW.ts +1 -0
  83. package/corpus/templates/content/app/i18n-data.ts +18 -0
  84. package/corpus/templates/content/changelog/2026-07-09-notion-conflict-choices-and-version-restores-preserve-docume.md +6 -0
  85. package/corpus/templates/content/server/lib/notion-sync.ts +80 -64
  86. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +29 -2
  87. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +86 -0
  88. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +57 -0
  89. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +11 -0
  90. package/corpus/templates/design/AGENTS.md +45 -10
  91. package/corpus/templates/design/actions/add-localhost-screens.ts +66 -13
  92. package/corpus/templates/design/actions/apply-component-prop-edit.ts +45 -36
  93. package/corpus/templates/design/actions/apply-motion-edit.ts +25 -31
  94. package/corpus/templates/design/actions/apply-shader-fill.ts +29 -42
  95. package/corpus/templates/design/actions/detach-component-instance.ts +337 -0
  96. package/corpus/templates/design/actions/export-design-as-figma-svg.ts +219 -0
  97. package/corpus/templates/design/actions/generate-design.ts +79 -12
  98. package/corpus/templates/design/actions/generate-screens.ts +57 -12
  99. package/corpus/templates/design/actions/get-figma-styles.ts +115 -0
  100. package/corpus/templates/design/actions/go-to-main-component.ts +254 -0
  101. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +4 -3
  102. package/corpus/templates/design/actions/import-design-source.ts +4 -31
  103. package/corpus/templates/design/actions/import-figma-clipboard.ts +134 -0
  104. package/corpus/templates/design/actions/import-figma-frame.ts +95 -0
  105. package/corpus/templates/design/actions/index-design-system-with-builder.ts +16 -1
  106. package/corpus/templates/design/actions/list-design-components.ts +104 -0
  107. package/corpus/templates/design/actions/list-figma-library-assets.ts +1 -24
  108. package/corpus/templates/design/actions/open-component-source.ts +2 -4
  109. package/corpus/templates/design/actions/present-design-variants.ts +412 -16
  110. package/corpus/templates/design/actions/preview-source-edit.ts +14 -1
  111. package/corpus/templates/design/actions/run-design-audit.ts +25 -3
  112. package/corpus/templates/design/actions/show-design-questions.ts +10 -0
  113. package/corpus/templates/design/actions/swap-component-instance.ts +544 -0
  114. package/corpus/templates/design/actions/take-design-screenshot.ts +45 -60
  115. package/corpus/templates/design/actions/write-local-file.ts +25 -1
  116. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +152 -0
  117. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1077 -58
  118. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +5 -5
  119. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +73 -21
  120. package/corpus/templates/design/app/components/design/EditPanel.tsx +16 -11
  121. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +364 -0
  122. package/corpus/templates/design/app/components/design/LayersPanel.tsx +180 -53
  123. package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +0 -2
  124. package/corpus/templates/design/app/components/design/MotionDock.tsx +144 -21
  125. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1152 -167
  126. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +27 -5
  127. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +23 -2
  128. package/corpus/templates/design/app/components/design/TokensPanel.tsx +63 -49
  129. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1293 -54
  130. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +7 -0
  131. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +6 -1
  132. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +23 -6
  133. package/corpus/templates/design/app/components/design/bridge/sample.bridge.ts +7 -0
  134. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +5 -0
  135. package/corpus/templates/design/app/components/design/code-workbench/editor/MonacoHost.tsx +14 -2
  136. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +37 -2
  137. package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open-guard.ts +25 -0
  138. package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open.ts +19 -1
  139. package/corpus/templates/design/app/components/design/code-workbench/model-registry.ts +43 -5
  140. package/corpus/templates/design/app/components/design/code-workbench/search/SearchView.tsx +27 -2
  141. package/corpus/templates/design/app/components/design/code-workbench/search/search-engine.ts +10 -1
  142. package/corpus/templates/design/app/components/design/code-workbench/store.tsx +19 -2
  143. package/corpus/templates/design/app/components/design/design-canvas/annotation-snapshot.ts +265 -0
  144. package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +16 -5
  145. package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +16 -2
  146. package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +7 -2
  147. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +3 -1
  148. package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +78 -8
  149. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +451 -73
  150. package/corpus/templates/design/app/components/design/edit-panel/document-colors.ts +12 -4
  151. package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +398 -272
  152. package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +41 -7
  153. package/corpus/templates/design/app/components/design/edit-panel/element-identity.ts +16 -3
  154. package/corpus/templates/design/app/components/design/edit-panel/field-primitives.tsx +24 -4
  155. package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +222 -4
  156. package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +171 -78
  157. package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +45 -5
  158. package/corpus/templates/design/app/components/design/edit-panel/layout-properties.tsx +299 -28
  159. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +120 -150
  160. package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +53 -1
  161. package/corpus/templates/design/app/components/design/edit-panel/position-layout-properties.tsx +284 -117
  162. package/corpus/templates/design/app/components/design/edit-panel/selection-helpers.ts +81 -0
  163. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +19 -14
  164. package/corpus/templates/design/app/components/design/edit-panel/style-options.ts +7 -0
  165. package/corpus/templates/design/app/components/design/edit-panel/typography-helpers.ts +155 -0
  166. package/corpus/templates/design/app/components/design/edit-panel/typography-properties.tsx +268 -46
  167. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +350 -31
  168. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +13 -4
  169. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +233 -23
  170. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +72 -2
  171. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +51 -11
  172. package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +84 -11
  173. package/corpus/templates/design/app/components/design/inspector/index.ts +3 -0
  174. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +552 -0
  175. package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +157 -0
  176. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +53 -0
  177. package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +184 -0
  178. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +403 -26
  179. package/corpus/templates/design/app/components/design/multi-screen/types.ts +20 -1
  180. package/corpus/templates/design/app/components/design/types.ts +33 -0
  181. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +59 -7
  182. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +119 -17
  183. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +275 -63
  184. package/corpus/templates/design/app/hooks/use-question-flow.ts +14 -3
  185. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +67 -10
  186. package/corpus/templates/design/app/i18n/zh-TW.ts +91 -1
  187. package/corpus/templates/design/app/i18n-data.ts +1217 -10
  188. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +577 -0
  189. package/corpus/templates/design/app/lib/agent-chat.ts +23 -0
  190. package/corpus/templates/design/app/lib/design-import.ts +24 -0
  191. package/corpus/templates/design/app/lib/figma-clipboard.ts +157 -0
  192. package/corpus/templates/design/app/lib/figma-svg-copy.ts +221 -0
  193. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +143 -30
  194. package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +15 -0
  195. package/corpus/templates/design/app/pages/design-editor/editor-session.ts +33 -0
  196. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +38 -0
  197. package/corpus/templates/design/app/pages/design-editor/overview-annotation-context.ts +157 -0
  198. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +62 -6
  199. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +245 -0
  200. package/corpus/templates/design/app/pages/design-editor/react-semantic-handoff.ts +586 -0
  201. package/corpus/templates/design/app/pages/design-editor/screen-auto-layout.ts +169 -0
  202. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +201 -1
  203. package/corpus/templates/design/changelog/2026-07-09-added-underline-strikethrough-and-text-case-uppercase-lowerc.md +6 -0
  204. package/corpus/templates/design/changelog/2026-07-09-annotate-now-draws-across-the-all-screens-canvas-without-swi.md +6 -0
  205. package/corpus/templates/design/changelog/2026-07-09-auto-layout-children-can-now-be-dragged-into-freeform-screen.md +6 -0
  206. package/corpus/templates/design/changelog/2026-07-09-board-shapes-now-stay-visible-and-nest-correctly-across-the-.md +6 -0
  207. package/corpus/templates/design/changelog/2026-07-09-component-source-navigation-now-opens-reliably-from-the-insp.md +6 -0
  208. package/corpus/templates/design/changelog/2026-07-09-cross-screen-local-react-layer-moves-now-hand-off-exact-sour.md +6 -0
  209. package/corpus/templates/design/changelog/2026-07-09-cross-screen-moves-undo-and-redo-now-keep-both-screens-stabl.md +6 -0
  210. package/corpus/templates/design/changelog/2026-07-09-detach-a-component-instance-into-plain-editable-eleme.md +6 -0
  211. package/corpus/templates/design/changelog/2026-07-09-dragging-layers-into-auto-layout-now-respects-ignore-auto-la.md +6 -0
  212. package/corpus/templates/design/changelog/2026-07-09-drawing-annotations-sent-to-the-agent-now-include-a-rendered.md +6 -0
  213. package/corpus/templates/design/changelog/2026-07-09-editor-sidebars-now-use-figma-matched-default-widths-for-a-r.md +6 -0
  214. package/corpus/templates/design/changelog/2026-07-09-empty-frames-and-rectangles-now-expose-full-auto-layout-cont.md +6 -0
  215. package/corpus/templates/design/changelog/2026-07-09-export-any-design-screen-as-a-real-vector-svg-for-figma-with.md +6 -0
  216. package/corpus/templates/design/changelog/2026-07-09-grid-auto-layout-now-uses-true-responsive-rows-and-columns-w.md +6 -0
  217. package/corpus/templates/design/changelog/2026-07-09-hidden-and-locked-screens-now-behave-consistently-between-la.md +6 -0
  218. package/corpus/templates/design/changelog/2026-07-09-image-fills-now-preserve-valid-urls-and-layered-fit-settings.md +6 -0
  219. package/corpus/templates/design/changelog/2026-07-09-import-figma-frames-as-pixel-accurate-design-screens-via-a-s.md +6 -0
  220. package/corpus/templates/design/changelog/2026-07-09-inspect-code-now-hides-internal-styling-metadata-and-wraps-l.md +6 -0
  221. package/corpus/templates/design/changelog/2026-07-09-inspector-blur-and-shadow-effects-now-preserve-other-effects.md +6 -0
  222. package/corpus/templates/design/changelog/2026-07-09-jump-to-a-components-other-instances-from-the-inspecto.md +6 -0
  223. package/corpus/templates/design/changelog/2026-07-09-keyboard-shortcuts-are-now-discoverable-in-a-figma-style-bot.md +6 -0
  224. package/corpus/templates/design/changelog/2026-07-09-layer-selection-now-keeps-cmd-or-ctrl-toggles-and-shift-rang.md +6 -0
  225. package/corpus/templates/design/changelog/2026-07-09-layers-dropped-into-auto-layout-now-participate-correctly-in.md +6 -0
  226. package/corpus/templates/design/changelog/2026-07-09-local-live-edit-screens-now-enter-and-leave-full-view-withou.md +6 -0
  227. package/corpus/templates/design/changelog/2026-07-09-local-preview-urls-now-live-in-the-inspector-instead-of-taki.md +6 -0
  228. package/corpus/templates/design/changelog/2026-07-09-local-react-layers-can-now-be-hidden-and-locked-from-layers-.md +6 -0
  229. package/corpus/templates/design/changelog/2026-07-09-local-react-layers-now-keep-exact-source-provenance-and-supp.md +6 -0
  230. package/corpus/templates/design/changelog/2026-07-09-local-react-visual-editing-now-loads-reliably-and-shows-live.md +6 -0
  231. package/corpus/templates/design/changelog/2026-07-09-paste-from-figma-cmd-v-now-imports-exact-editable-nodes-when.md +6 -0
  232. package/corpus/templates/design/changelog/2026-07-09-pressing-escape-after-selecting-a-screen-now-reveals-page-pr.md +6 -0
  233. package/corpus/templates/design/changelog/2026-07-09-right-click-overlapping-or-nested-objects-to-choose-the-exac.md +6 -0
  234. package/corpus/templates/design/changelog/2026-07-09-scale-edge-stretch-and-center-constraints-now-preserve-layer.md +6 -0
  235. package/corpus/templates/design/changelog/2026-07-09-screen-rows-can-now-be-reordered-in-layers-with-matching-can.md +6 -0
  236. package/corpus/templates/design/changelog/2026-07-09-screen-selection-stays-responsive-in-designs-with-hundreds-o.md +6 -0
  237. package/corpus/templates/design/changelog/2026-07-09-shift-a-now-enables-auto-layout-on-a-selected-screen-with-fl.md +6 -0
  238. package/corpus/templates/design/changelog/2026-07-09-swap-a-component-instance-for-another-from-the-libra.md +6 -0
  239. package/corpus/templates/design/changelog/2026-07-10-drawing-annotations-now-stay-available-to-retry-when-the.md +6 -0
  240. package/corpus/templates/design/e2e/global-setup.ts +3 -6
  241. package/corpus/templates/design/e2e/helpers.ts +27 -6
  242. package/corpus/templates/design/server/lib/design-to-figma-svg.ts +1412 -0
  243. package/corpus/templates/design/server/lib/figma-clipboard-match.ts +196 -0
  244. package/corpus/templates/design/server/lib/figma-node-import.ts +311 -0
  245. package/corpus/templates/design/server/lib/figma-node-to-html.ts +1334 -0
  246. package/corpus/templates/design/server/lib/figma-paste-fallback.ts +64 -0
  247. package/corpus/templates/design/server/lib/playwright-runtime.ts +78 -0
  248. package/corpus/templates/design/server/plugins/db.ts +41 -0
  249. package/corpus/templates/design/server/source-workspace.ts +64 -0
  250. package/corpus/templates/design/shared/board-file.ts +13 -2
  251. package/corpus/templates/design/shared/canvas-math.ts +231 -33
  252. package/corpus/templates/design/shared/code-layer.ts +122 -8
  253. package/corpus/templates/design/shared/component-library.ts +161 -0
  254. package/corpus/templates/design/shared/db-conflict.ts +27 -0
  255. package/corpus/templates/design/shared/design-source-capabilities.ts +2 -1
  256. package/corpus/templates/design/shared/figma-url.ts +149 -0
  257. package/corpus/templates/design/shared/pen-path.ts +16 -7
  258. package/corpus/templates/design/shared/source-mode.ts +1 -0
  259. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +7 -19
  260. package/corpus/templates/plan/changelog/2026-07-09-canvas-panning-now-keeps-zoom-fixed-even-at-the-edges.md +6 -0
  261. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +16 -1
  262. package/corpus/templates/slides/app/context/DeckContext.tsx +311 -70
  263. package/corpus/templates/slides/changelog/2026-07-09-fixed-the-slide-rail-going-permanently-stale-during-a-long-a.md +6 -0
  264. package/dist/agent/production-agent.d.ts +79 -6
  265. package/dist/agent/production-agent.d.ts.map +1 -1
  266. package/dist/agent/production-agent.js +161 -14
  267. package/dist/agent/production-agent.js.map +1 -1
  268. package/dist/agent/run-manager.d.ts.map +1 -1
  269. package/dist/agent/run-manager.js +29 -5
  270. package/dist/agent/run-manager.js.map +1 -1
  271. package/dist/agent/run-store.d.ts +46 -0
  272. package/dist/agent/run-store.d.ts.map +1 -1
  273. package/dist/agent/run-store.js +61 -0
  274. package/dist/agent/run-store.js.map +1 -1
  275. package/dist/cli/design-connect.d.ts +6 -0
  276. package/dist/cli/design-connect.d.ts.map +1 -1
  277. package/dist/cli/design-connect.js +336 -139
  278. package/dist/cli/design-connect.js.map +1 -1
  279. package/dist/cli/skills.d.ts.map +1 -1
  280. package/dist/cli/skills.js +14 -0
  281. package/dist/cli/skills.js.map +1 -1
  282. package/dist/client/AssistantChat.d.ts +2 -0
  283. package/dist/client/AssistantChat.d.ts.map +1 -1
  284. package/dist/client/AssistantChat.js +19 -3
  285. package/dist/client/AssistantChat.js.map +1 -1
  286. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  287. package/dist/client/MultiTabAssistantChat.js +38 -18
  288. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  289. package/dist/client/agent-chat.d.ts +60 -0
  290. package/dist/client/agent-chat.d.ts.map +1 -1
  291. package/dist/client/agent-chat.js +122 -1
  292. package/dist/client/agent-chat.js.map +1 -1
  293. package/dist/client/chat/index.d.ts +1 -1
  294. package/dist/client/chat/index.d.ts.map +1 -1
  295. package/dist/client/chat/index.js +1 -1
  296. package/dist/client/chat/index.js.map +1 -1
  297. package/dist/client/extensions/EmbeddedExtension.d.ts +12 -0
  298. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  299. package/dist/client/extensions/EmbeddedExtension.js +36 -2
  300. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  301. package/dist/client/index.d.ts +1 -1
  302. package/dist/client/index.d.ts.map +1 -1
  303. package/dist/client/index.js +1 -1
  304. package/dist/client/index.js.map +1 -1
  305. package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
  306. package/dist/client/rich-markdown-editor/useCollabReconcile.js +18 -6
  307. package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
  308. package/dist/collab/awareness-store.d.ts +5 -0
  309. package/dist/collab/awareness-store.d.ts.map +1 -1
  310. package/dist/collab/awareness-store.js +19 -12
  311. package/dist/collab/awareness-store.js.map +1 -1
  312. package/dist/collab/awareness.d.ts +2 -2
  313. package/dist/collab/awareness.d.ts.map +1 -1
  314. package/dist/collab/client.d.ts.map +1 -1
  315. package/dist/collab/client.js +13 -5
  316. package/dist/collab/client.js.map +1 -1
  317. package/dist/collab/index.d.ts +1 -0
  318. package/dist/collab/index.d.ts.map +1 -1
  319. package/dist/collab/index.js +1 -0
  320. package/dist/collab/index.js.map +1 -1
  321. package/dist/collab/recent-edits.d.ts +3 -1
  322. package/dist/collab/recent-edits.d.ts.map +1 -1
  323. package/dist/collab/recent-edits.js +57 -2
  324. package/dist/collab/recent-edits.js.map +1 -1
  325. package/dist/collab/routes.d.ts +1 -1
  326. package/dist/deploy/build.d.ts +24 -0
  327. package/dist/deploy/build.d.ts.map +1 -1
  328. package/dist/deploy/build.js +102 -5
  329. package/dist/deploy/build.js.map +1 -1
  330. package/dist/index.browser.d.ts +1 -1
  331. package/dist/index.browser.d.ts.map +1 -1
  332. package/dist/index.browser.js +1 -1
  333. package/dist/index.browser.js.map +1 -1
  334. package/dist/notifications/routes.d.ts +1 -1
  335. package/dist/observability/routes.d.ts +5 -5
  336. package/dist/progress/routes.d.ts +1 -1
  337. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  338. package/dist/server/agent-chat-plugin.js +87 -10
  339. package/dist/server/agent-chat-plugin.js.map +1 -1
  340. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  341. package/dist/server/builder-design-systems.d.ts +15 -0
  342. package/dist/server/builder-design-systems.d.ts.map +1 -1
  343. package/dist/server/builder-design-systems.js +9 -4
  344. package/dist/server/builder-design-systems.js.map +1 -1
  345. package/package.json +1 -1
@@ -15,7 +15,7 @@ import {
15
15
  getResizeCursorForHandle,
16
16
  resizeFrameGroupFromDelta,
17
17
  resizeFrameGroupToBounds,
18
- resizeRotatedFrameFromDelta,
18
+ resizeRotatedFrameFromDeltaWithSnap,
19
19
  rotateFrameGroupAroundCenter,
20
20
  rotatedRectIntersects,
21
21
  screenToCanvasPoint,
@@ -152,6 +152,7 @@ const EMPTY_SELECTED_LAYER_SELECTOR_GROUPS_BY_SCREEN: Record<
152
152
  string,
153
153
  string[][]
154
154
  > = {};
155
+ const EMPTY_SCREEN_IDS: readonly string[] = [];
155
156
  // Shared with canvas-math.ts (DEFAULT_CANVAS_MIN_ZOOM/DEFAULT_CANVAS_MAX_ZOOM)
156
157
  // so this surface's zoom clamp lives in one place instead of being
157
158
  // redeclared locally and drifting from the shared constant.
@@ -165,7 +166,9 @@ import {
165
166
  BOARD_SURFACE_BACKGROUND,
166
167
  getBoardContentKey,
167
168
  getBoardContentLayerSignature,
169
+ getBoardSurfaceContentBounds,
168
170
  getBoardSurfaceRenderContent,
171
+ getBoardSurfaceStaticPreviewContent,
169
172
  hasBoardSurfaceContent,
170
173
  } from "./multi-screen/board-surface-html";
171
174
  import {
@@ -197,9 +200,14 @@ import {
197
200
  isBreakpointSelectionTarget,
198
201
  } from "./multi-screen/iframe-targeting";
199
202
  import {
203
+ boardPointToBoardSurfaceLocalPoint,
204
+ boardSurfaceLocalPointToBoardPoint,
205
+ getBoardSurfaceRenderGeometry,
200
206
  getBoardSurfaceLayerStyle,
207
+ getBoardSurfaceStaticPreviewViewport,
201
208
  isLineupShrinkOnlyChange,
202
209
  OVERVIEW_FRAME_WIDTH,
210
+ shouldRenderBoardSurfaceStaticPreview,
203
211
  shouldSuppressLineupRecenter,
204
212
  SURFACE_PADDING,
205
213
  type LineupRecenterDuplicateArm,
@@ -280,6 +288,7 @@ import {
280
288
  findTopFrameEntryAtPoint,
281
289
  frameGeometryWithOverrides,
282
290
  frameStyleLeftTop,
291
+ geometryContainsPoint,
283
292
  getBreakpointFrameGeometry,
284
293
  getFrameCenter,
285
294
  getInitialFrameGeometry,
@@ -302,17 +311,84 @@ import {
302
311
  } from "./multi-screen/gradient-overlay-geometry";
303
312
  import {
304
313
  getPrimitiveDropTargetForPoint,
314
+ getPrimitiveLowZoomHitRect,
315
+ parsePrimitivesFromScreen,
305
316
  resolveNodeScreenId,
306
317
  type PrimitiveDropTarget,
307
318
  } from "./multi-screen/primitive-drop-target";
308
319
  import type { AlignmentGuide, CanvasFrameEntry } from "./multi-screen/types";
309
320
  import { vectorEditCanvasToLocalPoint } from "./multi-screen/vector-edit-geometry";
310
321
 
322
+ /**
323
+ * Imperatively writes a draft primitive's full visual state onto its cached
324
+ * DOM node — the outer box (left/top/width/height/rotation, matching
325
+ * DraftPrimitiveLayer's own inline style) plus, for kinds whose rendered
326
+ * content depends on geometry rather than plain CSS 100%-sizing (path/line/
327
+ * arrow's <path d>+viewBox, polygon/star's <polygon points>+viewBox), the
328
+ * inner SVG content too. Used by beginDraftResize's live mousemove tick (PERF9:
329
+ * ref-only writes instead of setDraftPrimitives per tick) and by
330
+ * cancelActiveDrag's Escape-cancel restore, so both paths stay in sync with
331
+ * exactly what DraftPrimitiveLayer/DraftPrimitiveContent would have rendered.
332
+ * rect/ellipse/text/frame kinds need no extra work here: their content divs
333
+ * are `size-full` and already track the outer box via normal CSS layout.
334
+ */
335
+ function applyDraftPrimitiveToDom(
336
+ element: HTMLElement,
337
+ draft: DraftPrimitive,
338
+ ): void {
339
+ const { geometry } = draft;
340
+ const { left, top } = frameStyleLeftTop(geometry);
341
+ element.style.left = `${left}px`;
342
+ element.style.top = `${top}px`;
343
+ element.style.width = `${geometry.width}px`;
344
+ element.style.height = `${geometry.height}px`;
345
+ element.style.transform = geometry.rotation
346
+ ? `rotate(${geometry.rotation}deg)`
347
+ : "";
348
+
349
+ if (
350
+ draft.kind === "path" ||
351
+ draft.kind === "line" ||
352
+ draft.kind === "arrow"
353
+ ) {
354
+ const svgEl = element.querySelector("svg");
355
+ const pathEl = element.querySelector("path");
356
+ svgEl?.setAttribute(
357
+ "viewBox",
358
+ `${geometry.x} ${geometry.y} ${geometry.width} ${geometry.height}`,
359
+ );
360
+ if (pathEl) {
361
+ const pathData =
362
+ draft.pathData ??
363
+ (draft.penPath
364
+ ? serializePenPath(draft.penPath)
365
+ : pointsToPath(draft.points ?? []));
366
+ pathEl.setAttribute("d", pathData);
367
+ if (draft.strokeWidth !== undefined) {
368
+ pathEl.setAttribute("stroke-width", String(draft.strokeWidth));
369
+ }
370
+ }
371
+ } else if (draft.kind === "polygon" || draft.kind === "star") {
372
+ const svgEl = element.querySelector("svg");
373
+ const polygonEl = element.querySelector("polygon");
374
+ svgEl?.setAttribute(
375
+ "viewBox",
376
+ `0 0 ${Math.max(1, geometry.width)} ${Math.max(1, geometry.height)}`,
377
+ );
378
+ polygonEl?.setAttribute(
379
+ "points",
380
+ polygonPointsForBox(draft.kind, geometry.width, geometry.height),
381
+ );
382
+ }
383
+ }
384
+
311
385
  export const MultiScreenCanvas = memo(function MultiScreenCanvas({
312
386
  screens,
313
387
  zoom,
314
388
  activeId,
315
389
  selectedScreenIds,
390
+ hiddenScreenIds = EMPTY_SCREEN_IDS,
391
+ lockedScreenIds = EMPTY_SCREEN_IDS,
316
392
  fullViewScreenIds,
317
393
  activeScreenHasHoveredChild = false,
318
394
  hoveredChildScreenId,
@@ -431,9 +507,179 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
431
507
  // error, not just a stale-closure risk). Kept in sync by the effect right
432
508
  // below the other prop/state-mirroring refs (see onGeometryChangeRef etc.).
433
509
  const effectiveToolRef = useRef<MultiScreenCanvasTool>("move");
434
- const [selectedIds, setSelectedIds] = useState<string[]>(
435
- selectedScreenIds ?? [],
510
+ const hiddenScreenIdSet = useMemo(
511
+ () => new Set(hiddenScreenIds),
512
+ [hiddenScreenIds],
513
+ );
514
+ const lockedScreenIdSet = useMemo(
515
+ () => new Set(lockedScreenIds),
516
+ [lockedScreenIds],
517
+ );
518
+ const renderedScreens = useMemo(
519
+ () => screens.filter((screen) => !hiddenScreenIdSet.has(screen.id)),
520
+ [hiddenScreenIdSet, screens],
521
+ );
522
+ const selectableScreens = useMemo(
523
+ () => renderedScreens.filter((screen) => !lockedScreenIdSet.has(screen.id)),
524
+ [lockedScreenIdSet, renderedScreens],
525
+ );
526
+ const [selectedIds, setSelectedIds] = useState<string[]>(() =>
527
+ (selectedScreenIds ?? []).filter((id) =>
528
+ selectableScreens.some((screen) => screen.id === id),
529
+ ),
530
+ );
531
+ const screenIndexById = useMemo(
532
+ () => new Map(screens.map((screen, index) => [screen.id, index] as const)),
533
+ [screens],
534
+ );
535
+ const [boardSurfaceFocusPoint, setBoardSurfaceFocusPoint] =
536
+ useState<Point | null>(null);
537
+ const pendingStaticBoardSelectionRef = useRef<{
538
+ nodeId: string;
539
+ point: Point;
540
+ } | null>(null);
541
+ const cancelPendingStaticBoardSelection = useCallback(() => {
542
+ pendingStaticBoardSelectionRef.current = null;
543
+ setBoardSurfaceFocusPoint(null);
544
+ }, []);
545
+ const boardSurfaceContentBounds = useMemo(
546
+ () => getBoardSurfaceContentBounds(boardFileContent),
547
+ [boardFileContent],
548
+ );
549
+ const boardViewportGeometry = useMemo((): FrameGeometry | undefined => {
550
+ if (surfaceSize.width <= 0 || surfaceSize.height <= 0) return undefined;
551
+ const scale = Math.max(0.0001, canvasZoom / 100);
552
+ return {
553
+ x: -pan.x / scale - SURFACE_PADDING,
554
+ y: -pan.y / scale - SURFACE_PADDING,
555
+ width: surfaceSize.width / scale,
556
+ height: surfaceSize.height / scale,
557
+ };
558
+ }, [canvasZoom, pan.x, pan.y, surfaceSize.height, surfaceSize.width]);
559
+ const boardSurfaceRenderGeometry = useMemo(() => {
560
+ if (!boardFrameGeometry) return undefined;
561
+ const focusGeometry = boardSurfaceFocusPoint
562
+ ? {
563
+ x: boardSurfaceFocusPoint.x,
564
+ y: boardSurfaceFocusPoint.y,
565
+ width: 1,
566
+ height: 1,
567
+ }
568
+ : (boardViewportGeometry ?? boardSurfaceContentBounds);
569
+ return getBoardSurfaceRenderGeometry({
570
+ logicalGeometry: boardFrameGeometry,
571
+ contentBounds: boardSurfaceContentBounds,
572
+ screenGeometries: [
573
+ ...Object.values(frameGeometry),
574
+ ...(boardViewportGeometry ? [boardViewportGeometry] : []),
575
+ ],
576
+ focus: focusGeometry ? getFrameCenter(focusGeometry) : undefined,
577
+ });
578
+ }, [
579
+ boardFrameGeometry,
580
+ boardSurfaceContentBounds,
581
+ boardSurfaceFocusPoint,
582
+ boardViewportGeometry,
583
+ frameGeometry,
584
+ ]);
585
+ const boardStaticPreviewViewport = useMemo(
586
+ () =>
587
+ boardFrameGeometry
588
+ ? getBoardSurfaceStaticPreviewViewport(boardFrameGeometry)
589
+ : null,
590
+ [boardFrameGeometry],
436
591
  );
592
+ const boardStaticPreviewContent = useMemo(() => {
593
+ if (
594
+ !boardFrameGeometry ||
595
+ !boardStaticPreviewViewport ||
596
+ !boardFileContent
597
+ ) {
598
+ return null;
599
+ }
600
+ return getBoardSurfaceStaticPreviewContent({
601
+ html: boardFileContent,
602
+ logicalGeometry: boardFrameGeometry,
603
+ viewport: boardStaticPreviewViewport,
604
+ });
605
+ }, [boardFileContent, boardFrameGeometry, boardStaticPreviewViewport]);
606
+ const showBoardStaticPreview = Boolean(
607
+ boardFrameGeometry &&
608
+ boardSurfaceRenderGeometry &&
609
+ boardStaticPreviewContent &&
610
+ shouldRenderBoardSurfaceStaticPreview({
611
+ zoom: canvasZoom,
612
+ viewportGeometry: boardViewportGeometry,
613
+ renderGeometry: boardSurfaceRenderGeometry,
614
+ }),
615
+ );
616
+ const boardStaticPrimitives = useMemo(() => {
617
+ if (!boardFileId || !boardFileContent) return [];
618
+ return parsePrimitivesFromScreen({
619
+ id: boardFileId,
620
+ filename: "__board__.html",
621
+ content: boardFileContent,
622
+ });
623
+ }, [boardFileContent, boardFileId]);
624
+ useEffect(() => {
625
+ cancelPendingStaticBoardSelection();
626
+ }, [cancelPendingStaticBoardSelection, canvasZoom, pan.x, pan.y]);
627
+ useEffect(() => {
628
+ cancelPendingStaticBoardSelection();
629
+ }, [
630
+ activeTool,
631
+ boardFileContent,
632
+ boardFileId,
633
+ boardFrameGeometry?.height,
634
+ boardFrameGeometry?.width,
635
+ boardFrameGeometry?.x,
636
+ boardFrameGeometry?.y,
637
+ cancelPendingStaticBoardSelection,
638
+ localActiveTool,
639
+ ]);
640
+ useEffect(() => {
641
+ if (!showBoardStaticPreview) cancelPendingStaticBoardSelection();
642
+ }, [cancelPendingStaticBoardSelection, showBoardStaticPreview]);
643
+ useEffect(() => {
644
+ const pending = pendingStaticBoardSelectionRef.current;
645
+ if (
646
+ !pending ||
647
+ !boardFileId ||
648
+ !boardSurfaceRenderGeometry ||
649
+ !geometryContainsPoint(boardSurfaceRenderGeometry, pending.point)
650
+ ) {
651
+ return;
652
+ }
653
+ const selector = `[data-agent-native-node-id="${CSS.escape(pending.nodeId)}"]`;
654
+ let secondFrame: number | null = null;
655
+ const firstFrame = window.requestAnimationFrame(() => {
656
+ secondFrame = window.requestAnimationFrame(() => {
657
+ if (pendingStaticBoardSelectionRef.current !== pending) return;
658
+ const iframe = findCanvasIframeForScreen(
659
+ surfaceRef.current,
660
+ boardFileId,
661
+ boardFileId,
662
+ );
663
+ const targetWindow = iframe?.contentWindow;
664
+ if (!targetWindow) return;
665
+ targetWindow.postMessage(
666
+ {
667
+ type: "select-element",
668
+ selector,
669
+ selectorCandidates: [selector],
670
+ },
671
+ "*",
672
+ );
673
+ if (pendingStaticBoardSelectionRef.current === pending) {
674
+ pendingStaticBoardSelectionRef.current = null;
675
+ }
676
+ });
677
+ });
678
+ return () => {
679
+ window.cancelAnimationFrame(firstFrame);
680
+ if (secondFrame !== null) window.cancelAnimationFrame(secondFrame);
681
+ };
682
+ }, [boardFileId, boardSurfaceRenderGeometry]);
437
683
  const selectedIdsRef = useRef(selectedIds);
438
684
  const dragState = useRef<DragState | null>(null);
439
685
  const dragCleanup = useRef<(() => void) | null>(null);
@@ -508,6 +754,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
508
754
  );
509
755
  const [duplicatePreview, setDuplicatePreview] =
510
756
  useState<DuplicatePreview | null>(null);
757
+ // PERF9: DOM node backing the alt-drag duplicate ghost, cached so
758
+ // beginDuplicateGesture's live mousemove tick can write left/top directly
759
+ // instead of calling setDuplicatePreview (a full re-render) every frame —
760
+ // same "mutate the DOM now, commit React state only when something
761
+ // conditional actually changes" discipline as the frame/draft drag paths.
762
+ const duplicatePreviewElRef = useRef<HTMLDivElement | null>(null);
511
763
  const [transformBadge, setTransformBadge] = useState<TransformBadge | null>(
512
764
  null,
513
765
  );
@@ -965,7 +1217,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
965
1217
  // to the first selected/first-on-board screen in that case. Mirror the
966
1218
  // same fallback chain so the anchor matches whichever frame actually
967
1219
  // drove the zoom-scale recompute.
968
- const referenceId = activeId ?? selectedIds[0] ?? screens[0]?.id;
1220
+ const referenceId =
1221
+ (activeId && renderedScreens.some((screen) => screen.id === activeId)
1222
+ ? activeId
1223
+ : undefined) ??
1224
+ selectedIds[0] ??
1225
+ renderedScreens[0]?.id;
969
1226
  const rect = surfaceRef.current?.getBoundingClientRect();
970
1227
  // Prefer the `geometryById` prop over `frameGeometryRef` here: a screen
971
1228
  // just created by the frame tool has its geometry committed straight
@@ -1018,10 +1275,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1018
1275
  // P18: an externally-driven zoom change (toolbar/keyboard) also moves
1019
1276
  // the canvas-space mapping the pen ghost preview was computed from.
1020
1277
  recomputePenPointerForViewChangeRef.current();
1021
- }, [zoom, activeId]);
1278
+ }, [zoom, activeId, renderedScreens, selectedIds]);
1022
1279
 
1023
1280
  useEffect(() => {
1024
- const currentIds = new Set(screens.map((screen) => screen.id));
1281
+ const selectableIds = new Set(selectableScreens.map((screen) => screen.id));
1025
1282
  // B5-9: see resolveFrameGeometrySync's doc comment — this used to notify
1026
1283
  // the parent (onGeometryChange -> queueFrameGeometrySave) with a brand
1027
1284
  // new screen's disposable getInitialFrameGeometry() fallback before its
@@ -1046,13 +1303,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1046
1303
  }
1047
1304
  }
1048
1305
  updateSelectedIds((current) => {
1049
- const next = current.filter((id) => currentIds.has(id));
1306
+ const next = current.filter((id) => selectableIds.has(id));
1050
1307
  return next.length === current.length ? current : next;
1051
1308
  });
1052
1309
  }, [
1053
1310
  geometryById,
1054
1311
  getResolvedMetadata,
1055
1312
  screens,
1313
+ selectableScreens,
1056
1314
  updateFrameGeometry,
1057
1315
  updateFrameGeometryRefOnly,
1058
1316
  updateSelectedIds,
@@ -1087,11 +1345,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1087
1345
 
1088
1346
  useEffect(() => {
1089
1347
  if (!selectedScreenIds) return;
1348
+ const selectableIds = new Set(selectableScreens.map((screen) => screen.id));
1349
+ const nextSelection = selectedScreenIds.filter((id) =>
1350
+ selectableIds.has(id),
1351
+ );
1090
1352
  // Remember the selection we're pushing in from the parent so the report
1091
1353
  // effects above can recognise (and not echo back) the resulting change.
1092
- propSyncedSelectionRef.current = selectedScreenIds;
1093
- updateSelectedIds(() => selectedScreenIds);
1094
- }, [screens, selectedScreenIds, updateSelectedIds]);
1354
+ propSyncedSelectionRef.current = nextSelection;
1355
+ updateSelectedIds(() => nextSelection);
1356
+ }, [selectableScreens, selectedScreenIds, updateSelectedIds]);
1095
1357
 
1096
1358
  useEffect(() => {
1097
1359
  if (
@@ -1102,8 +1364,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1102
1364
  }
1103
1365
  handledSelectAllRequestRef.current = selectAllRequest;
1104
1366
  updateSelectedDraftIds(() => []);
1105
- updateSelectedIds(() => screens.map((screen) => screen.id));
1106
- }, [screens, selectAllRequest, updateSelectedDraftIds, updateSelectedIds]);
1367
+ updateSelectedIds(() => selectableScreens.map((screen) => screen.id));
1368
+ }, [
1369
+ selectAllRequest,
1370
+ selectableScreens,
1371
+ updateSelectedDraftIds,
1372
+ updateSelectedIds,
1373
+ ]);
1107
1374
 
1108
1375
  useEffect(() => {
1109
1376
  if (
@@ -1158,14 +1425,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1158
1425
  }
1159
1426
  return;
1160
1427
  }
1161
- if (!surfaceRef.current || screens.length === 0) return;
1428
+ if (!surfaceRef.current || renderedScreens.length === 0) return;
1162
1429
  const rect = surfaceRef.current.getBoundingClientRect();
1163
1430
  const scale = zoomRef.current / 100;
1164
- const frames = screens.map((screen, index) => {
1431
+ const frames = renderedScreens.map((screen) => {
1165
1432
  const metadata = getResolvedMetadata(screen);
1166
1433
  const currentGeometry =
1167
1434
  frameGeometryRef.current[screen.id] ??
1168
- getInitialFrameGeometry(index, metadata);
1435
+ getInitialFrameGeometry(screenIndexById.get(screen.id) ?? 0, metadata);
1169
1436
  return getPreviewDeviceFrameGeometry({
1170
1437
  currentGeometry,
1171
1438
  metadata,
@@ -1174,7 +1441,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1174
1441
  });
1175
1442
  const bounds = getFrameGroupBounds(
1176
1443
  frames.map((geometry, index) => ({
1177
- id: screens[index]?.id ?? String(index),
1444
+ id: renderedScreens[index]?.id ?? String(index),
1178
1445
  geometry,
1179
1446
  })),
1180
1447
  );
@@ -1194,7 +1461,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1194
1461
  // Leave a Figma-like board gutter beside the last frame for quick drops/draws,
1195
1462
  // and fit tall single frames so lower canvas interactions remain reachable.
1196
1463
  const widthFitScale =
1197
- screens.length > 1 && totalWidth > 0
1464
+ renderedScreens.length > 1 && totalWidth > 0
1198
1465
  ? Math.max(0.1, (rect.width - 180) / totalWidth)
1199
1466
  : scale;
1200
1467
  const heightFitScale =
@@ -1273,16 +1540,27 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1273
1540
 
1274
1541
  const getCurrentFrameEntries = useCallback(
1275
1542
  () =>
1276
- screens.map((screen, index) => {
1543
+ renderedScreens.map((screen) => {
1277
1544
  const metadata = getResolvedMetadata(screen);
1278
1545
  return {
1279
1546
  id: screen.id,
1280
1547
  geometry:
1281
1548
  frameGeometryRef.current[screen.id] ??
1282
- getInitialFrameGeometry(index, metadata),
1549
+ getInitialFrameGeometry(
1550
+ screenIndexById.get(screen.id) ?? 0,
1551
+ metadata,
1552
+ ),
1283
1553
  };
1284
1554
  }),
1285
- [getResolvedMetadata, screens],
1555
+ [getResolvedMetadata, renderedScreens, screenIndexById],
1556
+ );
1557
+
1558
+ const getSelectableFrameEntries = useCallback(
1559
+ () =>
1560
+ getCurrentFrameEntries().filter(
1561
+ (entry) => !lockedScreenIdSet.has(entry.id),
1562
+ ),
1563
+ [getCurrentFrameEntries, lockedScreenIdSet],
1286
1564
  );
1287
1565
 
1288
1566
  const getCurrentDraftEntries = useCallback(
@@ -1301,7 +1579,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1301
1579
 
1302
1580
  const getFrameEntryAtPoint = useCallback(
1303
1581
  (point: Point) =>
1304
- findTopFrameEntryAtPoint(getCurrentFrameEntries(), point, {
1582
+ findTopFrameEntryAtPoint(getSelectableFrameEntries(), point, {
1305
1583
  // Screen wrappers give this same id a large z-index boost. Geometry
1306
1584
  // hit testing must mirror it or drops/draws on overlapping frames can
1307
1585
  // persist into a visually obscured sibling.
@@ -1313,7 +1591,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1313
1591
  ? activeId
1314
1592
  : screensRef.current[0]?.id),
1315
1593
  }),
1316
- [activeId, getCurrentFrameEntries],
1594
+ [activeId, getSelectableFrameEntries],
1317
1595
  );
1318
1596
 
1319
1597
  // Mirrors getFrameEntryAtPoint above, but hit-tests draft primitives
@@ -1453,8 +1731,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1453
1731
  const iframeId = targetScreen
1454
1732
  ? getActiveScreenIframeId(targetScreen)
1455
1733
  : targetId;
1456
- const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1457
- `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
1734
+ const targetIframe = findCanvasIframeForScreen(
1735
+ surfaceRef.current,
1736
+ iframeId,
1737
+ boardFileId,
1458
1738
  );
1459
1739
  targetIframe?.contentWindow?.postMessage(
1460
1740
  { type: "agent-native:hit-test-preview-clear" },
@@ -1525,21 +1805,44 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1525
1805
  const targetScreen = screensRef.current.find(
1526
1806
  (s) => s.id === candidate.id,
1527
1807
  );
1528
- if (!targetScreen) return Promise.resolve({});
1529
- const targetGeometry = candidate.geometry;
1530
- const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1531
- const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1532
- `[data-screen-iframe-id="${targetIframeId}"]`,
1533
- );
1808
+ const targetIsBoard = candidate.id === boardFileId;
1809
+ if (!targetScreen && !targetIsBoard) return Promise.resolve({});
1810
+ if (targetIsBoard && !boardSurfaceRenderGeometry) {
1811
+ return Promise.resolve({});
1812
+ }
1813
+ const targetIframe = targetIsBoard
1814
+ ? findCanvasIframeForScreen(
1815
+ surfaceRef.current,
1816
+ candidate.id,
1817
+ boardFileId,
1818
+ )
1819
+ : surfaceRef.current?.querySelector<HTMLIFrameElement>(
1820
+ `[data-screen-iframe-id="${CSS.escape(
1821
+ getActiveScreenIframeId(targetScreen!),
1822
+ )}"]`,
1823
+ );
1534
1824
  const targetContentWindow = targetIframe?.contentWindow;
1535
1825
  if (!targetContentWindow) return Promise.resolve({});
1536
- const { width: targetViewportWidth, height: targetViewportHeight } =
1537
- getTargetViewportMetadata(targetScreen, targetIframe);
1538
- const localPoint = boardPointToScreenLocalPoint(
1539
- boardPoint,
1540
- targetGeometry,
1541
- { width: targetViewportWidth, height: targetViewportHeight },
1542
- );
1826
+ const localPoint =
1827
+ targetIsBoard && boardSurfaceRenderGeometry
1828
+ ? boardPointToBoardSurfaceLocalPoint(
1829
+ boardPoint,
1830
+ boardSurfaceRenderGeometry,
1831
+ )
1832
+ : (() => {
1833
+ const {
1834
+ width: targetViewportWidth,
1835
+ height: targetViewportHeight,
1836
+ } = getTargetViewportMetadata(targetScreen!, targetIframe);
1837
+ return boardPointToScreenLocalPoint(
1838
+ boardPoint,
1839
+ candidate.geometry,
1840
+ {
1841
+ width: targetViewportWidth,
1842
+ height: targetViewportHeight,
1843
+ },
1844
+ );
1845
+ })();
1543
1846
 
1544
1847
  const correlationId = `hit-${Date.now()}-${Math.random()
1545
1848
  .toString(36)
@@ -1652,14 +1955,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1652
1955
  const targetScreen = screensRef.current.find(
1653
1956
  (s) => s.id === candidate.id,
1654
1957
  );
1655
- const targetIframeId = targetScreen
1656
- ? CSS.escape(getActiveScreenIframeId(targetScreen))
1657
- : null;
1658
- const targetIframe = targetIframeId
1958
+ const targetIsBoard = candidate.id === boardFileId;
1959
+ const targetIframe = targetScreen
1659
1960
  ? surfaceRef.current?.querySelector<HTMLIFrameElement>(
1660
- `[data-screen-iframe-id="${targetIframeId}"]`,
1961
+ `[data-screen-iframe-id="${CSS.escape(
1962
+ getActiveScreenIframeId(targetScreen),
1963
+ )}"]`,
1661
1964
  )
1662
- : null;
1965
+ : targetIsBoard
1966
+ ? findCanvasIframeForScreen(
1967
+ surfaceRef.current,
1968
+ candidate.id,
1969
+ boardFileId,
1970
+ )
1971
+ : null;
1663
1972
  const guide = targetScreen
1664
1973
  ? getCrossScreenDropGuideForHitTest({
1665
1974
  hit,
@@ -1669,7 +1978,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1669
1978
  targetIframe,
1670
1979
  ),
1671
1980
  })
1672
- : null;
1981
+ : targetIsBoard && boardSurfaceRenderGeometry
1982
+ ? getCrossScreenDropGuideForHitTest({
1983
+ hit,
1984
+ targetGeometry: boardSurfaceRenderGeometry,
1985
+ targetMetadata: {
1986
+ width:
1987
+ targetIframe?.clientWidth ||
1988
+ boardSurfaceRenderGeometry.width,
1989
+ height:
1990
+ targetIframe?.clientHeight ||
1991
+ boardSurfaceRenderGeometry.height,
1992
+ },
1993
+ })
1994
+ : null;
1673
1995
  setCrossScreenDropGuide(guide);
1674
1996
  });
1675
1997
  };
@@ -1706,6 +2028,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1706
2028
  : { boardX: boardPoint.x, boardY: boardPoint.y },
1707
2029
  );
1708
2030
  requestCrossScreenDropGuide(nextTarget, boardPoint);
2031
+ } else if (
2032
+ sourceScreenId !== boardFileId &&
2033
+ boardFileId &&
2034
+ boardFrameGeometry &&
2035
+ geometryContainsPoint(boardFrameGeometry, boardPoint)
2036
+ ) {
2037
+ const nextTarget = { id: boardFileId, geometry: boardFrameGeometry };
2038
+ if (crossScreenTargetRef.current?.id !== boardFileId) {
2039
+ clearCrossScreenPreviewGuide();
2040
+ }
2041
+ crossScreenTargetRef.current = nextTarget;
2042
+ setCrossScreenTarget(nextTarget);
2043
+ setCrossScreenGhost({ boardX: boardPoint.x, boardY: boardPoint.y });
2044
+ requestCrossScreenDropGuide(nextTarget, boardPoint);
1709
2045
  } else {
1710
2046
  clearCrossScreenPreviewGuide();
1711
2047
  crossScreenTargetRef.current = null;
@@ -1741,16 +2077,45 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1741
2077
  if (!targetCandidate) return;
1742
2078
 
1743
2079
  if (targetCandidate.id === boardFileId) {
1744
- onCrossScreenElementDropRef.current?.({
1745
- sourceSelector: payload.selector,
1746
- sourceNodeId: payload.sourceId,
1747
- sourceScreenId,
1748
- targetScreenId: targetCandidate.id,
1749
- targetCanvasPoint: lastBoardPoint,
1750
- targetLocalPoint: lastBoardPoint,
1751
- sourcePointerOffset: payload.sourcePointerOffset,
1752
- styleSnapshot: payload.styleSnapshot,
1753
- });
2080
+ void runHitTest(targetCandidate, lastBoardPoint).then(
2081
+ ({
2082
+ anchorNodeId,
2083
+ pendingNodeId,
2084
+ anchorSelector,
2085
+ placement,
2086
+ dropMode,
2087
+ anchorRect,
2088
+ }) => {
2089
+ const hasAnchor = Boolean(
2090
+ anchorNodeId || pendingNodeId || anchorSelector,
2091
+ );
2092
+ onCrossScreenElementDropRef.current?.({
2093
+ sourceSelector: payload.selector,
2094
+ sourceNodeId: payload.sourceId,
2095
+ sourceScreenId,
2096
+ targetScreenId: targetCandidate.id,
2097
+ targetAnchorNodeId: anchorNodeId,
2098
+ targetAnchorPendingNodeId: pendingNodeId,
2099
+ targetAnchorSelector: anchorSelector,
2100
+ targetAnchorPlacement: placement,
2101
+ targetDropMode: dropMode,
2102
+ targetAnchorRect: anchorRect,
2103
+ targetCanvasPoint: lastBoardPoint,
2104
+ // Anchor rects come from the finite board iframe and are local
2105
+ // to its render window. Use the same local space for nested
2106
+ // placement; root-level board drops keep persisted board coords.
2107
+ targetLocalPoint:
2108
+ hasAnchor && boardSurfaceRenderGeometry
2109
+ ? boardPointToBoardSurfaceLocalPoint(
2110
+ lastBoardPoint,
2111
+ boardSurfaceRenderGeometry,
2112
+ )
2113
+ : lastBoardPoint,
2114
+ sourcePointerOffset: payload.sourcePointerOffset,
2115
+ styleSnapshot: payload.styleSnapshot,
2116
+ });
2117
+ },
2118
+ );
1754
2119
  return;
1755
2120
  }
1756
2121
 
@@ -2048,11 +2413,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2048
2413
  let boardX: number;
2049
2414
  let boardY: number;
2050
2415
 
2051
- if (sourceScreenId === boardFileId && boardFrameGeometry) {
2416
+ if (sourceScreenId === boardFileId && boardSurfaceRenderGeometry) {
2052
2417
  // The board iframe is pixel-exact: 1 iframe pixel == 1 canvas unit.
2053
- // iframeX/iframeY are already in canvas space (no scale needed).
2054
- boardX = boardFrameGeometry.x + iframeX;
2055
- boardY = boardFrameGeometry.y + iframeY;
2418
+ // Its finite paint window can start anywhere within the much larger
2419
+ // logical board, so add the render origin (not the logical board's
2420
+ // fixed -65536 origin) to recover persisted canvas coordinates.
2421
+ const boardPoint = boardSurfaceLocalPointToBoardPoint(
2422
+ { x: iframeX, y: iframeY },
2423
+ boardSurfaceRenderGeometry,
2424
+ );
2425
+ boardX = boardPoint.x;
2426
+ boardY = boardPoint.y;
2056
2427
  } else {
2057
2428
  const sourceScreen = screensRef.current.find(
2058
2429
  (s) => s.id === sourceScreenId,
@@ -2107,6 +2478,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2107
2478
  activeId,
2108
2479
  boardFileId,
2109
2480
  boardFrameGeometry,
2481
+ boardSurfaceRenderGeometry,
2110
2482
  getFrameEntryAtPoint,
2111
2483
  getCanvasPoint,
2112
2484
  getResolvedMetadata,
@@ -2305,7 +2677,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2305
2677
  * to collect every screen. */
2306
2678
  const collectLayerMarqueeCandidates = useCallback(
2307
2679
  async (screenIds?: Set<string>) => {
2308
- const frameEntries = getCurrentFrameEntries().filter(
2680
+ const frameEntries = getSelectableFrameEntries().filter(
2309
2681
  (entry) => !screenIds || screenIds.has(entry.id),
2310
2682
  );
2311
2683
  const frameCandidates = await Promise.all(
@@ -2340,7 +2712,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2340
2712
  );
2341
2713
  const boardCandidates =
2342
2714
  boardFileId &&
2343
- boardFrameGeometry &&
2715
+ boardSurfaceRenderGeometry &&
2344
2716
  (!screenIds || screenIds.has(boardFileId))
2345
2717
  ? await (async () => {
2346
2718
  const infos = await requestSelectableElementInfos(boardFileId);
@@ -2354,13 +2726,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2354
2726
  width: info.boundingRect.width,
2355
2727
  height: info.boundingRect.height,
2356
2728
  },
2357
- boardFrameGeometry,
2729
+ boardSurfaceRenderGeometry,
2358
2730
  {
2359
- width: boardFrameGeometry.width,
2360
- height: boardFrameGeometry.height,
2731
+ width: boardSurfaceRenderGeometry.width,
2732
+ height: boardSurfaceRenderGeometry.height,
2361
2733
  },
2362
2734
  ),
2363
- frameGeometry: boardFrameGeometry,
2735
+ frameGeometry: boardSurfaceRenderGeometry,
2364
2736
  }));
2365
2737
  })()
2366
2738
  : [];
@@ -2368,8 +2740,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2368
2740
  },
2369
2741
  [
2370
2742
  boardFileId,
2371
- boardFrameGeometry,
2372
- getCurrentFrameEntries,
2743
+ boardSurfaceRenderGeometry,
2744
+ getSelectableFrameEntries,
2373
2745
  getResolvedMetadata,
2374
2746
  requestSelectableElementInfos,
2375
2747
  ],
@@ -2576,6 +2948,52 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2576
2948
  const position = frameStyleLeftTop(geometry, labelHeight);
2577
2949
  element.style.left = `${position.left}px`;
2578
2950
  element.style.top = `${position.top}px`;
2951
+ if (kind !== "frame") {
2952
+ // A draft resize (unlike a plain draft move) can also leave width/
2953
+ // height/rotation, and for path/line/arrow/polygon/star kinds the
2954
+ // inner SVG viewBox + path/polygon content, imperatively mutated
2955
+ // mid-gesture (see applyDraftPrimitiveToDom, used by
2956
+ // beginDraftResize's own live mousemove tick). Restoring it here
2957
+ // unconditionally is a harmless no-op for a plain draft move (whose
2958
+ // origin geometry never had a different width/height/rotation to
2959
+ // begin with) and fixes an Escape mid-draft-resize leaving the
2960
+ // shape's box/content visually stuck at its last dragged size.
2961
+ if ("kind" in origin) {
2962
+ applyDraftPrimitiveToDom(element, origin);
2963
+ }
2964
+ return;
2965
+ }
2966
+ // Resize/rotate (unlike plain move) can also leave width/height/
2967
+ // rotation imperatively mutated mid-gesture — restore those here too
2968
+ // so an Escape mid-resize/rotate doesn't leave the frame visually
2969
+ // stuck at its last dragged size/angle even though the geometry
2970
+ // state itself was already rolled back above.
2971
+ element.style.width = `${geometry.width}px`;
2972
+ element.style.transform = geometry.rotation
2973
+ ? `rotate(${geometry.rotation}deg)`
2974
+ : "";
2975
+ element.style.transformOrigin = `${geometry.width / 2}px ${
2976
+ labelHeight + geometry.height / 2
2977
+ }px`;
2978
+ const cardEl = element.querySelector<HTMLElement>("[data-screen-card]");
2979
+ if (cardEl) {
2980
+ cardEl.style.width = `${geometry.width}px`;
2981
+ cardEl.style.height = `${geometry.height}px`;
2982
+ }
2983
+ const iframeEl = element.querySelector<HTMLIFrameElement>(
2984
+ `[data-screen-iframe-id="${CSS.escape(targetId)}"]`,
2985
+ );
2986
+ const screen = screensRef.current.find((s) => s.id === targetId);
2987
+ if (iframeEl && screen) {
2988
+ const viewport = getScreenPreviewViewport(
2989
+ getResolvedMetadata(screen),
2990
+ geometry,
2991
+ );
2992
+ iframeEl.style.width = `${viewport.viewportWidth}px`;
2993
+ iframeEl.style.height = `${viewport.viewportHeight}px`;
2994
+ iframeEl.style.transform =
2995
+ viewport.scale === 1 ? "" : `scale(${viewport.scale})`;
2996
+ }
2579
2997
  });
2580
2998
 
2581
2999
  const selectionBox = surfaceRef.current?.querySelector<HTMLElement>(
@@ -2602,6 +3020,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2602
3020
  const position = frameStyleLeftTop(bounds);
2603
3021
  selectionBox.style.left = `${position.left}px`;
2604
3022
  selectionBox.style.top = `${position.top}px`;
3023
+ // Plain move never touches the selection box's width/height/rotation
3024
+ // (only left/top), but resize/rotate/group-rotate do — restore those
3025
+ // too so Escape can't leave the box's chrome a stale dragged size.
3026
+ selectionBox.style.width = `${bounds.width}px`;
3027
+ selectionBox.style.height = `${bounds.height}px`;
3028
+ const boxRotation =
3029
+ originGeometries.length === 1 ? (originGeometries[0].rotation ?? 0) : 0;
3030
+ selectionBox.style.transform = boxRotation
3031
+ ? `rotate(${boxRotation}deg)`
3032
+ : "";
3033
+ selectionBox.style.transformOrigin = `${bounds.width / 2}px ${bounds.height / 2}px`;
2605
3034
  };
2606
3035
 
2607
3036
  if (state) {
@@ -2611,33 +3040,36 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2611
3040
  state.type === "resize" ||
2612
3041
  state.type === "group-rotate"
2613
3042
  ) {
2614
- if (state.type === "move") {
2615
- // Move drags mutate left/top directly while React deliberately keeps
2616
- // its pre-drag props. A state rollback to those same values does not
2617
- // make React rewrite the externally-mutated styles, so Escape must
2618
- // restore the frame + selection-box DOM positions explicitly.
2619
- restoreImperativeMoveDom(
2620
- state.originFrames,
2621
- state.targetIds,
2622
- "frame",
2623
- );
2624
- }
3043
+ // Move/resize/group-rotate all mutate the frame(s)' DOM directly
3044
+ // (left/top for move; also width/height/rotation for resize and
3045
+ // group-rotate see restoreImperativeMoveDom's comment) while React
3046
+ // deliberately keeps its pre-drag props. A state rollback to those
3047
+ // same values does not make React rewrite the externally-mutated
3048
+ // styles, so Escape must restore the frame + selection-box DOM
3049
+ // state explicitly for every one of these gesture types, not just
3050
+ // move.
3051
+ restoreImperativeMoveDom(state.originFrames, state.targetIds, "frame");
2625
3052
  updateFrameGeometry((current) =>
2626
3053
  frameGeometryWithOverrides(current, state.originFrames),
2627
3054
  );
2628
3055
  } else if (state.type === "rotate") {
3056
+ // Single-frame rotate also mutates the frame's transform directly —
3057
+ // see the matching comment above.
3058
+ restoreImperativeMoveDom(
3059
+ { [state.frameId]: state.originFrame },
3060
+ [state.frameId],
3061
+ "frame",
3062
+ );
2629
3063
  updateFrameGeometry((current) => ({
2630
3064
  ...current,
2631
3065
  [state.frameId]: { ...state.originFrame },
2632
3066
  }));
2633
3067
  } else if (state.type === "draft-move" || state.type === "draft-resize") {
2634
- if (state.type === "draft-move") {
2635
- restoreImperativeMoveDom(
2636
- state.originDrafts,
2637
- state.targetIds,
2638
- "draft",
2639
- );
2640
- }
3068
+ // Both draft-move (left/top only) and draft-resize (also width/
3069
+ // height/rotation + inner SVG content, see restoreImperativeMoveDom's
3070
+ // draft-kind branch) mutate the draft's DOM node directly mid-gesture
3071
+ // — restore it here the same way move/resize/rotate do for frames.
3072
+ restoreImperativeMoveDom(state.originDrafts, state.targetIds, "draft");
2641
3073
  updateDraftPrimitives((current) =>
2642
3074
  current.map((draft) => {
2643
3075
  const origin = state.originDrafts[draft.id];
@@ -2689,6 +3121,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2689
3121
  return false;
2690
3122
  }, [
2691
3123
  finishDrag,
3124
+ getResolvedMetadata,
2692
3125
  updateDraftPrimitives,
2693
3126
  updateFrameGeometry,
2694
3127
  updateSelectedDraftIds,
@@ -2725,6 +3158,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2725
3158
  (e: React.MouseEvent) => {
2726
3159
  e.preventDefault();
2727
3160
  e.stopPropagation();
3161
+ cancelPendingStaticBoardSelection();
2728
3162
  dragState.current = {
2729
3163
  type: "pan",
2730
3164
  originClient: { x: e.clientX, y: e.clientY },
@@ -2760,7 +3194,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2760
3194
 
2761
3195
  installDragListeners(handleMouseMove, handlePanEnd);
2762
3196
  },
2763
- [finishDrag, installDragListeners],
3197
+ [cancelPendingStaticBoardSelection, finishDrag, installDragListeners],
2764
3198
  );
2765
3199
 
2766
3200
  const beginMarquee = useCallback(
@@ -2875,10 +3309,22 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2875
3309
  ) {
2876
3310
  state.hasMoved = true;
2877
3311
  }
3312
+
3313
+ // Never commit a live marquee rect/selection before the drag
3314
+ // threshold is crossed — matches every other gesture in this file
3315
+ // (move/resize/rotate/draft-move/draft-resize all gate on hasMoved
3316
+ // here). Without this, a shift-click that jitters a couple px near an
3317
+ // already-selected frame's edge runs xorMarqueeSelection against a
3318
+ // near-zero-size rect and can toggle that frame OUT of the selection
3319
+ // — a corruption that mouseup's shouldClearSelectionOnEmptyCanvasClick
3320
+ // never rolls back for the additive (shift) case, since it only
3321
+ // restores selection when the gesture is non-additive.
3322
+ if (!state.hasMoved) return;
3323
+
2878
3324
  setMarquee(rect);
2879
3325
 
2880
3326
  const chromeScale = chromeScaleFromZoom(zoomRef.current);
2881
- const hitIds = getCurrentFrameEntries()
3327
+ const hitIds = getSelectableFrameEntries()
2882
3328
  .filter((entry) =>
2883
3329
  rotatedRectIntersects(
2884
3330
  rect,
@@ -2940,7 +3386,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2940
3386
  finishDrag,
2941
3387
  getCanvasPoint,
2942
3388
  getCurrentDraftEntries,
2943
- getCurrentFrameEntries,
3389
+ getSelectableFrameEntries,
2944
3390
  installDragListeners,
2945
3391
  onLayerMarqueeSelectionChange,
2946
3392
  updateSelectedDraftIds,
@@ -3160,6 +3606,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3160
3606
  undefined,
3161
3607
  { nextTool: "pen" },
3162
3608
  );
3609
+ // Keep the Pen tool armed after Enter/Escape/closing a path, matching
3610
+ // Figma. The parent selection callback also receives nextTool="pen",
3611
+ // but board primitives intentionally bypass that generic callback and
3612
+ // asynchronous selection reconciliation can otherwise paint Move for a
3613
+ // frame. Drive the controlled tool explicitly at the commit boundary.
3614
+ onActiveToolChange?.("pen");
3163
3615
  clearActivePenPath();
3164
3616
  },
3165
3617
  [clearActivePenPath, commitDraftPrimitive, onActiveToolChange, toolProps],
@@ -4025,13 +4477,16 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4025
4477
  current.filter((draftId) => !persistedDraftIds.has(draftId)),
4026
4478
  );
4027
4479
  // Reparent each persisted node into the container primitive.
4480
+ // When the drop resolved to a before/after auto-layout slot
4481
+ // (see findAutoLayoutInsertionAnchor), anchor against that
4482
+ // sibling instead of always appending inside the container.
4028
4483
  persisted.forEach((entry) => {
4029
4484
  onPrimitiveReparentRef.current?.({
4030
4485
  sourceNodeId: entry.nodeId,
4031
4486
  sourceScreenId: entry.frameId,
4032
- targetNodeId: dropTarget.nodeId,
4487
+ targetNodeId: dropTarget.anchorNodeId ?? dropTarget.nodeId,
4033
4488
  targetScreenId: dropTarget.screenId,
4034
- placement: "inside",
4489
+ placement: dropTarget.placement ?? "inside",
4035
4490
  });
4036
4491
  });
4037
4492
  const lastPersisted = persisted[persisted.length - 1];
@@ -4165,6 +4620,23 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4165
4620
  setIsDragging(true);
4166
4621
  setDragCursor(getResizeCursor(handle));
4167
4622
 
4623
+ // PERF9: cache each resized draft's DOM node (and the selection-box
4624
+ // overlay tracking it) once, up front — mirrors beginDraftDrag/
4625
+ // beginFrameDrag/beginResize's imperative "mutate now, commit once"
4626
+ // discipline. This used to call updateDraftPrimitives (setDraftPrimitives)
4627
+ // on every mousemove tick, forcing a full MultiScreenCanvas re-render
4628
+ // per rAF frame during a drag.
4629
+ const resizedDraftEls = new Map<string, HTMLElement>();
4630
+ targetIds.forEach((targetId) => {
4631
+ const el = surfaceRef.current?.querySelector<HTMLElement>(
4632
+ `[data-draft-id="${CSS.escape(targetId)}"]`,
4633
+ );
4634
+ if (el) resizedDraftEls.set(targetId, el);
4635
+ });
4636
+ const selectionBoxEl = surfaceRef.current?.querySelector<HTMLElement>(
4637
+ "[data-frame-selection-box]",
4638
+ );
4639
+
4168
4640
  const handleMouseMove = (ev: MouseEvent) => {
4169
4641
  const state = dragState.current;
4170
4642
  if (!state || state.type !== "draft-resize") return;
@@ -4182,7 +4654,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4182
4654
  }
4183
4655
 
4184
4656
  // A resize handle click (or normal 1-2px hand jitter) must not resize
4185
- // the draft. Frame resize already enforces this same threshold.
4657
+ // the draft. Frame resize already enforces this same threshold. Draft
4658
+ // resizes use direct DOM mutation now (see below), so applying even a
4659
+ // 1px delta here would leave a phantom visual nudge React never
4660
+ // learns it needs to overwrite on mouseup — same rationale as
4661
+ // beginDraftDrag's matching guard.
4186
4662
  if (!state.hasMoved) return;
4187
4663
 
4188
4664
  const originEntries = state.targetIds.map((targetId) => ({
@@ -4229,7 +4705,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4229
4705
  // drag-start and must see whichever tool is active on THIS tick.
4230
4706
  const scaleK = effectiveToolRef.current === "scale";
4231
4707
 
4232
- updateDraftPrimitives((current) =>
4708
+ // PERF9: ref-only geometry write (no setDraftPrimitives) + direct DOM
4709
+ // mutation via applyDraftPrimitiveToDom, same "imperative now, commit
4710
+ // once" discipline as beginDraftDrag/beginFrameDrag/beginResize above.
4711
+ updateDraftPrimitivesRefOnly((current) =>
4233
4712
  current.map((draft) => {
4234
4713
  const origin = state.originDrafts[draft.id];
4235
4714
  const geometry = resizedById[draft.id];
@@ -4237,6 +4716,54 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4237
4716
  return applyDraftGeometry(origin, geometry, scaleK);
4238
4717
  }),
4239
4718
  );
4719
+ state.targetIds.forEach((targetId) => {
4720
+ const draft = draftPrimitivesRef.current.find(
4721
+ (candidate) => candidate.id === targetId,
4722
+ );
4723
+ const el = resizedDraftEls.get(targetId);
4724
+ if (draft && el) applyDraftPrimitiveToDom(el, draft);
4725
+ });
4726
+ if (selectionBoxEl) {
4727
+ const targetGeometries = state.targetIds
4728
+ .map(
4729
+ (targetId) =>
4730
+ draftPrimitivesRef.current.find(
4731
+ (candidate) => candidate.id === targetId,
4732
+ )?.geometry,
4733
+ )
4734
+ .filter((geometry): geometry is FrameGeometry => Boolean(geometry));
4735
+ const bounds =
4736
+ targetGeometries.length === 1
4737
+ ? targetGeometries[0]
4738
+ : (() => {
4739
+ const groupBounds = getFrameGroupBounds(
4740
+ targetGeometries.map((geometry) => ({ id: "", geometry })),
4741
+ );
4742
+ return groupBounds
4743
+ ? {
4744
+ x: groupBounds.left,
4745
+ y: groupBounds.top,
4746
+ width: groupBounds.width,
4747
+ height: groupBounds.height,
4748
+ }
4749
+ : null;
4750
+ })();
4751
+ if (bounds) {
4752
+ const { left, top } = frameStyleLeftTop(bounds);
4753
+ selectionBoxEl.style.left = `${left}px`;
4754
+ selectionBoxEl.style.top = `${top}px`;
4755
+ selectionBoxEl.style.width = `${bounds.width}px`;
4756
+ selectionBoxEl.style.height = `${bounds.height}px`;
4757
+ const rotation =
4758
+ targetGeometries.length === 1
4759
+ ? (targetGeometries[0].rotation ?? 0)
4760
+ : 0;
4761
+ selectionBoxEl.style.transform = rotation
4762
+ ? `rotate(${rotation}deg)`
4763
+ : "";
4764
+ selectionBoxEl.style.transformOrigin = `${bounds.width / 2}px ${bounds.height / 2}px`;
4765
+ }
4766
+ }
4240
4767
  setAlignmentGuides(snap.guides);
4241
4768
  showTransformFeedback(
4242
4769
  `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`,
@@ -4245,7 +4772,19 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4245
4772
  );
4246
4773
  };
4247
4774
 
4248
- installDragListeners(handleMouseMove, finishDrag);
4775
+ const handleMouseUp = () => {
4776
+ const state = dragState.current;
4777
+ if (state?.type === "draft-resize" && state.hasMoved) {
4778
+ // PERF9: the live resize above only wrote draftPrimitivesRef (no
4779
+ // setDraftPrimitives per tick) — reconcile React state with the
4780
+ // ref's final values here, exactly once, mirroring beginDraftDrag's
4781
+ // matching handleMouseUp comment.
4782
+ updateDraftPrimitives(() => draftPrimitivesRef.current);
4783
+ }
4784
+ finishDrag();
4785
+ };
4786
+
4787
+ installDragListeners(handleMouseMove, handleMouseUp);
4249
4788
  },
4250
4789
  [
4251
4790
  finishDrag,
@@ -4253,6 +4792,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4253
4792
  installDragListeners,
4254
4793
  showTransformFeedback,
4255
4794
  updateDraftPrimitives,
4795
+ updateDraftPrimitivesRefOnly,
4256
4796
  updateSelectedDraftIds,
4257
4797
  updateSelectedIds,
4258
4798
  ],
@@ -4285,7 +4825,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4285
4825
 
4286
4826
  const beginFrameDrag = useCallback(
4287
4827
  (id: string, e: React.MouseEvent) => {
4288
- if (e.button !== 0 || e.shiftKey) return;
4828
+ if (e.button !== 0 || e.shiftKey || lockedScreenIdSet.has(id)) return;
4289
4829
  e.preventDefault();
4290
4830
  e.stopPropagation();
4291
4831
  // Frame mousedowns stop propagation, so they never reach handleMouseDown.
@@ -4326,6 +4866,24 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4326
4866
  // Figma parity: object drags keep the default arrow cursor, never a
4327
4867
  // grabbing hand — see the matching comment in beginDraftDrag above.
4328
4868
 
4869
+ // The surface itself never moves mid-gesture, so its bounding rect is
4870
+ // invariant for the whole drag. Cache it once instead of letting the
4871
+ // allCommitted/getCanvasPoint branch below call getBoundingClientRect
4872
+ // on every tick — that read would force a synchronous layout reflow
4873
+ // right after this same tick's direct style writes (a classic
4874
+ // write-then-read thrash), scoped to primitive/layer drags specifically
4875
+ // (the only path that reaches that branch).
4876
+ const cachedSurfaceRect = surfaceRef.current?.getBoundingClientRect();
4877
+ const getCanvasPointFromCachedRect = (clientX: number, clientY: number) =>
4878
+ cachedSurfaceRect
4879
+ ? screenToCanvasPoint(
4880
+ { x: clientX, y: clientY },
4881
+ { ...panRef.current, zoom: zoomRef.current },
4882
+ { x: cachedSurfaceRect.left, y: cachedSurfaceRect.top },
4883
+ SURFACE_PADDING,
4884
+ )
4885
+ : getCanvasPoint(clientX, clientY);
4886
+
4329
4887
  // PERF9: cache each dragged screen's DOM node (and the selection-box
4330
4888
  // overlay tracking it) once, up front, instead of querying the DOM on
4331
4889
  // every rAF tick. onStartFrameDrag only ever fires with screen.id
@@ -4520,7 +5078,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4520
5078
  (targetId) => !currentFrameIds.includes(targetId),
4521
5079
  );
4522
5080
  if (allCommitted) {
4523
- const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
5081
+ const canvasPoint = getCanvasPointFromCachedRect(
5082
+ ev.clientX,
5083
+ ev.clientY,
5084
+ );
4524
5085
  updatePrimitiveDropTarget(
4525
5086
  findPrimitiveDropTarget(canvasPoint, state.primaryId),
4526
5087
  );
@@ -4549,12 +5110,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4549
5110
  if (allCommitted && dropTarget) {
4550
5111
  const sourceScreenId = resolvePrimitiveScreenId(state.primaryId);
4551
5112
  if (sourceScreenId) {
5113
+ // When the drop resolved to a before/after auto-layout slot
5114
+ // (see findAutoLayoutInsertionAnchor), anchor against that
5115
+ // sibling instead of always appending inside the container.
4552
5116
  onPrimitiveReparentRef.current?.({
4553
5117
  sourceNodeId: state.primaryId,
4554
5118
  sourceScreenId,
4555
- targetNodeId: dropTarget.nodeId,
5119
+ targetNodeId: dropTarget.anchorNodeId ?? dropTarget.nodeId,
4556
5120
  targetScreenId: dropTarget.screenId,
4557
- placement: "inside",
5121
+ placement: dropTarget.placement ?? "inside",
4558
5122
  });
4559
5123
  suppressNextPick.current = true;
4560
5124
  finishDrag();
@@ -4591,6 +5155,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4591
5155
  getCanvasPoint,
4592
5156
  getCurrentFrameEntries,
4593
5157
  installDragListeners,
5158
+ lockedScreenIdSet,
4594
5159
  onPick,
4595
5160
  resolvePrimitiveScreenId,
4596
5161
  showTransformFeedback,
@@ -4604,7 +5169,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4604
5169
 
4605
5170
  const beginResize = useCallback(
4606
5171
  (id: string, handle: ResizeHandle, e: React.MouseEvent) => {
4607
- if (e.button !== 0) return;
5172
+ if (e.button !== 0 || lockedScreenIdSet.has(id)) return;
4608
5173
  e.preventDefault();
4609
5174
  e.stopPropagation();
4610
5175
  suppressNextPick.current = true;
@@ -4650,6 +5215,131 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4650
5215
  : getResizeCursor(handle),
4651
5216
  );
4652
5217
 
5218
+ // PERF9: mirror beginFrameDrag's imperative-DOM discipline for resize
5219
+ // (previously missing here — every tick called the state-committing
5220
+ // updateFrameGeometry, forcing a full MultiScreenCanvas re-render on
5221
+ // every native mousemove, unthrottled by rAF). Cache each resized
5222
+ // screen's DOM nodes up front, write geometry directly to them per
5223
+ // tick via updateFrameGeometryRefOnly (ref-only, no setFrameGeometry),
5224
+ // and reconcile React state with one real commit in handleMouseUp.
5225
+ const frameLabelHeight =
5226
+ FRAME_LABEL_HEIGHT * chromeScaleFromZoom(zoomRef.current);
5227
+ const resizedFrameEls = new Map<string, HTMLElement>();
5228
+ const resizedScreenCardEls = new Map<string, HTMLElement>();
5229
+ const resizedContentIframeEls = new Map<string, HTMLIFrameElement>();
5230
+ const resizedMetadataById = new Map<
5231
+ string,
5232
+ { width: number; height: number }
5233
+ >();
5234
+ originEntries.forEach((entry) => {
5235
+ const frameEl = surfaceRef.current?.querySelector<HTMLElement>(
5236
+ `[data-frame-id="${CSS.escape(entry.id)}"]`,
5237
+ );
5238
+ if (frameEl) {
5239
+ resizedFrameEls.set(entry.id, frameEl);
5240
+ const cardEl =
5241
+ frameEl.querySelector<HTMLElement>("[data-screen-card]");
5242
+ if (cardEl) resizedScreenCardEls.set(entry.id, cardEl);
5243
+ // Only the plain preview iframe (no custom screenContent, e.g. a
5244
+ // fusion/localhost DesignCanvas) carries this attribute at this
5245
+ // render layer — its own iframe is 100%-sized and follows the
5246
+ // screen-card resize above via CSS with no extra write needed.
5247
+ const iframeEl = frameEl.querySelector<HTMLIFrameElement>(
5248
+ `[data-screen-iframe-id="${CSS.escape(entry.id)}"]`,
5249
+ );
5250
+ if (iframeEl) resizedContentIframeEls.set(entry.id, iframeEl);
5251
+ }
5252
+ const screen = screensRef.current.find((s) => s.id === entry.id);
5253
+ if (screen) {
5254
+ resizedMetadataById.set(entry.id, getResolvedMetadata(screen));
5255
+ }
5256
+ });
5257
+ const resizeSelectionBoxEl =
5258
+ surfaceRef.current?.querySelector<HTMLElement>(
5259
+ "[data-frame-selection-box]",
5260
+ );
5261
+
5262
+ const applyResizedGeometryToDom = (
5263
+ ids: string[],
5264
+ geometryById: FrameGeometryById,
5265
+ ) => {
5266
+ ids.forEach((targetId) => {
5267
+ const geometry = geometryById[targetId];
5268
+ if (!geometry) return;
5269
+ const frameEl = resizedFrameEls.get(targetId);
5270
+ if (frameEl) {
5271
+ const { left, top } = frameStyleLeftTop(geometry, frameLabelHeight);
5272
+ frameEl.style.left = `${left}px`;
5273
+ frameEl.style.top = `${top}px`;
5274
+ frameEl.style.width = `${geometry.width}px`;
5275
+ frameEl.style.transform = geometry.rotation
5276
+ ? `rotate(${geometry.rotation}deg)`
5277
+ : "";
5278
+ frameEl.style.transformOrigin = `${geometry.width / 2}px ${
5279
+ frameLabelHeight + geometry.height / 2
5280
+ }px`;
5281
+ }
5282
+ const cardEl = resizedScreenCardEls.get(targetId);
5283
+ if (cardEl) {
5284
+ cardEl.style.width = `${geometry.width}px`;
5285
+ cardEl.style.height = `${geometry.height}px`;
5286
+ }
5287
+ const iframeEl = resizedContentIframeEls.get(targetId);
5288
+ const metadata = resizedMetadataById.get(targetId);
5289
+ if (iframeEl && metadata) {
5290
+ const viewport = getScreenPreviewViewport(metadata, geometry);
5291
+ iframeEl.style.width = `${viewport.viewportWidth}px`;
5292
+ iframeEl.style.height = `${viewport.viewportHeight}px`;
5293
+ iframeEl.style.transform =
5294
+ viewport.scale === 1 ? "" : `scale(${viewport.scale})`;
5295
+ }
5296
+ });
5297
+
5298
+ // Keep the selection outline + resize/rotate handles glued to the
5299
+ // live geometry — see the matching comment in beginFrameDrag's move
5300
+ // handler for why SelectionBox's own geometry prop can't do this on
5301
+ // its own during a ref-only tick.
5302
+ if (resizeSelectionBoxEl) {
5303
+ const targetGeometries = ids
5304
+ .map((targetId) => geometryById[targetId])
5305
+ .filter((geometry): geometry is FrameGeometry => Boolean(geometry));
5306
+ const bounds =
5307
+ targetGeometries.length === 1
5308
+ ? targetGeometries[0]
5309
+ : (() => {
5310
+ const groupBounds = getFrameGroupBounds(
5311
+ targetGeometries.map((geometry) => ({
5312
+ id: "",
5313
+ geometry,
5314
+ })),
5315
+ );
5316
+ return groupBounds
5317
+ ? {
5318
+ x: groupBounds.left,
5319
+ y: groupBounds.top,
5320
+ width: groupBounds.width,
5321
+ height: groupBounds.height,
5322
+ }
5323
+ : null;
5324
+ })();
5325
+ if (bounds) {
5326
+ const { left, top } = frameStyleLeftTop(bounds);
5327
+ resizeSelectionBoxEl.style.left = `${left}px`;
5328
+ resizeSelectionBoxEl.style.top = `${top}px`;
5329
+ resizeSelectionBoxEl.style.width = `${bounds.width}px`;
5330
+ resizeSelectionBoxEl.style.height = `${bounds.height}px`;
5331
+ const rotation =
5332
+ targetGeometries.length === 1
5333
+ ? (targetGeometries[0].rotation ?? 0)
5334
+ : 0;
5335
+ resizeSelectionBoxEl.style.transform = rotation
5336
+ ? `rotate(${rotation}deg)`
5337
+ : "";
5338
+ resizeSelectionBoxEl.style.transformOrigin = `${bounds.width / 2}px ${bounds.height / 2}px`;
5339
+ }
5340
+ }
5341
+ };
5342
+
4653
5343
  const handleMouseMove = (ev: MouseEvent) => {
4654
5344
  const state = dragState.current;
4655
5345
  if (!state || state.type !== "resize") return;
@@ -4704,26 +5394,52 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4704
5394
  : null;
4705
5395
 
4706
5396
  if (singleRotatedFrame) {
4707
- const resizedGeometry = resizeRotatedFrameFromDelta(
4708
- singleRotatedFrame.geometry,
4709
- state.handle,
4710
- dx,
4711
- dy,
4712
- {
4713
- preserveAspectRatio: lockAspectRatio,
4714
- resizeFromCenter: ev.altKey,
4715
- minWidth: 1,
4716
- minHeight: 1,
4717
- },
4718
- );
4719
- updateFrameGeometry((current) => ({
4720
- ...current,
4721
- [singleRotatedFrame.id]: {
4722
- ...state.originFrames[singleRotatedFrame.id],
4723
- ...resizedGeometry,
4724
- },
4725
- }));
4726
- setAlignmentGuides([]);
5397
+ // Rotation-aware resize snapping (WORK ITEM 2): snap against the
5398
+ // same stationary siblings the unrotated group-resize path below
5399
+ // uses. See resizeRotatedFrameFromDeltaWithSnap's doc comment for
5400
+ // the documented approximation (snaps the frame's own unrotated
5401
+ // local bounds, not a full rotated-edge projection).
5402
+ const { frame: resizedGeometry, guides: rotatedSnapGuides } =
5403
+ resizeRotatedFrameFromDeltaWithSnap(
5404
+ singleRotatedFrame.geometry,
5405
+ state.handle,
5406
+ dx,
5407
+ dy,
5408
+ getCurrentFrameEntries().filter(
5409
+ (entry) => !state.targetIds.includes(entry.id),
5410
+ ),
5411
+ {
5412
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
5413
+ zoom: zoomRef.current,
5414
+ bypass: ev.metaKey || ev.ctrlKey,
5415
+ preserveAspectRatio: lockAspectRatio,
5416
+ },
5417
+ {
5418
+ preserveAspectRatio: lockAspectRatio,
5419
+ resizeFromCenter: ev.altKey,
5420
+ minWidth: 1,
5421
+ minHeight: 1,
5422
+ },
5423
+ );
5424
+ let nextGeometryById: FrameGeometryById | null = null;
5425
+ updateFrameGeometryRefOnly((current) => {
5426
+ const next = {
5427
+ ...current,
5428
+ [singleRotatedFrame.id]: {
5429
+ ...state.originFrames[singleRotatedFrame.id],
5430
+ ...resizedGeometry,
5431
+ },
5432
+ };
5433
+ nextGeometryById = next;
5434
+ return next;
5435
+ });
5436
+ if (nextGeometryById) {
5437
+ applyResizedGeometryToDom(
5438
+ [singleRotatedFrame.id],
5439
+ nextGeometryById,
5440
+ );
5441
+ }
5442
+ setAlignmentGuides(rotatedSnapGuides);
4727
5443
  showTransformFeedback(
4728
5444
  `${Math.round(resizedGeometry.width)} x ${Math.round(resizedGeometry.height)}`,
4729
5445
  ev.clientX,
@@ -4767,7 +5483,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4767
5483
  state.originBounds,
4768
5484
  snap.frame,
4769
5485
  );
4770
- updateFrameGeometry((current) => {
5486
+ let nextGeometryById: FrameGeometryById | null = null;
5487
+ updateFrameGeometryRefOnly((current) => {
4771
5488
  const next = { ...current };
4772
5489
  resizedEntries.forEach((entry) => {
4773
5490
  next[entry.id] = {
@@ -4775,8 +5492,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4775
5492
  ...entry.geometry,
4776
5493
  };
4777
5494
  });
5495
+ nextGeometryById = next;
4778
5496
  return next;
4779
5497
  });
5498
+ if (nextGeometryById) {
5499
+ applyResizedGeometryToDom(state.targetIds, nextGeometryById);
5500
+ }
4780
5501
  setAlignmentGuides(snap.guides);
4781
5502
  showTransformFeedback(
4782
5503
  `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`,
@@ -4794,7 +5515,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4794
5515
  );
4795
5516
  }
4796
5517
  if (state?.type === "resize" && state.hasMoved) {
5518
+ // PERF9: the live resize above only wrote frameGeometryRef (ref-only,
5519
+ // no setFrameGeometry per tick) — reconcile React state with the
5520
+ // ref's final values here, exactly once, mirroring beginFrameDrag's
5521
+ // matching handleMouseUp comment.
4797
5522
  const after = cloneFrameGeometryById(frameGeometryRef.current);
5523
+ setFrameGeometry(after);
5524
+ onGeometryChangeRef.current?.(after);
4798
5525
  onGeometryCommitRef.current?.(
4799
5526
  frameGeometryWithOverrides(after, state.originFrames),
4800
5527
  after,
@@ -4809,10 +5536,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4809
5536
  activeId,
4810
5537
  finishDrag,
4811
5538
  getCurrentFrameEntries,
5539
+ getResolvedMetadata,
4812
5540
  installDragListeners,
5541
+ lockedScreenIdSet,
4813
5542
  onPick,
4814
5543
  showTransformFeedback,
4815
5544
  updateFrameGeometry,
5545
+ updateFrameGeometryRefOnly,
4816
5546
  updateSelectedIds,
4817
5547
  ],
4818
5548
  );
@@ -4828,7 +5558,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4828
5558
 
4829
5559
  const beginRotate = useCallback(
4830
5560
  (id: string, e: React.MouseEvent) => {
4831
- if (e.button !== 0) return;
5561
+ if (e.button !== 0 || lockedScreenIdSet.has(id)) return;
4832
5562
  e.preventDefault();
4833
5563
  e.stopPropagation();
4834
5564
  suppressNextPick.current = true;
@@ -4863,6 +5593,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4863
5593
  rotateCursorDataUri(quantizeAngleTo8Buckets(originPointerAngle)),
4864
5594
  );
4865
5595
 
5596
+ // PERF9: same imperative-DOM discipline as beginFrameDrag/beginResize.
5597
+ // Rotation never changes width/height, so only the frame shell's own
5598
+ // transform (plus the glued selection box) needs a live write per
5599
+ // tick — no screen-card/iframe involvement like resize needs.
5600
+ const rotateFrameEl = surfaceRef.current?.querySelector<HTMLElement>(
5601
+ `[data-frame-id="${CSS.escape(id)}"]`,
5602
+ );
5603
+ const rotateSelectionBoxEl =
5604
+ surfaceRef.current?.querySelector<HTMLElement>(
5605
+ "[data-frame-selection-box]",
5606
+ );
5607
+
4866
5608
  const handleMouseMove = (ev: MouseEvent) => {
4867
5609
  const state = dragState.current;
4868
5610
  if (!state || state.type !== "rotate") return;
@@ -4885,13 +5627,19 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4885
5627
  const raw =
4886
5628
  state.originRotation + pointerAngle - state.originPointerAngle;
4887
5629
  const rotation = ev.shiftKey ? Math.round(raw / 15) * 15 : raw;
4888
- updateFrameGeometry((current) => ({
5630
+ const roundedRotation = Math.round(rotation * 10) / 10;
5631
+ updateFrameGeometryRefOnly((current) => ({
4889
5632
  ...current,
4890
5633
  [state.frameId]: {
4891
5634
  ...state.originFrame,
4892
- rotation: Math.round(rotation * 10) / 10,
5635
+ rotation: roundedRotation,
4893
5636
  },
4894
5637
  }));
5638
+ const rotateTransform = `rotate(${roundedRotation}deg)`;
5639
+ if (rotateFrameEl) rotateFrameEl.style.transform = rotateTransform;
5640
+ if (rotateSelectionBoxEl) {
5641
+ rotateSelectionBoxEl.style.transform = rotateTransform;
5642
+ }
4895
5643
  setDragCursor(
4896
5644
  rotateCursorDataUri(quantizeAngleTo8Buckets(pointerAngle)),
4897
5645
  );
@@ -4912,7 +5660,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4912
5660
  }));
4913
5661
  }
4914
5662
  if (state?.type === "rotate" && state.hasMoved) {
5663
+ // PERF9: the live rotate above only wrote frameGeometryRef
5664
+ // (ref-only, no setFrameGeometry per tick) — reconcile React state
5665
+ // with the ref's final values here, exactly once, mirroring
5666
+ // beginFrameDrag's matching handleMouseUp comment.
4915
5667
  const after = cloneFrameGeometryById(frameGeometryRef.current);
5668
+ setFrameGeometry(after);
5669
+ onGeometryChangeRef.current?.(after);
4916
5670
  onGeometryCommitRef.current?.(
4917
5671
  frameGeometryWithOverrides(after, {
4918
5672
  [state.frameId]: state.originFrame,
@@ -4932,9 +5686,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4932
5686
  getCanvasPoint,
4933
5687
  getCurrentFrameEntries,
4934
5688
  installDragListeners,
5689
+ lockedScreenIdSet,
4935
5690
  onPick,
4936
5691
  showTransformFeedback,
4937
5692
  updateFrameGeometry,
5693
+ updateFrameGeometryRefOnly,
4938
5694
  updateSelectedIds,
4939
5695
  ],
4940
5696
  );
@@ -4979,6 +5735,29 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4979
5735
  rotateCursorDataUri(quantizeAngleTo8Buckets(originPointerAngle)),
4980
5736
  );
4981
5737
 
5738
+ // PERF9: same imperative-DOM discipline as beginRotate/beginResize.
5739
+ // Unlike single rotate, orbiting around a shared center also changes
5740
+ // each member's x/y, so every frame shell needs a live left/top write,
5741
+ // not just transform. The individual PassiveSelectionBox outlines are
5742
+ // intentionally left to the next real render (they briefly lag behind
5743
+ // during the gesture, self-correcting at mouseup) — group rotate is a
5744
+ // rarer gesture and threading a live update through each of those too
5745
+ // is a larger change than this fix covers; the shared GroupSelectionBox
5746
+ // (the interactive handle the user is actually dragging) is kept glued.
5747
+ const groupRotateFrameEls = new Map<string, HTMLElement>();
5748
+ originEntries.forEach((entry) => {
5749
+ const frameEl = surfaceRef.current?.querySelector<HTMLElement>(
5750
+ `[data-frame-id="${CSS.escape(entry.id)}"]`,
5751
+ );
5752
+ if (frameEl) groupRotateFrameEls.set(entry.id, frameEl);
5753
+ });
5754
+ const groupRotateSelectionBoxEl =
5755
+ surfaceRef.current?.querySelector<HTMLElement>(
5756
+ "[data-frame-selection-box]",
5757
+ );
5758
+ const groupFrameLabelHeight =
5759
+ FRAME_LABEL_HEIGHT * chromeScaleFromZoom(zoomRef.current);
5760
+
4982
5761
  const handleMouseMove = (ev: MouseEvent) => {
4983
5762
  const state = dragState.current;
4984
5763
  if (!state || state.type !== "group-rotate") return;
@@ -5012,7 +5791,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5012
5791
  state.groupCenter,
5013
5792
  delta,
5014
5793
  );
5015
- updateFrameGeometry((current) => {
5794
+ let nextGeometryById: FrameGeometryById | null = null;
5795
+ updateFrameGeometryRefOnly((current) => {
5016
5796
  const next = { ...current };
5017
5797
  rotated.forEach((entry) => {
5018
5798
  next[entry.id] = {
@@ -5020,8 +5800,38 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5020
5800
  ...entry.geometry,
5021
5801
  };
5022
5802
  });
5803
+ nextGeometryById = next;
5023
5804
  return next;
5024
5805
  });
5806
+ if (nextGeometryById) {
5807
+ rotated.forEach((entry) => {
5808
+ const geometry = nextGeometryById![entry.id];
5809
+ const frameEl = groupRotateFrameEls.get(entry.id);
5810
+ if (!geometry || !frameEl) return;
5811
+ const { left, top } = frameStyleLeftTop(
5812
+ geometry,
5813
+ groupFrameLabelHeight,
5814
+ );
5815
+ frameEl.style.left = `${left}px`;
5816
+ frameEl.style.top = `${top}px`;
5817
+ frameEl.style.transform = geometry.rotation
5818
+ ? `rotate(${geometry.rotation}deg)`
5819
+ : "";
5820
+ });
5821
+ if (groupRotateSelectionBoxEl) {
5822
+ const groupBoundsNow = getFrameGroupBounds(rotated);
5823
+ if (groupBoundsNow) {
5824
+ const { left, top } = frameStyleLeftTop({
5825
+ x: groupBoundsNow.left,
5826
+ y: groupBoundsNow.top,
5827
+ });
5828
+ groupRotateSelectionBoxEl.style.left = `${left}px`;
5829
+ groupRotateSelectionBoxEl.style.top = `${top}px`;
5830
+ groupRotateSelectionBoxEl.style.width = `${groupBoundsNow.width}px`;
5831
+ groupRotateSelectionBoxEl.style.height = `${groupBoundsNow.height}px`;
5832
+ }
5833
+ }
5834
+ }
5025
5835
  showTransformFeedback(
5026
5836
  `${Math.round(delta)}deg`,
5027
5837
  ev.clientX,
@@ -5038,7 +5848,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5038
5848
  );
5039
5849
  }
5040
5850
  if (state?.type === "group-rotate" && state.hasMoved) {
5851
+ // PERF9: the live group-rotate above only wrote frameGeometryRef
5852
+ // (ref-only, no setFrameGeometry per tick) — reconcile React state
5853
+ // with the ref's final values here, exactly once, mirroring
5854
+ // beginFrameDrag's matching handleMouseUp comment.
5041
5855
  const after = cloneFrameGeometryById(frameGeometryRef.current);
5856
+ setFrameGeometry(after);
5857
+ onGeometryChangeRef.current?.(after);
5042
5858
  onGeometryCommitRef.current?.(
5043
5859
  frameGeometryWithOverrides(after, state.originFrames),
5044
5860
  after,
@@ -5057,12 +5873,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5057
5873
  installDragListeners,
5058
5874
  showTransformFeedback,
5059
5875
  updateFrameGeometry,
5876
+ updateFrameGeometryRefOnly,
5060
5877
  ],
5061
5878
  );
5062
5879
 
5063
5880
  const handleFrameClick = useCallback(
5064
5881
  (id: string, e: React.MouseEvent<HTMLElement>) => {
5065
5882
  e.stopPropagation();
5883
+ if (lockedScreenIdSet.has(id)) return;
5066
5884
  if (suppressNextPick.current) {
5067
5885
  suppressNextPick.current = false;
5068
5886
  return;
@@ -5091,24 +5909,42 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5091
5909
  updateSelectedIds(() => [id]);
5092
5910
  onPick(id);
5093
5911
  },
5094
- [activeId, onPick, updateSelectedDraftIds, updateSelectedIds],
5912
+ [
5913
+ activeId,
5914
+ lockedScreenIdSet,
5915
+ onPick,
5916
+ updateSelectedDraftIds,
5917
+ updateSelectedIds,
5918
+ ],
5095
5919
  );
5096
5920
 
5097
5921
  const handleFrameDoubleClick = useCallback(
5098
5922
  (id: string, e: React.MouseEvent<HTMLElement>) => {
5099
5923
  e.preventDefault();
5100
5924
  e.stopPropagation();
5925
+ if (lockedScreenIdSet.has(id)) {
5926
+ onEdit?.(id);
5927
+ return;
5928
+ }
5101
5929
  updateSelectedDraftIds(() => []);
5102
5930
  updateSelectedIds(() => [id]);
5103
5931
  onPick(id);
5104
5932
  onEdit?.(id);
5105
5933
  },
5106
- [onEdit, onPick, updateSelectedDraftIds, updateSelectedIds],
5934
+ [
5935
+ lockedScreenIdSet,
5936
+ onEdit,
5937
+ onPick,
5938
+ updateSelectedDraftIds,
5939
+ updateSelectedIds,
5940
+ ],
5107
5941
  );
5108
5942
 
5109
5943
  const beginDuplicateGesture = useCallback(
5110
5944
  (screen: ScreenFile, display: string, e: React.MouseEvent<HTMLElement>) => {
5111
- if (e.button !== 0 || !e.altKey) return;
5945
+ if (e.button !== 0 || !e.altKey || lockedScreenIdSet.has(screen.id)) {
5946
+ return;
5947
+ }
5112
5948
  e.preventDefault();
5113
5949
  e.stopPropagation();
5114
5950
  duplicateCleanup.current?.();
@@ -5155,25 +5991,60 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5155
5991
  // duplicate on mouseup.
5156
5992
  setDragCursor(e.altKey ? "copy" : null);
5157
5993
 
5994
+ // PERF: track the last committed canDuplicate/moved/cursor values in
5995
+ // plain closure locals (not state) so the mousemove tick below can tell
5996
+ // whether anything conditional actually changed without reading back
5997
+ // through React state (which the imperative writes below intentionally
5998
+ // stop keeping fresh every tick — see duplicatePreviewElRef).
5999
+ let lastCanDuplicate = !!onDuplicate;
6000
+ let lastMoved = false;
6001
+ let lastCursor: string | null = e.altKey ? "copy" : null;
6002
+
5158
6003
  const handleMouseMove = (ev: MouseEvent) => {
5159
6004
  const dx = ev.clientX - origin.x;
5160
6005
  const dy = ev.clientY - origin.y;
5161
6006
  const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD;
5162
6007
  const rect = surfaceRef.current?.getBoundingClientRect();
5163
- setDuplicatePreview({
5164
- display,
5165
- x: rect ? ev.clientX - rect.left + 16 : ev.clientX,
5166
- y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
5167
- width: previewWidth,
5168
- height: previewHeight,
5169
- // Live alt state, not just capability: if the user releases alt
5170
- // mid-drag the preview should visibly fall back to its "not armed"
5171
- // dashed/preview styling, matching that mouseup will then cancel
5172
- // the duplicate instead of creating one (see handleMouseUp below).
5173
- canDuplicate: !!onDuplicate && ev.altKey,
5174
- moved,
5175
- });
5176
- setDragCursor(ev.altKey ? "copy" : null);
6008
+ const x = rect ? ev.clientX - rect.left + 16 : ev.clientX;
6009
+ const y = rect ? ev.clientY - rect.top + 16 : ev.clientY;
6010
+ // Live alt state, not just capability: if the user releases alt
6011
+ // mid-drag the preview should visibly fall back to its "not armed"
6012
+ // dashed/preview styling, matching that mouseup will then cancel
6013
+ // the duplicate instead of creating one (see handleMouseUp below).
6014
+ const canDuplicate = !!onDuplicate && ev.altKey;
6015
+
6016
+ // PERF9: the ghost's position is a direct DOM write every tick
6017
+ // instead of a setDuplicatePreview (full re-render) — same
6018
+ // "imperative now, commit state only when something conditional
6019
+ // changes" discipline as the frame/draft drag paths above. Falls
6020
+ // back to setDuplicatePreview itself if the node hasn't mounted yet
6021
+ // (e.g. the very first tick right after mousedown, before React has
6022
+ // committed the initial preview).
6023
+ const el = duplicatePreviewElRef.current;
6024
+ if (el) {
6025
+ el.style.left = `${x}px`;
6026
+ el.style.top = `${y}px`;
6027
+ }
6028
+
6029
+ if (!el || canDuplicate !== lastCanDuplicate || moved !== lastMoved) {
6030
+ lastCanDuplicate = canDuplicate;
6031
+ lastMoved = moved;
6032
+ setDuplicatePreview({
6033
+ display,
6034
+ x,
6035
+ y,
6036
+ width: previewWidth,
6037
+ height: previewHeight,
6038
+ canDuplicate,
6039
+ moved,
6040
+ });
6041
+ }
6042
+
6043
+ const cursor = ev.altKey ? "copy" : null;
6044
+ if (cursor !== lastCursor) {
6045
+ lastCursor = cursor;
6046
+ setDragCursor(cursor);
6047
+ }
5177
6048
  };
5178
6049
 
5179
6050
  const cleanupDuplicateGesture = () => {
@@ -5247,6 +6118,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5247
6118
  finishDrag,
5248
6119
  getCurrentFrameEntries,
5249
6120
  installDragListeners,
6121
+ lockedScreenIdSet,
5250
6122
  onDuplicate,
5251
6123
  onPick,
5252
6124
  ],
@@ -5330,6 +6202,39 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5330
6202
  beginDraftCreation(creationTool, e);
5331
6203
  return;
5332
6204
  }
6205
+ if (
6206
+ e.button === 0 &&
6207
+ tool === "move" &&
6208
+ !onFrame &&
6209
+ showBoardStaticPreview &&
6210
+ boardSurfaceRenderGeometry
6211
+ ) {
6212
+ const point = getCanvasPoint(e.clientX, e.clientY);
6213
+ if (!geometryContainsPoint(boardSurfaceRenderGeometry, point)) {
6214
+ const hit = [...boardStaticPrimitives]
6215
+ .reverse()
6216
+ .find((primitive) =>
6217
+ geometryContainsPoint(
6218
+ getPrimitiveLowZoomHitRect(primitive, zoomRef.current),
6219
+ point,
6220
+ ),
6221
+ );
6222
+ if (hit) {
6223
+ e.preventDefault();
6224
+ e.stopPropagation();
6225
+ pendingStaticBoardSelectionRef.current = {
6226
+ nodeId: hit.nodeId,
6227
+ point,
6228
+ };
6229
+ // Re-window the one live Alpine iframe around the clicked static
6230
+ // primitive. DesignCanvas updates its content offset in place, so
6231
+ // the iframe node/srcdoc/runtime survive; the effect above selects
6232
+ // the same node through that live bridge after the offset settles.
6233
+ setBoardSurfaceFocusPoint(point);
6234
+ return;
6235
+ }
6236
+ }
6237
+ }
5333
6238
  if (e.button === 0 && !onFrame) {
5334
6239
  beginMarquee(e);
5335
6240
  }
@@ -5342,10 +6247,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5342
6247
  beginPenNodeCreation,
5343
6248
  beginVectorAnchorDrag,
5344
6249
  beginVectorHandleDrag,
6250
+ boardStaticPrimitives,
6251
+ boardSurfaceRenderGeometry,
5345
6252
  claimKeyboardFocus,
5346
6253
  getCanvasPoint,
5347
6254
  localActiveTool,
5348
6255
  setAltHoverMeasurement,
6256
+ showBoardStaticPreview,
5349
6257
  toggleVectorNodeType,
5350
6258
  vectorEdit,
5351
6259
  ],
@@ -5576,6 +6484,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5576
6484
  // it's an acceptable trade for keeping gesture start/end render-free.
5577
6485
  const markWheelGestureActive = useCallback(() => {
5578
6486
  if (wheelGestureActiveRef.current) return;
6487
+ cancelPendingStaticBoardSelection();
5579
6488
  wheelGestureActiveRef.current = true;
5580
6489
  setWheelCameraGestureActive(true);
5581
6490
  const surface = surfaceRef.current;
@@ -5594,7 +6503,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5594
6503
  element.style.pointerEvents = "none";
5595
6504
  });
5596
6505
  wheelGestureMutedElementsRef.current = muted;
5597
- }, []);
6506
+ }, [cancelPendingStaticBoardSelection]);
5598
6507
 
5599
6508
  const flushPendingWheelGesture = useCallback(() => {
5600
6509
  wheelGestureFrameRef.current = null;
@@ -6164,11 +7073,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6164
7073
  const canvasFrames = useMemo(() => {
6165
7074
  const cache = canvasFrameEntryCacheRef.current;
6166
7075
  const nextIds = new Set<string>();
6167
- const next = screens.map((screen, index) => {
7076
+ const next = renderedScreens.map((screen) => {
6168
7077
  nextIds.add(screen.id);
6169
7078
  const metadata = getResolvedMetadata(screen);
6170
7079
  const geometry =
6171
- frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata);
7080
+ frameGeometry[screen.id] ??
7081
+ getInitialFrameGeometry(screenIndexById.get(screen.id) ?? 0, metadata);
6172
7082
  const prior = cache.get(screen.id);
6173
7083
  if (
6174
7084
  prior &&
@@ -6192,7 +7102,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6192
7102
  }
6193
7103
  pruneResolvedMetadataCache(resolvedMetadataCacheRef.current, nextIds);
6194
7104
  return next;
6195
- }, [frameGeometry, getResolvedMetadata, screens]);
7105
+ }, [frameGeometry, getResolvedMetadata, renderedScreens, screenIndexById]);
6196
7106
  // Interaction-protected screens are never eligible for LRU eviction. Active
6197
7107
  // screen/frame selection are the primary signals; layer selection, native
6198
7108
  // file-drop targeting, gradient editing, and in-flight frame transforms are
@@ -6441,6 +7351,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6441
7351
 
6442
7352
  <div
6443
7353
  ref={worldRef}
7354
+ data-multi-screen-canvas-world
6444
7355
  className="pointer-events-none absolute"
6445
7356
  style={{
6446
7357
  left: 0,
@@ -6456,11 +7367,51 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6456
7367
  transformOrigin: "top left",
6457
7368
  }}
6458
7369
  >
7370
+ {showBoardStaticPreview &&
7371
+ boardFrameGeometry &&
7372
+ boardStaticPreviewViewport &&
7373
+ boardStaticPreviewContent ? (
7374
+ <div
7375
+ data-board-static-preview
7376
+ aria-hidden="true"
7377
+ style={{
7378
+ position: "absolute",
7379
+ left: SURFACE_PADDING + boardFrameGeometry.x,
7380
+ top: SURFACE_PADDING + boardFrameGeometry.y,
7381
+ width: boardFrameGeometry.width,
7382
+ height: boardFrameGeometry.height,
7383
+ overflow: "hidden",
7384
+ pointerEvents: "none",
7385
+ background: "transparent",
7386
+ zIndex: 0,
7387
+ }}
7388
+ >
7389
+ <iframe
7390
+ data-board-static-preview-iframe
7391
+ aria-hidden="true"
7392
+ tabIndex={-1}
7393
+ sandbox=""
7394
+ referrerPolicy="no-referrer"
7395
+ srcDoc={boardStaticPreviewContent}
7396
+ style={{
7397
+ display: "block",
7398
+ width: boardStaticPreviewViewport.width,
7399
+ height: boardStaticPreviewViewport.height,
7400
+ border: 0,
7401
+ pointerEvents: "none",
7402
+ transform: `scale(${boardFrameGeometry.width / boardStaticPreviewViewport.width}, ${boardFrameGeometry.height / boardStaticPreviewViewport.height})`,
7403
+ transformOrigin: "top left",
7404
+ background: "transparent",
7405
+ }}
7406
+ />
7407
+ </div>
7408
+ ) : null}
7409
+
6459
7410
  {boardFileId &&
6460
7411
  boardFileContent !== undefined &&
6461
7412
  hasBoardSurfaceContent(boardFileContent) &&
6462
7413
  (() => {
6463
- const boardGeo = boardFrameGeometry ?? {
7414
+ const boardGeo = boardSurfaceRenderGeometry ?? {
6464
7415
  x: 0,
6465
7416
  y: 0,
6466
7417
  width: 8192,
@@ -6569,6 +7520,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6569
7520
  screen={screen}
6570
7521
  metadata={metadata}
6571
7522
  geometry={geometry}
7523
+ locked={lockedScreenIdSet.has(screen.id)}
6572
7524
  screenContent={screenContentById.get(screen.id)}
6573
7525
  cullTier={screenCullTierById.get(screen.id) ?? "visible"}
6574
7526
  isActive={screen.id === activeId}
@@ -6904,33 +7856,57 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6904
7856
  ) : null}
6905
7857
 
6906
7858
  {primitiveDropTarget ? (
6907
- <span
6908
- data-primitive-drop-target
6909
- className="pointer-events-none absolute z-40 rounded-sm"
6910
- style={{
6911
- // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
6912
- left:
6913
- pan.x +
6914
- (SURFACE_PADDING + primitiveDropTarget.boardRect.x) * scale,
6915
- top:
6916
- pan.y +
6917
- (SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
6918
- width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
6919
- height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
6920
- transform: primitiveDropTarget.boardRect.rotation
6921
- ? `rotate(${primitiveDropTarget.boardRect.rotation}deg)`
6922
- : undefined,
6923
- // Match the in-screen inside-guide style: 2px accent border + 14%
6924
- // accent fill. Uses the same CSS variable as the DesignCanvas guide.
6925
- border: "2px solid var(--design-editor-accent-color)",
6926
- background:
6927
- "color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
6928
- }}
6929
- />
7859
+ primitiveDropTarget.placement === "before" ||
7860
+ primitiveDropTarget.placement === "after" ? (
7861
+ // Auto-layout flow-insert: reuse the exact same insertion-LINE
7862
+ // renderer the cross-screen hit-test guide uses (see
7863
+ // getCrossScreenDropGuideStyle) so both drop paths draw an
7864
+ // identical Figma-style indicator between siblings.
7865
+ <span
7866
+ data-primitive-drop-target
7867
+ data-primitive-drop-placement={primitiveDropTarget.placement}
7868
+ className="pointer-events-none absolute z-40 rounded-sm"
7869
+ style={getCrossScreenDropGuideStyle({
7870
+ guide: {
7871
+ placement: primitiveDropTarget.placement,
7872
+ axis: primitiveDropTarget.axis ?? "y",
7873
+ boardRect: primitiveDropTarget.boardRect,
7874
+ },
7875
+ pan,
7876
+ scale,
7877
+ })}
7878
+ />
7879
+ ) : (
7880
+ <span
7881
+ data-primitive-drop-target
7882
+ className="pointer-events-none absolute z-40 rounded-sm"
7883
+ style={{
7884
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
7885
+ left:
7886
+ pan.x +
7887
+ (SURFACE_PADDING + primitiveDropTarget.boardRect.x) * scale,
7888
+ top:
7889
+ pan.y +
7890
+ (SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
7891
+ width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
7892
+ height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
7893
+ transform: primitiveDropTarget.boardRect.rotation
7894
+ ? `rotate(${primitiveDropTarget.boardRect.rotation}deg)`
7895
+ : undefined,
7896
+ // Match the in-screen inside-guide style: 2px accent border + 14%
7897
+ // accent fill. Uses the same CSS variable as the DesignCanvas guide.
7898
+ border: "2px solid var(--design-editor-accent-color)",
7899
+ background:
7900
+ "color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
7901
+ }}
7902
+ />
7903
+ )
6930
7904
  ) : null}
6931
7905
 
6932
7906
  {duplicatePreview ? (
6933
7907
  <div
7908
+ ref={duplicatePreviewElRef}
7909
+ data-duplicate-preview-ghost
6934
7910
  className={cn(
6935
7911
  "pointer-events-none absolute z-20 rounded-lg border bg-background/90 shadow-2xl backdrop-blur-sm transition-colors",
6936
7912
  duplicatePreview.canDuplicate
@@ -6938,6 +7914,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6938
7914
  : "border-dashed border-muted-foreground/45",
6939
7915
  )}
6940
7916
  style={{
7917
+ // PERF9: left/top stay driven by state (matches the last value
7918
+ // beginDuplicateGesture's mousemove tick imperatively wrote, kept
7919
+ // in sync — see setDuplicatePreview's canDuplicate/moved-gated
7920
+ // calls below), but the DOM node's own style.left/top is what
7921
+ // actually moves every tick, not this re-render.
6941
7922
  left: duplicatePreview.x,
6942
7923
  top: duplicatePreview.y,
6943
7924
  width: duplicatePreview.width * Math.min(scale, 1),
@@ -7805,6 +8786,7 @@ interface ScreenProps {
7805
8786
  screen: ScreenFile;
7806
8787
  metadata: ResolvedScreenMetadata;
7807
8788
  geometry: FrameGeometry;
8789
+ locked: boolean;
7808
8790
  isActive: boolean;
7809
8791
  isSelected: boolean;
7810
8792
  isTopScreen: boolean;
@@ -7870,6 +8852,7 @@ const Screen = memo(function Screen({
7870
8852
  screen,
7871
8853
  metadata,
7872
8854
  geometry,
8855
+ locked,
7873
8856
  isActive,
7874
8857
  isSelected,
7875
8858
  isTopScreen,
@@ -7919,6 +8902,7 @@ const Screen = memo(function Screen({
7919
8902
  const [directlyHovered, setDirectlyHovered] = useState(false);
7920
8903
  const frameDirectlyHovered =
7921
8904
  (directlyHovered || isDirectlyHovered) &&
8905
+ !locked &&
7922
8906
  !creationToolActive &&
7923
8907
  !canvasGestureActive;
7924
8908
  const childHoverActive =
@@ -7949,6 +8933,7 @@ const Screen = memo(function Screen({
7949
8933
  !suppressFrameChromeForChild;
7950
8934
  const screenContentInteractive =
7951
8935
  Boolean(screenContent) &&
8936
+ !locked &&
7952
8937
  !penActive &&
7953
8938
  !creationToolActive &&
7954
8939
  !canvasGestureActive;