@agent-native/core 0.80.8 → 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 (281) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -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/error-format.ts +25 -0
  18. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +13 -11
  19. package/corpus/core/src/client/guided-questions.tsx +32 -4
  20. package/corpus/core/src/client/onboarding/OnboardingPanel.tsx +33 -1
  21. package/corpus/core/src/client/resources/BuiltinCapabilityDetail.tsx +8 -0
  22. package/corpus/core/src/client/resources/use-builtin-capabilities.ts +4 -2
  23. package/corpus/core/src/client/session-replay.ts +34 -0
  24. package/corpus/core/src/client/sharing/ShareButton.tsx +106 -21
  25. package/corpus/core/src/coding-tools/index.ts +1 -1
  26. package/corpus/core/src/mcp/index.ts +2 -0
  27. package/corpus/core/src/mcp/screen-memory-stdio.ts +290 -0
  28. package/corpus/core/src/mcp-client/builtin-capabilities.ts +13 -1
  29. package/corpus/core/src/mcp-client/index.ts +9 -0
  30. package/corpus/core/src/mcp-client/screen-memory-local.ts +461 -0
  31. package/corpus/core/src/onboarding/types.ts +7 -0
  32. package/corpus/core/src/server/agent-chat-plugin.ts +25 -0
  33. package/corpus/core/src/server/agents-bundle.ts +35 -7
  34. package/corpus/core/src/styles/agent-native.css +5 -0
  35. package/corpus/core/src/templates/default/app/global.css +35 -35
  36. package/corpus/templates/analytics/AGENTS.md +6 -0
  37. package/corpus/templates/analytics/actions/github-repo-files.ts +9 -0
  38. package/corpus/templates/analytics/app/components/layout/Layout.tsx +4 -4
  39. package/corpus/templates/analytics/app/global.css +144 -40
  40. package/corpus/templates/analytics/app/pages/adhoc/explorer/index.tsx +1 -1
  41. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +1 -1
  42. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +1 -1
  43. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +4 -4
  44. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +9 -9
  45. package/corpus/templates/analytics/changelog/2026-06-29-agents-can-search-and-read-connected-github-repositories-whe.md +6 -0
  46. package/corpus/templates/analytics/changelog/2026-06-29-sessions-and-explorer-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
  47. package/corpus/templates/assets/app/components/layout/Layout.tsx +3 -1
  48. package/corpus/templates/assets/app/components/library/LibraryPresetGrid.tsx +1 -1
  49. package/corpus/templates/assets/app/global.css +99 -35
  50. package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
  51. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +4 -4
  52. package/corpus/templates/assets/app/routes/library.tsx +4 -4
  53. package/corpus/templates/assets/changelog/2026-06-29-asset-library-and-detail-layouts-adapt-when-the-agent-sidebar.md +6 -0
  54. package/corpus/templates/brain/app/components/layout/Layout.tsx +1 -1
  55. package/corpus/templates/brain/app/global.css +63 -35
  56. package/corpus/templates/brain/app/routes/search.tsx +1 -1
  57. package/corpus/templates/brain/app/routes/settings.tsx +1 -1
  58. package/corpus/templates/brain/app/routes/sources.tsx +2 -2
  59. package/corpus/templates/brain/changelog/2026-06-29-search-settings-and-source-layouts-adapt-when-the-agent-sidebar.md +6 -0
  60. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +4 -4
  61. package/corpus/templates/calendar/app/components/calendar/FindTimePanel.tsx +3 -2
  62. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -2
  63. package/corpus/templates/calendar/app/global.css +69 -35
  64. package/corpus/templates/calendar/changelog/2026-06-29-event-and-find-time-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
  65. package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -1
  66. package/corpus/templates/chat/app/global.css +35 -35
  67. package/corpus/templates/chat/changelog/2026-06-29-chat-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  68. package/corpus/templates/clips/AGENTS.md +13 -0
  69. package/corpus/templates/clips/actions/get-screen-memory-status.ts +12 -0
  70. package/corpus/templates/clips/actions/query-screen-memory-context.ts +29 -0
  71. package/corpus/templates/clips/actions/view-screen.ts +2 -2
  72. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  73. package/corpus/templates/clips/app/global.css +47 -35
  74. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +3 -3
  75. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +45 -18
  76. package/corpus/templates/clips/changelog/2026-06-29-meeting-detail-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  77. package/corpus/templates/clips/changelog/2026-06-29-screen-memory-keeps-a-local-rolling-screen-buffer-for-agent-context.md +5 -0
  78. package/corpus/templates/clips/changelog/2026-06-29-video-chat-is-easier-to-find-on-recording-pages-and-s3-setup.md +6 -0
  79. package/corpus/templates/clips/chrome-extension/src/styles.css +39 -39
  80. package/corpus/templates/clips/desktop/src/app.tsx +290 -1
  81. package/corpus/templates/clips/desktop/src/shared/config.ts +83 -0
  82. package/corpus/templates/clips/desktop/src/styles.css +67 -52
  83. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +50 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +17 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +13 -13
  86. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +990 -0
  87. package/corpus/templates/clips/server/plugins/onboarding.ts +2 -0
  88. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +3 -3
  89. package/corpus/templates/content/app/components/layout/Layout.tsx +1 -1
  90. package/corpus/templates/content/app/global.css +29 -15
  91. package/corpus/templates/content/changelog/2026-06-29-database-gallery-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  92. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +25 -20
  93. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -0
  94. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +8 -0
  95. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +13 -0
  96. package/corpus/templates/design/AGENTS.md +20 -12
  97. package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1054 -0
  98. package/corpus/templates/design/actions/apply-visual-edit.ts +24 -4
  99. package/corpus/templates/design/actions/export-coding-handoff.ts +12 -4
  100. package/corpus/templates/design/actions/generate-design.ts +6 -21
  101. package/corpus/templates/design/actions/generate-screens.ts +43 -2
  102. package/corpus/templates/design/actions/get-design.ts +1 -0
  103. package/corpus/templates/design/actions/present-design-variants.ts +355 -30
  104. package/corpus/templates/design/actions/view-screen.ts +75 -31
  105. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +329 -69
  106. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +1 -1
  107. package/corpus/templates/design/app/components/design/EditPanel.tsx +479 -77
  108. package/corpus/templates/design/app/components/design/LayersPanel.tsx +380 -216
  109. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1050 -196
  110. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +83 -206
  111. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +1 -1
  112. package/corpus/templates/design/app/components/design/index.ts +0 -1
  113. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +225 -133
  114. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +122 -11
  115. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +24 -5
  116. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +57 -1
  117. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +37 -12
  118. package/corpus/templates/design/app/components/design/inspector/index.ts +1 -0
  119. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +12 -5
  120. package/corpus/templates/design/app/components/design/types.ts +9 -0
  121. package/corpus/templates/design/app/components/layout/Layout.tsx +23 -5
  122. package/corpus/templates/design/app/global.css +77 -49
  123. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  124. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +8 -1
  125. package/corpus/templates/design/app/i18n/zh-TW.ts +3 -0
  126. package/corpus/templates/design/app/i18n-data.ts +45 -3
  127. package/corpus/templates/design/app/pages/DesignEditor.tsx +2602 -657
  128. package/corpus/templates/design/app/root.tsx +1 -1
  129. package/corpus/templates/design/changelog/2026-06-29-containers-no-longer-block-selecting-nested-elements-and-nor.md +6 -0
  130. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-generate-and-refine-variants-from-stronger.md +6 -0
  131. package/corpus/templates/design/changelog/2026-06-29-design-inspector-controls-now-match-figmas-softer-dark-chrome.md +6 -0
  132. package/corpus/templates/design/changelog/2026-06-29-design-questions-now-appear-as-a-cleaner-canvas-intake-with-.md +6 -0
  133. package/corpus/templates/design/changelog/2026-06-29-design-variant-directions-now-appear-as-board-screens-with-chat-buttons.md +5 -0
  134. package/corpus/templates/design/changelog/2026-06-29-element-selection-no-longer-snaps-back-to-the-previous-layer.md +6 -0
  135. package/corpus/templates/design/changelog/2026-06-29-frame-resize-measurements-now-stay-beside-the-cursor-while-d.md +6 -0
  136. package/corpus/templates/design/changelog/2026-06-29-inspector-number-fields-have-cleaner-tooltips-and-draggable-.md +6 -0
  137. package/corpus/templates/design/changelog/2026-06-29-layer-locking-layer-nesting-generated-question-intake-and-im.md +6 -0
  138. package/corpus/templates/design/changelog/2026-06-29-layer-names-in-the-sidebar-can-scroll-horizontally-instead-o.md +6 -0
  139. package/corpus/templates/design/changelog/2026-06-29-layer-nesting-in-the-sidebar-now-uses-tighter-spacing-so-nam.md +6 -0
  140. package/corpus/templates/design/changelog/2026-06-29-layers-no-longer-repeat-document-wrapper-rows-when-a-screen-.md +6 -0
  141. package/corpus/templates/design/changelog/2026-06-29-overview-previews-now-keep-element-hover-and-selection-tied-.md +6 -0
  142. package/corpus/templates/design/changelog/2026-06-29-pressing-escape-now-cancels-a-stuck-screen-drag-in-the-overv.md +6 -0
  143. package/corpus/templates/design/changelog/2026-06-29-screen-overview-is-easier-to-find-and-named-screens-open-dir.md +6 -0
  144. package/corpus/templates/design/changelog/2026-06-29-screen-overview-only-highlights-screens-when-the-frame-or-ti.md +6 -0
  145. package/corpus/templates/design/changelog/2026-06-29-screen-overview-previews-now-resize-to-match-the-selected-de.md +6 -0
  146. package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-now-uses-a-pointer-cursor-instead-.md +6 -0
  147. package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-outlines-now-stay-locked-to-the-se.md +6 -0
  148. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zoom-now-reports-real-screen-scale-separatel.md +6 -0
  149. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-feels-smoother-and-selection-outline.md +6 -0
  150. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-now-stays-on-the-overview-canvas-ins.md +6 -0
  151. package/corpus/templates/design/changelog/2026-06-29-selecting-a-nested-layer-from-all-screens-now-keeps-you-in-a.md +6 -0
  152. package/corpus/templates/design/changelog/2026-06-29-share-now-groups-link-sharing-exports-and-agent-handoff.md +6 -0
  153. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-loading-skeleton-now-uses-a-softer-charcoa.md +6 -0
  154. package/corpus/templates/design/changelog/2026-06-29-the-device-preview-menu-now-sits-next-to-the-preview-button-.md +6 -0
  155. package/corpus/templates/design/changelog/2026-06-29-the-inspector-stays-read-only-while-an-empty-design-is-still.md +6 -0
  156. package/corpus/templates/design/changelog/2026-06-29-the-missing-design-view-now-matches-the-rest-of-the-editor-c.md +6 -0
  157. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-icon-and-tool-option-menus-better-match-the-edi.md +6 -0
  158. package/corpus/templates/design/changelog/2026-06-29-typography-controls-now-keep-the-font-name-visible-and-move-.md +6 -0
  159. package/corpus/templates/design/e2e/README.md +3 -3
  160. package/corpus/templates/design/e2e/global-setup.ts +11 -22
  161. package/corpus/templates/design/e2e/helpers.ts +122 -11
  162. package/corpus/templates/design/playwright.config.ts +9 -3
  163. package/corpus/templates/design/server/lib/coding-handoff.ts +126 -2
  164. package/corpus/templates/design/server/plugins/core-routes.ts +1 -2
  165. package/corpus/templates/design/server/routes/api/design-handoff/[id].zip.get.ts +86 -0
  166. package/corpus/templates/design/shared/code-layer.ts +125 -22
  167. package/corpus/templates/design/shared/generation-session.ts +41 -0
  168. package/corpus/templates/dispatch/app/global.css +79 -35
  169. package/corpus/templates/dispatch/app/routes/integrations.tsx +8 -7
  170. package/corpus/templates/dispatch/changelog/2026-06-29-integration-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  171. package/corpus/templates/forms/app/components/layout/Layout.tsx +6 -2
  172. package/corpus/templates/forms/app/global.css +64 -35
  173. package/corpus/templates/forms/app/pages/FormsListPage.tsx +2 -2
  174. package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +3 -3
  175. package/corpus/templates/forms/changelog/2026-06-29-form-list-and-insight-layouts-adapt-when-the-agent-sidebar.md +6 -0
  176. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +3 -1
  177. package/corpus/templates/macros/app/global.css +25 -9
  178. package/corpus/templates/macros/changelog/2026-06-29-macro-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  179. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +6 -2
  180. package/corpus/templates/mail/app/global.css +42 -36
  181. package/corpus/templates/mail/app/pages/InboxPage.tsx +1 -1
  182. package/corpus/templates/mail/changelog/2026-06-29-the-contact-panel-now-adapts-to-the-available-mail-pane-width.md +6 -0
  183. package/corpus/templates/plan/app/components/layout/Layout.tsx +3 -1
  184. package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +6 -7
  185. package/corpus/templates/plan/app/global.css +74 -42
  186. package/corpus/templates/plan/changelog/2026-06-29-plan-document-blocks-adapt-to-the-available-document-width.md +6 -0
  187. package/corpus/templates/plan/changelog/2026-06-29-plan-titles-and-summaries-can-be-edited-inline-without-focus.md +6 -0
  188. package/corpus/templates/slides/app/components/layout/Layout.tsx +3 -1
  189. package/corpus/templates/slides/app/global.css +49 -33
  190. package/corpus/templates/slides/changelog/2026-06-29-slide-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  191. package/corpus/templates/videos/app/components/layout/Layout.tsx +3 -1
  192. package/corpus/templates/videos/app/global.css +35 -35
  193. package/corpus/templates/videos/changelog/2026-06-29-video-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  194. package/dist/cli/mcp.d.ts.map +1 -1
  195. package/dist/cli/mcp.js +10 -0
  196. package/dist/cli/mcp.js.map +1 -1
  197. package/dist/cli/skills.d.ts.map +1 -1
  198. package/dist/cli/skills.js +58 -12
  199. package/dist/cli/skills.js.map +1 -1
  200. package/dist/client/error-format.d.ts.map +1 -1
  201. package/dist/client/error-format.js +17 -0
  202. package/dist/client/error-format.js.map +1 -1
  203. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  204. package/dist/client/extensions/ExtensionViewer.js +3 -4
  205. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  206. package/dist/client/guided-questions.d.ts +4 -0
  207. package/dist/client/guided-questions.d.ts.map +1 -1
  208. package/dist/client/guided-questions.js +16 -7
  209. package/dist/client/guided-questions.js.map +1 -1
  210. package/dist/client/onboarding/OnboardingPanel.js +26 -1
  211. package/dist/client/onboarding/OnboardingPanel.js.map +1 -1
  212. package/dist/client/resources/BuiltinCapabilityDetail.d.ts.map +1 -1
  213. package/dist/client/resources/BuiltinCapabilityDetail.js +1 -1
  214. package/dist/client/resources/BuiltinCapabilityDetail.js.map +1 -1
  215. package/dist/client/resources/use-builtin-capabilities.d.ts +1 -1
  216. package/dist/client/resources/use-builtin-capabilities.d.ts.map +1 -1
  217. package/dist/client/resources/use-builtin-capabilities.js +2 -1
  218. package/dist/client/resources/use-builtin-capabilities.js.map +1 -1
  219. package/dist/client/session-replay.d.ts.map +1 -1
  220. package/dist/client/session-replay.js +31 -0
  221. package/dist/client/session-replay.js.map +1 -1
  222. package/dist/client/sharing/ShareButton.d.ts +14 -0
  223. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  224. package/dist/client/sharing/ShareButton.js +36 -7
  225. package/dist/client/sharing/ShareButton.js.map +1 -1
  226. package/dist/coding-tools/index.js +1 -1
  227. package/dist/coding-tools/index.js.map +1 -1
  228. package/dist/collab/routes.d.ts +1 -1
  229. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  230. package/dist/mcp/index.d.ts +2 -0
  231. package/dist/mcp/index.d.ts.map +1 -1
  232. package/dist/mcp/index.js +1 -0
  233. package/dist/mcp/index.js.map +1 -1
  234. package/dist/mcp/screen-memory-stdio.d.ts +6 -0
  235. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -0
  236. package/dist/mcp/screen-memory-stdio.js +213 -0
  237. package/dist/mcp/screen-memory-stdio.js.map +1 -0
  238. package/dist/mcp-client/builtin-capabilities.d.ts +1 -1
  239. package/dist/mcp-client/builtin-capabilities.d.ts.map +1 -1
  240. package/dist/mcp-client/builtin-capabilities.js +10 -0
  241. package/dist/mcp-client/builtin-capabilities.js.map +1 -1
  242. package/dist/mcp-client/index.d.ts +1 -0
  243. package/dist/mcp-client/index.d.ts.map +1 -1
  244. package/dist/mcp-client/index.js +1 -0
  245. package/dist/mcp-client/index.js.map +1 -1
  246. package/dist/mcp-client/screen-memory-local.d.ts +61 -0
  247. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -0
  248. package/dist/mcp-client/screen-memory-local.js +336 -0
  249. package/dist/mcp-client/screen-memory-local.js.map +1 -0
  250. package/dist/notifications/routes.d.ts +2 -2
  251. package/dist/observability/routes.d.ts +8 -8
  252. package/dist/onboarding/types.d.ts +7 -0
  253. package/dist/onboarding/types.d.ts.map +1 -1
  254. package/dist/onboarding/types.js.map +1 -1
  255. package/dist/resources/handlers.d.ts +2 -2
  256. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  257. package/dist/server/agent-chat-plugin.js +26 -0
  258. package/dist/server/agent-chat-plugin.js.map +1 -1
  259. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  260. package/dist/server/agents-bundle.d.ts.map +1 -1
  261. package/dist/server/agents-bundle.js +22 -7
  262. package/dist/server/agents-bundle.js.map +1 -1
  263. package/dist/server/transcribe-voice.d.ts +1 -1
  264. package/dist/styles/agent-native.css +5 -0
  265. package/dist/templates/default/app/global.css +35 -35
  266. package/docs/content/locales/ar-SA/template-design.mdx +6 -0
  267. package/docs/content/locales/de-DE/template-design.mdx +8 -0
  268. package/docs/content/locales/es-ES/template-design.mdx +10 -2
  269. package/docs/content/locales/fr-FR/template-design.mdx +10 -2
  270. package/docs/content/locales/hi-IN/template-design.mdx +7 -0
  271. package/docs/content/locales/ja-JP/template-design.mdx +7 -0
  272. package/docs/content/locales/ko-KR/template-design.mdx +7 -0
  273. package/docs/content/locales/pt-BR/template-design.mdx +9 -2
  274. package/docs/content/locales/zh-CN/template-design.mdx +6 -0
  275. package/docs/content/locales/zh-TW/template-design.mdx +6 -0
  276. package/docs/content/template-design.mdx +9 -2
  277. package/package.json +1 -1
  278. package/src/templates/default/app/global.css +35 -35
  279. package/corpus/templates/design/app/components/design/VariantGrid.tsx +0 -189
  280. package/corpus/templates/design/app/components/design/VariantHandoffCard.tsx +0 -115
  281. package/corpus/templates/design/app/hooks/use-variant-flow.ts +0 -342
