@agent-native/core 0.80.7 → 0.80.9

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 (302) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +6 -0
  4. package/corpus/core/docs/content/locales/de-DE/template-design.mdx +8 -0
  5. package/corpus/core/docs/content/locales/es-ES/template-design.mdx +10 -2
  6. package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +10 -2
  7. package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +7 -0
  8. package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +7 -0
  9. package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +7 -0
  10. package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +9 -2
  11. package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +6 -0
  12. package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +6 -0
  13. package/corpus/core/docs/content/template-design.mdx +9 -2
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/cli/mcp.ts +10 -0
  16. package/corpus/core/src/cli/skills.ts +58 -12
  17. package/corpus/core/src/client/AssistantChat.tsx +70 -27
  18. package/corpus/core/src/client/analytics.ts +3 -1
  19. package/corpus/core/src/client/error-format.ts +25 -0
  20. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +13 -11
  21. package/corpus/core/src/client/guided-questions.tsx +32 -4
  22. package/corpus/core/src/client/onboarding/OnboardingPanel.tsx +33 -1
  23. package/corpus/core/src/client/resources/BuiltinCapabilityDetail.tsx +8 -0
  24. package/corpus/core/src/client/resources/use-builtin-capabilities.ts +4 -2
  25. package/corpus/core/src/client/session-replay.ts +34 -0
  26. package/corpus/core/src/client/sharing/ShareButton.tsx +106 -21
  27. package/corpus/core/src/coding-tools/index.ts +1 -1
  28. package/corpus/core/src/mcp/index.ts +2 -0
  29. package/corpus/core/src/mcp/screen-memory-stdio.ts +290 -0
  30. package/corpus/core/src/mcp-client/builtin-capabilities.ts +13 -1
  31. package/corpus/core/src/mcp-client/index.ts +9 -0
  32. package/corpus/core/src/mcp-client/screen-memory-local.ts +461 -0
  33. package/corpus/core/src/onboarding/types.ts +7 -0
  34. package/corpus/core/src/server/agent-chat-plugin.ts +25 -0
  35. package/corpus/core/src/server/agents-bundle.ts +35 -7
  36. package/corpus/core/src/styles/agent-native.css +5 -0
  37. package/corpus/core/src/templates/default/app/global.css +35 -35
  38. package/corpus/templates/analytics/AGENTS.md +6 -0
  39. package/corpus/templates/analytics/actions/github-repo-files.ts +9 -0
  40. package/corpus/templates/analytics/app/components/layout/Layout.tsx +4 -4
  41. package/corpus/templates/analytics/app/global.css +144 -40
  42. package/corpus/templates/analytics/app/pages/adhoc/explorer/index.tsx +1 -1
  43. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +1 -1
  44. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +1 -1
  45. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +4 -4
  46. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +9 -9
  47. package/corpus/templates/analytics/changelog/2026-06-29-agents-can-search-and-read-connected-github-repositories-whe.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-06-29-sessions-and-explorer-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
  49. package/corpus/templates/assets/app/components/layout/Layout.tsx +3 -1
  50. package/corpus/templates/assets/app/components/library/LibraryPresetGrid.tsx +1 -1
  51. package/corpus/templates/assets/app/global.css +99 -35
  52. package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
  53. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +4 -4
  54. package/corpus/templates/assets/app/routes/library.tsx +4 -4
  55. package/corpus/templates/assets/changelog/2026-06-29-asset-library-and-detail-layouts-adapt-when-the-agent-sidebar.md +6 -0
  56. package/corpus/templates/brain/app/components/layout/Layout.tsx +1 -1
  57. package/corpus/templates/brain/app/global.css +63 -35
  58. package/corpus/templates/brain/app/routes/search.tsx +1 -1
  59. package/corpus/templates/brain/app/routes/settings.tsx +1 -1
  60. package/corpus/templates/brain/app/routes/sources.tsx +2 -2
  61. package/corpus/templates/brain/changelog/2026-06-29-search-settings-and-source-layouts-adapt-when-the-agent-sidebar.md +6 -0
  62. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +4 -4
  63. package/corpus/templates/calendar/app/components/calendar/FindTimePanel.tsx +3 -2
  64. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -2
  65. package/corpus/templates/calendar/app/global.css +69 -35
  66. package/corpus/templates/calendar/changelog/2026-06-29-event-and-find-time-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
  67. package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -1
  68. package/corpus/templates/chat/app/global.css +35 -35
  69. package/corpus/templates/chat/changelog/2026-06-29-chat-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  70. package/corpus/templates/clips/AGENTS.md +13 -0
  71. package/corpus/templates/clips/actions/get-screen-memory-status.ts +12 -0
  72. package/corpus/templates/clips/actions/query-screen-memory-context.ts +29 -0
  73. package/corpus/templates/clips/actions/view-screen.ts +2 -2
  74. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  75. package/corpus/templates/clips/app/global.css +47 -35
  76. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +3 -3
  77. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +45 -18
  78. package/corpus/templates/clips/changelog/2026-06-29-meeting-detail-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  79. package/corpus/templates/clips/changelog/2026-06-29-screen-memory-keeps-a-local-rolling-screen-buffer-for-agent-context.md +5 -0
  80. package/corpus/templates/clips/changelog/2026-06-29-video-chat-is-easier-to-find-on-recording-pages-and-s3-setup.md +6 -0
  81. package/corpus/templates/clips/chrome-extension/src/styles.css +39 -39
  82. package/corpus/templates/clips/desktop/src/app.tsx +290 -1
  83. package/corpus/templates/clips/desktop/src/shared/config.ts +83 -0
  84. package/corpus/templates/clips/desktop/src/styles.css +67 -52
  85. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +50 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +17 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +13 -13
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +990 -0
  89. package/corpus/templates/clips/server/plugins/onboarding.ts +2 -0
  90. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  91. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +3 -3
  92. package/corpus/templates/content/app/components/layout/Layout.tsx +1 -1
  93. package/corpus/templates/content/app/global.css +29 -15
  94. package/corpus/templates/content/changelog/2026-06-29-database-gallery-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  95. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  96. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +25 -20
  97. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -0
  98. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +8 -0
  99. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +13 -0
  100. package/corpus/templates/design/AGENTS.md +24 -14
  101. package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1054 -0
  102. package/corpus/templates/design/actions/apply-visual-edit.ts +24 -4
  103. package/corpus/templates/design/actions/edit-design.ts +6 -3
  104. package/corpus/templates/design/actions/export-coding-handoff.ts +12 -4
  105. package/corpus/templates/design/actions/generate-design.ts +6 -21
  106. package/corpus/templates/design/actions/generate-screens.ts +48 -5
  107. package/corpus/templates/design/actions/get-design.ts +1 -0
  108. package/corpus/templates/design/actions/present-design-variants.ts +355 -30
  109. package/corpus/templates/design/actions/update-design.ts +47 -10
  110. package/corpus/templates/design/actions/update-file.ts +33 -8
  111. package/corpus/templates/design/actions/view-screen.ts +89 -31
  112. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +389 -71
  113. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +1 -1
  114. package/corpus/templates/design/app/components/design/EditPanel.tsx +596 -95
  115. package/corpus/templates/design/app/components/design/LayersPanel.tsx +428 -221
  116. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1197 -228
  117. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +94 -217
  118. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +28 -14
  119. package/corpus/templates/design/app/components/design/index.ts +0 -1
  120. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +228 -170
  121. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +140 -13
  122. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +39 -8
  123. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  124. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +75 -5
  125. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +37 -12
  126. package/corpus/templates/design/app/components/design/inspector/index.ts +1 -0
  127. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +17 -4
  128. package/corpus/templates/design/app/components/design/types.ts +9 -0
  129. package/corpus/templates/design/app/components/layout/Layout.tsx +23 -5
  130. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  131. package/corpus/templates/design/app/global.css +77 -49
  132. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  133. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +8 -1
  134. package/corpus/templates/design/app/i18n/zh-TW.ts +4 -0
  135. package/corpus/templates/design/app/i18n-data.ts +55 -3
  136. package/corpus/templates/design/app/pages/DesignEditor.tsx +2783 -658
  137. package/corpus/templates/design/app/root.tsx +1 -1
  138. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  139. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  140. package/corpus/templates/design/changelog/2026-06-29-containers-no-longer-block-selecting-nested-elements-and-nor.md +6 -0
  141. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  142. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  143. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-generate-and-refine-variants-from-stronger.md +6 -0
  144. package/corpus/templates/design/changelog/2026-06-29-design-inspector-controls-now-match-figmas-softer-dark-chrome.md +6 -0
  145. package/corpus/templates/design/changelog/2026-06-29-design-questions-now-appear-as-a-cleaner-canvas-intake-with-.md +6 -0
  146. package/corpus/templates/design/changelog/2026-06-29-design-variant-directions-now-appear-as-board-screens-with-chat-buttons.md +5 -0
  147. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  148. package/corpus/templates/design/changelog/2026-06-29-element-selection-no-longer-snaps-back-to-the-previous-layer.md +6 -0
  149. package/corpus/templates/design/changelog/2026-06-29-frame-resize-measurements-now-stay-beside-the-cursor-while-d.md +6 -0
  150. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  151. package/corpus/templates/design/changelog/2026-06-29-inspector-number-fields-have-cleaner-tooltips-and-draggable-.md +6 -0
  152. package/corpus/templates/design/changelog/2026-06-29-layer-locking-layer-nesting-generated-question-intake-and-im.md +6 -0
  153. package/corpus/templates/design/changelog/2026-06-29-layer-names-in-the-sidebar-can-scroll-horizontally-instead-o.md +6 -0
  154. package/corpus/templates/design/changelog/2026-06-29-layer-nesting-in-the-sidebar-now-uses-tighter-spacing-so-nam.md +6 -0
  155. package/corpus/templates/design/changelog/2026-06-29-layers-no-longer-repeat-document-wrapper-rows-when-a-screen-.md +6 -0
  156. package/corpus/templates/design/changelog/2026-06-29-overview-previews-now-keep-element-hover-and-selection-tied-.md +6 -0
  157. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  158. package/corpus/templates/design/changelog/2026-06-29-pressing-escape-now-cancels-a-stuck-screen-drag-in-the-overv.md +6 -0
  159. package/corpus/templates/design/changelog/2026-06-29-screen-overview-is-easier-to-find-and-named-screens-open-dir.md +6 -0
  160. package/corpus/templates/design/changelog/2026-06-29-screen-overview-only-highlights-screens-when-the-frame-or-ti.md +6 -0
  161. package/corpus/templates/design/changelog/2026-06-29-screen-overview-previews-now-resize-to-match-the-selected-de.md +6 -0
  162. package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-now-uses-a-pointer-cursor-instead-.md +6 -0
  163. package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-outlines-now-stay-locked-to-the-se.md +6 -0
  164. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zoom-now-reports-real-screen-scale-separatel.md +6 -0
  165. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-feels-smoother-and-selection-outline.md +6 -0
  166. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-now-stays-on-the-overview-canvas-ins.md +6 -0
  167. package/corpus/templates/design/changelog/2026-06-29-selecting-a-nested-layer-from-all-screens-now-keeps-you-in-a.md +6 -0
  168. package/corpus/templates/design/changelog/2026-06-29-share-now-groups-link-sharing-exports-and-agent-handoff.md +6 -0
  169. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  170. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-loading-skeleton-now-uses-a-softer-charcoa.md +6 -0
  171. package/corpus/templates/design/changelog/2026-06-29-the-device-preview-menu-now-sits-next-to-the-preview-button-.md +6 -0
  172. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  173. package/corpus/templates/design/changelog/2026-06-29-the-inspector-stays-read-only-while-an-empty-design-is-still.md +6 -0
  174. package/corpus/templates/design/changelog/2026-06-29-the-missing-design-view-now-matches-the-rest-of-the-editor-c.md +6 -0
  175. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-icon-and-tool-option-menus-better-match-the-edi.md +6 -0
  176. package/corpus/templates/design/changelog/2026-06-29-typography-controls-now-keep-the-font-name-visible-and-move-.md +6 -0
  177. package/corpus/templates/design/e2e/README.md +4 -3
  178. package/corpus/templates/design/e2e/global-setup.ts +24 -23
  179. package/corpus/templates/design/e2e/helpers.ts +122 -11
  180. package/corpus/templates/design/playwright.config.ts +19 -4
  181. package/corpus/templates/design/server/lib/coding-handoff.ts +126 -2
  182. package/corpus/templates/design/server/plugins/core-routes.ts +1 -2
  183. package/corpus/templates/design/server/routes/api/design-handoff/[id].zip.get.ts +86 -0
  184. package/corpus/templates/design/shared/code-layer.ts +125 -22
  185. package/corpus/templates/design/shared/color-utils.ts +159 -0
  186. package/corpus/templates/design/shared/generation-session.ts +42 -0
  187. package/corpus/templates/dispatch/app/global.css +79 -35
  188. package/corpus/templates/dispatch/app/routes/integrations.tsx +8 -7
  189. package/corpus/templates/dispatch/changelog/2026-06-29-integration-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  190. package/corpus/templates/forms/app/components/layout/Layout.tsx +6 -2
  191. package/corpus/templates/forms/app/global.css +64 -35
  192. package/corpus/templates/forms/app/pages/FormsListPage.tsx +2 -2
  193. package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +3 -3
  194. package/corpus/templates/forms/changelog/2026-06-29-form-list-and-insight-layouts-adapt-when-the-agent-sidebar.md +6 -0
  195. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +3 -1
  196. package/corpus/templates/macros/app/global.css +25 -9
  197. package/corpus/templates/macros/changelog/2026-06-29-macro-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  198. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +6 -2
  199. package/corpus/templates/mail/app/global.css +42 -36
  200. package/corpus/templates/mail/app/pages/InboxPage.tsx +1 -1
  201. package/corpus/templates/mail/changelog/2026-06-29-the-contact-panel-now-adapts-to-the-available-mail-pane-width.md +6 -0
  202. package/corpus/templates/plan/app/components/layout/Layout.tsx +3 -1
  203. package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +6 -7
  204. package/corpus/templates/plan/app/global.css +74 -42
  205. package/corpus/templates/plan/changelog/2026-06-29-plan-document-blocks-adapt-to-the-available-document-width.md +6 -0
  206. package/corpus/templates/plan/changelog/2026-06-29-plan-titles-and-summaries-can-be-edited-inline-without-focus.md +6 -0
  207. package/corpus/templates/slides/app/components/layout/Layout.tsx +3 -1
  208. package/corpus/templates/slides/app/global.css +49 -33
  209. package/corpus/templates/slides/changelog/2026-06-29-slide-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  210. package/corpus/templates/videos/app/components/layout/Layout.tsx +3 -1
  211. package/corpus/templates/videos/app/global.css +35 -35
  212. package/corpus/templates/videos/changelog/2026-06-29-video-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  213. package/dist/cli/mcp.d.ts.map +1 -1
  214. package/dist/cli/mcp.js +10 -0
  215. package/dist/cli/mcp.js.map +1 -1
  216. package/dist/cli/skills.d.ts.map +1 -1
  217. package/dist/cli/skills.js +58 -12
  218. package/dist/cli/skills.js.map +1 -1
  219. package/dist/client/AssistantChat.d.ts.map +1 -1
  220. package/dist/client/AssistantChat.js +54 -24
  221. package/dist/client/AssistantChat.js.map +1 -1
  222. package/dist/client/analytics.d.ts.map +1 -1
  223. package/dist/client/analytics.js +2 -1
  224. package/dist/client/analytics.js.map +1 -1
  225. package/dist/client/error-format.d.ts.map +1 -1
  226. package/dist/client/error-format.js +17 -0
  227. package/dist/client/error-format.js.map +1 -1
  228. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  229. package/dist/client/extensions/ExtensionViewer.js +3 -4
  230. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  231. package/dist/client/guided-questions.d.ts +4 -0
  232. package/dist/client/guided-questions.d.ts.map +1 -1
  233. package/dist/client/guided-questions.js +16 -7
  234. package/dist/client/guided-questions.js.map +1 -1
  235. package/dist/client/onboarding/OnboardingPanel.js +26 -1
  236. package/dist/client/onboarding/OnboardingPanel.js.map +1 -1
  237. package/dist/client/resources/BuiltinCapabilityDetail.d.ts.map +1 -1
  238. package/dist/client/resources/BuiltinCapabilityDetail.js +1 -1
  239. package/dist/client/resources/BuiltinCapabilityDetail.js.map +1 -1
  240. package/dist/client/resources/use-builtin-capabilities.d.ts +1 -1
  241. package/dist/client/resources/use-builtin-capabilities.d.ts.map +1 -1
  242. package/dist/client/resources/use-builtin-capabilities.js +2 -1
  243. package/dist/client/resources/use-builtin-capabilities.js.map +1 -1
  244. package/dist/client/session-replay.d.ts.map +1 -1
  245. package/dist/client/session-replay.js +31 -0
  246. package/dist/client/session-replay.js.map +1 -1
  247. package/dist/client/sharing/ShareButton.d.ts +14 -0
  248. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  249. package/dist/client/sharing/ShareButton.js +36 -7
  250. package/dist/client/sharing/ShareButton.js.map +1 -1
  251. package/dist/coding-tools/index.js +1 -1
  252. package/dist/coding-tools/index.js.map +1 -1
  253. package/dist/collab/routes.d.ts +1 -1
  254. package/dist/mcp/index.d.ts +2 -0
  255. package/dist/mcp/index.d.ts.map +1 -1
  256. package/dist/mcp/index.js +1 -0
  257. package/dist/mcp/index.js.map +1 -1
  258. package/dist/mcp/screen-memory-stdio.d.ts +6 -0
  259. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -0
  260. package/dist/mcp/screen-memory-stdio.js +213 -0
  261. package/dist/mcp/screen-memory-stdio.js.map +1 -0
  262. package/dist/mcp-client/builtin-capabilities.d.ts +1 -1
  263. package/dist/mcp-client/builtin-capabilities.d.ts.map +1 -1
  264. package/dist/mcp-client/builtin-capabilities.js +10 -0
  265. package/dist/mcp-client/builtin-capabilities.js.map +1 -1
  266. package/dist/mcp-client/index.d.ts +1 -0
  267. package/dist/mcp-client/index.d.ts.map +1 -1
  268. package/dist/mcp-client/index.js +1 -0
  269. package/dist/mcp-client/index.js.map +1 -1
  270. package/dist/mcp-client/screen-memory-local.d.ts +61 -0
  271. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -0
  272. package/dist/mcp-client/screen-memory-local.js +336 -0
  273. package/dist/mcp-client/screen-memory-local.js.map +1 -0
  274. package/dist/onboarding/types.d.ts +7 -0
  275. package/dist/onboarding/types.d.ts.map +1 -1
  276. package/dist/onboarding/types.js.map +1 -1
  277. package/dist/resources/handlers.d.ts +2 -2
  278. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  279. package/dist/server/agent-chat-plugin.js +26 -0
  280. package/dist/server/agent-chat-plugin.js.map +1 -1
  281. package/dist/server/agents-bundle.d.ts.map +1 -1
  282. package/dist/server/agents-bundle.js +22 -7
  283. package/dist/server/agents-bundle.js.map +1 -1
  284. package/dist/server/transcribe-voice.d.ts +1 -1
  285. package/dist/styles/agent-native.css +5 -0
  286. package/dist/templates/default/app/global.css +35 -35
  287. package/docs/content/locales/ar-SA/template-design.mdx +6 -0
  288. package/docs/content/locales/de-DE/template-design.mdx +8 -0
  289. package/docs/content/locales/es-ES/template-design.mdx +10 -2
  290. package/docs/content/locales/fr-FR/template-design.mdx +10 -2
  291. package/docs/content/locales/hi-IN/template-design.mdx +7 -0
  292. package/docs/content/locales/ja-JP/template-design.mdx +7 -0
  293. package/docs/content/locales/ko-KR/template-design.mdx +7 -0
  294. package/docs/content/locales/pt-BR/template-design.mdx +9 -2
  295. package/docs/content/locales/zh-CN/template-design.mdx +6 -0
  296. package/docs/content/locales/zh-TW/template-design.mdx +6 -0
  297. package/docs/content/template-design.mdx +9 -2
  298. package/package.json +1 -1
  299. package/src/templates/default/app/global.css +35 -35
  300. package/corpus/templates/design/app/components/design/VariantGrid.tsx +0 -189
  301. package/corpus/templates/design/app/components/design/VariantHandoffCard.tsx +0 -115
  302. package/corpus/templates/design/app/hooks/use-variant-flow.ts +0 -258
@@ -1,5 +1,9 @@
1
1
  import { useT } from "@agent-native/core/client";
2
2
  import {
3
+ DEFAULT_ASSIGNED_REGION_GAP,
4
+ DEFAULT_ASSIGNED_REGION_HEIGHT,
5
+ DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
6
+ DEFAULT_ASSIGNED_REGION_WIDTH,
3
7
  DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
4
8
  appendPolylinePoint,
5
9
  computeMoveSnap,
@@ -30,10 +34,12 @@ import {
30
34
  } from "@shared/pen-path";
31
35
  import { IconCopy, IconMaximize } from "@tabler/icons-react";
32
36
  import {
37
+ memo,
33
38
  useRef,
34
39
  useState,
35
40
  useCallback,
36
41
  useEffect,
42
+ useMemo,
37
43
  type CSSProperties,
38
44
  type ReactNode,
39
45
  } from "react";
@@ -41,6 +47,8 @@ import {
41
47
  import { prettyScreenName } from "@/lib/screen-names";
42
48
  import { cn } from "@/lib/utils";
43
49
 
50
+ import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "./types";
51
+
44
52
  interface ScreenFile {
45
53
  id: string;
46
54
  filename: string;
@@ -51,6 +59,7 @@ interface ScreenFile {
51
59
  previewState?: string;
52
60
  status?: string;
53
61
  title?: string;
62
+ updatedAt?: string;
54
63
  width?: number;
55
64
  height?: number;
56
65
  url?: string;
@@ -125,6 +134,11 @@ interface MultiScreenCanvasProps {
125
134
  screens: ScreenFile[];
126
135
  zoom: number;
127
136
  activeId?: string | null;
137
+ selectedScreenIds?: string[];
138
+ activeScreenHasHoveredChild?: boolean;
139
+ hoveredChildScreenId?: string | null;
140
+ directlyHoveredScreenId?: string | null;
141
+ previewDeviceFrame?: DeviceFrameType;
128
142
  activeTool?: MultiScreenCanvasTool;
129
143
  toolProps?: CanvasToolProps;
130
144
  onActiveToolChange?: (tool: MultiScreenCanvasTool) => void;
@@ -143,25 +157,27 @@ interface MultiScreenCanvasProps {
143
157
  screenId: string,
144
158
  primitive: CanvasPrimitiveInsert,
145
159
  ) => boolean | string;
160
+ onPrimitiveCreated?: (screenId: string, nodeId: string) => void;
146
161
  onCreateScreenFrame?: (geometry: FrameGeometry) => void;
147
162
  onDeleteSelection?: (ids: string[]) => boolean | void;
148
163
  onZoomChange?: (zoom: number) => void;
149
- onZoomToEdit?: (id: string) => void;
150
- zoomToEditThreshold?: number;
151
164
  renderScreenContent?: (
152
165
  screen: ScreenFile,
153
166
  metadata: ResolvedScreenMetadata,
154
167
  geometry: FrameGeometry,
155
168
  ) => ReactNode;
169
+ onScreenSelectionChange?: (ids: string[]) => void;
156
170
  selectAllRequest?: number;
157
171
  clearSelectionRequest?: number;
172
+ onSelectionChange?: (selectedIds: string[]) => void;
158
173
  }
159
174
 
160
175
  /**
161
176
  * design-editor overview canvas. Renders every file in the design as a movable,
162
177
  * resizable frame inside an infinite, pannable surface.
163
178
  */
164
- const SCREEN_WIDTH = 320;
179
+ export const OVERVIEW_FRAME_WIDTH = 320;
180
+ const SCREEN_WIDTH = OVERVIEW_FRAME_WIDTH;
165
181
  const SCREEN_HEIGHT = 640;
166
182
  const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
167
183
  const SCREEN_GAP = 56;
@@ -169,12 +185,70 @@ const SURFACE_PADDING = 240;
169
185
  const DUPLICATE_DRAG_THRESHOLD = 6;
170
186
  const DRAG_THRESHOLD = 3;
171
187
  const FRAME_LABEL_HEIGHT = 28;
188
+ const FRAME_HEADER_BUTTON_OUTSIDE_WIDTH = 260;
189
+ const FRAME_HEADER_BUTTON_RESERVE = 116;
190
+ const FRAME_HEADER_DIMENSIONS_WIDTH = 72;
191
+ const FRAME_HEADER_TITLE_CHAR_WIDTH = 6.5;
192
+ const FRAME_HEADER_MIN_TITLE_WIDTH = 28;
193
+ const FRAME_HEADER_MAX_TITLE_WIDTH = 180;
194
+ const TRANSFORM_BADGE_OFFSET = 12;
195
+ const TRANSFORM_BADGE_EDGE_PADDING = 8;
196
+ const TRANSFORM_BADGE_HEIGHT = 28;
197
+ const TRANSFORM_BADGE_MIN_WIDTH = 64;
198
+ const TRANSFORM_BADGE_MAX_WIDTH = 180;
172
199
  const MIN_ZOOM = 2;
173
200
  const MAX_ZOOM = 800;
174
- const ZOOM_SENSITIVITY = 0.002;
175
- const MAX_WHEEL_ZOOM_DELTA = 80;
201
+ const ZOOM_SENSITIVITY = 0.01;
202
+ const MAX_WHEEL_ZOOM_DELTA = 120;
176
203
  const MAX_WHEEL_PAN_DELTA = 140;
177
204
  const PIXEL_GRID_ZOOM = 800;
205
+ // During a zoom gesture the constant-size selection chrome is frozen (we don't
206
+ // re-render); on commit it recomputes to its fixed screen size. These transitions
207
+ // are enabled only for that brief settle, so normal selection, resize, and
208
+ // screen-switch geometry stays pinned to the frame.
209
+ const CHROME_SETTLE_MS = 150;
210
+ const CHROME_OPACITY_TRANSITION = "opacity 150ms ease-out";
211
+ const CHROME_BORDER_SETTLE_TRANSITION = `inset ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
212
+ const SELECTION_BOX_SETTLE_TRANSITION = `border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
213
+ const CHROME_HANDLE_SETTLE_TRANSITION = `width ${CHROME_SETTLE_MS}ms ease-out, height ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, top ${CHROME_SETTLE_MS}ms ease-out, bottom ${CHROME_SETTLE_MS}ms ease-out, left ${CHROME_SETTLE_MS}ms ease-out, right ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
214
+ // Frame header (name + dimensions + "Full view" button) is counter-scaled via
215
+ // transform to stay a fixed screen size; ease that scale on zoom-settle. opacity
216
+ // is included so the button's hover-fade (transition-opacity) keeps working.
217
+ const CHROME_LABEL_SETTLE_TRANSITION = `transform ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
218
+
219
+ export function getChromeBorderTransition(chromeSettling: boolean) {
220
+ return chromeSettling
221
+ ? CHROME_BORDER_SETTLE_TRANSITION
222
+ : CHROME_OPACITY_TRANSITION;
223
+ }
224
+
225
+ export function getSelectionBoxTransition(chromeSettling: boolean) {
226
+ return chromeSettling ? SELECTION_BOX_SETTLE_TRANSITION : "none";
227
+ }
228
+
229
+ export function isDirectScreenHoverTarget(
230
+ target: EventTarget | null,
231
+ currentTarget: HTMLElement,
232
+ ) {
233
+ if (target === currentTarget) return true;
234
+ const element =
235
+ target && typeof (target as Element).closest === "function"
236
+ ? (target as Element)
237
+ : null;
238
+ return !!element && !element.closest("[data-screen-content]");
239
+ }
240
+
241
+ function getChromeHandleTransition(chromeSettling: boolean) {
242
+ return chromeSettling
243
+ ? CHROME_HANDLE_SETTLE_TRANSITION
244
+ : CHROME_OPACITY_TRANSITION;
245
+ }
246
+
247
+ function getChromeLabelTransition(chromeSettling: boolean) {
248
+ return chromeSettling
249
+ ? CHROME_LABEL_SETTLE_TRANSITION
250
+ : CHROME_OPACITY_TRANSITION;
251
+ }
178
252
  const DRAFT_FRAME_WIDTH = 320;
179
253
  const DRAFT_FRAME_HEIGHT = 640;
180
254
  const DRAFT_RECT_WIDTH = 160;
@@ -389,10 +463,29 @@ type DragState =
389
463
  | DraftCreateDragState
390
464
  | PenNodeDragState;
391
465
 
466
+ type PendingWheelGesture =
467
+ | {
468
+ mode: "zoom";
469
+ deltaY: number;
470
+ cursor: Point;
471
+ clientX: number;
472
+ clientY: number;
473
+ }
474
+ | {
475
+ mode: "pan";
476
+ deltaX: number;
477
+ deltaY: number;
478
+ };
479
+
392
480
  export function MultiScreenCanvas({
393
481
  screens,
394
482
  zoom,
395
483
  activeId,
484
+ selectedScreenIds,
485
+ activeScreenHasHoveredChild = false,
486
+ hoveredChildScreenId,
487
+ directlyHoveredScreenId,
488
+ previewDeviceFrame = "none",
396
489
  activeTool,
397
490
  toolProps,
398
491
  onActiveToolChange,
@@ -405,14 +498,15 @@ export function MultiScreenCanvas({
405
498
  onGeometryChange,
406
499
  onGeometryCommit,
407
500
  onCreatePrimitive,
501
+ onPrimitiveCreated,
408
502
  onCreateScreenFrame,
409
503
  onDeleteSelection,
410
504
  onZoomChange,
411
- onZoomToEdit,
412
- zoomToEditThreshold,
413
505
  renderScreenContent,
506
+ onScreenSelectionChange,
414
507
  selectAllRequest,
415
508
  clearSelectionRequest,
509
+ onSelectionChange,
416
510
  }: MultiScreenCanvasProps) {
417
511
  const t = useT();
418
512
  const surfaceRef = useRef<HTMLDivElement>(null);
@@ -440,7 +534,7 @@ export function MultiScreenCanvas({
440
534
  const [localActiveTool, setLocalActiveTool] =
441
535
  useState<MultiScreenCanvasTool>("move");
442
536
  const [selectedIds, setSelectedIds] = useState<string[]>(
443
- activeId ? [activeId] : [],
537
+ selectedScreenIds ?? [],
444
538
  );
445
539
  const selectedIdsRef = useRef(selectedIds);
446
540
  const dragState = useRef<DragState | null>(null);
@@ -460,6 +554,26 @@ export function MultiScreenCanvas({
460
554
  const [dragCursor, setDragCursor] = useState<string | null>(null);
461
555
  const suppressNextPick = useRef(false);
462
556
  const feedbackTimerRef = useRef<number | null>(null);
557
+ const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
558
+ const wheelGestureFrameRef = useRef<number | null>(null);
559
+ const worldRef = useRef<HTMLDivElement>(null);
560
+ const pixelGridRef = useRef<HTMLDivElement>(null);
561
+ const viewCommitTimerRef = useRef<number | null>(null);
562
+ const pendingChromeSettleRef = useRef(false);
563
+ const chromeSettleTimerRef = useRef<number | null>(null);
564
+ const [chromeSettling, setChromeSettling] = useState(false);
565
+ const previousPreviewDeviceFrameRef = useRef(previewDeviceFrame);
566
+
567
+ const getResolvedMetadata = useCallback(
568
+ (screen: ScreenFile) =>
569
+ resolveScreenMetadata(
570
+ screen,
571
+ metadataById?.[screen.id],
572
+ getScreenMetadata?.(screen),
573
+ previewDeviceFrame,
574
+ ),
575
+ [getScreenMetadata, metadataById, previewDeviceFrame],
576
+ );
463
577
 
464
578
  useEffect(() => {
465
579
  onGeometryChangeRef.current = onGeometryChange;
@@ -543,10 +657,20 @@ export function MultiScreenCanvas({
543
657
  frameGeometryRef.current = frameGeometry;
544
658
  }, [frameGeometry]);
545
659
 
660
+ const onSelectionChangeRef = useRef(onSelectionChange);
661
+ useEffect(() => {
662
+ onSelectionChangeRef.current = onSelectionChange;
663
+ }, [onSelectionChange]);
664
+
546
665
  useEffect(() => {
547
666
  selectedIdsRef.current = selectedIds;
667
+ onSelectionChangeRef.current?.(selectedIds);
548
668
  }, [selectedIds]);
549
669
 
670
+ useEffect(() => {
671
+ onScreenSelectionChange?.(selectedIds);
672
+ }, [onScreenSelectionChange, selectedIds]);
673
+
550
674
  useEffect(() => {
551
675
  draftPrimitivesRef.current = draftPrimitives;
552
676
  }, [draftPrimitives]);
@@ -569,8 +693,9 @@ export function MultiScreenCanvas({
569
693
  screens.forEach((screen, index) => {
570
694
  const existing = current[screen.id];
571
695
  const persisted = geometryById?.[screen.id];
696
+ const metadata = getResolvedMetadata(screen);
572
697
  const resolved = {
573
- ...getInitialFrameGeometry(index),
698
+ ...getInitialFrameGeometry(index, metadata),
574
699
  ...persisted,
575
700
  } as FrameGeometry;
576
701
  next[screen.id] = persisted ? resolved : (existing ?? resolved);
@@ -588,14 +713,47 @@ export function MultiScreenCanvas({
588
713
  const next = current.filter((id) => currentIds.has(id));
589
714
  return next.length === current.length ? current : next;
590
715
  });
591
- }, [geometryById, screens, updateFrameGeometry, updateSelectedIds]);
716
+ }, [
717
+ geometryById,
718
+ getResolvedMetadata,
719
+ screens,
720
+ updateFrameGeometry,
721
+ updateSelectedIds,
722
+ ]);
723
+
724
+ useEffect(() => {
725
+ const previous = previousPreviewDeviceFrameRef.current;
726
+ previousPreviewDeviceFrameRef.current = previewDeviceFrame;
727
+ if (previous === previewDeviceFrame) return;
728
+
729
+ updateFrameGeometry((current) => {
730
+ const next = { ...current };
731
+ let changed = false;
732
+
733
+ screens.forEach((screen, index) => {
734
+ const metadata = getResolvedMetadata(screen);
735
+ const currentGeometry =
736
+ current[screen.id] ?? getInitialFrameGeometry(index, metadata);
737
+ const nextHeight = getOverviewFrameHeight(
738
+ currentGeometry.width,
739
+ metadata,
740
+ );
741
+ if (currentGeometry.height === nextHeight) return;
742
+ next[screen.id] = {
743
+ ...currentGeometry,
744
+ height: nextHeight,
745
+ };
746
+ changed = true;
747
+ });
748
+
749
+ return changed ? next : current;
750
+ });
751
+ }, [getResolvedMetadata, previewDeviceFrame, screens, updateFrameGeometry]);
592
752
 
593
753
  useEffect(() => {
594
- if (!activeId) return;
595
- updateSelectedIds((current) =>
596
- current.includes(activeId) ? current : [activeId],
597
- );
598
- }, [activeId, updateSelectedIds]);
754
+ if (!selectedScreenIds) return;
755
+ updateSelectedIds(() => selectedScreenIds);
756
+ }, [screens, selectedScreenIds, updateSelectedIds]);
599
757
 
600
758
  useEffect(() => {
601
759
  if (
@@ -628,11 +786,18 @@ export function MultiScreenCanvas({
628
786
  useEffect(() => {
629
787
  if (!surfaceRef.current || screens.length === 0) return;
630
788
  const rect = surfaceRef.current.getBoundingClientRect();
631
- const columns = Math.min(screens.length, 3);
632
- const rows = Math.ceil(screens.length / columns);
633
789
  const scale = zoomRef.current / 100;
634
- const totalWidth = columns * SCREEN_WIDTH + (columns - 1) * SCREEN_GAP;
635
- const totalHeight = rows * SCREEN_CARD_HEIGHT + (rows - 1) * SCREEN_GAP;
790
+ const frames = screens.map((screen, index) =>
791
+ getInitialFrameGeometry(index, getResolvedMetadata(screen)),
792
+ );
793
+ const bounds = getFrameGroupBounds(
794
+ frames.map((geometry, index) => ({
795
+ id: screens[index]?.id ?? String(index),
796
+ geometry,
797
+ })),
798
+ );
799
+ const totalWidth = bounds?.width ?? SCREEN_WIDTH;
800
+ const totalHeight = bounds?.height ?? SCREEN_CARD_HEIGHT;
636
801
  const visualLeft = Math.max(24, (rect.width - totalWidth * scale) / 2);
637
802
  const visualTop = Math.max(24, (rect.height - totalHeight * scale) / 2);
638
803
  const nextPan = {
@@ -641,9 +806,9 @@ export function MultiScreenCanvas({
641
806
  };
642
807
  panRef.current = nextPan;
643
808
  setPan(nextPan);
644
- // Only on mount or when screen count changes, not on every pan update.
809
+ // Only on mount, screen-count changes, or device-preview changes.
645
810
  // eslint-disable-next-line react-hooks/exhaustive-deps
646
- }, [screens.length]);
811
+ }, [previewDeviceFrame, screens.length]);
647
812
 
648
813
  useEffect(() => {
649
814
  return () => {
@@ -652,6 +817,15 @@ export function MultiScreenCanvas({
652
817
  if (feedbackTimerRef.current !== null) {
653
818
  window.clearTimeout(feedbackTimerRef.current);
654
819
  }
820
+ if (wheelGestureFrameRef.current !== null) {
821
+ window.cancelAnimationFrame(wheelGestureFrameRef.current);
822
+ }
823
+ if (viewCommitTimerRef.current !== null) {
824
+ window.clearTimeout(viewCommitTimerRef.current);
825
+ }
826
+ if (chromeSettleTimerRef.current !== null) {
827
+ window.clearTimeout(chromeSettleTimerRef.current);
828
+ }
655
829
  };
656
830
  }, []);
657
831
 
@@ -682,8 +856,17 @@ export function MultiScreenCanvas({
682
856
  }, []);
683
857
 
684
858
  const getCurrentFrameEntries = useCallback(
685
- () => getFrameEntries(screens, frameGeometryRef.current),
686
- [screens],
859
+ () =>
860
+ screens.map((screen, index) => {
861
+ const metadata = getResolvedMetadata(screen);
862
+ return {
863
+ id: screen.id,
864
+ geometry:
865
+ frameGeometryRef.current[screen.id] ??
866
+ getInitialFrameGeometry(index, metadata),
867
+ };
868
+ }),
869
+ [getResolvedMetadata, screens],
687
870
  );
688
871
 
689
872
  const getCurrentDraftEntries = useCallback(
@@ -704,17 +887,20 @@ export function MultiScreenCanvas({
704
887
  (point: Point) =>
705
888
  getCurrentFrameEntries()
706
889
  .map((entry, index) => ({ ...entry, index }))
707
- .filter((entry) =>
708
- rectContainsPoint(
709
- {
710
- left: entry.geometry.x,
711
- top: entry.geometry.y,
712
- right: entry.geometry.x + entry.geometry.width,
713
- bottom: entry.geometry.y + entry.geometry.height,
714
- },
890
+ .filter((entry) => {
891
+ const bounds = {
892
+ left: entry.geometry.x,
893
+ top: entry.geometry.y,
894
+ right: entry.geometry.x + entry.geometry.width,
895
+ bottom: entry.geometry.y + entry.geometry.height,
896
+ };
897
+ const local = rotatePointAroundCenter(
715
898
  point,
716
- ),
717
- )
899
+ getFrameCenter(entry.geometry),
900
+ entry.geometry.rotation ?? 0,
901
+ );
902
+ return rectContainsPoint(bounds, local);
903
+ })
718
904
  .sort(
719
905
  (a, b) =>
720
906
  (b.geometry.z ?? 0) - (a.geometry.z ?? 0) || b.index - a.index,
@@ -722,14 +908,6 @@ export function MultiScreenCanvas({
722
908
  [getCurrentFrameEntries],
723
909
  );
724
910
 
725
- const getFrameAtClientPoint = useCallback(
726
- (clientX: number, clientY: number) => {
727
- const point = canvasPointFromClient(clientX, clientY);
728
- return getFrameEntryAtPoint(point)?.id;
729
- },
730
- [canvasPointFromClient, getFrameEntryAtPoint],
731
- );
732
-
733
911
  const deleteSelectedItems = useCallback(() => {
734
912
  const frameIds = selectedIdsRef.current.filter(
735
913
  (id) => frameGeometryRef.current[id],
@@ -782,6 +960,9 @@ export function MultiScreenCanvas({
782
960
  handleMouseUp: (ev: MouseEvent) => void,
783
961
  ) => {
784
962
  dragCleanup.current?.();
963
+ const restorePreviewPointerEvents = mutePreviewIframePointerEvents(
964
+ surfaceRef.current,
965
+ );
785
966
  let lastMouseEvent: MouseEvent | null = null;
786
967
  const move = (ev: MouseEvent) => {
787
968
  lastMouseEvent = ev;
@@ -799,6 +980,7 @@ export function MultiScreenCanvas({
799
980
  window.removeEventListener("mousemove", move);
800
981
  window.removeEventListener("mouseup", up);
801
982
  window.removeEventListener("blur", cleanupOnBlur);
983
+ restorePreviewPointerEvents();
802
984
  dragCleanup.current = null;
803
985
  };
804
986
  window.addEventListener("mousemove", move);
@@ -821,10 +1003,34 @@ export function MultiScreenCanvas({
821
1003
 
822
1004
  const showTransformFeedback = useCallback(
823
1005
  (text: string, clientX: number, clientY: number) => {
1006
+ const estimatedWidth = Math.min(
1007
+ TRANSFORM_BADGE_MAX_WIDTH,
1008
+ Math.max(TRANSFORM_BADGE_MIN_WIDTH, text.length * 7 + 16),
1009
+ );
1010
+ const maxX = Math.max(
1011
+ TRANSFORM_BADGE_EDGE_PADDING,
1012
+ window.innerWidth - estimatedWidth - TRANSFORM_BADGE_EDGE_PADDING,
1013
+ );
1014
+ const maxY = Math.max(
1015
+ TRANSFORM_BADGE_EDGE_PADDING,
1016
+ window.innerHeight -
1017
+ TRANSFORM_BADGE_HEIGHT -
1018
+ TRANSFORM_BADGE_EDGE_PADDING,
1019
+ );
1020
+ const preferredX =
1021
+ clientX + TRANSFORM_BADGE_OFFSET + estimatedWidth <=
1022
+ window.innerWidth - TRANSFORM_BADGE_EDGE_PADDING
1023
+ ? clientX + TRANSFORM_BADGE_OFFSET
1024
+ : clientX - estimatedWidth - TRANSFORM_BADGE_OFFSET;
1025
+ const preferredY =
1026
+ clientY + TRANSFORM_BADGE_OFFSET + TRANSFORM_BADGE_HEIGHT <=
1027
+ window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
1028
+ ? clientY + TRANSFORM_BADGE_OFFSET
1029
+ : clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
824
1030
  setTransformBadge({
825
1031
  text,
826
- x: clientX + 12,
827
- y: clientY + 12,
1032
+ x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
1033
+ y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
828
1034
  });
829
1035
  },
830
1036
  [],
@@ -846,6 +1052,74 @@ export function MultiScreenCanvas({
846
1052
  dragCleanup.current?.();
847
1053
  }, []);
848
1054
 
1055
+ const cancelActiveDrag = useCallback(() => {
1056
+ let cancelled = false;
1057
+ const state = dragState.current;
1058
+
1059
+ if (state) {
1060
+ cancelled = true;
1061
+ if (state.type === "move" || state.type === "resize") {
1062
+ updateFrameGeometry((current) =>
1063
+ frameGeometryWithOverrides(current, state.originFrames),
1064
+ );
1065
+ } else if (state.type === "rotate") {
1066
+ updateFrameGeometry((current) => ({
1067
+ ...current,
1068
+ [state.frameId]: { ...state.originFrame },
1069
+ }));
1070
+ } else if (state.type === "draft-move" || state.type === "draft-resize") {
1071
+ updateDraftPrimitives((current) =>
1072
+ current.map((draft) => {
1073
+ const origin = state.originDrafts[draft.id];
1074
+ return origin ? cloneDraftPrimitive(origin) : draft;
1075
+ }),
1076
+ );
1077
+ } else if (state.type === "marquee") {
1078
+ updateSelectedIds(() => state.baseSelectedIds);
1079
+ updateSelectedDraftIds(() => state.baseSelectedDraftIds);
1080
+ } else if (state.type === "pen-node") {
1081
+ setActivePenPath(
1082
+ state.pathBefore ? clonePenPath(state.pathBefore) : null,
1083
+ );
1084
+ setPenGesturePreview(null);
1085
+ setPenPointer(null);
1086
+ setPenCloseHover(false);
1087
+ }
1088
+ }
1089
+
1090
+ if (duplicateCleanup.current) {
1091
+ cancelled = true;
1092
+ duplicateCleanup.current();
1093
+ }
1094
+
1095
+ if (cancelled || dragCleanup.current) {
1096
+ finishDrag();
1097
+ return true;
1098
+ }
1099
+ return false;
1100
+ }, [
1101
+ finishDrag,
1102
+ updateDraftPrimitives,
1103
+ updateFrameGeometry,
1104
+ updateSelectedDraftIds,
1105
+ updateSelectedIds,
1106
+ ]);
1107
+
1108
+ useEffect(() => {
1109
+ const handleKeyDown = (event: KeyboardEvent) => {
1110
+ if (event.key !== "Escape") return;
1111
+ if (!cancelActiveDrag()) return;
1112
+ event.preventDefault();
1113
+ event.stopPropagation();
1114
+ event.stopImmediatePropagation();
1115
+ };
1116
+
1117
+ window.addEventListener("keydown", handleKeyDown, true);
1118
+ return () => {
1119
+ window.removeEventListener("keydown", handleKeyDown, true);
1120
+ };
1121
+ }, [cancelActiveDrag]);
1122
+
849
1123
  const beginPan = useCallback(
850
1124
  (e: React.MouseEvent) => {
851
1125
  e.preventDefault();
@@ -912,12 +1186,22 @@ export function MultiScreenCanvas({
912
1186
 
913
1187
  const hitIds = getCurrentFrameEntries()
914
1188
  .filter((entry) =>
915
- rectIntersects(rect, getSelectableBounds(entry.geometry)),
1189
+ rotatedRectIntersects(
1190
+ rect,
1191
+ getSelectableBounds(entry.geometry),
1192
+ getFrameCenter(entry.geometry),
1193
+ entry.geometry.rotation ?? 0,
1194
+ ),
916
1195
  )
917
1196
  .map((entry) => entry.id);
918
1197
  const hitDraftIds = getCurrentDraftEntries()
919
1198
  .filter((entry) =>
920
- rectIntersects(rect, getSelectableBounds(entry.geometry)),
1199
+ rotatedRectIntersects(
1200
+ rect,
1201
+ getSelectableBounds(entry.geometry),
1202
+ getFrameCenter(entry.geometry),
1203
+ entry.geometry.rotation ?? 0,
1204
+ ),
921
1205
  )
922
1206
  .map((entry) => entry.id);
923
1207
  updateSelectedIds(() =>
@@ -962,19 +1246,41 @@ export function MultiScreenCanvas({
962
1246
  : undefined;
963
1247
  if (preferred) return preferred;
964
1248
 
965
- return entries
966
- .filter(({ geometry }) =>
967
- rectContainsPoint(
968
- {
969
- left: geometry.x,
970
- top: geometry.y,
971
- right: geometry.x + geometry.width,
972
- bottom: geometry.y + geometry.height,
973
- },
974
- getFrameCenter(draft.geometry),
975
- ),
976
- )
1249
+ const draftCenter = getFrameCenter(draft.geometry);
1250
+
1251
+ // Primary: find the frame whose bounds contain the draft's center point.
1252
+ const containing = entries
1253
+ .filter(({ geometry }) => {
1254
+ const bounds = {
1255
+ left: geometry.x,
1256
+ top: geometry.y,
1257
+ right: geometry.x + geometry.width,
1258
+ bottom: geometry.y + geometry.height,
1259
+ };
1260
+ const local = rotatePointAroundCenter(
1261
+ draftCenter,
1262
+ getFrameCenter(geometry),
1263
+ geometry.rotation ?? 0,
1264
+ );
1265
+ return rectContainsPoint(bounds, local);
1266
+ })
977
1267
  .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
1268
+
1269
+ if (containing) return containing;
1270
+
1271
+ // Fallback: if no frame contains the center (shape drawn outside all
1272
+ // frames), use the nearest frame so the shape still persists rather than
1273
+ // becoming a lost draft primitive. With a single screen this is always
1274
+ // that screen; with multiple screens it's the visually closest one.
1275
+ if (entries.length === 0) return undefined;
1276
+ if (entries.length === 1) return entries[0];
1277
+ return entries.slice().sort((a, b) => {
1278
+ const ca = getFrameCenter(a.geometry);
1279
+ const cb = getFrameCenter(b.geometry);
1280
+ const da = Math.hypot(draftCenter.x - ca.x, draftCenter.y - ca.y);
1281
+ const db = Math.hypot(draftCenter.x - cb.x, draftCenter.y - cb.y);
1282
+ return da - db;
1283
+ })[0];
978
1284
  },
979
1285
  [getCurrentFrameEntries],
980
1286
  );
@@ -1028,7 +1334,8 @@ export function MultiScreenCanvas({
1028
1334
  current.filter((draft) => draft.id !== nextDraft.id),
1029
1335
  );
1030
1336
  updateSelectedDraftIds(() => []);
1031
- updateSelectedIds(() => [persisted.nodeId]);
1337
+ updateSelectedIds(() => []);
1338
+ onPrimitiveCreated?.(persisted.frameId, persisted.nodeId);
1032
1339
  return;
1033
1340
  }
1034
1341
 
@@ -1038,6 +1345,7 @@ export function MultiScreenCanvas({
1038
1345
  },
1039
1346
  [
1040
1347
  persistDraftPrimitive,
1348
+ onPrimitiveCreated,
1041
1349
  updateDraftPrimitives,
1042
1350
  updateSelectedDraftIds,
1043
1351
  updateSelectedIds,
@@ -1499,16 +1807,6 @@ export function MultiScreenCanvas({
1499
1807
  );
1500
1808
  const lastNodeId = persisted[persisted.length - 1]?.nodeId;
1501
1809
  if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
1502
- } else {
1503
- // No draft landed in a frame — restore each moved draft to its
1504
- // original position so the move is atomic (commit or revert).
1505
- updateDraftPrimitives((current) =>
1506
- current.map((draft) => {
1507
- if (!state.targetIds.includes(draft.id)) return draft;
1508
- const origin = state.originDrafts[draft.id];
1509
- return origin ?? draft;
1510
- }),
1511
- );
1512
1810
  }
1513
1811
  }
1514
1812
  finishDrag();
@@ -1679,14 +1977,21 @@ export function MultiScreenCanvas({
1679
1977
  if (e.button !== 0 || e.shiftKey) return;
1680
1978
  e.preventDefault();
1681
1979
  e.stopPropagation();
1980
+ // Frame mousedowns stop propagation, so they never reach handleMouseDown.
1981
+ // Clear stale suppression here too so a prior gesture can't swallow this
1982
+ // frame's selecting click. This gesture re-arms suppression on mouse-up
1983
+ // only if it actually moves.
1984
+ suppressNextPick.current = false;
1682
1985
 
1683
1986
  const currentSelectedIds = selectedIdsRef.current;
1684
1987
  const targetIds = currentSelectedIds.includes(id)
1685
1988
  ? currentSelectedIds
1686
1989
  : [id];
1990
+ if (activeId !== id) {
1991
+ onPick(id);
1992
+ }
1687
1993
  if (!currentSelectedIds.includes(id)) {
1688
1994
  updateSelectedIds(() => [id]);
1689
- onPick(id);
1690
1995
  }
1691
1996
  updateSelectedDraftIds(() => []);
1692
1997
 
@@ -1781,6 +2086,7 @@ export function MultiScreenCanvas({
1781
2086
  installDragListeners(handleMouseMove, handleMouseUp);
1782
2087
  },
1783
2088
  [
2089
+ activeId,
1784
2090
  finishDrag,
1785
2091
  getCurrentFrameEntries,
1786
2092
  installDragListeners,
@@ -1799,6 +2105,10 @@ export function MultiScreenCanvas({
1799
2105
  e.stopPropagation();
1800
2106
  suppressNextPick.current = true;
1801
2107
 
2108
+ if (activeId !== id) {
2109
+ onPick(id);
2110
+ }
2111
+
1802
2112
  const currentSelectedIds = selectedIdsRef.current;
1803
2113
  const targetIds = currentSelectedIds.includes(id)
1804
2114
  ? currentSelectedIds
@@ -1907,9 +2217,11 @@ export function MultiScreenCanvas({
1907
2217
  installDragListeners(handleMouseMove, handleMouseUp);
1908
2218
  },
1909
2219
  [
2220
+ activeId,
1910
2221
  finishDrag,
1911
2222
  getCurrentFrameEntries,
1912
2223
  installDragListeners,
2224
+ onPick,
1913
2225
  showTransformFeedback,
1914
2226
  updateFrameGeometry,
1915
2227
  updateSelectedIds,
@@ -1932,6 +2244,10 @@ export function MultiScreenCanvas({
1932
2244
  e.stopPropagation();
1933
2245
  suppressNextPick.current = true;
1934
2246
 
2247
+ if (activeId !== id) {
2248
+ onPick(id);
2249
+ }
2250
+
1935
2251
  const originFrame = getCurrentFrameEntries().find(
1936
2252
  (entry) => entry.id === id,
1937
2253
  )?.geometry;
@@ -2003,10 +2319,12 @@ export function MultiScreenCanvas({
2003
2319
  installDragListeners(handleMouseMove, handleMouseUp);
2004
2320
  },
2005
2321
  [
2322
+ activeId,
2006
2323
  finishDrag,
2007
2324
  getCanvasPoint,
2008
2325
  getCurrentFrameEntries,
2009
2326
  installDragListeners,
2327
+ onPick,
2010
2328
  showTransformFeedback,
2011
2329
  updateFrameGeometry,
2012
2330
  updateSelectedIds,
@@ -2023,11 +2341,20 @@ export function MultiScreenCanvas({
2023
2341
 
2024
2342
  if (e.shiftKey) {
2025
2343
  updateSelectedDraftIds(() => []);
2026
- updateSelectedIds((current) =>
2027
- current.includes(id)
2028
- ? current.filter((selectedId) => selectedId !== id)
2029
- : [...current, id],
2030
- );
2344
+ const currentSelectedIds = selectedIdsRef.current;
2345
+ const nextSelectedIds = currentSelectedIds.includes(id)
2346
+ ? currentSelectedIds.filter((selectedId) => selectedId !== id)
2347
+ : [...currentSelectedIds, id];
2348
+ updateSelectedIds(() => nextSelectedIds);
2349
+ const nextPrimaryId =
2350
+ nextSelectedIds.length === 0
2351
+ ? null
2352
+ : nextSelectedIds.includes(id)
2353
+ ? id
2354
+ : (nextSelectedIds[nextSelectedIds.length - 1] ?? null);
2355
+ if (nextPrimaryId && nextPrimaryId !== activeId) {
2356
+ onPick(nextPrimaryId);
2357
+ }
2031
2358
  return;
2032
2359
  }
2033
2360
 
@@ -2035,7 +2362,7 @@ export function MultiScreenCanvas({
2035
2362
  updateSelectedIds(() => [id]);
2036
2363
  onPick(id);
2037
2364
  },
2038
- [onPick, updateSelectedDraftIds, updateSelectedIds],
2365
+ [activeId, onPick, updateSelectedDraftIds, updateSelectedIds],
2039
2366
  );
2040
2367
 
2041
2368
  const handleFrameDoubleClick = useCallback(
@@ -2161,6 +2488,10 @@ export function MultiScreenCanvas({
2161
2488
 
2162
2489
  const handleMouseDown = useCallback(
2163
2490
  (e: React.MouseEvent) => {
2491
+ // Clear any stale pick-suppression left over from a prior resize/rotate/move
2492
+ // gesture that never received its trailing frame click — otherwise it would
2493
+ // silently swallow this unrelated interaction.
2494
+ suppressNextPick.current = false;
2164
2495
  const target = e.target as HTMLElement;
2165
2496
  const onFrame = !!target.closest("[data-frame-shell]");
2166
2497
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
@@ -2204,6 +2535,136 @@ export function MultiScreenCanvas({
2204
2535
  [activeTool, localActiveTool, updatePenPointer],
2205
2536
  );
2206
2537
 
2538
+ // Push the current pan/zoom straight to the DOM. A wheel/pinch gesture must
2539
+ // NEVER re-render React's canvas tree during the gesture: each render re-runs
2540
+ // renderScreenContent (which re-creates the active screen's live DesignCanvas
2541
+ // iframe) and, with React DevTools attached, serializes every render over the
2542
+ // extension bridge — that re-render storm is the real source of zoom jank, not
2543
+ // layout/paint. We mutate the transform directly here and reconcile React
2544
+ // state once, after the gesture settles, via scheduleViewCommit().
2545
+ const applyViewToDom = useCallback(() => {
2546
+ const nextScale = zoomRef.current / 100;
2547
+ const p = panRef.current;
2548
+ const world = worldRef.current;
2549
+ if (world) {
2550
+ // 2D translate (not translate3d) so the layer is not GPU-pinned to a
2551
+ // stale low-res raster — keeps zoomed-in content crisp.
2552
+ world.style.transform = `translate(${p.x}px, ${p.y}px) scale(${nextScale})`;
2553
+ }
2554
+ const grid = pixelGridRef.current;
2555
+ if (grid) {
2556
+ grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
2557
+ grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
2558
+ }
2559
+ }, []);
2560
+
2561
+ const startChromeSettle = useCallback(() => {
2562
+ if (chromeSettleTimerRef.current !== null) {
2563
+ window.clearTimeout(chromeSettleTimerRef.current);
2564
+ }
2565
+ setChromeSettling(true);
2566
+ chromeSettleTimerRef.current = window.setTimeout(() => {
2567
+ chromeSettleTimerRef.current = null;
2568
+ setChromeSettling(false);
2569
+ }, CHROME_SETTLE_MS);
2570
+ }, []);
2571
+
2572
+ const commitView = useCallback(() => {
2573
+ viewCommitTimerRef.current = null;
2574
+ const shouldSettleChrome = pendingChromeSettleRef.current;
2575
+ pendingChromeSettleRef.current = false;
2576
+ if (shouldSettleChrome) startChromeSettle();
2577
+ setCanvasZoom(zoomRef.current);
2578
+ setPan(panRef.current);
2579
+ onZoomChange?.(zoomRef.current);
2580
+ }, [onZoomChange, startChromeSettle]);
2581
+
2582
+ // Debounced: only commit to React state once the gesture has been idle for a
2583
+ // beat, so a continuous pinch produces zero re-renders until the user pauses.
2584
+ const scheduleViewCommit = useCallback(
2585
+ (options?: { settleChrome?: boolean }) => {
2586
+ if (options?.settleChrome) {
2587
+ pendingChromeSettleRef.current = true;
2588
+ }
2589
+ if (viewCommitTimerRef.current !== null) {
2590
+ window.clearTimeout(viewCommitTimerRef.current);
2591
+ }
2592
+ viewCommitTimerRef.current = window.setTimeout(commitView, 120);
2593
+ },
2594
+ [commitView],
2595
+ );
2596
+
2597
+ const flushPendingWheelGesture = useCallback(() => {
2598
+ wheelGestureFrameRef.current = null;
2599
+ const gesture = pendingWheelGestureRef.current;
2600
+ pendingWheelGestureRef.current = null;
2601
+ if (!gesture) return;
2602
+
2603
+ if (gesture.mode === "zoom") {
2604
+ const currentZoom = zoomRef.current;
2605
+ const zoomDeltaY = clamp(
2606
+ gesture.deltaY,
2607
+ -MAX_WHEEL_ZOOM_DELTA,
2608
+ MAX_WHEEL_ZOOM_DELTA,
2609
+ );
2610
+ const nextZoom = clamp(
2611
+ currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
2612
+ MIN_ZOOM,
2613
+ MAX_ZOOM,
2614
+ );
2615
+ if (nextZoom === currentZoom) return;
2616
+
2617
+ const nextPan = getPanForZoomToCursor({
2618
+ pan: panRef.current,
2619
+ cursor: gesture.cursor,
2620
+ oldZoom: currentZoom,
2621
+ nextZoom,
2622
+ });
2623
+ zoomRef.current = nextZoom;
2624
+ panRef.current = nextPan;
2625
+ applyViewToDom();
2626
+ scheduleViewCommit({ settleChrome: true });
2627
+ return;
2628
+ }
2629
+
2630
+ const nextPan = {
2631
+ x: panRef.current.x - gesture.deltaX,
2632
+ y: panRef.current.y - gesture.deltaY,
2633
+ };
2634
+ panRef.current = nextPan;
2635
+ applyViewToDom();
2636
+ scheduleViewCommit();
2637
+ }, [applyViewToDom, scheduleViewCommit]);
2638
+
2639
+ const enqueueWheelGesture = useCallback(
2640
+ (gesture: PendingWheelGesture) => {
2641
+ const pending = pendingWheelGestureRef.current;
2642
+ if (pending?.mode === "zoom" && gesture.mode === "zoom") {
2643
+ pendingWheelGestureRef.current = {
2644
+ mode: "zoom",
2645
+ deltaY: pending.deltaY + gesture.deltaY,
2646
+ cursor: gesture.cursor,
2647
+ clientX: gesture.clientX,
2648
+ clientY: gesture.clientY,
2649
+ };
2650
+ } else if (pending?.mode === "pan" && gesture.mode === "pan") {
2651
+ pendingWheelGestureRef.current = {
2652
+ mode: "pan",
2653
+ deltaX: pending.deltaX + gesture.deltaX,
2654
+ deltaY: pending.deltaY + gesture.deltaY,
2655
+ };
2656
+ } else {
2657
+ pendingWheelGestureRef.current = gesture;
2658
+ }
2659
+
2660
+ if (wheelGestureFrameRef.current !== null) return;
2661
+ wheelGestureFrameRef.current = window.requestAnimationFrame(
2662
+ flushPendingWheelGesture,
2663
+ );
2664
+ },
2665
+ [flushPendingWheelGesture],
2666
+ );
2667
+
2207
2668
  const handleWheelEvent = useCallback(
2208
2669
  (event: WheelEvent) => {
2209
2670
  const rect = surfaceRef.current?.getBoundingClientRect();
@@ -2213,40 +2674,18 @@ export function MultiScreenCanvas({
2213
2674
  const delta = getWheelDelta(event);
2214
2675
 
2215
2676
  if (event.ctrlKey || event.metaKey) {
2216
- const currentZoom = zoomRef.current;
2217
2677
  const zoomDeltaY = clamp(
2218
2678
  delta.y,
2219
2679
  -MAX_WHEEL_ZOOM_DELTA,
2220
2680
  MAX_WHEEL_ZOOM_DELTA,
2221
2681
  );
2222
- const nextZoom = clamp(
2223
- currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
2224
- MIN_ZOOM,
2225
- MAX_ZOOM,
2226
- );
2227
- if (nextZoom === currentZoom) return;
2228
-
2229
- const nextPan = getPanForZoomToCursor({
2230
- pan: panRef.current,
2682
+ enqueueWheelGesture({
2683
+ mode: "zoom",
2684
+ deltaY: zoomDeltaY,
2231
2685
  cursor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
2232
- oldZoom: currentZoom,
2233
- nextZoom,
2686
+ clientX: event.clientX,
2687
+ clientY: event.clientY,
2234
2688
  });
2235
- const zoomEditFrameId =
2236
- zoomToEditThreshold !== undefined &&
2237
- currentZoom < zoomToEditThreshold &&
2238
- nextZoom >= zoomToEditThreshold
2239
- ? getFrameAtClientPoint(event.clientX, event.clientY)
2240
- : undefined;
2241
-
2242
- zoomRef.current = nextZoom;
2243
- panRef.current = nextPan;
2244
- setCanvasZoom(nextZoom);
2245
- setPan(nextPan);
2246
- onZoomChange?.(nextZoom);
2247
- if (zoomEditFrameId) {
2248
- onZoomToEdit?.(zoomEditFrameId);
2249
- }
2250
2689
  return;
2251
2690
  }
2252
2691
 
@@ -2260,14 +2699,9 @@ export function MultiScreenCanvas({
2260
2699
  -MAX_WHEEL_PAN_DELTA,
2261
2700
  MAX_WHEEL_PAN_DELTA,
2262
2701
  );
2263
- const nextPan = {
2264
- x: panRef.current.x - deltaX,
2265
- y: panRef.current.y - deltaY,
2266
- };
2267
- panRef.current = nextPan;
2268
- setPan(nextPan);
2702
+ enqueueWheelGesture({ mode: "pan", deltaX, deltaY });
2269
2703
  },
2270
- [getFrameAtClientPoint, onZoomChange, onZoomToEdit, zoomToEditThreshold],
2704
+ [enqueueWheelGesture],
2271
2705
  );
2272
2706
 
2273
2707
  useEffect(() => {
@@ -2462,12 +2896,59 @@ export function MultiScreenCanvas({
2462
2896
  if (tool !== "pen") clearActivePenPath();
2463
2897
  }, [activeTool, clearActivePenPath, localActiveTool]);
2464
2898
 
2899
+ // Cmd+D / Ctrl+D: duplicate the selected frame with a visible offset so the
2900
+ // copy doesn't land exactly on top of the original (Figma-style behaviour).
2901
+ useEffect(() => {
2902
+ if (!onDuplicate) return;
2903
+ const handleKeyDown = (event: KeyboardEvent) => {
2904
+ if (activePenPathRef.current) return;
2905
+ if (
2906
+ !(event.metaKey || event.ctrlKey) ||
2907
+ event.key.toLowerCase() !== "d" ||
2908
+ isEditableHotkeyTarget(event.target)
2909
+ ) {
2910
+ return;
2911
+ }
2912
+ // Only act on frame IDs — filter out canvas primitives (sub-elements).
2913
+ const frameIds = selectedIdsRef.current.filter(
2914
+ (id) => frameGeometryRef.current[id],
2915
+ );
2916
+ const targetId = frameIds[0];
2917
+ if (!targetId) return;
2918
+ const screen = screens.find((s) => s.id === targetId);
2919
+ if (!screen) return;
2920
+ const sourceGeometry = frameGeometryRef.current[targetId];
2921
+ if (!sourceGeometry) return;
2922
+ event.preventDefault();
2923
+ event.stopPropagation();
2924
+ event.stopImmediatePropagation();
2925
+ // Offset the duplicate by one grid gap to the right (and slightly down)
2926
+ // so it is visually distinct from the original, mirroring Figma's behaviour.
2927
+ const canvasPosition = {
2928
+ x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
2929
+ y: sourceGeometry.y,
2930
+ };
2931
+ onDuplicate(targetId, {
2932
+ mode: "alt-click",
2933
+ screen,
2934
+ canvasPosition,
2935
+ dropCanvasPosition: canvasPosition,
2936
+ });
2937
+ };
2938
+
2939
+ window.addEventListener("keydown", handleKeyDown, true);
2940
+ return () => {
2941
+ window.removeEventListener("keydown", handleKeyDown, true);
2942
+ };
2943
+ }, [onDuplicate, screens]);
2944
+
2465
2945
  const scale = canvasZoom / 100;
2466
2946
  const chromeScale = scale > 0 ? 1 / scale : 1;
2467
2947
  const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
2468
2948
  const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
2469
2949
  const penActive = effectiveTool === "pen";
2470
2950
  const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
2951
+ const canvasGestureActive = isDragging || isPanning;
2471
2952
  const displayedPenPath = penGesturePreview
2472
2953
  ? penGesturePreview
2473
2954
  : activePenPath && penPointer && activePenPath.nodes.length > 0
@@ -2488,10 +2969,29 @@ export function MultiScreenCanvas({
2488
2969
  : effectiveTool === "hand"
2489
2970
  ? "grab"
2490
2971
  : "default";
2491
- const canvasFrames = screens.map((screen, index) => ({
2492
- screen,
2493
- geometry: frameGeometry[screen.id] ?? getInitialFrameGeometry(index),
2494
- }));
2972
+ const canvasFrames = screens.map((screen, index) => {
2973
+ const metadata = getResolvedMetadata(screen);
2974
+ return {
2975
+ screen,
2976
+ metadata,
2977
+ geometry:
2978
+ frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata),
2979
+ };
2980
+ });
2981
+ const screenContentById = useMemo(() => {
2982
+ if (!renderScreenContent) return new Map<string, ReactNode>();
2983
+ return new Map(
2984
+ screens.map((screen, index) => {
2985
+ const metadata = getResolvedMetadata(screen);
2986
+ const geometry =
2987
+ frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata);
2988
+ return [
2989
+ screen.id,
2990
+ renderScreenContent(screen, metadata, geometry),
2991
+ ] as const;
2992
+ }),
2993
+ );
2994
+ }, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
2495
2995
  const selectedFrameEntries = canvasFrames
2496
2996
  .filter(({ screen }) => selectedIdSet.has(screen.id))
2497
2997
  .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
@@ -2507,16 +3007,25 @@ export function MultiScreenCanvas({
2507
3007
  selectedDraftEntries.length > 1
2508
3008
  ? getFrameGroupBounds(selectedDraftEntries)
2509
3009
  : null;
3010
+ const singleSelectedFrame =
3011
+ selectedFrameEntries.length === 1 && !selectedGroupBounds
3012
+ ? selectedFrameEntries[0]
3013
+ : null;
3014
+ const singleSelectedDraft =
3015
+ selectedDraftEntries.length === 1 && !selectedDraftGroupBounds
3016
+ ? selectedDraftEntries[0]
3017
+ : null;
2510
3018
  return (
2511
3019
  <div
2512
3020
  ref={surfaceRef}
2513
- className="relative h-full w-full select-none overflow-hidden bg-background"
3021
+ className="relative h-full w-full select-none overflow-hidden"
2514
3022
  onMouseDown={handleMouseDown}
2515
3023
  onMouseMove={handleMouseMove}
2516
3024
  style={{ cursor: surfaceCursor, touchAction: "none" }}
2517
3025
  >
2518
3026
  {showPixelGrid ? (
2519
3027
  <div
3028
+ ref={pixelGridRef}
2520
3029
  className="pointer-events-none absolute inset-0 opacity-60"
2521
3030
  style={{
2522
3031
  backgroundImage:
@@ -2528,34 +3037,44 @@ export function MultiScreenCanvas({
2528
3037
  ) : null}
2529
3038
 
2530
3039
  <div
3040
+ ref={worldRef}
2531
3041
  className="pointer-events-none absolute"
2532
3042
  style={{
2533
- left: pan.x,
2534
- top: pan.y,
2535
- transform: `scale(${scale})`,
3043
+ left: 0,
3044
+ top: 0,
3045
+ // Plain 2D transform — NO will-change / translate3d. Forcing a
3046
+ // compositor layer pins a low-res cached raster that the GPU stretches
3047
+ // when you zoom in, leaving screen content permanently blurry. A 2D
3048
+ // transform lets the browser re-rasterize crisply at rest. Zoom smoothness
3049
+ // comes from never re-rendering React during the gesture (see
3050
+ // flushPendingWheelGesture / applyViewToDom), not from layer promotion —
3051
+ // the trace proved paint/composite was never the bottleneck.
3052
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
2536
3053
  transformOrigin: "top left",
2537
3054
  }}
2538
3055
  >
2539
- {canvasFrames.map(({ screen, geometry }) => {
2540
- const metadata = resolveScreenMetadata(
2541
- screen,
2542
- metadataById?.[screen.id],
2543
- getScreenMetadata?.(screen),
2544
- );
3056
+ {canvasFrames.map(({ screen, metadata, geometry }) => {
2545
3057
  return (
2546
3058
  <Screen
2547
3059
  key={screen.id}
2548
3060
  screen={screen}
2549
3061
  metadata={metadata}
2550
3062
  geometry={geometry}
2551
- screenContent={renderScreenContent?.(screen, metadata, geometry)}
3063
+ screenContent={screenContentById.get(screen.id)}
2552
3064
  isActive={screen.id === activeId}
2553
3065
  isSelected={selectedIdSet.has(screen.id)}
3066
+ isDirectlyHovered={screen.id === directlyHoveredScreenId}
3067
+ hasHoveredChild={
3068
+ (screen.id === activeId && activeScreenHasHoveredChild) ||
3069
+ screen.id === hoveredChildScreenId
3070
+ }
2554
3071
  groupSelected={hasGroupSelection}
2555
3072
  handlesEnabled={!hasGroupSelection}
2556
3073
  penActive={penActive}
2557
3074
  creationToolActive={creationToolActive}
3075
+ canvasGestureActive={canvasGestureActive}
2558
3076
  chromeScale={chromeScale}
3077
+ chromeSettling={chromeSettling}
2559
3078
  onPick={handleFrameClick}
2560
3079
  onEdit={handleFrameDoubleClick}
2561
3080
  onStartFrameDrag={beginFrameDrag}
@@ -2574,6 +3093,7 @@ export function MultiScreenCanvas({
2574
3093
  groupSelected={Boolean(selectedDraftGroupBounds)}
2575
3094
  penActive={penActive}
2576
3095
  chromeScale={chromeScale}
3096
+ chromeSettling={chromeSettling}
2577
3097
  onClick={handleDraftClick}
2578
3098
  onStartDrag={beginDraftDrag}
2579
3099
  onStartResize={beginDraftResize}
@@ -2588,6 +3108,7 @@ export function MultiScreenCanvas({
2588
3108
  groupSelected={false}
2589
3109
  penActive={penActive}
2590
3110
  chromeScale={chromeScale}
3111
+ chromeSettling={chromeSettling}
2591
3112
  onClick={() => {}}
2592
3113
  onStartDrag={() => {}}
2593
3114
  onStartResize={() => {}}
@@ -2598,10 +3119,39 @@ export function MultiScreenCanvas({
2598
3119
  <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
2599
3120
  ) : null}
2600
3121
 
3122
+ {singleSelectedFrame ? (
3123
+ <SelectionBox
3124
+ geometry={singleSelectedFrame.geometry}
3125
+ chromeScale={chromeScale}
3126
+ chromeSettling={chromeSettling}
3127
+ showRotate
3128
+ onStartResize={(handle, event) =>
3129
+ beginResize(singleSelectedFrame.id, handle, event)
3130
+ }
3131
+ onStartRotate={(event) =>
3132
+ beginRotate(singleSelectedFrame.id, event)
3133
+ }
3134
+ />
3135
+ ) : null}
3136
+
3137
+ {singleSelectedDraft ? (
3138
+ <SelectionBox
3139
+ geometry={singleSelectedDraft.geometry}
3140
+ chromeScale={chromeScale}
3141
+ chromeSettling={chromeSettling}
3142
+ showRotate={false}
3143
+ onStartResize={(handle, event) =>
3144
+ beginDraftResize(singleSelectedDraft.id, handle, event)
3145
+ }
3146
+ onStartRotate={() => {}}
3147
+ />
3148
+ ) : null}
3149
+
2601
3150
  {selectedGroupBounds ? (
2602
3151
  <GroupSelectionBox
2603
3152
  bounds={selectedGroupBounds}
2604
3153
  chromeScale={chromeScale}
3154
+ chromeSettling={chromeSettling}
2605
3155
  onStartResize={beginGroupResize}
2606
3156
  />
2607
3157
  ) : null}
@@ -2610,6 +3160,7 @@ export function MultiScreenCanvas({
2610
3160
  <GroupSelectionBox
2611
3161
  bounds={selectedDraftGroupBounds}
2612
3162
  chromeScale={chromeScale}
3163
+ chromeSettling={chromeSettling}
2613
3164
  onStartResize={beginDraftGroupResize}
2614
3165
  />
2615
3166
  ) : null}
@@ -2635,20 +3186,23 @@ export function MultiScreenCanvas({
2635
3186
  }
2636
3187
  />
2637
3188
  ))}
2638
-
2639
- {marquee ? (
2640
- <span
2641
- className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
2642
- style={{
2643
- left: SURFACE_PADDING + marquee.x,
2644
- top: SURFACE_PADDING + marquee.y,
2645
- width: marquee.width,
2646
- height: marquee.height,
2647
- }}
2648
- />
2649
- ) : null}
2650
3189
  </div>
2651
3190
 
3191
+ {marquee ? (
3192
+ <span
3193
+ className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
3194
+ style={{
3195
+ // Convert canvas-space marquee to surface-space so this overlay
3196
+ // is never clipped or hidden by the canvas transform container.
3197
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3198
+ left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
3199
+ top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
3200
+ width: Math.max(1, marquee.width * scale),
3201
+ height: Math.max(1, marquee.height * scale),
3202
+ }}
3203
+ />
3204
+ ) : null}
3205
+
2652
3206
  {duplicatePreview ? (
2653
3207
  <div
2654
3208
  className={cn(
@@ -2684,7 +3238,8 @@ export function MultiScreenCanvas({
2684
3238
 
2685
3239
  {transformBadge ? (
2686
3240
  <div
2687
- className="pointer-events-none absolute z-50 rounded border border-border bg-background/95 px-1.5 py-0.5 font-mono text-[11px] leading-5 text-foreground shadow-lg backdrop-blur"
3241
+ data-transform-badge
3242
+ className="pointer-events-none fixed z-50 rounded border border-border bg-background/95 px-1.5 py-0.5 font-mono text-[11px] leading-5 text-foreground shadow-lg backdrop-blur"
2688
3243
  style={{ left: transformBadge.x, top: transformBadge.y }}
2689
3244
  >
2690
3245
  {transformBadge.text}
@@ -2700,6 +3255,7 @@ function DraftPrimitiveLayer({
2700
3255
  groupSelected,
2701
3256
  penActive,
2702
3257
  chromeScale,
3258
+ chromeSettling,
2703
3259
  preview = false,
2704
3260
  onClick,
2705
3261
  onStartDrag,
@@ -2710,6 +3266,7 @@ function DraftPrimitiveLayer({
2710
3266
  groupSelected: boolean;
2711
3267
  penActive: boolean;
2712
3268
  chromeScale: number;
3269
+ chromeSettling: boolean;
2713
3270
  preview?: boolean;
2714
3271
  onClick: (id: string, e: React.MouseEvent) => void;
2715
3272
  onStartDrag: (id: string, e: React.MouseEvent) => void;
@@ -2727,7 +3284,7 @@ function DraftPrimitiveLayer({
2727
3284
  type="button"
2728
3285
  className={cn(
2729
3286
  "group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
2730
- preview || penActive ? "cursor-crosshair" : "cursor-move",
3287
+ preview || penActive ? "cursor-crosshair" : "cursor-pointer",
2731
3288
  )}
2732
3289
  style={{
2733
3290
  left: SURFACE_PADDING + geometry.x,
@@ -2752,20 +3309,24 @@ function DraftPrimitiveLayer({
2752
3309
  <span
2753
3310
  className={cn(
2754
3311
  "pointer-events-none absolute rounded-sm border transition-opacity",
2755
- selected
3312
+ preview
2756
3313
  ? "border-[var(--design-editor-accent-color)] opacity-100"
2757
- : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
3314
+ : selected
3315
+ ? "border-transparent opacity-0"
3316
+ : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
2758
3317
  )}
2759
3318
  style={{
2760
3319
  inset: -5 * chromeScale,
2761
3320
  borderWidth: 1.5 * chromeScale,
3321
+ transition: getChromeBorderTransition(chromeSettling),
2762
3322
  }}
2763
3323
  />
2764
3324
  <ResizeHandles
2765
- active={selected || preview}
2766
- enabled={!penActive && ((isSelected && !groupSelected) || preview)}
3325
+ active={preview}
3326
+ enabled={!penActive && preview}
2767
3327
  showRotate={false}
2768
3328
  chromeScale={chromeScale}
3329
+ chromeSettling={chromeSettling}
2769
3330
  onStartResize={(handle, event) =>
2770
3331
  onStartResize(draft.id, handle, event)
2771
3332
  }
@@ -3006,35 +3567,21 @@ function isPoint(point: Point | undefined): point is Point {
3006
3567
  return !!point;
3007
3568
  }
3008
3569
 
3009
- function Screen({
3010
- screen,
3011
- metadata,
3012
- geometry,
3013
- isActive,
3014
- isSelected,
3015
- groupSelected,
3016
- handlesEnabled,
3017
- penActive,
3018
- creationToolActive,
3019
- chromeScale,
3020
- onPick,
3021
- onEdit,
3022
- onStartFrameDrag,
3023
- onStartResize,
3024
- onStartRotate,
3025
- onStartDuplicateGesture,
3026
- screenContent,
3027
- }: {
3570
+ interface ScreenProps {
3028
3571
  screen: ScreenFile;
3029
3572
  metadata: ResolvedScreenMetadata;
3030
3573
  geometry: FrameGeometry;
3031
3574
  isActive: boolean;
3032
3575
  isSelected: boolean;
3576
+ isDirectlyHovered: boolean;
3577
+ hasHoveredChild: boolean;
3033
3578
  groupSelected: boolean;
3034
3579
  handlesEnabled: boolean;
3035
3580
  penActive: boolean;
3036
3581
  creationToolActive: boolean;
3582
+ canvasGestureActive: boolean;
3037
3583
  chromeScale: number;
3584
+ chromeSettling: boolean;
3038
3585
  screenContent?: ReactNode;
3039
3586
  onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
3040
3587
  onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
@@ -3050,15 +3597,75 @@ function Screen({
3050
3597
  display: string,
3051
3598
  e: React.MouseEvent<HTMLElement>,
3052
3599
  ) => void;
3053
- }) {
3600
+ }
3601
+
3602
+ const Screen = memo(function Screen({
3603
+ screen,
3604
+ metadata,
3605
+ geometry,
3606
+ isActive,
3607
+ isSelected,
3608
+ isDirectlyHovered,
3609
+ hasHoveredChild,
3610
+ groupSelected,
3611
+ handlesEnabled,
3612
+ penActive,
3613
+ creationToolActive,
3614
+ canvasGestureActive,
3615
+ chromeScale,
3616
+ chromeSettling,
3617
+ onPick,
3618
+ onEdit,
3619
+ onStartFrameDrag,
3620
+ onStartResize,
3621
+ onStartRotate,
3622
+ onStartDuplicateGesture,
3623
+ screenContent,
3624
+ }: ScreenProps) {
3054
3625
  const t = useT();
3055
3626
  const display = metadata.title ?? prettyScreenName(screen.filename);
3056
3627
  const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
3628
+ const previewViewport = getScreenPreviewViewport(metadata, geometry);
3057
3629
  const suppressNextClick = useRef(false);
3058
- const emphasized = isActive || isSelected;
3630
+ const [directlyHovered, setDirectlyHovered] = useState(false);
3631
+ const frameDirectlyHovered =
3632
+ (directlyHovered || isDirectlyHovered) &&
3633
+ !creationToolActive &&
3634
+ !canvasGestureActive;
3635
+ const emphasized = isSelected || frameDirectlyHovered;
3636
+ const activeOrEmphasized = isActive || emphasized;
3059
3637
  const selectionOutlined = isSelected && !groupSelected;
3060
- const showHoverOutline =
3061
- !creationToolActive && (!isSelected || !groupSelected);
3638
+ const showHoverChrome =
3639
+ frameDirectlyHovered && !isSelected && !groupSelected && !hasHoveredChild;
3640
+ const screenContentInteractive =
3641
+ Boolean(screenContent) &&
3642
+ !penActive &&
3643
+ !creationToolActive &&
3644
+ !canvasGestureActive;
3645
+ const updateDirectHover = useCallback((next: boolean) => {
3646
+ setDirectlyHovered((current) => (current === next ? current : next));
3647
+ }, []);
3648
+ const frameLabelHeight = FRAME_LABEL_HEIGHT * chromeScale;
3649
+ const frameScreenWidth = geometry.width / Math.max(chromeScale, 0.001);
3650
+ const fullViewOutsideFrame =
3651
+ frameScreenWidth < FRAME_HEADER_BUTTON_OUTSIDE_WIDTH;
3652
+ const labelInfoMaxWidth = Math.max(
3653
+ 64,
3654
+ frameScreenWidth - (fullViewOutsideFrame ? 8 : FRAME_HEADER_BUTTON_RESERVE),
3655
+ );
3656
+ const estimatedTitleWidth = clamp(
3657
+ display.length * FRAME_HEADER_TITLE_CHAR_WIDTH,
3658
+ FRAME_HEADER_MIN_TITLE_WIDTH,
3659
+ FRAME_HEADER_MAX_TITLE_WIDTH,
3660
+ );
3661
+ const showDimensions =
3662
+ frameScreenWidth >=
3663
+ estimatedTitleWidth +
3664
+ FRAME_HEADER_DIMENSIONS_WIDTH +
3665
+ (fullViewOutsideFrame ? 16 : FRAME_HEADER_BUTTON_RESERVE);
3666
+ const fullViewMaxWidth = fullViewOutsideFrame
3667
+ ? 120
3668
+ : Math.max(84, Math.min(180, frameScreenWidth * 0.46));
3062
3669
 
3063
3670
  return (
3064
3671
  <div
@@ -3066,29 +3673,58 @@ function Screen({
3066
3673
  className="group/frame pointer-events-auto absolute"
3067
3674
  style={{
3068
3675
  left: SURFACE_PADDING + geometry.x,
3069
- top: SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT,
3676
+ top: SURFACE_PADDING + geometry.y - frameLabelHeight,
3070
3677
  width: geometry.width,
3071
3678
  transform: geometry.rotation
3072
3679
  ? `rotate(${geometry.rotation}deg)`
3073
3680
  : undefined,
3074
- transformOrigin: `${geometry.width / 2}px ${FRAME_LABEL_HEIGHT + geometry.height / 2}px`,
3681
+ transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
3075
3682
  zIndex: geometry.z,
3076
3683
  }}
3077
3684
  >
3078
3685
  <div
3079
- className="flex h-7 w-full cursor-default items-center justify-between gap-2 px-1"
3686
+ className="relative w-full cursor-default"
3687
+ style={{ height: frameLabelHeight }}
3688
+ onClick={(e) => {
3689
+ e.stopPropagation();
3690
+ if (suppressNextClick.current) {
3691
+ suppressNextClick.current = false;
3692
+ return;
3693
+ }
3694
+ if (e.detail > 1) return;
3695
+ onPick(screen.id, e);
3696
+ }}
3697
+ onDoubleClick={(e) => {
3698
+ e.preventDefault();
3699
+ e.stopPropagation();
3700
+ onEdit(screen.id, e);
3701
+ }}
3080
3702
  onMouseDown={(e) => {
3081
3703
  if (e.button !== 0) return;
3082
3704
  if (penActive || creationToolActive) return;
3705
+ if (e.altKey) {
3706
+ onStartDuplicateGesture(screen, display, e);
3707
+ return;
3708
+ }
3083
3709
  if (e.shiftKey) {
3084
3710
  e.stopPropagation();
3085
- onPick(screen.id, e);
3086
3711
  return;
3087
3712
  }
3088
3713
  onStartFrameDrag(screen.id, e);
3089
3714
  }}
3090
3715
  >
3091
- <div className="flex min-w-0 items-center gap-1.5">
3716
+ <div
3717
+ data-frame-label
3718
+ className="absolute left-1 top-1/2 flex min-w-0 items-center gap-1.5"
3719
+ onMouseEnter={() => updateDirectHover(true)}
3720
+ onMouseLeave={() => updateDirectHover(false)}
3721
+ style={{
3722
+ maxWidth: labelInfoMaxWidth,
3723
+ transform: `translateY(-50%) scale(${chromeScale})`,
3724
+ transformOrigin: "left center",
3725
+ transition: getChromeLabelTransition(chromeSettling),
3726
+ }}
3727
+ >
3092
3728
  <span
3093
3729
  className={cn(
3094
3730
  "h-1.5 w-1.5 shrink-0 rounded-full",
@@ -3096,26 +3732,45 @@ function Screen({
3096
3732
  )}
3097
3733
  />
3098
3734
  <span
3735
+ data-frame-title
3099
3736
  className={cn(
3100
- "truncate text-[11px] font-medium",
3101
- emphasized ? "text-foreground" : "text-muted-foreground",
3737
+ "min-w-0 truncate text-[11px] font-medium",
3738
+ emphasized
3739
+ ? "text-[var(--design-editor-accent-color)]"
3740
+ : activeOrEmphasized
3741
+ ? "text-foreground"
3742
+ : "text-muted-foreground",
3102
3743
  )}
3103
3744
  title={screen.filename}
3104
3745
  >
3105
3746
  {display}
3106
3747
  </span>
3107
- <span className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline">
3108
- {metadata.width} x {metadata.height}
3109
- </span>
3748
+ {showDimensions ? (
3749
+ <span
3750
+ data-frame-dimensions
3751
+ className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline"
3752
+ >
3753
+ {previewViewport.displayWidth} x {previewViewport.displayHeight}
3754
+ </span>
3755
+ ) : null}
3110
3756
  </div>
3111
3757
  <button
3112
3758
  type="button"
3759
+ data-frame-full-view
3113
3760
  className={cn(
3114
- "relative z-40 flex h-5 max-w-[46%] shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
3761
+ "absolute top-1/2 z-40 flex h-5 shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
3115
3762
  "hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
3116
- "group-hover/frame:opacity-100 group-focus-within/frame:opacity-100",
3117
3763
  emphasized && "opacity-100",
3764
+ fullViewOutsideFrame ? "left-full" : "right-1",
3118
3765
  )}
3766
+ style={{
3767
+ maxWidth: fullViewMaxWidth,
3768
+ transform: `translateY(-50%) scale(${chromeScale})`,
3769
+ transformOrigin: fullViewOutsideFrame
3770
+ ? "left center"
3771
+ : "right center",
3772
+ transition: getChromeLabelTransition(chromeSettling),
3773
+ }}
3119
3774
  aria-label={t("multiScreenCanvas.fullView")}
3120
3775
  title={t("multiScreenCanvas.fullView")}
3121
3776
  onClick={(event) => onEdit(screen.id, event)}
@@ -3123,6 +3778,8 @@ function Screen({
3123
3778
  event.preventDefault();
3124
3779
  event.stopPropagation();
3125
3780
  }}
3781
+ onMouseEnter={() => updateDirectHover(true)}
3782
+ onMouseLeave={() => updateDirectHover(false)}
3126
3783
  >
3127
3784
  <IconMaximize className="size-3 shrink-0" />
3128
3785
  <span className="truncate">{t("multiScreenCanvas.fullView")}</span>
@@ -3133,7 +3790,10 @@ function Screen({
3133
3790
  role="button"
3134
3791
  tabIndex={0}
3135
3792
  onClick={(e) => {
3136
- if (isInteractiveScreenContentTarget(e.target)) return;
3793
+ if (isInteractiveScreenContentTarget(e.target)) {
3794
+ e.stopPropagation();
3795
+ return;
3796
+ }
3137
3797
  e.stopPropagation();
3138
3798
  if (suppressNextClick.current) {
3139
3799
  suppressNextClick.current = false;
@@ -3143,13 +3803,19 @@ function Screen({
3143
3803
  onPick(screen.id, e);
3144
3804
  }}
3145
3805
  onDoubleClick={(e) => {
3146
- if (isInteractiveScreenContentTarget(e.target)) return;
3806
+ if (isInteractiveScreenContentTarget(e.target)) {
3807
+ e.stopPropagation();
3808
+ return;
3809
+ }
3147
3810
  e.preventDefault();
3148
3811
  e.stopPropagation();
3149
3812
  }}
3150
3813
  onMouseDown={(e) => {
3814
+ if (isInteractiveScreenContentTarget(e.target)) {
3815
+ e.stopPropagation();
3816
+ return;
3817
+ }
3151
3818
  if (creationToolActive) return;
3152
- if (isInteractiveScreenContentTarget(e.target)) return;
3153
3819
  if (e.detail > 1) {
3154
3820
  e.stopPropagation();
3155
3821
  return;
@@ -3168,46 +3834,52 @@ function Screen({
3168
3834
  onStartFrameDrag(screen.id, e);
3169
3835
  }
3170
3836
  }}
3837
+ onMouseMove={(e) => {
3838
+ updateDirectHover(
3839
+ isDirectScreenHoverTarget(e.target, e.currentTarget),
3840
+ );
3841
+ }}
3842
+ onMouseLeave={() => updateDirectHover(false)}
3171
3843
  className={cn(
3172
3844
  "group/artboard relative block overflow-visible rounded-lg bg-background text-left outline-none transition-colors",
3173
3845
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
3174
3846
  emphasized
3175
3847
  ? "text-foreground"
3176
- : "text-muted-foreground hover:text-foreground",
3848
+ : cn(
3849
+ "text-muted-foreground",
3850
+ showHoverChrome && "hover:text-foreground",
3851
+ ),
3177
3852
  )}
3178
3853
  style={{
3179
3854
  width: geometry.width,
3180
3855
  height: geometry.height,
3181
- cursor:
3182
- penActive || creationToolActive
3183
- ? "crosshair"
3184
- : isSelected
3185
- ? "move"
3186
- : "pointer",
3856
+ cursor: penActive || creationToolActive ? "crosshair" : "pointer",
3187
3857
  touchAction: "none",
3188
3858
  }}
3189
3859
  >
3190
3860
  <span
3861
+ data-screen-hover-outline
3191
3862
  className={cn(
3192
3863
  "pointer-events-none absolute border transition-opacity",
3193
- selectionOutlined
3864
+ showHoverChrome
3194
3865
  ? "border-[var(--design-editor-accent-color)] opacity-100"
3195
- : showHoverOutline
3196
- ? "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100"
3197
- : "border-transparent opacity-0",
3866
+ : "border-transparent opacity-0",
3198
3867
  )}
3199
3868
  style={{
3200
3869
  inset: -5 * chromeScale,
3201
3870
  borderRadius: 13 * chromeScale,
3202
3871
  borderWidth: 1.5 * chromeScale,
3872
+ transition: getChromeBorderTransition(chromeSettling),
3203
3873
  }}
3204
3874
  />
3205
3875
  <span
3876
+ data-screen-content
3206
3877
  className={cn(
3207
3878
  "relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
3208
- "border-border group-hover/artboard:border-muted-foreground/60",
3879
+ "border-border",
3880
+ showHoverChrome && "border-muted-foreground/60",
3209
3881
  )}
3210
- style={{ pointerEvents: penActive ? "none" : undefined }}
3882
+ style={{ pointerEvents: screenContentInteractive ? "auto" : "none" }}
3211
3883
  >
3212
3884
  {screenContent ?? (
3213
3885
  <iframe
@@ -3217,12 +3889,24 @@ function Screen({
3217
3889
  loading="lazy"
3218
3890
  className="pointer-events-none border-0"
3219
3891
  style={{
3220
- width: metadata.width,
3221
- height: metadata.height,
3222
- transform: `scale(${geometry.width / metadata.width}, ${
3223
- geometry.height / metadata.height
3224
- })`,
3892
+ width: previewViewport.viewportWidth,
3893
+ height: previewViewport.viewportHeight,
3894
+ // Untouched same-aspect overview thumbnails may scale uniformly.
3895
+ // User-resized frames use their real iframe viewport so
3896
+ // responsive layouts recompute instead of getting stretched.
3897
+ transform:
3898
+ previewViewport.scale === 1
3899
+ ? undefined
3900
+ : `scale(${previewViewport.scale})`,
3225
3901
  transformOrigin: "top left",
3902
+ backgroundColor: "white",
3903
+ colorScheme: "light",
3904
+ // Prevent the browser from discarding the composited layer at
3905
+ // fractional zoom levels, which causes the iframe to go black.
3906
+ // backface-visibility:hidden forces the browser to keep the
3907
+ // backing store alive even when the effective scale is very small
3908
+ // (e.g. 0.25 iframe scale × 0.5 canvas zoom = 0.125 total).
3909
+ backfaceVisibility: "hidden",
3226
3910
  }}
3227
3911
  title={screen.filename}
3228
3912
  />
@@ -3233,50 +3917,132 @@ function Screen({
3233
3917
  aria-hidden="true"
3234
3918
  />
3235
3919
  ) : null}
3920
+ {canvasGestureActive && screenContent ? (
3921
+ <span
3922
+ data-screen-interaction-shield
3923
+ className="pointer-events-auto absolute inset-0 z-30"
3924
+ aria-hidden="true"
3925
+ />
3926
+ ) : null}
3236
3927
  <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
3237
3928
  </span>
3238
3929
  <ResizeHandles
3239
- active={selectionOutlined}
3240
- enabled={!penActive && !creationToolActive && handlesEnabled}
3930
+ active={false}
3931
+ enabled={
3932
+ !selectionOutlined &&
3933
+ !penActive &&
3934
+ !creationToolActive &&
3935
+ handlesEnabled
3936
+ }
3937
+ showOnHover={false}
3241
3938
  showRotate
3242
3939
  chromeScale={chromeScale}
3940
+ chromeSettling={chromeSettling}
3243
3941
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
3244
3942
  onStartRotate={(e) => onStartRotate(screen.id, e)}
3245
3943
  />
3246
3944
  </div>
3247
3945
  </div>
3248
3946
  );
3947
+ }, areScreenPropsEqual);
3948
+
3949
+ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
3950
+ return (
3951
+ prev.screen === next.screen &&
3952
+ prev.screenContent === next.screenContent &&
3953
+ sameResolvedMetadata(prev.metadata, next.metadata) &&
3954
+ sameFrameGeometry(prev.geometry, next.geometry) &&
3955
+ prev.isActive === next.isActive &&
3956
+ prev.isSelected === next.isSelected &&
3957
+ prev.isDirectlyHovered === next.isDirectlyHovered &&
3958
+ prev.hasHoveredChild === next.hasHoveredChild &&
3959
+ prev.groupSelected === next.groupSelected &&
3960
+ prev.handlesEnabled === next.handlesEnabled &&
3961
+ prev.penActive === next.penActive &&
3962
+ prev.creationToolActive === next.creationToolActive &&
3963
+ prev.canvasGestureActive === next.canvasGestureActive &&
3964
+ prev.chromeScale === next.chromeScale &&
3965
+ prev.chromeSettling === next.chromeSettling &&
3966
+ prev.onPick === next.onPick &&
3967
+ prev.onEdit === next.onEdit &&
3968
+ prev.onStartFrameDrag === next.onStartFrameDrag &&
3969
+ prev.onStartResize === next.onStartResize &&
3970
+ prev.onStartRotate === next.onStartRotate &&
3971
+ prev.onStartDuplicateGesture === next.onStartDuplicateGesture
3972
+ );
3249
3973
  }
3250
3974
 
3251
3975
  function GroupSelectionBox({
3252
3976
  bounds,
3253
3977
  chromeScale,
3978
+ chromeSettling,
3254
3979
  onStartResize,
3255
3980
  }: {
3256
3981
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
3257
3982
  chromeScale: number;
3983
+ chromeSettling: boolean;
3984
+ onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
3985
+ }) {
3986
+ return (
3987
+ <SelectionBox
3988
+ geometry={{
3989
+ x: bounds.left,
3990
+ y: bounds.top,
3991
+ width: bounds.width,
3992
+ height: bounds.height,
3993
+ }}
3994
+ chromeScale={chromeScale}
3995
+ chromeSettling={chromeSettling}
3996
+ showRotate={false}
3997
+ onStartResize={onStartResize}
3998
+ onStartRotate={() => {}}
3999
+ />
4000
+ );
4001
+ }
4002
+
4003
+ function SelectionBox({
4004
+ geometry,
4005
+ chromeScale,
4006
+ chromeSettling,
4007
+ showRotate = true,
4008
+ onStartResize,
4009
+ onStartRotate,
4010
+ }: {
4011
+ geometry: FrameGeometry;
4012
+ chromeScale: number;
4013
+ chromeSettling: boolean;
4014
+ showRotate?: boolean;
3258
4015
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
4016
+ onStartRotate: (e: React.MouseEvent) => void;
3259
4017
  }) {
3260
4018
  return (
3261
4019
  <div
4020
+ data-frame-selection-box
3262
4021
  data-frame-shell
3263
- className="pointer-events-none absolute z-30 border border-[var(--design-editor-accent-color)]"
4022
+ className="pointer-events-none absolute border border-[var(--design-editor-accent-color)]"
3264
4023
  style={{
3265
- left: SURFACE_PADDING + bounds.left,
3266
- top: SURFACE_PADDING + bounds.top,
3267
- width: bounds.width,
3268
- height: bounds.height,
4024
+ left: SURFACE_PADDING + geometry.x,
4025
+ top: SURFACE_PADDING + geometry.y,
4026
+ width: geometry.width,
4027
+ height: geometry.height,
3269
4028
  borderRadius: 13 * chromeScale,
3270
4029
  borderWidth: 1.5 * chromeScale,
4030
+ transition: getSelectionBoxTransition(chromeSettling),
4031
+ transform: geometry.rotation
4032
+ ? `rotate(${geometry.rotation}deg)`
4033
+ : undefined,
4034
+ transformOrigin: `${geometry.width / 2}px ${geometry.height / 2}px`,
4035
+ zIndex: 1_000_000,
3271
4036
  }}
3272
4037
  >
3273
4038
  <ResizeHandles
3274
4039
  active
3275
4040
  enabled
3276
- showRotate={false}
4041
+ showRotate={showRotate}
3277
4042
  chromeScale={chromeScale}
4043
+ chromeSettling={chromeSettling}
3278
4044
  onStartResize={onStartResize}
3279
- onStartRotate={() => {}}
4045
+ onStartRotate={onStartRotate}
3280
4046
  />
3281
4047
  </div>
3282
4048
  );
@@ -3285,15 +4051,19 @@ function GroupSelectionBox({
3285
4051
  function ResizeHandles({
3286
4052
  active,
3287
4053
  enabled,
4054
+ showOnHover = true,
3288
4055
  showRotate = true,
3289
4056
  chromeScale = 1,
4057
+ chromeSettling = false,
3290
4058
  onStartResize,
3291
4059
  onStartRotate,
3292
4060
  }: {
3293
4061
  active: boolean;
3294
4062
  enabled: boolean;
4063
+ showOnHover?: boolean;
3295
4064
  showRotate?: boolean;
3296
4065
  chromeScale?: number;
4066
+ chromeSettling?: boolean;
3297
4067
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
3298
4068
  onStartRotate: (e: React.MouseEvent) => void;
3299
4069
  }) {
@@ -3303,7 +4073,11 @@ function ResizeHandles({
3303
4073
  "pointer-events-auto absolute z-20 rounded-[2px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)] shadow transition-opacity",
3304
4074
  active
3305
4075
  ? "opacity-100"
3306
- : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4076
+ : cn(
4077
+ "opacity-0",
4078
+ showOnHover &&
4079
+ "group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4080
+ ),
3307
4081
  );
3308
4082
  const edgeHandleClass =
3309
4083
  "pointer-events-auto absolute z-10 bg-transparent opacity-0";
@@ -3313,18 +4087,28 @@ function ResizeHandles({
3313
4087
  {EDGE_RESIZE_HANDLE_CONFIGS.map((config) => (
3314
4088
  <span
3315
4089
  key={config.handle}
3316
- data-resize-handle
4090
+ data-resize-handle={config.handle}
3317
4091
  className={edgeHandleClass}
3318
- style={edgeHandleStyle(config.handle, config.cursor, chromeScale)}
4092
+ style={edgeHandleStyle(
4093
+ config.handle,
4094
+ config.cursor,
4095
+ chromeScale,
4096
+ chromeSettling,
4097
+ )}
3319
4098
  onMouseDown={(e) => onStartResize(config.handle, e)}
3320
4099
  />
3321
4100
  ))}
3322
4101
  {CORNER_RESIZE_HANDLE_CONFIGS.map((config) => (
3323
4102
  <span
3324
4103
  key={config.handle}
3325
- data-resize-handle
4104
+ data-resize-handle={config.handle}
3326
4105
  className={visibleHandleClass}
3327
- style={cornerHandleStyle(config.handle, config.cursor, chromeScale)}
4106
+ style={cornerHandleStyle(
4107
+ config.handle,
4108
+ config.cursor,
4109
+ chromeScale,
4110
+ chromeSettling,
4111
+ )}
3328
4112
  onMouseDown={(e) => onStartResize(config.handle, e)}
3329
4113
  />
3330
4114
  ))}
@@ -3337,9 +4121,17 @@ function ResizeHandles({
3337
4121
  "pointer-events-auto absolute z-10 size-5 rounded-full transition-opacity active:cursor-grabbing",
3338
4122
  active
3339
4123
  ? "opacity-100"
3340
- : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4124
+ : cn(
4125
+ "opacity-0",
4126
+ showOnHover &&
4127
+ "group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4128
+ ),
4129
+ )}
4130
+ style={rotateHandleStyle(
4131
+ config.corner,
4132
+ chromeScale,
4133
+ chromeSettling,
3341
4134
  )}
3342
- style={rotateHandleStyle(config.corner, chromeScale)}
3343
4135
  onMouseDown={onStartRotate}
3344
4136
  />
3345
4137
  ))
@@ -3381,12 +4173,14 @@ function edgeHandleStyle(
3381
4173
  handle: ResizeHandle,
3382
4174
  cursor: string,
3383
4175
  chromeScale: number,
4176
+ chromeSettling: boolean,
3384
4177
  ): CSSProperties {
3385
4178
  const size = 14 * chromeScale;
3386
4179
  const offset = -size / 2;
3387
4180
  if (handle === "n" || handle === "s") {
3388
4181
  return {
3389
4182
  cursor,
4183
+ transition: getChromeHandleTransition(chromeSettling),
3390
4184
  left: 0,
3391
4185
  right: 0,
3392
4186
  height: size,
@@ -3395,6 +4189,7 @@ function edgeHandleStyle(
3395
4189
  }
3396
4190
  return {
3397
4191
  cursor,
4192
+ transition: getChromeHandleTransition(chromeSettling),
3398
4193
  top: 0,
3399
4194
  bottom: 0,
3400
4195
  width: size,
@@ -3406,11 +4201,13 @@ function cornerHandleStyle(
3406
4201
  handle: ResizeHandle,
3407
4202
  cursor: string,
3408
4203
  chromeScale: number,
4204
+ chromeSettling: boolean,
3409
4205
  ): CSSProperties {
3410
4206
  const size = 10 * chromeScale;
3411
4207
  const offset = -size / 2;
3412
4208
  return {
3413
4209
  cursor,
4210
+ transition: getChromeHandleTransition(chromeSettling),
3414
4211
  width: size,
3415
4212
  height: size,
3416
4213
  borderWidth: Math.max(1, 1.25 * chromeScale),
@@ -3419,11 +4216,16 @@ function cornerHandleStyle(
3419
4216
  };
3420
4217
  }
3421
4218
 
3422
- function rotateHandleStyle(corner: string, chromeScale: number): CSSProperties {
4219
+ function rotateHandleStyle(
4220
+ corner: string,
4221
+ chromeScale: number,
4222
+ chromeSettling: boolean,
4223
+ ): CSSProperties {
3423
4224
  const size = 28 * chromeScale;
3424
4225
  const offset = -34 * chromeScale;
3425
4226
  return {
3426
4227
  cursor: "grab",
4228
+ transition: getChromeHandleTransition(chromeSettling),
3427
4229
  width: size,
3428
4230
  height: size,
3429
4231
  ...(corner.includes("n") ? { top: offset } : { bottom: offset }),
@@ -3443,25 +4245,72 @@ interface BoundsRect {
3443
4245
  bottom: number;
3444
4246
  }
3445
4247
 
3446
- function getInitialFrameGeometry(index: number): FrameGeometry {
4248
+ interface ScreenViewportSize {
4249
+ width: number;
4250
+ height: number;
4251
+ }
4252
+
4253
+ function getInitialFrameGeometry(
4254
+ index: number,
4255
+ metadata?: ScreenViewportSize,
4256
+ ): FrameGeometry {
4257
+ // Seed default frames with the actual screen dimensions and the 3-column grid
4258
+ // the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
4259
+ // a design without persisted geometry opens centered. (The larger
4260
+ // assigned-region grid is only for the agent's generation planning, not the
4261
+ // editor's default placement.)
3447
4262
  const column = index % 3;
3448
4263
  const row = Math.floor(index / 3);
4264
+ const height = getOverviewFrameHeight(SCREEN_WIDTH, metadata);
3449
4265
  return {
3450
4266
  x: column * (SCREEN_WIDTH + SCREEN_GAP),
3451
- y: row * (SCREEN_CARD_HEIGHT + SCREEN_GAP),
4267
+ y: row * (height + FRAME_LABEL_HEIGHT + SCREEN_GAP),
3452
4268
  width: SCREEN_WIDTH,
3453
- height: SCREEN_HEIGHT,
4269
+ height,
3454
4270
  };
3455
4271
  }
3456
4272
 
3457
- function getFrameEntries(
3458
- screens: ScreenFile[],
3459
- geometryById: FrameGeometryById,
3460
- ): FrameEntry[] {
3461
- return screens.map((screen, index) => ({
3462
- id: screen.id,
3463
- geometry: geometryById[screen.id] ?? getInitialFrameGeometry(index),
3464
- }));
4273
+ function getOverviewFrameHeight(width: number, metadata?: ScreenViewportSize) {
4274
+ const sourceWidth =
4275
+ metadata?.width && metadata.width > 0 ? metadata.width : 1280;
4276
+ const sourceHeight =
4277
+ metadata?.height && metadata.height > 0 ? metadata.height : 2560;
4278
+ return Math.max(80, Math.round((width * sourceHeight) / sourceWidth));
4279
+ }
4280
+
4281
+ function getScreenPreviewViewport(
4282
+ metadata: ScreenViewportSize,
4283
+ geometry: ScreenViewportSize,
4284
+ ) {
4285
+ const metadataWidth = Math.max(1, Math.round(metadata.width));
4286
+ const metadataHeight = Math.max(1, Math.round(metadata.height));
4287
+ const geometryWidth = Math.max(1, Math.round(geometry.width));
4288
+ const geometryHeight = Math.max(1, Math.round(geometry.height));
4289
+ const metadataAspect = metadataWidth / metadataHeight;
4290
+ const geometryAspect = geometryWidth / geometryHeight;
4291
+ const aspectMatches = Math.abs(metadataAspect - geometryAspect) < 0.005;
4292
+
4293
+ if (aspectMatches) {
4294
+ return {
4295
+ viewportWidth: metadataWidth,
4296
+ viewportHeight: metadataHeight,
4297
+ displayWidth: metadataWidth,
4298
+ displayHeight: metadataHeight,
4299
+ scale:
4300
+ Math.abs(metadataWidth - geometryWidth) < 0.5 &&
4301
+ Math.abs(metadataHeight - geometryHeight) < 0.5
4302
+ ? 1
4303
+ : geometryWidth / metadataWidth,
4304
+ };
4305
+ }
4306
+
4307
+ return {
4308
+ viewportWidth: geometryWidth,
4309
+ viewportHeight: geometryHeight,
4310
+ displayWidth: geometryWidth,
4311
+ displayHeight: geometryHeight,
4312
+ scale: 1,
4313
+ };
3465
4314
  }
3466
4315
 
3467
4316
  function dedupeIds(ids: string[]) {
@@ -3519,6 +4368,22 @@ function isInteractiveScreenContentTarget(target: EventTarget | null) {
3519
4368
  );
3520
4369
  }
3521
4370
 
4371
+ function mutePreviewIframePointerEvents(root: HTMLElement | null) {
4372
+ if (!root) return () => {};
4373
+ const previous = new Map<HTMLIFrameElement, string>();
4374
+ root
4375
+ .querySelectorAll<HTMLIFrameElement>("[data-design-preview-iframe]")
4376
+ .forEach((iframe) => {
4377
+ previous.set(iframe, iframe.style.pointerEvents);
4378
+ iframe.style.pointerEvents = "none";
4379
+ });
4380
+ return () => {
4381
+ previous.forEach((pointerEvents, iframe) => {
4382
+ if (iframe.isConnected) iframe.style.pointerEvents = pointerEvents;
4383
+ });
4384
+ };
4385
+ }
4386
+
3522
4387
  function frameBoundsToGeometry(bounds: {
3523
4388
  left: number;
3524
4389
  top: number;
@@ -3571,6 +4436,84 @@ function rectContainsPoint(bounds: BoundsRect, point: Point) {
3571
4436
  );
3572
4437
  }
3573
4438
 
4439
+ /** Rotate `point` into the local (unrotated) space of a frame whose center is
4440
+ * `center` and whose CSS transform is `rotate(degrees deg)`. Passing the
4441
+ * inverse rotation maps world-space coordinates into the frame's local space
4442
+ * so unrotated bounds tests remain correct. */
4443
+ function rotatePointAroundCenter(
4444
+ point: Point,
4445
+ center: Point,
4446
+ degrees: number,
4447
+ ): Point {
4448
+ if (!degrees) return point;
4449
+ const rad = (-degrees * Math.PI) / 180; // inverse rotation
4450
+ const dx = point.x - center.x;
4451
+ const dy = point.y - center.y;
4452
+ const cos = Math.cos(rad);
4453
+ const sin = Math.sin(rad);
4454
+ return {
4455
+ x: center.x + dx * cos - dy * sin,
4456
+ y: center.y + dx * sin + dy * cos,
4457
+ };
4458
+ }
4459
+
4460
+ /** Test whether a marquee rect (in canvas space) intersects an OBB described by
4461
+ * `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
4462
+ * We map the four marquee corners into the frame's local space and check
4463
+ * whether any corner is inside the bounds; we also check the reverse (any
4464
+ * frame corner inside the marquee) to handle the case where the marquee is
4465
+ * entirely contained within the rotated frame. */
4466
+ function rotatedRectIntersects(
4467
+ rect: MarqueeRect,
4468
+ bounds: BoundsRect,
4469
+ center: Point,
4470
+ degrees: number,
4471
+ ): boolean {
4472
+ if (!degrees) {
4473
+ return rectIntersects(rect, bounds);
4474
+ }
4475
+ // Four corners of the marquee rect in canvas space.
4476
+ const marqueeCorners: Point[] = [
4477
+ { x: rect.x, y: rect.y },
4478
+ { x: rect.x + rect.width, y: rect.y },
4479
+ { x: rect.x + rect.width, y: rect.y + rect.height },
4480
+ { x: rect.x, y: rect.y + rect.height },
4481
+ ];
4482
+ // Test: any marquee corner inside the unrotated frame bounds?
4483
+ for (const corner of marqueeCorners) {
4484
+ const local = rotatePointAroundCenter(corner, center, degrees);
4485
+ if (rectContainsPoint(bounds, local)) return true;
4486
+ }
4487
+ // Four corners of the unrotated frame in canvas space (rotate them outward).
4488
+ const frameCorners: Point[] = [
4489
+ { x: bounds.left, y: bounds.top },
4490
+ { x: bounds.right, y: bounds.top },
4491
+ { x: bounds.right, y: bounds.bottom },
4492
+ { x: bounds.left, y: bounds.bottom },
4493
+ ];
4494
+ const rad = (degrees * Math.PI) / 180;
4495
+ const cos = Math.cos(rad);
4496
+ const sin = Math.sin(rad);
4497
+ const marqueeRight = rect.x + rect.width;
4498
+ const marqueeBottom = rect.y + rect.height;
4499
+ // Test: any rotated frame corner inside the marquee rect?
4500
+ for (const fc of frameCorners) {
4501
+ const dx = fc.x - center.x;
4502
+ const dy = fc.y - center.y;
4503
+ const wx = center.x + dx * cos - dy * sin;
4504
+ const wy = center.y + dx * sin + dy * cos;
4505
+ if (
4506
+ wx >= rect.x &&
4507
+ wx <= marqueeRight &&
4508
+ wy >= rect.y &&
4509
+ wy <= marqueeBottom
4510
+ ) {
4511
+ return true;
4512
+ }
4513
+ }
4514
+ return false;
4515
+ }
4516
+
3574
4517
  function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
3575
4518
  return (
3576
4519
  a.x === b.x &&
@@ -3582,6 +4525,20 @@ function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
3582
4525
  );
3583
4526
  }
3584
4527
 
4528
+ function sameResolvedMetadata(
4529
+ a: ResolvedScreenMetadata,
4530
+ b: ResolvedScreenMetadata,
4531
+ ) {
4532
+ return (
4533
+ a.source === b.source &&
4534
+ a.previewState === b.previewState &&
4535
+ a.title === b.title &&
4536
+ a.width === b.width &&
4537
+ a.height === b.height &&
4538
+ a.previewUrl === b.previewUrl
4539
+ );
4540
+ }
4541
+
3585
4542
  function cloneFrameGeometryById(
3586
4543
  geometryById: FrameGeometryById,
3587
4544
  ): FrameGeometryById {
@@ -4009,6 +4966,7 @@ function resolveScreenMetadata(
4009
4966
  screen: ScreenFile,
4010
4967
  keyedMetadata?: ScreenMetadata,
4011
4968
  getterMetadata?: ScreenMetadata,
4969
+ previewDeviceFrame: DeviceFrameType = "none",
4012
4970
  ): ResolvedScreenMetadata {
4013
4971
  const metadata = { ...screen, ...keyedMetadata, ...getterMetadata };
4014
4972
  const previewUrl =
@@ -4016,9 +4974,16 @@ function resolveScreenMetadata(
4016
4974
  metadata.previewUrl ??
4017
4975
  screen.previewUrl ??
4018
4976
  getPreviewUrl(screen.content);
4019
- const width = metadata.width && metadata.width > 0 ? metadata.width : 1280;
4977
+ const deviceViewport =
4978
+ previewDeviceFrame === "none"
4979
+ ? undefined
4980
+ : DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
4981
+ const width =
4982
+ deviceViewport?.width ??
4983
+ (metadata.width && metadata.width > 0 ? metadata.width : 1280);
4020
4984
  const height =
4021
- metadata.height && metadata.height > 0 ? metadata.height : 2560;
4985
+ deviceViewport?.height ??
4986
+ (metadata.height && metadata.height > 0 ? metadata.height : 2560);
4022
4987
  return {
4023
4988
  source:
4024
4989
  normalizeSource(metadata.sourceType ?? metadata.source) ??
@@ -4116,3 +5081,7 @@ function getUrl(value: string | undefined) {
4116
5081
  return undefined;
4117
5082
  }
4118
5083
  }
5084
+
5085
+ function clampNumber(value: number, min: number, max: number) {
5086
+ return Math.min(max, Math.max(min, value));
5087
+ }