@@ -34,10 +34,12 @@ import {
34
34
  } from "@shared/pen-path";
35
35
  import { IconCopy, IconMaximize } from "@tabler/icons-react";
36
36
  import {
37
+ memo,
37
38
  useRef,
38
39
  useState,
39
40
  useCallback,
40
41
  useEffect,
42
+ useMemo,
41
43
  type CSSProperties,
42
44
  type ReactNode,
43
45
  } from "react";
@@ -45,6 +47,8 @@ import {
45
47
  import { prettyScreenName } from "@/lib/screen-names";
46
48
  import { cn } from "@/lib/utils";
47
49
 
50
+ import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "./types";
51
+
48
52
  interface ScreenFile {
49
53
  id: string;
50
54
  filename: string;
@@ -55,6 +59,7 @@ interface ScreenFile {
55
59
  previewState?: string;
56
60
  status?: string;
57
61
  title?: string;
62
+ updatedAt?: string;
58
63
  width?: number;
59
64
  height?: number;
60
65
  url?: string;
@@ -129,6 +134,11 @@ interface MultiScreenCanvasProps {
129
134
  screens: ScreenFile[];
130
135
  zoom: number;
131
136
  activeId?: string | null;
137
+ selectedScreenIds?: string[];
138
+ activeScreenHasHoveredChild?: boolean;
139
+ hoveredChildScreenId?: string | null;
140
+ directlyHoveredScreenId?: string | null;
141
+ previewDeviceFrame?: DeviceFrameType;
132
142
  activeTool?: MultiScreenCanvasTool;
133
143
  toolProps?: CanvasToolProps;
134
144
  onActiveToolChange?: (tool: MultiScreenCanvasTool) => void;
@@ -147,16 +157,16 @@ interface MultiScreenCanvasProps {
147
157
  screenId: string,
148
158
  primitive: CanvasPrimitiveInsert,
149
159
  ) => boolean | string;
160
+ onPrimitiveCreated?: (screenId: string, nodeId: string) => void;
150
161
  onCreateScreenFrame?: (geometry: FrameGeometry) => void;
151
162
  onDeleteSelection?: (ids: string[]) => boolean | void;
152
163
  onZoomChange?: (zoom: number) => void;
153
- onZoomToEdit?: (id: string) => void;
154
- zoomToEditThreshold?: number;
155
164
  renderScreenContent?: (
156
165
  screen: ScreenFile,
157
166
  metadata: ResolvedScreenMetadata,
158
167
  geometry: FrameGeometry,
159
168
  ) => ReactNode;
169
+ onScreenSelectionChange?: (ids: string[]) => void;
160
170
  selectAllRequest?: number;
161
171
  clearSelectionRequest?: number;
162
172
  onSelectionChange?: (selectedIds: string[]) => void;
@@ -166,7 +176,8 @@ interface MultiScreenCanvasProps {
166
176
  * design-editor overview canvas. Renders every file in the design as a movable,
167
177
  * resizable frame inside an infinite, pannable surface.
168
178
  */
169
- const SCREEN_WIDTH = 320;
179
+ export const OVERVIEW_FRAME_WIDTH = 320;
180
+ const SCREEN_WIDTH = OVERVIEW_FRAME_WIDTH;
170
181
  const SCREEN_HEIGHT = 640;
171
182
  const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
172
183
  const SCREEN_GAP = 56;
@@ -174,12 +185,70 @@ const SURFACE_PADDING = 240;
174
185
  const DUPLICATE_DRAG_THRESHOLD = 6;
175
186
  const DRAG_THRESHOLD = 3;
176
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;
177
199
  const MIN_ZOOM = 2;
178
200
  const MAX_ZOOM = 800;
179
- const ZOOM_SENSITIVITY = 0.002;
180
- const MAX_WHEEL_ZOOM_DELTA = 80;
201
+ const ZOOM_SENSITIVITY = 0.01;
202
+ const MAX_WHEEL_ZOOM_DELTA = 120;
181
203
  const MAX_WHEEL_PAN_DELTA = 140;
182
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
+ }
183
252
  const DRAFT_FRAME_WIDTH = 320;
184
253
  const DRAFT_FRAME_HEIGHT = 640;
185
254
  const DRAFT_RECT_WIDTH = 160;
@@ -394,10 +463,29 @@ type DragState =
394
463
  | DraftCreateDragState
395
464
  | PenNodeDragState;
396
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
+
397
480
  export function MultiScreenCanvas({
398
481
  screens,
399
482
  zoom,
400
483
  activeId,
484
+ selectedScreenIds,
485
+ activeScreenHasHoveredChild = false,
486
+ hoveredChildScreenId,
487
+ directlyHoveredScreenId,
488
+ previewDeviceFrame = "none",
401
489
  activeTool,
402
490
  toolProps,
403
491
  onActiveToolChange,
@@ -410,12 +498,12 @@ export function MultiScreenCanvas({
410
498
  onGeometryChange,
411
499
  onGeometryCommit,
412
500
  onCreatePrimitive,
501
+ onPrimitiveCreated,
413
502
  onCreateScreenFrame,
414
503
  onDeleteSelection,
415
504
  onZoomChange,
416
- onZoomToEdit,
417
- zoomToEditThreshold,
418
505
  renderScreenContent,
506
+ onScreenSelectionChange,
419
507
  selectAllRequest,
420
508
  clearSelectionRequest,
421
509
  onSelectionChange,
@@ -446,7 +534,7 @@ export function MultiScreenCanvas({
446
534
  const [localActiveTool, setLocalActiveTool] =
447
535
  useState<MultiScreenCanvasTool>("move");
448
536
  const [selectedIds, setSelectedIds] = useState<string[]>(
449
- activeId ? [activeId] : [],
537
+ selectedScreenIds ?? [],
450
538
  );
451
539
  const selectedIdsRef = useRef(selectedIds);
452
540
  const dragState = useRef<DragState | null>(null);
@@ -466,6 +554,26 @@ export function MultiScreenCanvas({
466
554
  const [dragCursor, setDragCursor] = useState<string | null>(null);
467
555
  const suppressNextPick = useRef(false);
468
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
+ );
469
577
 
470
578
  useEffect(() => {
471
579
  onGeometryChangeRef.current = onGeometryChange;
@@ -559,6 +667,10 @@ export function MultiScreenCanvas({
559
667
  onSelectionChangeRef.current?.(selectedIds);
560
668
  }, [selectedIds]);
561
669
 
670
+ useEffect(() => {
671
+ onScreenSelectionChange?.(selectedIds);
672
+ }, [onScreenSelectionChange, selectedIds]);
673
+
562
674
  useEffect(() => {
563
675
  draftPrimitivesRef.current = draftPrimitives;
564
676
  }, [draftPrimitives]);
@@ -581,8 +693,9 @@ export function MultiScreenCanvas({
581
693
  screens.forEach((screen, index) => {
582
694
  const existing = current[screen.id];
583
695
  const persisted = geometryById?.[screen.id];
696
+ const metadata = getResolvedMetadata(screen);
584
697
  const resolved = {
585
- ...getInitialFrameGeometry(index),
698
+ ...getInitialFrameGeometry(index, metadata),
586
699
  ...persisted,
587
700
  } as FrameGeometry;
588
701
  next[screen.id] = persisted ? resolved : (existing ?? resolved);
@@ -600,14 +713,47 @@ export function MultiScreenCanvas({
600
713
  const next = current.filter((id) => currentIds.has(id));
601
714
  return next.length === current.length ? current : next;
602
715
  });
603
- }, [geometryById, screens, updateFrameGeometry, updateSelectedIds]);
716
+ }, [
717
+ geometryById,
718
+ getResolvedMetadata,
719
+ screens,
720
+ updateFrameGeometry,
721
+ updateSelectedIds,
722
+ ]);
604
723
 
605
724
  useEffect(() => {
606
- if (!activeId) return;
607
- updateSelectedIds((current) =>
608
- current.includes(activeId) ? current : [activeId],
609
- );
610
- }, [activeId, updateSelectedIds]);
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]);
752
+
753
+ useEffect(() => {
754
+ if (!selectedScreenIds) return;
755
+ updateSelectedIds(() => selectedScreenIds);
756
+ }, [screens, selectedScreenIds, updateSelectedIds]);
611
757
 
612
758
  useEffect(() => {
613
759
  if (
@@ -640,11 +786,18 @@ export function MultiScreenCanvas({
640
786
  useEffect(() => {
641
787
  if (!surfaceRef.current || screens.length === 0) return;
642
788
  const rect = surfaceRef.current.getBoundingClientRect();
643
- const columns = Math.min(screens.length, 3);
644
- const rows = Math.ceil(screens.length / columns);
645
789
  const scale = zoomRef.current / 100;
646
- const totalWidth = columns * SCREEN_WIDTH + (columns - 1) * SCREEN_GAP;
647
- 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;
648
801
  const visualLeft = Math.max(24, (rect.width - totalWidth * scale) / 2);
649
802
  const visualTop = Math.max(24, (rect.height - totalHeight * scale) / 2);
650
803
  const nextPan = {
@@ -653,9 +806,9 @@ export function MultiScreenCanvas({
653
806
  };
654
807
  panRef.current = nextPan;
655
808
  setPan(nextPan);
656
- // Only on mount or when screen count changes, not on every pan update.
809
+ // Only on mount, screen-count changes, or device-preview changes.
657
810
  // eslint-disable-next-line react-hooks/exhaustive-deps
658
- }, [screens.length]);
811
+ }, [previewDeviceFrame, screens.length]);
659
812
 
660
813
  useEffect(() => {
661
814
  return () => {
@@ -664,6 +817,15 @@ export function MultiScreenCanvas({
664
817
  if (feedbackTimerRef.current !== null) {
665
818
  window.clearTimeout(feedbackTimerRef.current);
666
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
+ }
667
829
  };
668
830
  }, []);
669
831
 
@@ -694,8 +856,17 @@ export function MultiScreenCanvas({
694
856
  }, []);
695
857
 
696
858
  const getCurrentFrameEntries = useCallback(
697
- () => getFrameEntries(screens, frameGeometryRef.current),
698
- [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],
699
870
  );
700
871
 
701
872
  const getCurrentDraftEntries = useCallback(
@@ -737,14 +908,6 @@ export function MultiScreenCanvas({
737
908
  [getCurrentFrameEntries],
738
909
  );
739
910
 
740
- const getFrameAtClientPoint = useCallback(
741
- (clientX: number, clientY: number) => {
742
- const point = canvasPointFromClient(clientX, clientY);
743
- return getFrameEntryAtPoint(point)?.id;
744
- },
745
- [canvasPointFromClient, getFrameEntryAtPoint],
746
- );
747
-
748
911
  const deleteSelectedItems = useCallback(() => {
749
912
  const frameIds = selectedIdsRef.current.filter(
750
913
  (id) => frameGeometryRef.current[id],
@@ -797,6 +960,9 @@ export function MultiScreenCanvas({
797
960
  handleMouseUp: (ev: MouseEvent) => void,
798
961
  ) => {
799
962
  dragCleanup.current?.();
963
+ const restorePreviewPointerEvents = mutePreviewIframePointerEvents(
964
+ surfaceRef.current,
965
+ );
800
966
  let lastMouseEvent: MouseEvent | null = null;
801
967
  const move = (ev: MouseEvent) => {
802
968
  lastMouseEvent = ev;
@@ -814,6 +980,7 @@ export function MultiScreenCanvas({
814
980
  window.removeEventListener("mousemove", move);
815
981
  window.removeEventListener("mouseup", up);
816
982
  window.removeEventListener("blur", cleanupOnBlur);
983
+ restorePreviewPointerEvents();
817
984
  dragCleanup.current = null;
818
985
  };
819
986
  window.addEventListener("mousemove", move);
@@ -836,10 +1003,34 @@ export function MultiScreenCanvas({
836
1003
 
837
1004
  const showTransformFeedback = useCallback(
838
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;
839
1030
  setTransformBadge({
840
1031
  text,
841
- x: clientX + 12,
842
- y: clientY + 12,
1032
+ x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
1033
+ y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
843
1034
  });
844
1035
  },
845
1036
  [],
@@ -861,6 +1052,74 @@ export function MultiScreenCanvas({
861
1052
  dragCleanup.current?.();
862
1053
  }, []);
863
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
+
864
1123
  const beginPan = useCallback(
865
1124
  (e: React.MouseEvent) => {
866
1125
  e.preventDefault();
@@ -987,7 +1246,10 @@ export function MultiScreenCanvas({
987
1246
  : undefined;
988
1247
  if (preferred) return preferred;
989
1248
 
990
- return entries
1249
+ const draftCenter = getFrameCenter(draft.geometry);
1250
+
1251
+ // Primary: find the frame whose bounds contain the draft's center point.
1252
+ const containing = entries
991
1253
  .filter(({ geometry }) => {
992
1254
  const bounds = {
993
1255
  left: geometry.x,
@@ -995,7 +1257,6 @@ export function MultiScreenCanvas({
995
1257
  right: geometry.x + geometry.width,
996
1258
  bottom: geometry.y + geometry.height,
997
1259
  };
998
- const draftCenter = getFrameCenter(draft.geometry);
999
1260
  const local = rotatePointAroundCenter(
1000
1261
  draftCenter,
1001
1262
  getFrameCenter(geometry),
@@ -1004,6 +1265,22 @@ export function MultiScreenCanvas({
1004
1265
  return rectContainsPoint(bounds, local);
1005
1266
  })
1006
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];
1007
1284
  },
1008
1285
  [getCurrentFrameEntries],
1009
1286
  );
@@ -1057,7 +1334,8 @@ export function MultiScreenCanvas({
1057
1334
  current.filter((draft) => draft.id !== nextDraft.id),
1058
1335
  );
1059
1336
  updateSelectedDraftIds(() => []);
1060
- updateSelectedIds(() => [persisted.nodeId]);
1337
+ updateSelectedIds(() => []);
1338
+ onPrimitiveCreated?.(persisted.frameId, persisted.nodeId);
1061
1339
  return;
1062
1340
  }
1063
1341
 
@@ -1067,6 +1345,7 @@ export function MultiScreenCanvas({
1067
1345
  },
1068
1346
  [
1069
1347
  persistDraftPrimitive,
1348
+ onPrimitiveCreated,
1070
1349
  updateDraftPrimitives,
1071
1350
  updateSelectedDraftIds,
1072
1351
  updateSelectedIds,
@@ -1708,9 +1987,11 @@ export function MultiScreenCanvas({
1708
1987
  const targetIds = currentSelectedIds.includes(id)
1709
1988
  ? currentSelectedIds
1710
1989
  : [id];
1990
+ if (activeId !== id) {
1991
+ onPick(id);
1992
+ }
1711
1993
  if (!currentSelectedIds.includes(id)) {
1712
1994
  updateSelectedIds(() => [id]);
1713
- onPick(id);
1714
1995
  }
1715
1996
  updateSelectedDraftIds(() => []);
1716
1997
 
@@ -1805,6 +2086,7 @@ export function MultiScreenCanvas({
1805
2086
  installDragListeners(handleMouseMove, handleMouseUp);
1806
2087
  },
1807
2088
  [
2089
+ activeId,
1808
2090
  finishDrag,
1809
2091
  getCurrentFrameEntries,
1810
2092
  installDragListeners,
@@ -1823,6 +2105,10 @@ export function MultiScreenCanvas({
1823
2105
  e.stopPropagation();
1824
2106
  suppressNextPick.current = true;
1825
2107
 
2108
+ if (activeId !== id) {
2109
+ onPick(id);
2110
+ }
2111
+
1826
2112
  const currentSelectedIds = selectedIdsRef.current;
1827
2113
  const targetIds = currentSelectedIds.includes(id)
1828
2114
  ? currentSelectedIds
@@ -1931,9 +2217,11 @@ export function MultiScreenCanvas({
1931
2217
  installDragListeners(handleMouseMove, handleMouseUp);
1932
2218
  },
1933
2219
  [
2220
+ activeId,
1934
2221
  finishDrag,
1935
2222
  getCurrentFrameEntries,
1936
2223
  installDragListeners,
2224
+ onPick,
1937
2225
  showTransformFeedback,
1938
2226
  updateFrameGeometry,
1939
2227
  updateSelectedIds,
@@ -1956,6 +2244,10 @@ export function MultiScreenCanvas({
1956
2244
  e.stopPropagation();
1957
2245
  suppressNextPick.current = true;
1958
2246
 
2247
+ if (activeId !== id) {
2248
+ onPick(id);
2249
+ }
2250
+
1959
2251
  const originFrame = getCurrentFrameEntries().find(
1960
2252
  (entry) => entry.id === id,
1961
2253
  )?.geometry;
@@ -2027,10 +2319,12 @@ export function MultiScreenCanvas({
2027
2319
  installDragListeners(handleMouseMove, handleMouseUp);
2028
2320
  },
2029
2321
  [
2322
+ activeId,
2030
2323
  finishDrag,
2031
2324
  getCanvasPoint,
2032
2325
  getCurrentFrameEntries,
2033
2326
  installDragListeners,
2327
+ onPick,
2034
2328
  showTransformFeedback,
2035
2329
  updateFrameGeometry,
2036
2330
  updateSelectedIds,
@@ -2047,11 +2341,20 @@ export function MultiScreenCanvas({
2047
2341
 
2048
2342
  if (e.shiftKey) {
2049
2343
  updateSelectedDraftIds(() => []);
2050
- updateSelectedIds((current) =>
2051
- current.includes(id)
2052
- ? current.filter((selectedId) => selectedId !== id)
2053
- : [...current, id],
2054
- );
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
+ }
2055
2358
  return;
2056
2359
  }
2057
2360
 
@@ -2059,7 +2362,7 @@ export function MultiScreenCanvas({
2059
2362
  updateSelectedIds(() => [id]);
2060
2363
  onPick(id);
2061
2364
  },
2062
- [onPick, updateSelectedDraftIds, updateSelectedIds],
2365
+ [activeId, onPick, updateSelectedDraftIds, updateSelectedIds],
2063
2366
  );
2064
2367
 
2065
2368
  const handleFrameDoubleClick = useCallback(
@@ -2232,6 +2535,136 @@ export function MultiScreenCanvas({
2232
2535
  [activeTool, localActiveTool, updatePenPointer],
2233
2536
  );
2234
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
+
2235
2668
  const handleWheelEvent = useCallback(
2236
2669
  (event: WheelEvent) => {
2237
2670
  const rect = surfaceRef.current?.getBoundingClientRect();
@@ -2241,40 +2674,18 @@ export function MultiScreenCanvas({
2241
2674
  const delta = getWheelDelta(event);
2242
2675
 
2243
2676
  if (event.ctrlKey || event.metaKey) {
2244
- const currentZoom = zoomRef.current;
2245
2677
  const zoomDeltaY = clamp(
2246
2678
  delta.y,
2247
2679
  -MAX_WHEEL_ZOOM_DELTA,
2248
2680
  MAX_WHEEL_ZOOM_DELTA,
2249
2681
  );
2250
- const nextZoom = clamp(
2251
- currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
2252
- MIN_ZOOM,
2253
- MAX_ZOOM,
2254
- );
2255
- if (nextZoom === currentZoom) return;
2256
-
2257
- const nextPan = getPanForZoomToCursor({
2258
- pan: panRef.current,
2682
+ enqueueWheelGesture({
2683
+ mode: "zoom",
2684
+ deltaY: zoomDeltaY,
2259
2685
  cursor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
2260
- oldZoom: currentZoom,
2261
- nextZoom,
2686
+ clientX: event.clientX,
2687
+ clientY: event.clientY,
2262
2688
  });
2263
- const zoomEditFrameId =
2264
- zoomToEditThreshold !== undefined &&
2265
- currentZoom < zoomToEditThreshold &&
2266
- nextZoom >= zoomToEditThreshold
2267
- ? getFrameAtClientPoint(event.clientX, event.clientY)
2268
- : undefined;
2269
-
2270
- zoomRef.current = nextZoom;
2271
- panRef.current = nextPan;
2272
- setCanvasZoom(nextZoom);
2273
- setPan(nextPan);
2274
- onZoomChange?.(nextZoom);
2275
- if (zoomEditFrameId) {
2276
- onZoomToEdit?.(zoomEditFrameId);
2277
- }
2278
2689
  return;
2279
2690
  }
2280
2691
 
@@ -2288,14 +2699,9 @@ export function MultiScreenCanvas({
2288
2699
  -MAX_WHEEL_PAN_DELTA,
2289
2700
  MAX_WHEEL_PAN_DELTA,
2290
2701
  );
2291
- const nextPan = {
2292
- x: panRef.current.x - deltaX,
2293
- y: panRef.current.y - deltaY,
2294
- };
2295
- panRef.current = nextPan;
2296
- setPan(nextPan);
2702
+ enqueueWheelGesture({ mode: "pan", deltaX, deltaY });
2297
2703
  },
2298
- [getFrameAtClientPoint, onZoomChange, onZoomToEdit, zoomToEditThreshold],
2704
+ [enqueueWheelGesture],
2299
2705
  );
2300
2706
 
2301
2707
  useEffect(() => {
@@ -2490,12 +2896,59 @@ export function MultiScreenCanvas({
2490
2896
  if (tool !== "pen") clearActivePenPath();
2491
2897
  }, [activeTool, clearActivePenPath, localActiveTool]);
2492
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
+
2493
2945
  const scale = canvasZoom / 100;
2494
2946
  const chromeScale = scale > 0 ? 1 / scale : 1;
2495
2947
  const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
2496
2948
  const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
2497
2949
  const penActive = effectiveTool === "pen";
2498
2950
  const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
2951
+ const canvasGestureActive = isDragging || isPanning;
2499
2952
  const displayedPenPath = penGesturePreview
2500
2953
  ? penGesturePreview
2501
2954
  : activePenPath && penPointer && activePenPath.nodes.length > 0
@@ -2516,10 +2969,29 @@ export function MultiScreenCanvas({
2516
2969
  : effectiveTool === "hand"
2517
2970
  ? "grab"
2518
2971
  : "default";
2519
- const canvasFrames = screens.map((screen, index) => ({
2520
- screen,
2521
- geometry: frameGeometry[screen.id] ?? getInitialFrameGeometry(index),
2522
- }));
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]);
2523
2995
  const selectedFrameEntries = canvasFrames
2524
2996
  .filter(({ screen }) => selectedIdSet.has(screen.id))
2525
2997
  .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
@@ -2535,16 +3007,25 @@ export function MultiScreenCanvas({
2535
3007
  selectedDraftEntries.length > 1
2536
3008
  ? getFrameGroupBounds(selectedDraftEntries)
2537
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;
2538
3018
  return (
2539
3019
  <div
2540
3020
  ref={surfaceRef}
2541
- className="relative h-full w-full select-none overflow-hidden bg-background"
3021
+ className="relative h-full w-full select-none overflow-hidden"
2542
3022
  onMouseDown={handleMouseDown}
2543
3023
  onMouseMove={handleMouseMove}
2544
3024
  style={{ cursor: surfaceCursor, touchAction: "none" }}
2545
3025
  >
2546
3026
  {showPixelGrid ? (
2547
3027
  <div
3028
+ ref={pixelGridRef}
2548
3029
  className="pointer-events-none absolute inset-0 opacity-60"
2549
3030
  style={{
2550
3031
  backgroundImage:
@@ -2556,34 +3037,44 @@ export function MultiScreenCanvas({
2556
3037
  ) : null}
2557
3038
 
2558
3039
  <div
3040
+ ref={worldRef}
2559
3041
  className="pointer-events-none absolute"
2560
3042
  style={{
2561
- left: pan.x,
2562
- top: pan.y,
2563
- 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})`,
2564
3053
  transformOrigin: "top left",
2565
3054
  }}
2566
3055
  >
2567
- {canvasFrames.map(({ screen, geometry }) => {
2568
- const metadata = resolveScreenMetadata(
2569
- screen,
2570
- metadataById?.[screen.id],
2571
- getScreenMetadata?.(screen),
2572
- );
3056
+ {canvasFrames.map(({ screen, metadata, geometry }) => {
2573
3057
  return (
2574
3058
  <Screen
2575
3059
  key={screen.id}
2576
3060
  screen={screen}
2577
3061
  metadata={metadata}
2578
3062
  geometry={geometry}
2579
- screenContent={renderScreenContent?.(screen, metadata, geometry)}
3063
+ screenContent={screenContentById.get(screen.id)}
2580
3064
  isActive={screen.id === activeId}
2581
3065
  isSelected={selectedIdSet.has(screen.id)}
3066
+ isDirectlyHovered={screen.id === directlyHoveredScreenId}
3067
+ hasHoveredChild={
3068
+ (screen.id === activeId && activeScreenHasHoveredChild) ||
3069
+ screen.id === hoveredChildScreenId
3070
+ }
2582
3071
  groupSelected={hasGroupSelection}
2583
3072
  handlesEnabled={!hasGroupSelection}
2584
3073
  penActive={penActive}
2585
3074
  creationToolActive={creationToolActive}
3075
+ canvasGestureActive={canvasGestureActive}
2586
3076
  chromeScale={chromeScale}
3077
+ chromeSettling={chromeSettling}
2587
3078
  onPick={handleFrameClick}
2588
3079
  onEdit={handleFrameDoubleClick}
2589
3080
  onStartFrameDrag={beginFrameDrag}
@@ -2602,6 +3093,7 @@ export function MultiScreenCanvas({
2602
3093
  groupSelected={Boolean(selectedDraftGroupBounds)}
2603
3094
  penActive={penActive}
2604
3095
  chromeScale={chromeScale}
3096
+ chromeSettling={chromeSettling}
2605
3097
  onClick={handleDraftClick}
2606
3098
  onStartDrag={beginDraftDrag}
2607
3099
  onStartResize={beginDraftResize}
@@ -2616,6 +3108,7 @@ export function MultiScreenCanvas({
2616
3108
  groupSelected={false}
2617
3109
  penActive={penActive}
2618
3110
  chromeScale={chromeScale}
3111
+ chromeSettling={chromeSettling}
2619
3112
  onClick={() => {}}
2620
3113
  onStartDrag={() => {}}
2621
3114
  onStartResize={() => {}}
@@ -2626,10 +3119,39 @@ export function MultiScreenCanvas({
2626
3119
  <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
2627
3120
  ) : null}
2628
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
+
2629
3150
  {selectedGroupBounds ? (
2630
3151
  <GroupSelectionBox
2631
3152
  bounds={selectedGroupBounds}
2632
3153
  chromeScale={chromeScale}
3154
+ chromeSettling={chromeSettling}
2633
3155
  onStartResize={beginGroupResize}
2634
3156
  />
2635
3157
  ) : null}
@@ -2638,6 +3160,7 @@ export function MultiScreenCanvas({
2638
3160
  <GroupSelectionBox
2639
3161
  bounds={selectedDraftGroupBounds}
2640
3162
  chromeScale={chromeScale}
3163
+ chromeSettling={chromeSettling}
2641
3164
  onStartResize={beginDraftGroupResize}
2642
3165
  />
2643
3166
  ) : null}
@@ -2663,20 +3186,23 @@ export function MultiScreenCanvas({
2663
3186
  }
2664
3187
  />
2665
3188
  ))}
2666
-
2667
- {marquee ? (
2668
- <span
2669
- className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
2670
- style={{
2671
- left: SURFACE_PADDING + marquee.x,
2672
- top: SURFACE_PADDING + marquee.y,
2673
- width: marquee.width,
2674
- height: marquee.height,
2675
- }}
2676
- />
2677
- ) : null}
2678
3189
  </div>
2679
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
+
2680
3206
  {duplicatePreview ? (
2681
3207
  <div
2682
3208
  className={cn(
@@ -2712,7 +3238,8 @@ export function MultiScreenCanvas({
2712
3238
 
2713
3239
  {transformBadge ? (
2714
3240
  <div
2715
- 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"
2716
3243
  style={{ left: transformBadge.x, top: transformBadge.y }}
2717
3244
  >
2718
3245
  {transformBadge.text}
@@ -2728,6 +3255,7 @@ function DraftPrimitiveLayer({
2728
3255
  groupSelected,
2729
3256
  penActive,
2730
3257
  chromeScale,
3258
+ chromeSettling,
2731
3259
  preview = false,
2732
3260
  onClick,
2733
3261
  onStartDrag,
@@ -2738,6 +3266,7 @@ function DraftPrimitiveLayer({
2738
3266
  groupSelected: boolean;
2739
3267
  penActive: boolean;
2740
3268
  chromeScale: number;
3269
+ chromeSettling: boolean;
2741
3270
  preview?: boolean;
2742
3271
  onClick: (id: string, e: React.MouseEvent) => void;
2743
3272
  onStartDrag: (id: string, e: React.MouseEvent) => void;
@@ -2755,7 +3284,7 @@ function DraftPrimitiveLayer({
2755
3284
  type="button"
2756
3285
  className={cn(
2757
3286
  "group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
2758
- preview || penActive ? "cursor-crosshair" : "cursor-move",
3287
+ preview || penActive ? "cursor-crosshair" : "cursor-pointer",
2759
3288
  )}
2760
3289
  style={{
2761
3290
  left: SURFACE_PADDING + geometry.x,
@@ -2780,20 +3309,24 @@ function DraftPrimitiveLayer({
2780
3309
  <span
2781
3310
  className={cn(
2782
3311
  "pointer-events-none absolute rounded-sm border transition-opacity",
2783
- selected
3312
+ preview
2784
3313
  ? "border-[var(--design-editor-accent-color)] opacity-100"
2785
- : "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",
2786
3317
  )}
2787
3318
  style={{
2788
3319
  inset: -5 * chromeScale,
2789
3320
  borderWidth: 1.5 * chromeScale,
3321
+ transition: getChromeBorderTransition(chromeSettling),
2790
3322
  }}
2791
3323
  />
2792
3324
  <ResizeHandles
2793
- active={selected || preview}
2794
- enabled={!penActive && ((isSelected && !groupSelected) || preview)}
3325
+ active={preview}
3326
+ enabled={!penActive && preview}
2795
3327
  showRotate={false}
2796
3328
  chromeScale={chromeScale}
3329
+ chromeSettling={chromeSettling}
2797
3330
  onStartResize={(handle, event) =>
2798
3331
  onStartResize(draft.id, handle, event)
2799
3332
  }
@@ -3034,35 +3567,21 @@ function isPoint(point: Point | undefined): point is Point {
3034
3567
  return !!point;
3035
3568
  }
3036
3569
 
3037
- function Screen({
3038
- screen,
3039
- metadata,
3040
- geometry,
3041
- isActive,
3042
- isSelected,
3043
- groupSelected,
3044
- handlesEnabled,
3045
- penActive,
3046
- creationToolActive,
3047
- chromeScale,
3048
- onPick,
3049
- onEdit,
3050
- onStartFrameDrag,
3051
- onStartResize,
3052
- onStartRotate,
3053
- onStartDuplicateGesture,
3054
- screenContent,
3055
- }: {
3570
+ interface ScreenProps {
3056
3571
  screen: ScreenFile;
3057
3572
  metadata: ResolvedScreenMetadata;
3058
3573
  geometry: FrameGeometry;
3059
3574
  isActive: boolean;
3060
3575
  isSelected: boolean;
3576
+ isDirectlyHovered: boolean;
3577
+ hasHoveredChild: boolean;
3061
3578
  groupSelected: boolean;
3062
3579
  handlesEnabled: boolean;
3063
3580
  penActive: boolean;
3064
3581
  creationToolActive: boolean;
3582
+ canvasGestureActive: boolean;
3065
3583
  chromeScale: number;
3584
+ chromeSettling: boolean;
3066
3585
  screenContent?: ReactNode;
3067
3586
  onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
3068
3587
  onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
@@ -3078,15 +3597,75 @@ function Screen({
3078
3597
  display: string,
3079
3598
  e: React.MouseEvent<HTMLElement>,
3080
3599
  ) => void;
3081
- }) {
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) {
3082
3625
  const t = useT();
3083
3626
  const display = metadata.title ?? prettyScreenName(screen.filename);
3084
3627
  const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
3628
+ const previewViewport = getScreenPreviewViewport(metadata, geometry);
3085
3629
  const suppressNextClick = useRef(false);
3086
- 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;
3087
3637
  const selectionOutlined = isSelected && !groupSelected;
3088
- const showHoverOutline =
3089
- !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));
3090
3669
 
3091
3670
  return (
3092
3671
  <div
@@ -3094,17 +3673,32 @@ function Screen({
3094
3673
  className="group/frame pointer-events-auto absolute"
3095
3674
  style={{
3096
3675
  left: SURFACE_PADDING + geometry.x,
3097
- top: SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT,
3676
+ top: SURFACE_PADDING + geometry.y - frameLabelHeight,
3098
3677
  width: geometry.width,
3099
3678
  transform: geometry.rotation
3100
3679
  ? `rotate(${geometry.rotation}deg)`
3101
3680
  : undefined,
3102
- transformOrigin: `${geometry.width / 2}px ${FRAME_LABEL_HEIGHT + geometry.height / 2}px`,
3681
+ transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
3103
3682
  zIndex: geometry.z,
3104
3683
  }}
3105
3684
  >
3106
3685
  <div
3107
- 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
+ }}
3108
3702
  onMouseDown={(e) => {
3109
3703
  if (e.button !== 0) return;
3110
3704
  if (penActive || creationToolActive) return;
@@ -3114,13 +3708,23 @@ function Screen({
3114
3708
  }
3115
3709
  if (e.shiftKey) {
3116
3710
  e.stopPropagation();
3117
- onPick(screen.id, e);
3118
3711
  return;
3119
3712
  }
3120
3713
  onStartFrameDrag(screen.id, e);
3121
3714
  }}
3122
3715
  >
3123
- <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
+ >
3124
3728
  <span
3125
3729
  className={cn(
3126
3730
  "h-1.5 w-1.5 shrink-0 rounded-full",
@@ -3128,26 +3732,45 @@ function Screen({
3128
3732
  )}
3129
3733
  />
3130
3734
  <span
3735
+ data-frame-title
3131
3736
  className={cn(
3132
- "truncate text-[11px] font-medium",
3133
- 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",
3134
3743
  )}
3135
3744
  title={screen.filename}
3136
3745
  >
3137
3746
  {display}
3138
3747
  </span>
3139
- <span className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline">
3140
- {metadata.width} x {metadata.height}
3141
- </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}
3142
3756
  </div>
3143
3757
  <button
3144
3758
  type="button"
3759
+ data-frame-full-view
3145
3760
  className={cn(
3146
- "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",
3147
3762
  "hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
3148
- "group-hover/frame:opacity-100 group-focus-within/frame:opacity-100",
3149
3763
  emphasized && "opacity-100",
3764
+ fullViewOutsideFrame ? "left-full" : "right-1",
3150
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
+ }}
3151
3774
  aria-label={t("multiScreenCanvas.fullView")}
3152
3775
  title={t("multiScreenCanvas.fullView")}
3153
3776
  onClick={(event) => onEdit(screen.id, event)}
@@ -3155,6 +3778,8 @@ function Screen({
3155
3778
  event.preventDefault();
3156
3779
  event.stopPropagation();
3157
3780
  }}
3781
+ onMouseEnter={() => updateDirectHover(true)}
3782
+ onMouseLeave={() => updateDirectHover(false)}
3158
3783
  >
3159
3784
  <IconMaximize className="size-3 shrink-0" />
3160
3785
  <span className="truncate">{t("multiScreenCanvas.fullView")}</span>
@@ -3165,7 +3790,10 @@ function Screen({
3165
3790
  role="button"
3166
3791
  tabIndex={0}
3167
3792
  onClick={(e) => {
3168
- if (isInteractiveScreenContentTarget(e.target)) return;
3793
+ if (isInteractiveScreenContentTarget(e.target)) {
3794
+ e.stopPropagation();
3795
+ return;
3796
+ }
3169
3797
  e.stopPropagation();
3170
3798
  if (suppressNextClick.current) {
3171
3799
  suppressNextClick.current = false;
@@ -3175,13 +3803,19 @@ function Screen({
3175
3803
  onPick(screen.id, e);
3176
3804
  }}
3177
3805
  onDoubleClick={(e) => {
3178
- if (isInteractiveScreenContentTarget(e.target)) return;
3806
+ if (isInteractiveScreenContentTarget(e.target)) {
3807
+ e.stopPropagation();
3808
+ return;
3809
+ }
3179
3810
  e.preventDefault();
3180
3811
  e.stopPropagation();
3181
3812
  }}
3182
3813
  onMouseDown={(e) => {
3814
+ if (isInteractiveScreenContentTarget(e.target)) {
3815
+ e.stopPropagation();
3816
+ return;
3817
+ }
3183
3818
  if (creationToolActive) return;
3184
- if (isInteractiveScreenContentTarget(e.target)) return;
3185
3819
  if (e.detail > 1) {
3186
3820
  e.stopPropagation();
3187
3821
  return;
@@ -3200,46 +3834,52 @@ function Screen({
3200
3834
  onStartFrameDrag(screen.id, e);
3201
3835
  }
3202
3836
  }}
3837
+ onMouseMove={(e) => {
3838
+ updateDirectHover(
3839
+ isDirectScreenHoverTarget(e.target, e.currentTarget),
3840
+ );
3841
+ }}
3842
+ onMouseLeave={() => updateDirectHover(false)}
3203
3843
  className={cn(
3204
3844
  "group/artboard relative block overflow-visible rounded-lg bg-background text-left outline-none transition-colors",
3205
3845
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
3206
3846
  emphasized
3207
3847
  ? "text-foreground"
3208
- : "text-muted-foreground hover:text-foreground",
3848
+ : cn(
3849
+ "text-muted-foreground",
3850
+ showHoverChrome && "hover:text-foreground",
3851
+ ),
3209
3852
  )}
3210
3853
  style={{
3211
3854
  width: geometry.width,
3212
3855
  height: geometry.height,
3213
- cursor:
3214
- penActive || creationToolActive
3215
- ? "crosshair"
3216
- : isSelected
3217
- ? "move"
3218
- : "pointer",
3856
+ cursor: penActive || creationToolActive ? "crosshair" : "pointer",
3219
3857
  touchAction: "none",
3220
3858
  }}
3221
3859
  >
3222
3860
  <span
3861
+ data-screen-hover-outline
3223
3862
  className={cn(
3224
3863
  "pointer-events-none absolute border transition-opacity",
3225
- selectionOutlined
3864
+ showHoverChrome
3226
3865
  ? "border-[var(--design-editor-accent-color)] opacity-100"
3227
- : showHoverOutline
3228
- ? "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100"
3229
- : "border-transparent opacity-0",
3866
+ : "border-transparent opacity-0",
3230
3867
  )}
3231
3868
  style={{
3232
3869
  inset: -5 * chromeScale,
3233
3870
  borderRadius: 13 * chromeScale,
3234
3871
  borderWidth: 1.5 * chromeScale,
3872
+ transition: getChromeBorderTransition(chromeSettling),
3235
3873
  }}
3236
3874
  />
3237
3875
  <span
3876
+ data-screen-content
3238
3877
  className={cn(
3239
3878
  "relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
3240
- "border-border group-hover/artboard:border-muted-foreground/60",
3879
+ "border-border",
3880
+ showHoverChrome && "border-muted-foreground/60",
3241
3881
  )}
3242
- style={{ pointerEvents: penActive ? "none" : undefined }}
3882
+ style={{ pointerEvents: screenContentInteractive ? "auto" : "none" }}
3243
3883
  >
3244
3884
  {screenContent ?? (
3245
3885
  <iframe
@@ -3249,12 +3889,24 @@ function Screen({
3249
3889
  loading="lazy"
3250
3890
  className="pointer-events-none border-0"
3251
3891
  style={{
3252
- width: metadata.width,
3253
- height: metadata.height,
3254
- transform: `scale(${geometry.width / metadata.width}, ${
3255
- geometry.height / metadata.height
3256
- })`,
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})`,
3257
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",
3258
3910
  }}
3259
3911
  title={screen.filename}
3260
3912
  />
@@ -3265,50 +3917,132 @@ function Screen({
3265
3917
  aria-hidden="true"
3266
3918
  />
3267
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}
3268
3927
  <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
3269
3928
  </span>
3270
3929
  <ResizeHandles
3271
- active={selectionOutlined}
3272
- enabled={!penActive && !creationToolActive && handlesEnabled}
3930
+ active={false}
3931
+ enabled={
3932
+ !selectionOutlined &&
3933
+ !penActive &&
3934
+ !creationToolActive &&
3935
+ handlesEnabled
3936
+ }
3937
+ showOnHover={false}
3273
3938
  showRotate
3274
3939
  chromeScale={chromeScale}
3940
+ chromeSettling={chromeSettling}
3275
3941
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
3276
3942
  onStartRotate={(e) => onStartRotate(screen.id, e)}
3277
3943
  />
3278
3944
  </div>
3279
3945
  </div>
3280
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
+ );
3281
3973
  }
3282
3974
 
3283
3975
  function GroupSelectionBox({
3284
3976
  bounds,
3285
3977
  chromeScale,
3978
+ chromeSettling,
3286
3979
  onStartResize,
3287
3980
  }: {
3288
3981
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
3289
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;
3290
4015
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
4016
+ onStartRotate: (e: React.MouseEvent) => void;
3291
4017
  }) {
3292
4018
  return (
3293
4019
  <div
4020
+ data-frame-selection-box
3294
4021
  data-frame-shell
3295
- 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)]"
3296
4023
  style={{
3297
- left: SURFACE_PADDING + bounds.left,
3298
- top: SURFACE_PADDING + bounds.top,
3299
- width: bounds.width,
3300
- height: bounds.height,
4024
+ left: SURFACE_PADDING + geometry.x,
4025
+ top: SURFACE_PADDING + geometry.y,
4026
+ width: geometry.width,
4027
+ height: geometry.height,
3301
4028
  borderRadius: 13 * chromeScale,
3302
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,
3303
4036
  }}
3304
4037
  >
3305
4038
  <ResizeHandles
3306
4039
  active
3307
4040
  enabled
3308
- showRotate={false}
4041
+ showRotate={showRotate}
3309
4042
  chromeScale={chromeScale}
4043
+ chromeSettling={chromeSettling}
3310
4044
  onStartResize={onStartResize}
3311
- onStartRotate={() => {}}
4045
+ onStartRotate={onStartRotate}
3312
4046
  />
3313
4047
  </div>
3314
4048
  );
@@ -3317,15 +4051,19 @@ function GroupSelectionBox({
3317
4051
  function ResizeHandles({
3318
4052
  active,
3319
4053
  enabled,
4054
+ showOnHover = true,
3320
4055
  showRotate = true,
3321
4056
  chromeScale = 1,
4057
+ chromeSettling = false,
3322
4058
  onStartResize,
3323
4059
  onStartRotate,
3324
4060
  }: {
3325
4061
  active: boolean;
3326
4062
  enabled: boolean;
4063
+ showOnHover?: boolean;
3327
4064
  showRotate?: boolean;
3328
4065
  chromeScale?: number;
4066
+ chromeSettling?: boolean;
3329
4067
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
3330
4068
  onStartRotate: (e: React.MouseEvent) => void;
3331
4069
  }) {
@@ -3335,7 +4073,11 @@ function ResizeHandles({
3335
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",
3336
4074
  active
3337
4075
  ? "opacity-100"
3338
- : "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
+ ),
3339
4081
  );
3340
4082
  const edgeHandleClass =
3341
4083
  "pointer-events-auto absolute z-10 bg-transparent opacity-0";
@@ -3345,18 +4087,28 @@ function ResizeHandles({
3345
4087
  {EDGE_RESIZE_HANDLE_CONFIGS.map((config) => (
3346
4088
  <span
3347
4089
  key={config.handle}
3348
- data-resize-handle
4090
+ data-resize-handle={config.handle}
3349
4091
  className={edgeHandleClass}
3350
- style={edgeHandleStyle(config.handle, config.cursor, chromeScale)}
4092
+ style={edgeHandleStyle(
4093
+ config.handle,
4094
+ config.cursor,
4095
+ chromeScale,
4096
+ chromeSettling,
4097
+ )}
3351
4098
  onMouseDown={(e) => onStartResize(config.handle, e)}
3352
4099
  />
3353
4100
  ))}
3354
4101
  {CORNER_RESIZE_HANDLE_CONFIGS.map((config) => (
3355
4102
  <span
3356
4103
  key={config.handle}
3357
- data-resize-handle
4104
+ data-resize-handle={config.handle}
3358
4105
  className={visibleHandleClass}
3359
- style={cornerHandleStyle(config.handle, config.cursor, chromeScale)}
4106
+ style={cornerHandleStyle(
4107
+ config.handle,
4108
+ config.cursor,
4109
+ chromeScale,
4110
+ chromeSettling,
4111
+ )}
3360
4112
  onMouseDown={(e) => onStartResize(config.handle, e)}
3361
4113
  />
3362
4114
  ))}
@@ -3369,9 +4121,17 @@ function ResizeHandles({
3369
4121
  "pointer-events-auto absolute z-10 size-5 rounded-full transition-opacity active:cursor-grabbing",
3370
4122
  active
3371
4123
  ? "opacity-100"
3372
- : "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,
3373
4134
  )}
3374
- style={rotateHandleStyle(config.corner, chromeScale)}
3375
4135
  onMouseDown={onStartRotate}
3376
4136
  />
3377
4137
  ))
@@ -3413,12 +4173,14 @@ function edgeHandleStyle(
3413
4173
  handle: ResizeHandle,
3414
4174
  cursor: string,
3415
4175
  chromeScale: number,
4176
+ chromeSettling: boolean,
3416
4177
  ): CSSProperties {
3417
4178
  const size = 14 * chromeScale;
3418
4179
  const offset = -size / 2;
3419
4180
  if (handle === "n" || handle === "s") {
3420
4181
  return {
3421
4182
  cursor,
4183
+ transition: getChromeHandleTransition(chromeSettling),
3422
4184
  left: 0,
3423
4185
  right: 0,
3424
4186
  height: size,
@@ -3427,6 +4189,7 @@ function edgeHandleStyle(
3427
4189
  }
3428
4190
  return {
3429
4191
  cursor,
4192
+ transition: getChromeHandleTransition(chromeSettling),
3430
4193
  top: 0,
3431
4194
  bottom: 0,
3432
4195
  width: size,
@@ -3438,11 +4201,13 @@ function cornerHandleStyle(
3438
4201
  handle: ResizeHandle,
3439
4202
  cursor: string,
3440
4203
  chromeScale: number,
4204
+ chromeSettling: boolean,
3441
4205
  ): CSSProperties {
3442
4206
  const size = 10 * chromeScale;
3443
4207
  const offset = -size / 2;
3444
4208
  return {
3445
4209
  cursor,
4210
+ transition: getChromeHandleTransition(chromeSettling),
3446
4211
  width: size,
3447
4212
  height: size,
3448
4213
  borderWidth: Math.max(1, 1.25 * chromeScale),
@@ -3451,11 +4216,16 @@ function cornerHandleStyle(
3451
4216
  };
3452
4217
  }
3453
4218
 
3454
- function rotateHandleStyle(corner: string, chromeScale: number): CSSProperties {
4219
+ function rotateHandleStyle(
4220
+ corner: string,
4221
+ chromeScale: number,
4222
+ chromeSettling: boolean,
4223
+ ): CSSProperties {
3455
4224
  const size = 28 * chromeScale;
3456
4225
  const offset = -34 * chromeScale;
3457
4226
  return {
3458
4227
  cursor: "grab",
4228
+ transition: getChromeHandleTransition(chromeSettling),
3459
4229
  width: size,
3460
4230
  height: size,
3461
4231
  ...(corner.includes("n") ? { top: offset } : { bottom: offset }),
@@ -3475,7 +4245,15 @@ interface BoundsRect {
3475
4245
  bottom: number;
3476
4246
  }
3477
4247
 
3478
- 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 {
3479
4257
  // Seed default frames with the actual screen dimensions and the 3-column grid
3480
4258
  // the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
3481
4259
  // a design without persisted geometry opens centered. (The larger
@@ -3483,22 +4261,56 @@ function getInitialFrameGeometry(index: number): FrameGeometry {
3483
4261
  // editor's default placement.)
3484
4262
  const column = index % 3;
3485
4263
  const row = Math.floor(index / 3);
4264
+ const height = getOverviewFrameHeight(SCREEN_WIDTH, metadata);
3486
4265
  return {
3487
4266
  x: column * (SCREEN_WIDTH + SCREEN_GAP),
3488
- y: row * (SCREEN_CARD_HEIGHT + SCREEN_GAP),
4267
+ y: row * (height + FRAME_LABEL_HEIGHT + SCREEN_GAP),
3489
4268
  width: SCREEN_WIDTH,
3490
- height: SCREEN_HEIGHT,
4269
+ height,
3491
4270
  };
3492
4271
  }
3493
4272
 
3494
- function getFrameEntries(
3495
- screens: ScreenFile[],
3496
- geometryById: FrameGeometryById,
3497
- ): FrameEntry[] {
3498
- return screens.map((screen, index) => ({
3499
- id: screen.id,
3500
- geometry: geometryById[screen.id] ?? getInitialFrameGeometry(index),
3501
- }));
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
+ };
3502
4314
  }
3503
4315
 
3504
4316
  function dedupeIds(ids: string[]) {
@@ -3556,6 +4368,22 @@ function isInteractiveScreenContentTarget(target: EventTarget | null) {
3556
4368
  );
3557
4369
  }
3558
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
+
3559
4387
  function frameBoundsToGeometry(bounds: {
3560
4388
  left: number;
3561
4389
  top: number;
@@ -3697,6 +4525,20 @@ function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
3697
4525
  );
3698
4526
  }
3699
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
+
3700
4542
  function cloneFrameGeometryById(
3701
4543
  geometryById: FrameGeometryById,
3702
4544
  ): FrameGeometryById {
@@ -4124,6 +4966,7 @@ function resolveScreenMetadata(
4124
4966
  screen: ScreenFile,
4125
4967
  keyedMetadata?: ScreenMetadata,
4126
4968
  getterMetadata?: ScreenMetadata,
4969
+ previewDeviceFrame: DeviceFrameType = "none",
4127
4970
  ): ResolvedScreenMetadata {
4128
4971
  const metadata = { ...screen, ...keyedMetadata, ...getterMetadata };
4129
4972
  const previewUrl =
@@ -4131,9 +4974,16 @@ function resolveScreenMetadata(
4131
4974
  metadata.previewUrl ??
4132
4975
  screen.previewUrl ??
4133
4976
  getPreviewUrl(screen.content);
4134
- 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);
4135
4984
  const height =
4136
- metadata.height && metadata.height > 0 ? metadata.height : 2560;
4985
+ deviceViewport?.height ??
4986
+ (metadata.height && metadata.height > 0 ? metadata.height : 2560);
4137
4987
  return {
4138
4988
  source:
4139
4989
  normalizeSource(metadata.sourceType ?? metadata.source) ??
@@ -4231,3 +5081,7 @@ function getUrl(value: string | undefined) {
4231
5081
  return undefined;
4232
5082
  }
4233
5083
  }
5084
+
5085
+ function clampNumber(value: number, min: number, max: number) {
5086
+ return Math.min(max, Math.max(min, value));
5087
+ }