@agent-native/core 0.80.8 → 0.80.10

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 (359) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +19 -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/design-connect.ts +28 -2
  16. package/corpus/core/src/cli/mcp.ts +10 -0
  17. package/corpus/core/src/cli/skills.ts +103 -36
  18. package/corpus/core/src/client/error-format.ts +25 -0
  19. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +13 -11
  20. package/corpus/core/src/client/guided-questions.tsx +32 -4
  21. package/corpus/core/src/client/onboarding/OnboardingPanel.tsx +33 -1
  22. package/corpus/core/src/client/resources/BuiltinCapabilityDetail.tsx +8 -0
  23. package/corpus/core/src/client/resources/use-builtin-capabilities.ts +4 -2
  24. package/corpus/core/src/client/session-replay.ts +34 -0
  25. package/corpus/core/src/client/sharing/ShareButton.tsx +106 -21
  26. package/corpus/core/src/coding-tools/index.ts +1 -1
  27. package/corpus/core/src/mcp/index.ts +2 -0
  28. package/corpus/core/src/mcp/screen-memory-stdio.ts +290 -0
  29. package/corpus/core/src/mcp-client/builtin-capabilities.ts +13 -1
  30. package/corpus/core/src/mcp-client/index.ts +9 -0
  31. package/corpus/core/src/mcp-client/screen-memory-local.ts +461 -0
  32. package/corpus/core/src/onboarding/types.ts +7 -0
  33. package/corpus/core/src/server/agent-chat-plugin.ts +25 -0
  34. package/corpus/core/src/server/agents-bundle.ts +35 -7
  35. package/corpus/core/src/styles/agent-native.css +5 -0
  36. package/corpus/core/src/templates/default/app/global.css +35 -35
  37. package/corpus/templates/analytics/AGENTS.md +6 -0
  38. package/corpus/templates/analytics/actions/github-repo-files.ts +9 -0
  39. package/corpus/templates/analytics/app/components/layout/Layout.tsx +4 -4
  40. package/corpus/templates/analytics/app/global.css +144 -40
  41. package/corpus/templates/analytics/app/pages/adhoc/explorer/index.tsx +1 -1
  42. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +1 -1
  43. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +1 -1
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +4 -4
  45. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +9 -9
  46. package/corpus/templates/analytics/changelog/2026-06-29-agents-can-search-and-read-connected-github-repositories-whe.md +6 -0
  47. package/corpus/templates/analytics/changelog/2026-06-29-sessions-and-explorer-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
  48. package/corpus/templates/assets/app/components/layout/Layout.tsx +3 -1
  49. package/corpus/templates/assets/app/components/library/LibraryPresetGrid.tsx +1 -1
  50. package/corpus/templates/assets/app/global.css +99 -35
  51. package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
  52. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +4 -4
  53. package/corpus/templates/assets/app/routes/library.tsx +4 -4
  54. package/corpus/templates/assets/changelog/2026-06-29-asset-library-and-detail-layouts-adapt-when-the-agent-sidebar.md +6 -0
  55. package/corpus/templates/brain/app/components/layout/Layout.tsx +1 -1
  56. package/corpus/templates/brain/app/global.css +63 -35
  57. package/corpus/templates/brain/app/routes/search.tsx +1 -1
  58. package/corpus/templates/brain/app/routes/settings.tsx +1 -1
  59. package/corpus/templates/brain/app/routes/sources.tsx +2 -2
  60. package/corpus/templates/brain/changelog/2026-06-29-search-settings-and-source-layouts-adapt-when-the-agent-sidebar.md +6 -0
  61. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +4 -4
  62. package/corpus/templates/calendar/app/components/calendar/FindTimePanel.tsx +3 -2
  63. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -2
  64. package/corpus/templates/calendar/app/global.css +69 -35
  65. package/corpus/templates/calendar/changelog/2026-06-29-event-and-find-time-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
  66. package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -1
  67. package/corpus/templates/chat/app/global.css +35 -35
  68. package/corpus/templates/chat/changelog/2026-06-29-chat-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  69. package/corpus/templates/clips/AGENTS.md +13 -0
  70. package/corpus/templates/clips/actions/get-screen-memory-status.ts +12 -0
  71. package/corpus/templates/clips/actions/query-screen-memory-context.ts +29 -0
  72. package/corpus/templates/clips/actions/view-screen.ts +2 -2
  73. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  74. package/corpus/templates/clips/app/global.css +47 -35
  75. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +3 -3
  76. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +45 -18
  77. package/corpus/templates/clips/changelog/2026-06-29-meeting-detail-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  78. package/corpus/templates/clips/changelog/2026-06-29-screen-memory-keeps-a-local-rolling-screen-buffer-for-agent-context.md +5 -0
  79. package/corpus/templates/clips/changelog/2026-06-29-video-chat-is-easier-to-find-on-recording-pages-and-s3-setup.md +6 -0
  80. package/corpus/templates/clips/chrome-extension/src/styles.css +39 -39
  81. package/corpus/templates/clips/desktop/src/app.tsx +290 -1
  82. package/corpus/templates/clips/desktop/src/shared/config.ts +83 -0
  83. package/corpus/templates/clips/desktop/src/styles.css +67 -52
  84. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +50 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +17 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +13 -13
  87. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +990 -0
  88. package/corpus/templates/clips/server/plugins/onboarding.ts +2 -0
  89. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +3 -3
  90. package/corpus/templates/content/app/components/layout/Layout.tsx +1 -1
  91. package/corpus/templates/content/app/global.css +29 -15
  92. package/corpus/templates/content/changelog/2026-06-29-database-gallery-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  93. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +25 -20
  94. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -0
  95. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +8 -0
  96. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +13 -0
  97. package/corpus/templates/design/AGENTS.md +20 -12
  98. package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1054 -0
  99. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  100. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  101. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  102. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  103. package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
  104. package/corpus/templates/design/actions/apply-design-state.ts +200 -0
  105. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  106. package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
  107. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  108. package/corpus/templates/design/actions/apply-visual-edit.ts +214 -8
  109. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  110. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  111. package/corpus/templates/design/actions/create-component.ts +447 -0
  112. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  113. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  114. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  115. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  116. package/corpus/templates/design/actions/export-coding-handoff.ts +12 -4
  117. package/corpus/templates/design/actions/generate-design.ts +6 -21
  118. package/corpus/templates/design/actions/generate-screens.ts +43 -2
  119. package/corpus/templates/design/actions/get-component-details.ts +242 -0
  120. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  121. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  122. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  123. package/corpus/templates/design/actions/get-design.ts +1 -0
  124. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  125. package/corpus/templates/design/actions/index-components.ts +258 -0
  126. package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
  127. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  128. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  129. package/corpus/templates/design/actions/list-design-states.ts +72 -0
  130. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  131. package/corpus/templates/design/actions/open-component-source.ts +237 -0
  132. package/corpus/templates/design/actions/present-design-variants.ts +355 -30
  133. package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
  134. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  135. package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
  136. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  137. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  138. package/corpus/templates/design/actions/run-design-audit.ts +421 -0
  139. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  140. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  141. package/corpus/templates/design/actions/view-screen.ts +75 -31
  142. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1738 -135
  143. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +1 -1
  144. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
  145. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  146. package/corpus/templates/design/app/components/design/EditPanel.tsx +1873 -132
  147. package/corpus/templates/design/app/components/design/LayersPanel.tsx +384 -219
  148. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  149. package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
  150. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +2103 -295
  151. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +83 -206
  152. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
  153. package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
  154. package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
  155. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +1 -1
  156. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  157. package/corpus/templates/design/app/components/design/index.ts +16 -1
  158. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +238 -137
  159. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +188 -14
  160. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +24 -5
  161. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +57 -1
  162. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  163. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  164. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +37 -12
  165. package/corpus/templates/design/app/components/design/inspector/index.ts +1 -0
  166. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +12 -5
  167. package/corpus/templates/design/app/components/design/types.ts +9 -0
  168. package/corpus/templates/design/app/components/layout/Layout.tsx +23 -5
  169. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  170. package/corpus/templates/design/app/global.css +77 -49
  171. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  172. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  173. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +11 -2
  174. package/corpus/templates/design/app/i18n/zh-TW.ts +34 -0
  175. package/corpus/templates/design/app/i18n-data.ts +314 -3
  176. package/corpus/templates/design/app/pages/DesignEditor.tsx +4929 -892
  177. package/corpus/templates/design/app/root.tsx +1 -1
  178. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  179. package/corpus/templates/design/changelog/2026-06-29-containers-no-longer-block-selecting-nested-elements-and-nor.md +6 -0
  180. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  181. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-generate-and-refine-variants-from-stronger.md +6 -0
  182. package/corpus/templates/design/changelog/2026-06-29-design-inspector-controls-now-match-figmas-softer-dark-chrome.md +6 -0
  183. package/corpus/templates/design/changelog/2026-06-29-design-questions-now-appear-as-a-cleaner-canvas-intake-with-.md +6 -0
  184. package/corpus/templates/design/changelog/2026-06-29-design-variant-directions-now-appear-as-board-screens-with-chat-buttons.md +5 -0
  185. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  186. package/corpus/templates/design/changelog/2026-06-29-element-selection-no-longer-snaps-back-to-the-previous-layer.md +6 -0
  187. package/corpus/templates/design/changelog/2026-06-29-frame-resize-measurements-now-stay-beside-the-cursor-while-d.md +6 -0
  188. package/corpus/templates/design/changelog/2026-06-29-inspector-number-fields-have-cleaner-tooltips-and-draggable-.md +6 -0
  189. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  190. package/corpus/templates/design/changelog/2026-06-29-layer-locking-layer-nesting-generated-question-intake-and-im.md +6 -0
  191. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  192. package/corpus/templates/design/changelog/2026-06-29-layer-names-in-the-sidebar-can-scroll-horizontally-instead-o.md +6 -0
  193. package/corpus/templates/design/changelog/2026-06-29-layer-nesting-in-the-sidebar-now-uses-tighter-spacing-so-nam.md +6 -0
  194. package/corpus/templates/design/changelog/2026-06-29-layers-no-longer-repeat-document-wrapper-rows-when-a-screen-.md +6 -0
  195. package/corpus/templates/design/changelog/2026-06-29-overview-previews-now-keep-element-hover-and-selection-tied-.md +6 -0
  196. package/corpus/templates/design/changelog/2026-06-29-pressing-escape-now-cancels-a-stuck-screen-drag-in-the-overv.md +6 -0
  197. package/corpus/templates/design/changelog/2026-06-29-screen-overview-is-easier-to-find-and-named-screens-open-dir.md +6 -0
  198. package/corpus/templates/design/changelog/2026-06-29-screen-overview-only-highlights-screens-when-the-frame-or-ti.md +6 -0
  199. package/corpus/templates/design/changelog/2026-06-29-screen-overview-previews-now-resize-to-match-the-selected-de.md +6 -0
  200. package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-now-uses-a-pointer-cursor-instead-.md +6 -0
  201. package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-outlines-now-stay-locked-to-the-se.md +6 -0
  202. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zoom-now-reports-real-screen-scale-separatel.md +6 -0
  203. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-feels-smoother-and-selection-outline.md +6 -0
  204. package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-now-stays-on-the-overview-canvas-ins.md +6 -0
  205. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  206. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  207. package/corpus/templates/design/changelog/2026-06-29-selecting-a-nested-layer-from-all-screens-now-keeps-you-in-a.md +6 -0
  208. package/corpus/templates/design/changelog/2026-06-29-share-now-groups-link-sharing-exports-and-agent-handoff.md +6 -0
  209. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  210. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-loading-skeleton-now-uses-a-softer-charcoa.md +6 -0
  211. package/corpus/templates/design/changelog/2026-06-29-the-device-preview-menu-now-sits-next-to-the-preview-button-.md +6 -0
  212. package/corpus/templates/design/changelog/2026-06-29-the-inspector-stays-read-only-while-an-empty-design-is-still.md +6 -0
  213. package/corpus/templates/design/changelog/2026-06-29-the-missing-design-view-now-matches-the-rest-of-the-editor-c.md +6 -0
  214. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-icon-and-tool-option-menus-better-match-the-edi.md +6 -0
  215. package/corpus/templates/design/changelog/2026-06-29-typography-controls-now-keep-the-font-name-visible-and-move-.md +6 -0
  216. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  217. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  218. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  219. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  220. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  221. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  222. package/corpus/templates/design/e2e/README.md +3 -3
  223. package/corpus/templates/design/e2e/global-setup.ts +11 -22
  224. package/corpus/templates/design/e2e/helpers.ts +128 -11
  225. package/corpus/templates/design/playwright.config.ts +9 -3
  226. package/corpus/templates/design/server/db/schema.ts +123 -0
  227. package/corpus/templates/design/server/lib/coding-handoff.ts +126 -2
  228. package/corpus/templates/design/server/plugins/core-routes.ts +1 -2
  229. package/corpus/templates/design/server/plugins/db.ts +90 -0
  230. package/corpus/templates/design/server/routes/api/design-handoff/[id].zip.get.ts +86 -0
  231. package/corpus/templates/design/shared/builder-app.ts +297 -0
  232. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  233. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  234. package/corpus/templates/design/shared/code-layer.ts +1141 -93
  235. package/corpus/templates/design/shared/component-model.ts +239 -0
  236. package/corpus/templates/design/shared/design-review.ts +275 -0
  237. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  238. package/corpus/templates/design/shared/design-state.ts +112 -0
  239. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  240. package/corpus/templates/design/shared/generation-session.ts +41 -0
  241. package/corpus/templates/design/shared/motion-compiler.ts +278 -0
  242. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  243. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  244. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  245. package/corpus/templates/design/shared/source-mode.ts +245 -0
  246. package/corpus/templates/dispatch/app/global.css +79 -35
  247. package/corpus/templates/dispatch/app/routes/integrations.tsx +8 -7
  248. package/corpus/templates/dispatch/changelog/2026-06-29-integration-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  249. package/corpus/templates/forms/app/components/layout/Layout.tsx +6 -2
  250. package/corpus/templates/forms/app/global.css +64 -35
  251. package/corpus/templates/forms/app/pages/FormsListPage.tsx +2 -2
  252. package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +3 -3
  253. package/corpus/templates/forms/changelog/2026-06-29-form-list-and-insight-layouts-adapt-when-the-agent-sidebar.md +6 -0
  254. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +3 -1
  255. package/corpus/templates/macros/app/global.css +25 -9
  256. package/corpus/templates/macros/changelog/2026-06-29-macro-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  257. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +6 -2
  258. package/corpus/templates/mail/app/global.css +42 -36
  259. package/corpus/templates/mail/app/pages/InboxPage.tsx +1 -1
  260. package/corpus/templates/mail/changelog/2026-06-29-the-contact-panel-now-adapts-to-the-available-mail-pane-width.md +6 -0
  261. package/corpus/templates/plan/app/components/layout/Layout.tsx +3 -1
  262. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  263. package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +6 -7
  264. package/corpus/templates/plan/app/global.css +74 -42
  265. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  266. package/corpus/templates/plan/changelog/2026-06-29-plan-document-blocks-adapt-to-the-available-document-width.md +6 -0
  267. package/corpus/templates/plan/changelog/2026-06-29-plan-titles-and-summaries-can-be-edited-inline-without-focus.md +6 -0
  268. package/corpus/templates/slides/app/components/layout/Layout.tsx +3 -1
  269. package/corpus/templates/slides/app/global.css +49 -33
  270. package/corpus/templates/slides/changelog/2026-06-29-slide-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  271. package/corpus/templates/videos/app/components/layout/Layout.tsx +3 -1
  272. package/corpus/templates/videos/app/global.css +35 -35
  273. package/corpus/templates/videos/changelog/2026-06-29-video-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
  274. package/dist/cli/design-connect.d.ts.map +1 -1
  275. package/dist/cli/design-connect.js +28 -2
  276. package/dist/cli/design-connect.js.map +1 -1
  277. package/dist/cli/mcp.d.ts.map +1 -1
  278. package/dist/cli/mcp.js +10 -0
  279. package/dist/cli/mcp.js.map +1 -1
  280. package/dist/cli/skills.d.ts.map +1 -1
  281. package/dist/cli/skills.js +95 -33
  282. package/dist/cli/skills.js.map +1 -1
  283. package/dist/client/error-format.d.ts.map +1 -1
  284. package/dist/client/error-format.js +17 -0
  285. package/dist/client/error-format.js.map +1 -1
  286. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  287. package/dist/client/extensions/ExtensionViewer.js +3 -4
  288. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  289. package/dist/client/guided-questions.d.ts +4 -0
  290. package/dist/client/guided-questions.d.ts.map +1 -1
  291. package/dist/client/guided-questions.js +16 -7
  292. package/dist/client/guided-questions.js.map +1 -1
  293. package/dist/client/onboarding/OnboardingPanel.js +26 -1
  294. package/dist/client/onboarding/OnboardingPanel.js.map +1 -1
  295. package/dist/client/resources/BuiltinCapabilityDetail.d.ts.map +1 -1
  296. package/dist/client/resources/BuiltinCapabilityDetail.js +1 -1
  297. package/dist/client/resources/BuiltinCapabilityDetail.js.map +1 -1
  298. package/dist/client/resources/use-builtin-capabilities.d.ts +1 -1
  299. package/dist/client/resources/use-builtin-capabilities.d.ts.map +1 -1
  300. package/dist/client/resources/use-builtin-capabilities.js +2 -1
  301. package/dist/client/resources/use-builtin-capabilities.js.map +1 -1
  302. package/dist/client/session-replay.d.ts.map +1 -1
  303. package/dist/client/session-replay.js +31 -0
  304. package/dist/client/session-replay.js.map +1 -1
  305. package/dist/client/sharing/ShareButton.d.ts +14 -0
  306. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  307. package/dist/client/sharing/ShareButton.js +36 -7
  308. package/dist/client/sharing/ShareButton.js.map +1 -1
  309. package/dist/coding-tools/index.js +1 -1
  310. package/dist/coding-tools/index.js.map +1 -1
  311. package/dist/mcp/index.d.ts +2 -0
  312. package/dist/mcp/index.d.ts.map +1 -1
  313. package/dist/mcp/index.js +1 -0
  314. package/dist/mcp/index.js.map +1 -1
  315. package/dist/mcp/screen-memory-stdio.d.ts +6 -0
  316. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -0
  317. package/dist/mcp/screen-memory-stdio.js +213 -0
  318. package/dist/mcp/screen-memory-stdio.js.map +1 -0
  319. package/dist/mcp-client/builtin-capabilities.d.ts +1 -1
  320. package/dist/mcp-client/builtin-capabilities.d.ts.map +1 -1
  321. package/dist/mcp-client/builtin-capabilities.js +10 -0
  322. package/dist/mcp-client/builtin-capabilities.js.map +1 -1
  323. package/dist/mcp-client/index.d.ts +1 -0
  324. package/dist/mcp-client/index.d.ts.map +1 -1
  325. package/dist/mcp-client/index.js +1 -0
  326. package/dist/mcp-client/index.js.map +1 -1
  327. package/dist/mcp-client/screen-memory-local.d.ts +61 -0
  328. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -0
  329. package/dist/mcp-client/screen-memory-local.js +336 -0
  330. package/dist/mcp-client/screen-memory-local.js.map +1 -0
  331. package/dist/observability/routes.d.ts +3 -3
  332. package/dist/onboarding/types.d.ts +7 -0
  333. package/dist/onboarding/types.d.ts.map +1 -1
  334. package/dist/onboarding/types.js.map +1 -1
  335. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  336. package/dist/server/agent-chat-plugin.js +26 -0
  337. package/dist/server/agent-chat-plugin.js.map +1 -1
  338. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  339. package/dist/server/agents-bundle.d.ts.map +1 -1
  340. package/dist/server/agents-bundle.js +22 -7
  341. package/dist/server/agents-bundle.js.map +1 -1
  342. package/dist/styles/agent-native.css +5 -0
  343. package/dist/templates/default/app/global.css +35 -35
  344. package/docs/content/locales/ar-SA/template-design.mdx +6 -0
  345. package/docs/content/locales/de-DE/template-design.mdx +8 -0
  346. package/docs/content/locales/es-ES/template-design.mdx +10 -2
  347. package/docs/content/locales/fr-FR/template-design.mdx +10 -2
  348. package/docs/content/locales/hi-IN/template-design.mdx +7 -0
  349. package/docs/content/locales/ja-JP/template-design.mdx +7 -0
  350. package/docs/content/locales/ko-KR/template-design.mdx +7 -0
  351. package/docs/content/locales/pt-BR/template-design.mdx +9 -2
  352. package/docs/content/locales/zh-CN/template-design.mdx +6 -0
  353. package/docs/content/locales/zh-TW/template-design.mdx +6 -0
  354. package/docs/content/template-design.mdx +9 -2
  355. package/package.json +1 -1
  356. package/src/templates/default/app/global.css +35 -35
  357. package/corpus/templates/design/app/components/design/VariantGrid.tsx +0 -189
  358. package/corpus/templates/design/app/components/design/VariantHandoffCard.tsx +0 -115
  359. package/corpus/templates/design/app/hooks/use-variant-flow.ts +0 -342
@@ -32,12 +32,14 @@ import {
32
32
  type PenNode,
33
33
  type PenPath,
34
34
  } from "@shared/pen-path";
35
- import { IconCopy, IconMaximize } from "@tabler/icons-react";
35
+ import { IconCopy, IconMaximize, IconPlus } 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,9 @@ import {
45
47
  import { prettyScreenName } from "@/lib/screen-names";
46
48
  import { cn } from "@/lib/utils";
47
49
 
50
+ import { canvasPrimitiveReactStyle } from "./canvas-primitive-style";
51
+ import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "./types";
52
+
48
53
  interface ScreenFile {
49
54
  id: string;
50
55
  filename: string;
@@ -55,10 +60,19 @@ interface ScreenFile {
55
60
  previewState?: string;
56
61
  status?: string;
57
62
  title?: string;
63
+ updatedAt?: string;
58
64
  width?: number;
59
65
  height?: number;
60
66
  url?: string;
61
67
  previewUrl?: string;
68
+ /**
69
+ * When set, renders multiple side-by-side breakpoint frames (mobile-first,
70
+ * §6.4). Each entry is a pixel width; the active breakpoint determines the
71
+ * edit scope (Tailwind prefix: base / md: / lg: / xl:).
72
+ */
73
+ breakpointWidths?: number[];
74
+ /** Id of the currently active breakpoint frame for this screen. */
75
+ activeBreakpointWidth?: number;
62
76
  }
63
77
 
64
78
  type ScreenSourceType = "localhost" | "fusion" | "inline";
@@ -129,6 +143,12 @@ interface MultiScreenCanvasProps {
129
143
  screens: ScreenFile[];
130
144
  zoom: number;
131
145
  activeId?: string | null;
146
+ selectedScreenIds?: string[];
147
+ fullViewScreenIds?: string[];
148
+ activeScreenHasHoveredChild?: boolean;
149
+ hoveredChildScreenId?: string | null;
150
+ directlyHoveredScreenId?: string | null;
151
+ previewDeviceFrame?: DeviceFrameType;
132
152
  activeTool?: MultiScreenCanvasTool;
133
153
  toolProps?: CanvasToolProps;
134
154
  onActiveToolChange?: (tool: MultiScreenCanvasTool) => void;
@@ -147,26 +167,60 @@ interface MultiScreenCanvasProps {
147
167
  screenId: string,
148
168
  primitive: CanvasPrimitiveInsert,
149
169
  ) => boolean | string;
170
+ onPrimitiveCreated?: (screenId: string, nodeId: string) => void;
171
+ onPrimitiveReparent?: (args: {
172
+ sourceNodeId: string;
173
+ sourceScreenId: string;
174
+ targetNodeId: string;
175
+ targetScreenId: string;
176
+ placement: "inside";
177
+ }) => void;
150
178
  onCreateScreenFrame?: (geometry: FrameGeometry) => void;
151
179
  onDeleteSelection?: (ids: string[]) => boolean | void;
152
180
  onZoomChange?: (zoom: number) => void;
153
- onZoomToEdit?: (id: string) => void;
154
- zoomToEditThreshold?: number;
155
181
  renderScreenContent?: (
156
182
  screen: ScreenFile,
157
183
  metadata: ResolvedScreenMetadata,
158
184
  geometry: FrameGeometry,
159
185
  ) => ReactNode;
186
+ onScreenSelectionChange?: (ids: string[]) => void;
160
187
  selectAllRequest?: number;
161
188
  clearSelectionRequest?: number;
189
+ /**
190
+ * Called when the user clicks the + affordance on a screen's breakpoint
191
+ * row to add the next standard breakpoint width (390 / 768 / 1280).
192
+ */
193
+ onAddBreakpoint?: (screenId: string, widthPx: number) => void;
194
+ /**
195
+ * Called when the user clicks a breakpoint frame header to make it the
196
+ * active edit scope.
197
+ */
198
+ onActiveBreakpointChange?: (
199
+ screenId: string,
200
+ widthPx: number | undefined,
201
+ ) => void;
162
202
  onSelectionChange?: (selectedIds: string[]) => void;
203
+ /**
204
+ * Called when the user drags an element out of the active screen's iframe
205
+ * and drops it onto a different screen. The bridge in the source iframe
206
+ * posts { type:"agent-native:cross-screen-drag" } messages; the host
207
+ * translates them to board coords, finds the target frame, and calls this
208
+ * prop with the resolved ids.
209
+ */
210
+ onCrossScreenElementDrop?: (args: {
211
+ sourceSelector: string;
212
+ sourceNodeId?: string;
213
+ sourceScreenId: string;
214
+ targetScreenId: string;
215
+ }) => void;
163
216
  }
164
217
 
165
218
  /**
166
219
  * design-editor overview canvas. Renders every file in the design as a movable,
167
220
  * resizable frame inside an infinite, pannable surface.
168
221
  */
169
- const SCREEN_WIDTH = 320;
222
+ export const OVERVIEW_FRAME_WIDTH = 320;
223
+ const SCREEN_WIDTH = OVERVIEW_FRAME_WIDTH;
170
224
  const SCREEN_HEIGHT = 640;
171
225
  const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
172
226
  const SCREEN_GAP = 56;
@@ -174,12 +228,70 @@ const SURFACE_PADDING = 240;
174
228
  const DUPLICATE_DRAG_THRESHOLD = 6;
175
229
  const DRAG_THRESHOLD = 3;
176
230
  const FRAME_LABEL_HEIGHT = 28;
231
+ const FRAME_HEADER_BUTTON_OUTSIDE_WIDTH = 260;
232
+ const FRAME_HEADER_BUTTON_RESERVE = 116;
233
+ const FRAME_HEADER_DIMENSIONS_WIDTH = 72;
234
+ const FRAME_HEADER_TITLE_CHAR_WIDTH = 6.5;
235
+ const FRAME_HEADER_MIN_TITLE_WIDTH = 28;
236
+ const FRAME_HEADER_MAX_TITLE_WIDTH = 180;
237
+ const TRANSFORM_BADGE_OFFSET = 12;
238
+ const TRANSFORM_BADGE_EDGE_PADDING = 8;
239
+ const TRANSFORM_BADGE_HEIGHT = 28;
240
+ const TRANSFORM_BADGE_MIN_WIDTH = 64;
241
+ const TRANSFORM_BADGE_MAX_WIDTH = 180;
177
242
  const MIN_ZOOM = 2;
178
243
  const MAX_ZOOM = 800;
179
- const ZOOM_SENSITIVITY = 0.002;
180
- const MAX_WHEEL_ZOOM_DELTA = 80;
244
+ const ZOOM_SENSITIVITY = 0.01;
245
+ const MAX_WHEEL_ZOOM_DELTA = 120;
181
246
  const MAX_WHEEL_PAN_DELTA = 140;
182
247
  const PIXEL_GRID_ZOOM = 800;
248
+ // During a zoom gesture the constant-size selection chrome is frozen (we don't
249
+ // re-render); on commit it recomputes to its fixed screen size. These transitions
250
+ // are enabled only for that brief settle, so normal selection, resize, and
251
+ // screen-switch geometry stays pinned to the frame.
252
+ const CHROME_SETTLE_MS = 150;
253
+ const CHROME_OPACITY_TRANSITION = "opacity 150ms ease-out";
254
+ 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}`;
255
+ const SELECTION_BOX_SETTLE_TRANSITION = `border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
256
+ 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}`;
257
+ // Frame header (name + dimensions + "Full view" button) is counter-scaled via
258
+ // transform to stay a fixed screen size; ease that scale on zoom-settle. opacity
259
+ // is included so the button's hover-fade (transition-opacity) keeps working.
260
+ const CHROME_LABEL_SETTLE_TRANSITION = `transform ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
261
+
262
+ export function getChromeBorderTransition(chromeSettling: boolean) {
263
+ return chromeSettling
264
+ ? CHROME_BORDER_SETTLE_TRANSITION
265
+ : CHROME_OPACITY_TRANSITION;
266
+ }
267
+
268
+ export function getSelectionBoxTransition(chromeSettling: boolean) {
269
+ return chromeSettling ? SELECTION_BOX_SETTLE_TRANSITION : "none";
270
+ }
271
+
272
+ export function isDirectScreenHoverTarget(
273
+ target: EventTarget | null,
274
+ currentTarget: HTMLElement,
275
+ ) {
276
+ if (target === currentTarget) return true;
277
+ const element =
278
+ target && typeof (target as Element).closest === "function"
279
+ ? (target as Element)
280
+ : null;
281
+ return !!element && !element.closest("[data-screen-content]");
282
+ }
283
+
284
+ function getChromeHandleTransition(chromeSettling: boolean) {
285
+ return chromeSettling
286
+ ? CHROME_HANDLE_SETTLE_TRANSITION
287
+ : CHROME_OPACITY_TRANSITION;
288
+ }
289
+
290
+ function getChromeLabelTransition(chromeSettling: boolean) {
291
+ return chromeSettling
292
+ ? CHROME_LABEL_SETTLE_TRANSITION
293
+ : CHROME_OPACITY_TRANSITION;
294
+ }
183
295
  const DRAFT_FRAME_WIDTH = 320;
184
296
  const DRAFT_FRAME_HEIGHT = 640;
185
297
  const DRAFT_RECT_WIDTH = 160;
@@ -394,10 +506,30 @@ type DragState =
394
506
  | DraftCreateDragState
395
507
  | PenNodeDragState;
396
508
 
509
+ type PendingWheelGesture =
510
+ | {
511
+ mode: "zoom";
512
+ deltaY: number;
513
+ cursor: Point;
514
+ clientX: number;
515
+ clientY: number;
516
+ }
517
+ | {
518
+ mode: "pan";
519
+ deltaX: number;
520
+ deltaY: number;
521
+ };
522
+
397
523
  export function MultiScreenCanvas({
398
524
  screens,
399
525
  zoom,
400
526
  activeId,
527
+ selectedScreenIds,
528
+ fullViewScreenIds,
529
+ activeScreenHasHoveredChild = false,
530
+ hoveredChildScreenId,
531
+ directlyHoveredScreenId,
532
+ previewDeviceFrame = "none",
401
533
  activeTool,
402
534
  toolProps,
403
535
  onActiveToolChange,
@@ -410,15 +542,19 @@ export function MultiScreenCanvas({
410
542
  onGeometryChange,
411
543
  onGeometryCommit,
412
544
  onCreatePrimitive,
545
+ onPrimitiveCreated,
546
+ onPrimitiveReparent,
413
547
  onCreateScreenFrame,
414
548
  onDeleteSelection,
415
549
  onZoomChange,
416
- onZoomToEdit,
417
- zoomToEditThreshold,
418
550
  renderScreenContent,
551
+ onScreenSelectionChange,
419
552
  selectAllRequest,
420
553
  clearSelectionRequest,
554
+ onAddBreakpoint,
555
+ onActiveBreakpointChange,
421
556
  onSelectionChange,
557
+ onCrossScreenElementDrop,
422
558
  }: MultiScreenCanvasProps) {
423
559
  const t = useT();
424
560
  const surfaceRef = useRef<HTMLDivElement>(null);
@@ -430,6 +566,7 @@ export function MultiScreenCanvas({
430
566
  const frameGeometryRef = useRef(frameGeometry);
431
567
  const onGeometryChangeRef = useRef(onGeometryChange);
432
568
  const onGeometryCommitRef = useRef(onGeometryCommit);
569
+ const screensRef = useRef(screens);
433
570
  const [draftPrimitives, setDraftPrimitives] = useState<DraftPrimitive[]>([]);
434
571
  const draftPrimitivesRef = useRef(draftPrimitives);
435
572
  const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
@@ -446,7 +583,7 @@ export function MultiScreenCanvas({
446
583
  const [localActiveTool, setLocalActiveTool] =
447
584
  useState<MultiScreenCanvasTool>("move");
448
585
  const [selectedIds, setSelectedIds] = useState<string[]>(
449
- activeId ? [activeId] : [],
586
+ selectedScreenIds ?? [],
450
587
  );
451
588
  const selectedIdsRef = useRef(selectedIds);
452
589
  const dragState = useRef<DragState | null>(null);
@@ -464,8 +601,56 @@ export function MultiScreenCanvas({
464
601
  null,
465
602
  );
466
603
  const [dragCursor, setDragCursor] = useState<string | null>(null);
604
+ const [primitiveDropTarget, setPrimitiveDropTarget] =
605
+ useState<PrimitiveDropTarget | null>(null);
606
+ const primitiveDropTargetRef = useRef<PrimitiveDropTarget | null>(null);
607
+ const onPrimitiveReparentRef = useRef(onPrimitiveReparent);
608
+
609
+ // Cross-screen element drag state — driven by postMessage from the source iframe.
610
+ interface CrossScreenDragGhost {
611
+ /** Board-space point where the ghost is shown (follows the cursor). */
612
+ boardX: number;
613
+ boardY: number;
614
+ }
615
+ interface CrossScreenDragTarget {
616
+ /** The screen frame that is the candidate drop target. */
617
+ id: string;
618
+ geometry: FrameGeometry;
619
+ }
620
+ const [crossScreenGhost, setCrossScreenGhost] =
621
+ useState<CrossScreenDragGhost | null>(null);
622
+ const [crossScreenTarget, setCrossScreenTarget] =
623
+ useState<CrossScreenDragTarget | null>(null);
624
+ /** Ref kept in sync with state so the message handler can read without closures. */
625
+ const crossScreenTargetRef = useRef<CrossScreenDragTarget | null>(null);
626
+ /** The most-recent drag message payload — kept for use in the "end" handler. */
627
+ const crossScreenDragMsgRef = useRef<{
628
+ selector: string;
629
+ sourceId?: string;
630
+ } | null>(null);
631
+ const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
467
632
  const suppressNextPick = useRef(false);
468
633
  const feedbackTimerRef = useRef<number | null>(null);
634
+ const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
635
+ const wheelGestureFrameRef = useRef<number | null>(null);
636
+ const worldRef = useRef<HTMLDivElement>(null);
637
+ const pixelGridRef = useRef<HTMLDivElement>(null);
638
+ const viewCommitTimerRef = useRef<number | null>(null);
639
+ const pendingChromeSettleRef = useRef(false);
640
+ const chromeSettleTimerRef = useRef<number | null>(null);
641
+ const [chromeSettling, setChromeSettling] = useState(false);
642
+ const previousPreviewDeviceFrameRef = useRef(previewDeviceFrame);
643
+
644
+ const getResolvedMetadata = useCallback(
645
+ (screen: ScreenFile) =>
646
+ resolveScreenMetadata(
647
+ screen,
648
+ metadataById?.[screen.id],
649
+ getScreenMetadata?.(screen),
650
+ previewDeviceFrame,
651
+ ),
652
+ [getScreenMetadata, metadataById, previewDeviceFrame],
653
+ );
469
654
 
470
655
  useEffect(() => {
471
656
  onGeometryChangeRef.current = onGeometryChange;
@@ -475,6 +660,18 @@ export function MultiScreenCanvas({
475
660
  onGeometryCommitRef.current = onGeometryCommit;
476
661
  }, [onGeometryCommit]);
477
662
 
663
+ useEffect(() => {
664
+ onPrimitiveReparentRef.current = onPrimitiveReparent;
665
+ }, [onPrimitiveReparent]);
666
+
667
+ useEffect(() => {
668
+ onCrossScreenElementDropRef.current = onCrossScreenElementDrop;
669
+ }, [onCrossScreenElementDrop]);
670
+
671
+ useEffect(() => {
672
+ screensRef.current = screens;
673
+ }, [screens]);
674
+
478
675
  useEffect(() => {
479
676
  activePenPathRef.current = activePenPath;
480
677
  }, [activePenPath]);
@@ -559,6 +756,10 @@ export function MultiScreenCanvas({
559
756
  onSelectionChangeRef.current?.(selectedIds);
560
757
  }, [selectedIds]);
561
758
 
759
+ useEffect(() => {
760
+ onScreenSelectionChange?.(selectedIds);
761
+ }, [onScreenSelectionChange, selectedIds]);
762
+
562
763
  useEffect(() => {
563
764
  draftPrimitivesRef.current = draftPrimitives;
564
765
  }, [draftPrimitives]);
@@ -581,8 +782,9 @@ export function MultiScreenCanvas({
581
782
  screens.forEach((screen, index) => {
582
783
  const existing = current[screen.id];
583
784
  const persisted = geometryById?.[screen.id];
785
+ const metadata = getResolvedMetadata(screen);
584
786
  const resolved = {
585
- ...getInitialFrameGeometry(index),
787
+ ...getInitialFrameGeometry(index, metadata),
586
788
  ...persisted,
587
789
  } as FrameGeometry;
588
790
  next[screen.id] = persisted ? resolved : (existing ?? resolved);
@@ -600,14 +802,45 @@ export function MultiScreenCanvas({
600
802
  const next = current.filter((id) => currentIds.has(id));
601
803
  return next.length === current.length ? current : next;
602
804
  });
603
- }, [geometryById, screens, updateFrameGeometry, updateSelectedIds]);
805
+ }, [
806
+ geometryById,
807
+ getResolvedMetadata,
808
+ screens,
809
+ updateFrameGeometry,
810
+ updateSelectedIds,
811
+ ]);
812
+
813
+ useEffect(() => {
814
+ const previous = previousPreviewDeviceFrameRef.current;
815
+ previousPreviewDeviceFrameRef.current = previewDeviceFrame;
816
+ if (previous === previewDeviceFrame) return;
817
+
818
+ updateFrameGeometry((current) => {
819
+ const next = { ...current };
820
+ let changed = false;
821
+
822
+ screens.forEach((screen, index) => {
823
+ const metadata = getResolvedMetadata(screen);
824
+ const currentGeometry =
825
+ current[screen.id] ?? getInitialFrameGeometry(index, metadata);
826
+ const nextGeometry = getPreviewDeviceFrameGeometry({
827
+ currentGeometry,
828
+ metadata,
829
+ previewDeviceFrame,
830
+ });
831
+ if (sameFrameGeometry(currentGeometry, nextGeometry)) return;
832
+ next[screen.id] = nextGeometry;
833
+ changed = true;
834
+ });
835
+
836
+ return changed ? next : current;
837
+ });
838
+ }, [getResolvedMetadata, previewDeviceFrame, screens, updateFrameGeometry]);
604
839
 
605
840
  useEffect(() => {
606
- if (!activeId) return;
607
- updateSelectedIds((current) =>
608
- current.includes(activeId) ? current : [activeId],
609
- );
610
- }, [activeId, updateSelectedIds]);
841
+ if (!selectedScreenIds) return;
842
+ updateSelectedIds(() => selectedScreenIds);
843
+ }, [screens, selectedScreenIds, updateSelectedIds]);
611
844
 
612
845
  useEffect(() => {
613
846
  if (
@@ -640,11 +873,26 @@ export function MultiScreenCanvas({
640
873
  useEffect(() => {
641
874
  if (!surfaceRef.current || screens.length === 0) return;
642
875
  const rect = surfaceRef.current.getBoundingClientRect();
643
- const columns = Math.min(screens.length, 3);
644
- const rows = Math.ceil(screens.length / columns);
645
876
  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;
877
+ const frames = screens.map((screen, index) => {
878
+ const metadata = getResolvedMetadata(screen);
879
+ const currentGeometry =
880
+ frameGeometryRef.current[screen.id] ??
881
+ getInitialFrameGeometry(index, metadata);
882
+ return getPreviewDeviceFrameGeometry({
883
+ currentGeometry,
884
+ metadata,
885
+ previewDeviceFrame,
886
+ });
887
+ });
888
+ const bounds = getFrameGroupBounds(
889
+ frames.map((geometry, index) => ({
890
+ id: screens[index]?.id ?? String(index),
891
+ geometry,
892
+ })),
893
+ );
894
+ const totalWidth = bounds?.width ?? SCREEN_WIDTH;
895
+ const totalHeight = bounds?.height ?? SCREEN_CARD_HEIGHT;
648
896
  const visualLeft = Math.max(24, (rect.width - totalWidth * scale) / 2);
649
897
  const visualTop = Math.max(24, (rect.height - totalHeight * scale) / 2);
650
898
  const nextPan = {
@@ -653,9 +901,9 @@ export function MultiScreenCanvas({
653
901
  };
654
902
  panRef.current = nextPan;
655
903
  setPan(nextPan);
656
- // Only on mount or when screen count changes, not on every pan update.
904
+ // Only on mount, screen-count changes, or device-preview changes.
657
905
  // eslint-disable-next-line react-hooks/exhaustive-deps
658
- }, [screens.length]);
906
+ }, [previewDeviceFrame, screens.length]);
659
907
 
660
908
  useEffect(() => {
661
909
  return () => {
@@ -664,6 +912,15 @@ export function MultiScreenCanvas({
664
912
  if (feedbackTimerRef.current !== null) {
665
913
  window.clearTimeout(feedbackTimerRef.current);
666
914
  }
915
+ if (wheelGestureFrameRef.current !== null) {
916
+ window.cancelAnimationFrame(wheelGestureFrameRef.current);
917
+ }
918
+ if (viewCommitTimerRef.current !== null) {
919
+ window.clearTimeout(viewCommitTimerRef.current);
920
+ }
921
+ if (chromeSettleTimerRef.current !== null) {
922
+ window.clearTimeout(chromeSettleTimerRef.current);
923
+ }
667
924
  };
668
925
  }, []);
669
926
 
@@ -694,8 +951,17 @@ export function MultiScreenCanvas({
694
951
  }, []);
695
952
 
696
953
  const getCurrentFrameEntries = useCallback(
697
- () => getFrameEntries(screens, frameGeometryRef.current),
698
- [screens],
954
+ () =>
955
+ screens.map((screen, index) => {
956
+ const metadata = getResolvedMetadata(screen);
957
+ return {
958
+ id: screen.id,
959
+ geometry:
960
+ frameGeometryRef.current[screen.id] ??
961
+ getInitialFrameGeometry(index, metadata),
962
+ };
963
+ }),
964
+ [getResolvedMetadata, screens],
699
965
  );
700
966
 
701
967
  const getCurrentDraftEntries = useCallback(
@@ -737,13 +1003,154 @@ export function MultiScreenCanvas({
737
1003
  [getCurrentFrameEntries],
738
1004
  );
739
1005
 
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
- );
1006
+ // ── Cross-screen element drag receiver ────────────────────────────────────
1007
+ // The source iframe (the active interactive screen) posts
1008
+ // { type: "agent-native:cross-screen-drag", phase, selector, sourceId,
1009
+ // iframeX, iframeY, viewportW, viewportH }
1010
+ // during element drags that the bridge wants the host to handle.
1011
+ useEffect(() => {
1012
+ if (!onCrossScreenElementDrop) return;
1013
+
1014
+ const clearCrossScreenDrag = () => {
1015
+ setCrossScreenGhost(null);
1016
+ setCrossScreenTarget(null);
1017
+ crossScreenTargetRef.current = null;
1018
+ crossScreenDragMsgRef.current = null;
1019
+ };
1020
+
1021
+ const handleMessage = (event: MessageEvent) => {
1022
+ if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
1023
+ return;
1024
+ }
1025
+ const msg = event.data as {
1026
+ type: string;
1027
+ phase: "move" | "end" | "cancel";
1028
+ selector?: string;
1029
+ sourceId?: string;
1030
+ iframeX?: number;
1031
+ iframeY?: number;
1032
+ viewportW?: number;
1033
+ viewportH?: number;
1034
+ };
1035
+
1036
+ if (msg.phase === "cancel") {
1037
+ clearCrossScreenDrag();
1038
+ return;
1039
+ }
1040
+
1041
+ // The drag originates from the active screen (only the active screen has
1042
+ // a live interactive iframe posting these messages).
1043
+ const sourceScreenId = activeId;
1044
+ if (!sourceScreenId) {
1045
+ // Always clear visual state (ghost + highlight) when we have no active
1046
+ // screen to attribute the drag to — regardless of the phase. Without
1047
+ // this, a "move" message arriving after activeId became null would leave
1048
+ // stale ghost/target state visible on the canvas.
1049
+ clearCrossScreenDrag();
1050
+ return;
1051
+ }
1052
+
1053
+ if (msg.phase === "move") {
1054
+ const { iframeX, iframeY, viewportW, viewportH, selector, sourceId } =
1055
+ msg;
1056
+ if (
1057
+ iframeX === undefined ||
1058
+ iframeY === undefined ||
1059
+ viewportW === undefined ||
1060
+ viewportH === undefined
1061
+ ) {
1062
+ return;
1063
+ }
1064
+
1065
+ // Remember the latest drag payload for use on "end".
1066
+ crossScreenDragMsgRef.current = {
1067
+ selector: selector ?? "",
1068
+ sourceId,
1069
+ };
1070
+
1071
+ // Pointer is inside the source iframe — let the bridge handle it.
1072
+ if (
1073
+ iframeX >= 0 &&
1074
+ iframeY >= 0 &&
1075
+ iframeX <= viewportW &&
1076
+ iframeY <= viewportH
1077
+ ) {
1078
+ clearCrossScreenDrag();
1079
+ return;
1080
+ }
1081
+
1082
+ // Translate iframe coords → board coords using source frame geometry.
1083
+ // Board math mirrors primitiveLocalToBoardRect:
1084
+ // boardX = frame.x + iframeX * (frame.width / metadata.width)
1085
+ // boardY = frame.y + iframeY * (frame.height / metadata.height)
1086
+ const sourceScreen = screensRef.current.find(
1087
+ (s) => s.id === sourceScreenId,
1088
+ );
1089
+ const sourceGeometry = frameGeometryRef.current[sourceScreenId];
1090
+ if (!sourceScreen || !sourceGeometry) {
1091
+ clearCrossScreenDrag();
1092
+ return;
1093
+ }
1094
+ const sourceMetadata = getResolvedMetadata(sourceScreen);
1095
+ const scaleX = sourceGeometry.width / Math.max(1, sourceMetadata.width);
1096
+ const scaleY =
1097
+ sourceGeometry.height / Math.max(1, sourceMetadata.height);
1098
+ const boardX = sourceGeometry.x + iframeX * scaleX;
1099
+ const boardY = sourceGeometry.y + iframeY * scaleY;
1100
+ const boardPoint = { x: boardX, y: boardY };
1101
+
1102
+ setCrossScreenGhost({ boardX, boardY });
1103
+
1104
+ // Find which screen frame the board point falls in.
1105
+ const target = getFrameEntryAtPoint(boardPoint);
1106
+ if (target && target.id !== sourceScreenId) {
1107
+ const nextTarget = { id: target.id, geometry: target.geometry };
1108
+ crossScreenTargetRef.current = nextTarget;
1109
+ setCrossScreenTarget(nextTarget);
1110
+ } else {
1111
+ crossScreenTargetRef.current = null;
1112
+ setCrossScreenTarget(null);
1113
+ }
1114
+ return;
1115
+ }
1116
+
1117
+ if (msg.phase === "end") {
1118
+ const candidate = crossScreenTargetRef.current;
1119
+ // Use the saved payload from the last "move" as the primary source of
1120
+ // truth; fall back to the "end" message's own fields in case the ref
1121
+ // was cleared (e.g. a brief re-entry into the source iframe nulled it
1122
+ // via clearCrossScreenDrag while pointerOutsideIframe remained true).
1123
+ const payload = crossScreenDragMsgRef.current ?? {
1124
+ selector: msg.selector ?? "",
1125
+ sourceId: msg.sourceId,
1126
+ };
1127
+ clearCrossScreenDrag();
1128
+ // Guard: require at least one stable identifier (selector or sourceId)
1129
+ // so the drop handler in DesignEditor can resolve the node. An empty
1130
+ // selector AND a missing sourceId would produce nodeAttrId="" and
1131
+ // moveNodeBetweenDocuments would fail with a generic error toast.
1132
+ const hasIdentifier = !!(payload.selector || payload.sourceId);
1133
+ if (candidate && hasIdentifier && sourceScreenId) {
1134
+ onCrossScreenElementDropRef.current?.({
1135
+ sourceSelector: payload.selector,
1136
+ sourceNodeId: payload.sourceId,
1137
+ sourceScreenId,
1138
+ targetScreenId: candidate.id,
1139
+ });
1140
+ }
1141
+ }
1142
+ };
1143
+
1144
+ window.addEventListener("message", handleMessage);
1145
+ return () => {
1146
+ window.removeEventListener("message", handleMessage);
1147
+ };
1148
+ }, [
1149
+ activeId,
1150
+ getFrameEntryAtPoint,
1151
+ getResolvedMetadata,
1152
+ onCrossScreenElementDrop,
1153
+ ]);
747
1154
 
748
1155
  const deleteSelectedItems = useCallback(() => {
749
1156
  const frameIds = selectedIdsRef.current.filter(
@@ -797,6 +1204,9 @@ export function MultiScreenCanvas({
797
1204
  handleMouseUp: (ev: MouseEvent) => void,
798
1205
  ) => {
799
1206
  dragCleanup.current?.();
1207
+ const restorePreviewPointerEvents = mutePreviewIframePointerEvents(
1208
+ surfaceRef.current,
1209
+ );
800
1210
  let lastMouseEvent: MouseEvent | null = null;
801
1211
  const move = (ev: MouseEvent) => {
802
1212
  lastMouseEvent = ev;
@@ -814,6 +1224,7 @@ export function MultiScreenCanvas({
814
1224
  window.removeEventListener("mousemove", move);
815
1225
  window.removeEventListener("mouseup", up);
816
1226
  window.removeEventListener("blur", cleanupOnBlur);
1227
+ restorePreviewPointerEvents();
817
1228
  dragCleanup.current = null;
818
1229
  };
819
1230
  window.addEventListener("mousemove", move);
@@ -836,15 +1247,64 @@ export function MultiScreenCanvas({
836
1247
 
837
1248
  const showTransformFeedback = useCallback(
838
1249
  (text: string, clientX: number, clientY: number) => {
1250
+ const estimatedWidth = Math.min(
1251
+ TRANSFORM_BADGE_MAX_WIDTH,
1252
+ Math.max(TRANSFORM_BADGE_MIN_WIDTH, text.length * 7 + 16),
1253
+ );
1254
+ const maxX = Math.max(
1255
+ TRANSFORM_BADGE_EDGE_PADDING,
1256
+ window.innerWidth - estimatedWidth - TRANSFORM_BADGE_EDGE_PADDING,
1257
+ );
1258
+ const maxY = Math.max(
1259
+ TRANSFORM_BADGE_EDGE_PADDING,
1260
+ window.innerHeight -
1261
+ TRANSFORM_BADGE_HEIGHT -
1262
+ TRANSFORM_BADGE_EDGE_PADDING,
1263
+ );
1264
+ const preferredX =
1265
+ clientX + TRANSFORM_BADGE_OFFSET + estimatedWidth <=
1266
+ window.innerWidth - TRANSFORM_BADGE_EDGE_PADDING
1267
+ ? clientX + TRANSFORM_BADGE_OFFSET
1268
+ : clientX - estimatedWidth - TRANSFORM_BADGE_OFFSET;
1269
+ const preferredY =
1270
+ clientY + TRANSFORM_BADGE_OFFSET + TRANSFORM_BADGE_HEIGHT <=
1271
+ window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
1272
+ ? clientY + TRANSFORM_BADGE_OFFSET
1273
+ : clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
839
1274
  setTransformBadge({
840
1275
  text,
841
- x: clientX + 12,
842
- y: clientY + 12,
1276
+ x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
1277
+ y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
843
1278
  });
844
1279
  },
845
1280
  [],
846
1281
  );
847
1282
 
1283
+ const updatePrimitiveDropTarget = useCallback(
1284
+ (target: PrimitiveDropTarget | null) => {
1285
+ primitiveDropTargetRef.current = target;
1286
+ setPrimitiveDropTarget(target);
1287
+ },
1288
+ [],
1289
+ );
1290
+
1291
+ const findPrimitiveDropTarget = useCallback(
1292
+ (
1293
+ point: Point,
1294
+ draggedNodeId: string | null,
1295
+ ): PrimitiveDropTarget | null => {
1296
+ if (!onPrimitiveReparentRef.current) return null;
1297
+ return getPrimitiveDropTargetForPoint(
1298
+ point,
1299
+ draggedNodeId,
1300
+ screensRef.current,
1301
+ frameGeometryRef.current,
1302
+ getResolvedMetadata,
1303
+ );
1304
+ },
1305
+ [getResolvedMetadata],
1306
+ );
1307
+
848
1308
  const finishDrag = useCallback(() => {
849
1309
  if (feedbackTimerRef.current !== null) {
850
1310
  window.clearTimeout(feedbackTimerRef.current);
@@ -858,9 +1318,79 @@ export function MultiScreenCanvas({
858
1318
  setAlignmentGuides([]);
859
1319
  setTransformBadge(null);
860
1320
  setDragCursor(null);
1321
+ primitiveDropTargetRef.current = null;
1322
+ setPrimitiveDropTarget(null);
861
1323
  dragCleanup.current?.();
862
1324
  }, []);
863
1325
 
1326
+ const cancelActiveDrag = useCallback(() => {
1327
+ let cancelled = false;
1328
+ const state = dragState.current;
1329
+
1330
+ if (state) {
1331
+ cancelled = true;
1332
+ if (state.type === "move" || state.type === "resize") {
1333
+ updateFrameGeometry((current) =>
1334
+ frameGeometryWithOverrides(current, state.originFrames),
1335
+ );
1336
+ } else if (state.type === "rotate") {
1337
+ updateFrameGeometry((current) => ({
1338
+ ...current,
1339
+ [state.frameId]: { ...state.originFrame },
1340
+ }));
1341
+ } else if (state.type === "draft-move" || state.type === "draft-resize") {
1342
+ updateDraftPrimitives((current) =>
1343
+ current.map((draft) => {
1344
+ const origin = state.originDrafts[draft.id];
1345
+ return origin ? cloneDraftPrimitive(origin) : draft;
1346
+ }),
1347
+ );
1348
+ } else if (state.type === "marquee") {
1349
+ updateSelectedIds(() => state.baseSelectedIds);
1350
+ updateSelectedDraftIds(() => state.baseSelectedDraftIds);
1351
+ } else if (state.type === "pen-node") {
1352
+ setActivePenPath(
1353
+ state.pathBefore ? clonePenPath(state.pathBefore) : null,
1354
+ );
1355
+ setPenGesturePreview(null);
1356
+ setPenPointer(null);
1357
+ setPenCloseHover(false);
1358
+ }
1359
+ }
1360
+
1361
+ if (duplicateCleanup.current) {
1362
+ cancelled = true;
1363
+ duplicateCleanup.current();
1364
+ }
1365
+
1366
+ if (cancelled || dragCleanup.current) {
1367
+ finishDrag();
1368
+ return true;
1369
+ }
1370
+ return false;
1371
+ }, [
1372
+ finishDrag,
1373
+ updateDraftPrimitives,
1374
+ updateFrameGeometry,
1375
+ updateSelectedDraftIds,
1376
+ updateSelectedIds,
1377
+ ]);
1378
+
1379
+ useEffect(() => {
1380
+ const handleKeyDown = (event: KeyboardEvent) => {
1381
+ if (event.key !== "Escape") return;
1382
+ if (!cancelActiveDrag()) return;
1383
+ event.preventDefault();
1384
+ event.stopPropagation();
1385
+ event.stopImmediatePropagation();
1386
+ };
1387
+
1388
+ window.addEventListener("keydown", handleKeyDown, true);
1389
+ return () => {
1390
+ window.removeEventListener("keydown", handleKeyDown, true);
1391
+ };
1392
+ }, [cancelActiveDrag]);
1393
+
864
1394
  const beginPan = useCallback(
865
1395
  (e: React.MouseEvent) => {
866
1396
  e.preventDefault();
@@ -987,7 +1517,10 @@ export function MultiScreenCanvas({
987
1517
  : undefined;
988
1518
  if (preferred) return preferred;
989
1519
 
990
- return entries
1520
+ const draftCenter = getFrameCenter(draft.geometry);
1521
+
1522
+ // Primary: find the frame whose bounds contain the draft's center point.
1523
+ const containing = entries
991
1524
  .filter(({ geometry }) => {
992
1525
  const bounds = {
993
1526
  left: geometry.x,
@@ -995,7 +1528,6 @@ export function MultiScreenCanvas({
995
1528
  right: geometry.x + geometry.width,
996
1529
  bottom: geometry.y + geometry.height,
997
1530
  };
998
- const draftCenter = getFrameCenter(draft.geometry);
999
1531
  const local = rotatePointAroundCenter(
1000
1532
  draftCenter,
1001
1533
  getFrameCenter(geometry),
@@ -1004,6 +1536,22 @@ export function MultiScreenCanvas({
1004
1536
  return rectContainsPoint(bounds, local);
1005
1537
  })
1006
1538
  .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
1539
+
1540
+ if (containing) return containing;
1541
+
1542
+ // Fallback: if no frame contains the center (shape drawn outside all
1543
+ // frames), use the nearest frame so the shape still persists rather than
1544
+ // becoming a lost draft primitive. With a single screen this is always
1545
+ // that screen; with multiple screens it's the visually closest one.
1546
+ if (entries.length === 0) return undefined;
1547
+ if (entries.length === 1) return entries[0];
1548
+ return entries.slice().sort((a, b) => {
1549
+ const ca = getFrameCenter(a.geometry);
1550
+ const cb = getFrameCenter(b.geometry);
1551
+ const da = Math.hypot(draftCenter.x - ca.x, draftCenter.y - ca.y);
1552
+ const db = Math.hypot(draftCenter.x - cb.x, draftCenter.y - cb.y);
1553
+ return da - db;
1554
+ })[0];
1007
1555
  },
1008
1556
  [getCurrentFrameEntries],
1009
1557
  );
@@ -1057,7 +1605,8 @@ export function MultiScreenCanvas({
1057
1605
  current.filter((draft) => draft.id !== nextDraft.id),
1058
1606
  );
1059
1607
  updateSelectedDraftIds(() => []);
1060
- updateSelectedIds(() => [persisted.nodeId]);
1608
+ updateSelectedIds(() => []);
1609
+ onPrimitiveCreated?.(persisted.frameId, persisted.nodeId);
1061
1610
  return;
1062
1611
  }
1063
1612
 
@@ -1067,6 +1616,7 @@ export function MultiScreenCanvas({
1067
1616
  },
1068
1617
  [
1069
1618
  persistDraftPrimitive,
1619
+ onPrimitiveCreated,
1070
1620
  updateDraftPrimitives,
1071
1621
  updateSelectedDraftIds,
1072
1622
  updateSelectedIds,
@@ -1254,11 +1804,15 @@ export function MultiScreenCanvas({
1254
1804
 
1255
1805
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
1256
1806
  const originFrameId = getFrameEntryAtPoint(originCanvas)?.id;
1257
- const initialGeometry = getDraftGeometryForTool(
1258
- tool,
1259
- originCanvas,
1260
- originCanvas,
1261
- );
1807
+ // B1 fix: seed a zero-size preview at the click origin instead of
1808
+ // getDraftGeometryForTool(tool, origin, origin) which returns the
1809
+ // default 160x120 size and causes a visible flash before the drag delta
1810
+ // applies. The real size only matters at mouseup (createDraftPrimitive)
1811
+ // which already handles the "no drag" click-to-place case correctly.
1812
+ const initialGeometry =
1813
+ tool === "pen" || tool === "line" || tool === "arrow"
1814
+ ? getDraftGeometryForTool(tool, originCanvas, originCanvas)
1815
+ : { x: originCanvas.x, y: originCanvas.y, width: 0, height: 0 };
1262
1816
  const initialPoints =
1263
1817
  tool === "pen"
1264
1818
  ? [originCanvas]
@@ -1494,40 +2048,94 @@ export function MultiScreenCanvas({
1494
2048
  ev.clientX,
1495
2049
  ev.clientY,
1496
2050
  );
2051
+
2052
+ // Primitive drop-into-container detection: check if the dragged draft
2053
+ // is hovering over a committed container primitive on any screen.
2054
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
2055
+ const primitiveTarget = findPrimitiveDropTarget(canvasPoint, null);
2056
+ updatePrimitiveDropTarget(primitiveTarget);
1497
2057
  };
1498
2058
 
1499
2059
  const handleMouseUp = () => {
1500
2060
  const state = dragState.current;
2061
+ const dropTarget = primitiveDropTargetRef.current;
1501
2062
  if (state?.type === "draft-move" && state.hasMoved) {
1502
- const persisted: Array<{
1503
- draftId: string;
1504
- frameId: string;
1505
- nodeId: string;
1506
- }> = [];
1507
- draftPrimitivesRef.current.forEach((draft) => {
1508
- if (!state.targetIds.includes(draft.id)) return;
1509
- const result = persistDraftPrimitive(draft);
1510
- if (result) {
1511
- persisted.push({
1512
- draftId: draft.id,
1513
- frameId: result.frameId,
1514
- nodeId: result.nodeId,
2063
+ if (dropTarget) {
2064
+ // Drop into a container primitive: persist the draft into the
2065
+ // target's screen, then call onPrimitiveReparent to nest it.
2066
+ const persisted: Array<{
2067
+ draftId: string;
2068
+ frameId: string;
2069
+ nodeId: string;
2070
+ }> = [];
2071
+ draftPrimitivesRef.current.forEach((draft) => {
2072
+ if (!state.targetIds.includes(draft.id)) return;
2073
+ // Persist into the target's screen (not just any containing frame)
2074
+ const result = persistDraftPrimitive(draft, dropTarget.screenId);
2075
+ if (result) {
2076
+ persisted.push({
2077
+ draftId: draft.id,
2078
+ frameId: result.frameId,
2079
+ nodeId: result.nodeId,
2080
+ });
2081
+ }
2082
+ });
2083
+
2084
+ if (persisted.length > 0) {
2085
+ const persistedDraftIds = new Set(
2086
+ persisted.map((entry) => entry.draftId),
2087
+ );
2088
+ updateDraftPrimitives((current) =>
2089
+ current.filter((draft) => !persistedDraftIds.has(draft.id)),
2090
+ );
2091
+ updateSelectedDraftIds((current) =>
2092
+ current.filter((draftId) => !persistedDraftIds.has(draftId)),
2093
+ );
2094
+ // Reparent each persisted node into the container primitive.
2095
+ persisted.forEach((entry) => {
2096
+ onPrimitiveReparentRef.current?.({
2097
+ sourceNodeId: entry.nodeId,
2098
+ sourceScreenId: entry.frameId,
2099
+ targetNodeId: dropTarget.nodeId,
2100
+ targetScreenId: dropTarget.screenId,
2101
+ placement: "inside",
2102
+ });
1515
2103
  });
2104
+ const lastNodeId = persisted[persisted.length - 1]?.nodeId;
2105
+ if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
2106
+ }
2107
+ } else {
2108
+ // Normal drop: persist into whichever screen contains the draft.
2109
+ const persisted: Array<{
2110
+ draftId: string;
2111
+ frameId: string;
2112
+ nodeId: string;
2113
+ }> = [];
2114
+ draftPrimitivesRef.current.forEach((draft) => {
2115
+ if (!state.targetIds.includes(draft.id)) return;
2116
+ const result = persistDraftPrimitive(draft);
2117
+ if (result) {
2118
+ persisted.push({
2119
+ draftId: draft.id,
2120
+ frameId: result.frameId,
2121
+ nodeId: result.nodeId,
2122
+ });
2123
+ }
2124
+ });
2125
+
2126
+ if (persisted.length > 0) {
2127
+ const persistedDraftIds = new Set(
2128
+ persisted.map((entry) => entry.draftId),
2129
+ );
2130
+ updateDraftPrimitives((current) =>
2131
+ current.filter((draft) => !persistedDraftIds.has(draft.id)),
2132
+ );
2133
+ updateSelectedDraftIds((current) =>
2134
+ current.filter((draftId) => !persistedDraftIds.has(draftId)),
2135
+ );
2136
+ const lastNodeId = persisted[persisted.length - 1]?.nodeId;
2137
+ if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
1516
2138
  }
1517
- });
1518
-
1519
- if (persisted.length > 0) {
1520
- const persistedDraftIds = new Set(
1521
- persisted.map((entry) => entry.draftId),
1522
- );
1523
- updateDraftPrimitives((current) =>
1524
- current.filter((draft) => !persistedDraftIds.has(draft.id)),
1525
- );
1526
- updateSelectedDraftIds((current) =>
1527
- current.filter((draftId) => !persistedDraftIds.has(draftId)),
1528
- );
1529
- const lastNodeId = persisted[persisted.length - 1]?.nodeId;
1530
- if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
1531
2139
  }
1532
2140
  }
1533
2141
  finishDrag();
@@ -1536,12 +2144,15 @@ export function MultiScreenCanvas({
1536
2144
  installDragListeners(handleMouseMove, handleMouseUp);
1537
2145
  },
1538
2146
  [
2147
+ findPrimitiveDropTarget,
1539
2148
  finishDrag,
2149
+ getCanvasPoint,
1540
2150
  getCurrentCanvasEntries,
1541
2151
  installDragListeners,
1542
2152
  persistDraftPrimitive,
1543
2153
  showTransformFeedback,
1544
2154
  updateDraftPrimitives,
2155
+ updatePrimitiveDropTarget,
1545
2156
  updateSelectedDraftIds,
1546
2157
  updateSelectedIds,
1547
2158
  ],
@@ -1708,9 +2319,11 @@ export function MultiScreenCanvas({
1708
2319
  const targetIds = currentSelectedIds.includes(id)
1709
2320
  ? currentSelectedIds
1710
2321
  : [id];
2322
+ if (activeId !== id) {
2323
+ onPick(id);
2324
+ }
1711
2325
  if (!currentSelectedIds.includes(id)) {
1712
2326
  updateSelectedIds(() => [id]);
1713
- onPick(id);
1714
2327
  }
1715
2328
  updateSelectedDraftIds(() => []);
1716
2329
 
@@ -1787,11 +2400,51 @@ export function MultiScreenCanvas({
1787
2400
  ev.clientX,
1788
2401
  ev.clientY,
1789
2402
  );
2403
+
2404
+ // When all dragged ids are committed primitive nodeIds (not screen
2405
+ // frames), check for a container primitive drop target to highlight.
2406
+ const currentFrameIds = Object.keys(frameGeometryRef.current);
2407
+ const allCommitted = state.targetIds.every(
2408
+ (targetId) => !currentFrameIds.includes(targetId),
2409
+ );
2410
+ if (allCommitted) {
2411
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
2412
+ updatePrimitiveDropTarget(
2413
+ findPrimitiveDropTarget(canvasPoint, state.primaryId),
2414
+ );
2415
+ }
1790
2416
  };
1791
2417
 
1792
2418
  const handleMouseUp = () => {
1793
2419
  const state = dragState.current;
2420
+ const dropTarget = primitiveDropTargetRef.current;
1794
2421
  if (state?.type === "move" && state.hasMoved) {
2422
+ // If all dragged ids are committed primitive nodeIds (not screen
2423
+ // frames), attempt a primitive reparent on drop.
2424
+ const currentFrameIds = Object.keys(frameGeometryRef.current);
2425
+ const allCommitted = state.targetIds.every(
2426
+ (targetId) => !currentFrameIds.includes(targetId),
2427
+ );
2428
+ if (allCommitted && dropTarget) {
2429
+ const sourceScreenId = resolveNodeScreenId(
2430
+ state.primaryId,
2431
+ screensRef.current,
2432
+ );
2433
+ if (sourceScreenId) {
2434
+ onPrimitiveReparentRef.current?.({
2435
+ sourceNodeId: state.primaryId,
2436
+ sourceScreenId,
2437
+ targetNodeId: dropTarget.nodeId,
2438
+ targetScreenId: dropTarget.screenId,
2439
+ placement: "inside",
2440
+ });
2441
+ suppressNextPick.current = true;
2442
+ finishDrag();
2443
+ return;
2444
+ }
2445
+ }
2446
+
2447
+ // Normal screen-frame geometry commit.
1795
2448
  const after = cloneFrameGeometryById(frameGeometryRef.current);
1796
2449
  onGeometryCommitRef.current?.(
1797
2450
  frameGeometryWithOverrides(after, state.originFrames),
@@ -1805,12 +2458,16 @@ export function MultiScreenCanvas({
1805
2458
  installDragListeners(handleMouseMove, handleMouseUp);
1806
2459
  },
1807
2460
  [
2461
+ activeId,
2462
+ findPrimitiveDropTarget,
1808
2463
  finishDrag,
2464
+ getCanvasPoint,
1809
2465
  getCurrentFrameEntries,
1810
2466
  installDragListeners,
1811
2467
  onPick,
1812
2468
  showTransformFeedback,
1813
2469
  updateFrameGeometry,
2470
+ updatePrimitiveDropTarget,
1814
2471
  updateSelectedDraftIds,
1815
2472
  updateSelectedIds,
1816
2473
  ],
@@ -1823,6 +2480,10 @@ export function MultiScreenCanvas({
1823
2480
  e.stopPropagation();
1824
2481
  suppressNextPick.current = true;
1825
2482
 
2483
+ if (activeId !== id) {
2484
+ onPick(id);
2485
+ }
2486
+
1826
2487
  const currentSelectedIds = selectedIdsRef.current;
1827
2488
  const targetIds = currentSelectedIds.includes(id)
1828
2489
  ? currentSelectedIds
@@ -1931,9 +2592,11 @@ export function MultiScreenCanvas({
1931
2592
  installDragListeners(handleMouseMove, handleMouseUp);
1932
2593
  },
1933
2594
  [
2595
+ activeId,
1934
2596
  finishDrag,
1935
2597
  getCurrentFrameEntries,
1936
2598
  installDragListeners,
2599
+ onPick,
1937
2600
  showTransformFeedback,
1938
2601
  updateFrameGeometry,
1939
2602
  updateSelectedIds,
@@ -1956,6 +2619,10 @@ export function MultiScreenCanvas({
1956
2619
  e.stopPropagation();
1957
2620
  suppressNextPick.current = true;
1958
2621
 
2622
+ if (activeId !== id) {
2623
+ onPick(id);
2624
+ }
2625
+
1959
2626
  const originFrame = getCurrentFrameEntries().find(
1960
2627
  (entry) => entry.id === id,
1961
2628
  )?.geometry;
@@ -2027,10 +2694,12 @@ export function MultiScreenCanvas({
2027
2694
  installDragListeners(handleMouseMove, handleMouseUp);
2028
2695
  },
2029
2696
  [
2697
+ activeId,
2030
2698
  finishDrag,
2031
2699
  getCanvasPoint,
2032
2700
  getCurrentFrameEntries,
2033
2701
  installDragListeners,
2702
+ onPick,
2034
2703
  showTransformFeedback,
2035
2704
  updateFrameGeometry,
2036
2705
  updateSelectedIds,
@@ -2047,11 +2716,20 @@ export function MultiScreenCanvas({
2047
2716
 
2048
2717
  if (e.shiftKey) {
2049
2718
  updateSelectedDraftIds(() => []);
2050
- updateSelectedIds((current) =>
2051
- current.includes(id)
2052
- ? current.filter((selectedId) => selectedId !== id)
2053
- : [...current, id],
2054
- );
2719
+ const currentSelectedIds = selectedIdsRef.current;
2720
+ const nextSelectedIds = currentSelectedIds.includes(id)
2721
+ ? currentSelectedIds.filter((selectedId) => selectedId !== id)
2722
+ : [...currentSelectedIds, id];
2723
+ updateSelectedIds(() => nextSelectedIds);
2724
+ const nextPrimaryId =
2725
+ nextSelectedIds.length === 0
2726
+ ? null
2727
+ : nextSelectedIds.includes(id)
2728
+ ? id
2729
+ : (nextSelectedIds[nextSelectedIds.length - 1] ?? null);
2730
+ if (nextPrimaryId && nextPrimaryId !== activeId) {
2731
+ onPick(nextPrimaryId);
2732
+ }
2055
2733
  return;
2056
2734
  }
2057
2735
 
@@ -2059,7 +2737,7 @@ export function MultiScreenCanvas({
2059
2737
  updateSelectedIds(() => [id]);
2060
2738
  onPick(id);
2061
2739
  },
2062
- [onPick, updateSelectedDraftIds, updateSelectedIds],
2740
+ [activeId, onPick, updateSelectedDraftIds, updateSelectedIds],
2063
2741
  );
2064
2742
 
2065
2743
  const handleFrameDoubleClick = useCallback(
@@ -2232,6 +2910,136 @@ export function MultiScreenCanvas({
2232
2910
  [activeTool, localActiveTool, updatePenPointer],
2233
2911
  );
2234
2912
 
2913
+ // Push the current pan/zoom straight to the DOM. A wheel/pinch gesture must
2914
+ // NEVER re-render React's canvas tree during the gesture: each render re-runs
2915
+ // renderScreenContent (which re-creates the active screen's live DesignCanvas
2916
+ // iframe) and, with React DevTools attached, serializes every render over the
2917
+ // extension bridge — that re-render storm is the real source of zoom jank, not
2918
+ // layout/paint. We mutate the transform directly here and reconcile React
2919
+ // state once, after the gesture settles, via scheduleViewCommit().
2920
+ const applyViewToDom = useCallback(() => {
2921
+ const nextScale = zoomRef.current / 100;
2922
+ const p = panRef.current;
2923
+ const world = worldRef.current;
2924
+ if (world) {
2925
+ // 2D translate (not translate3d) so the layer is not GPU-pinned to a
2926
+ // stale low-res raster — keeps zoomed-in content crisp.
2927
+ world.style.transform = `translate(${p.x}px, ${p.y}px) scale(${nextScale})`;
2928
+ }
2929
+ const grid = pixelGridRef.current;
2930
+ if (grid) {
2931
+ grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
2932
+ grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
2933
+ }
2934
+ }, []);
2935
+
2936
+ const startChromeSettle = useCallback(() => {
2937
+ if (chromeSettleTimerRef.current !== null) {
2938
+ window.clearTimeout(chromeSettleTimerRef.current);
2939
+ }
2940
+ setChromeSettling(true);
2941
+ chromeSettleTimerRef.current = window.setTimeout(() => {
2942
+ chromeSettleTimerRef.current = null;
2943
+ setChromeSettling(false);
2944
+ }, CHROME_SETTLE_MS);
2945
+ }, []);
2946
+
2947
+ const commitView = useCallback(() => {
2948
+ viewCommitTimerRef.current = null;
2949
+ const shouldSettleChrome = pendingChromeSettleRef.current;
2950
+ pendingChromeSettleRef.current = false;
2951
+ if (shouldSettleChrome) startChromeSettle();
2952
+ setCanvasZoom(zoomRef.current);
2953
+ setPan(panRef.current);
2954
+ onZoomChange?.(zoomRef.current);
2955
+ }, [onZoomChange, startChromeSettle]);
2956
+
2957
+ // Debounced: only commit to React state once the gesture has been idle for a
2958
+ // beat, so a continuous pinch produces zero re-renders until the user pauses.
2959
+ const scheduleViewCommit = useCallback(
2960
+ (options?: { settleChrome?: boolean }) => {
2961
+ if (options?.settleChrome) {
2962
+ pendingChromeSettleRef.current = true;
2963
+ }
2964
+ if (viewCommitTimerRef.current !== null) {
2965
+ window.clearTimeout(viewCommitTimerRef.current);
2966
+ }
2967
+ viewCommitTimerRef.current = window.setTimeout(commitView, 120);
2968
+ },
2969
+ [commitView],
2970
+ );
2971
+
2972
+ const flushPendingWheelGesture = useCallback(() => {
2973
+ wheelGestureFrameRef.current = null;
2974
+ const gesture = pendingWheelGestureRef.current;
2975
+ pendingWheelGestureRef.current = null;
2976
+ if (!gesture) return;
2977
+
2978
+ if (gesture.mode === "zoom") {
2979
+ const currentZoom = zoomRef.current;
2980
+ const zoomDeltaY = clamp(
2981
+ gesture.deltaY,
2982
+ -MAX_WHEEL_ZOOM_DELTA,
2983
+ MAX_WHEEL_ZOOM_DELTA,
2984
+ );
2985
+ const nextZoom = clamp(
2986
+ currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
2987
+ MIN_ZOOM,
2988
+ MAX_ZOOM,
2989
+ );
2990
+ if (nextZoom === currentZoom) return;
2991
+
2992
+ const nextPan = getPanForZoomToCursor({
2993
+ pan: panRef.current,
2994
+ cursor: gesture.cursor,
2995
+ oldZoom: currentZoom,
2996
+ nextZoom,
2997
+ });
2998
+ zoomRef.current = nextZoom;
2999
+ panRef.current = nextPan;
3000
+ applyViewToDom();
3001
+ scheduleViewCommit({ settleChrome: true });
3002
+ return;
3003
+ }
3004
+
3005
+ const nextPan = {
3006
+ x: panRef.current.x - gesture.deltaX,
3007
+ y: panRef.current.y - gesture.deltaY,
3008
+ };
3009
+ panRef.current = nextPan;
3010
+ applyViewToDom();
3011
+ scheduleViewCommit();
3012
+ }, [applyViewToDom, scheduleViewCommit]);
3013
+
3014
+ const enqueueWheelGesture = useCallback(
3015
+ (gesture: PendingWheelGesture) => {
3016
+ const pending = pendingWheelGestureRef.current;
3017
+ if (pending?.mode === "zoom" && gesture.mode === "zoom") {
3018
+ pendingWheelGestureRef.current = {
3019
+ mode: "zoom",
3020
+ deltaY: pending.deltaY + gesture.deltaY,
3021
+ cursor: gesture.cursor,
3022
+ clientX: gesture.clientX,
3023
+ clientY: gesture.clientY,
3024
+ };
3025
+ } else if (pending?.mode === "pan" && gesture.mode === "pan") {
3026
+ pendingWheelGestureRef.current = {
3027
+ mode: "pan",
3028
+ deltaX: pending.deltaX + gesture.deltaX,
3029
+ deltaY: pending.deltaY + gesture.deltaY,
3030
+ };
3031
+ } else {
3032
+ pendingWheelGestureRef.current = gesture;
3033
+ }
3034
+
3035
+ if (wheelGestureFrameRef.current !== null) return;
3036
+ wheelGestureFrameRef.current = window.requestAnimationFrame(
3037
+ flushPendingWheelGesture,
3038
+ );
3039
+ },
3040
+ [flushPendingWheelGesture],
3041
+ );
3042
+
2235
3043
  const handleWheelEvent = useCallback(
2236
3044
  (event: WheelEvent) => {
2237
3045
  const rect = surfaceRef.current?.getBoundingClientRect();
@@ -2241,40 +3049,18 @@ export function MultiScreenCanvas({
2241
3049
  const delta = getWheelDelta(event);
2242
3050
 
2243
3051
  if (event.ctrlKey || event.metaKey) {
2244
- const currentZoom = zoomRef.current;
2245
3052
  const zoomDeltaY = clamp(
2246
3053
  delta.y,
2247
3054
  -MAX_WHEEL_ZOOM_DELTA,
2248
3055
  MAX_WHEEL_ZOOM_DELTA,
2249
3056
  );
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,
3057
+ enqueueWheelGesture({
3058
+ mode: "zoom",
3059
+ deltaY: zoomDeltaY,
2259
3060
  cursor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
2260
- oldZoom: currentZoom,
2261
- nextZoom,
3061
+ clientX: event.clientX,
3062
+ clientY: event.clientY,
2262
3063
  });
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
3064
  return;
2279
3065
  }
2280
3066
 
@@ -2288,14 +3074,9 @@ export function MultiScreenCanvas({
2288
3074
  -MAX_WHEEL_PAN_DELTA,
2289
3075
  MAX_WHEEL_PAN_DELTA,
2290
3076
  );
2291
- const nextPan = {
2292
- x: panRef.current.x - deltaX,
2293
- y: panRef.current.y - deltaY,
2294
- };
2295
- panRef.current = nextPan;
2296
- setPan(nextPan);
3077
+ enqueueWheelGesture({ mode: "pan", deltaX, deltaY });
2297
3078
  },
2298
- [getFrameAtClientPoint, onZoomChange, onZoomToEdit, zoomToEditThreshold],
3079
+ [enqueueWheelGesture],
2299
3080
  );
2300
3081
 
2301
3082
  useEffect(() => {
@@ -2490,25 +3271,73 @@ export function MultiScreenCanvas({
2490
3271
  if (tool !== "pen") clearActivePenPath();
2491
3272
  }, [activeTool, clearActivePenPath, localActiveTool]);
2492
3273
 
2493
- const scale = canvasZoom / 100;
2494
- const chromeScale = scale > 0 ? 1 / scale : 1;
2495
- const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
2496
- const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
2497
- const penActive = effectiveTool === "pen";
2498
- const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
2499
- const displayedPenPath = penGesturePreview
2500
- ? penGesturePreview
2501
- : activePenPath && penPointer && activePenPath.nodes.length > 0
2502
- ? penCloseHover
2503
- ? closePenPath(activePenPath)
2504
- : appendPenNode(activePenPath, createCornerNode(penPointer))
2505
- : activePenPath;
2506
- const selectedIdSet = new Set(selectedIds);
2507
- const selectedDraftIdSet = new Set(selectedDraftIds);
2508
- const surfaceCursor = isPanning
2509
- ? "grabbing"
2510
- : dragCursor
2511
- ? dragCursor
3274
+ // Cmd+D / Ctrl+D: duplicate the selected frame with a visible offset so the
3275
+ // copy doesn't land exactly on top of the original (Figma-style behaviour).
3276
+ useEffect(() => {
3277
+ if (!onDuplicate) return;
3278
+ const handleKeyDown = (event: KeyboardEvent) => {
3279
+ if (activePenPathRef.current) return;
3280
+ if (
3281
+ !(event.metaKey || event.ctrlKey) ||
3282
+ event.key.toLowerCase() !== "d" ||
3283
+ isEditableHotkeyTarget(event.target)
3284
+ ) {
3285
+ return;
3286
+ }
3287
+ // Only act on frame IDs — filter out canvas primitives (sub-elements).
3288
+ const frameIds = selectedIdsRef.current.filter(
3289
+ (id) => frameGeometryRef.current[id],
3290
+ );
3291
+ const targetId = frameIds[0];
3292
+ if (!targetId) return;
3293
+ const screen = screens.find((s) => s.id === targetId);
3294
+ if (!screen) return;
3295
+ const sourceGeometry = frameGeometryRef.current[targetId];
3296
+ if (!sourceGeometry) return;
3297
+ event.preventDefault();
3298
+ event.stopPropagation();
3299
+ event.stopImmediatePropagation();
3300
+ // Offset the duplicate by one grid gap to the right (and slightly down)
3301
+ // so it is visually distinct from the original, mirroring Figma's behaviour.
3302
+ const canvasPosition = {
3303
+ x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
3304
+ y: sourceGeometry.y,
3305
+ };
3306
+ onDuplicate(targetId, {
3307
+ mode: "alt-click",
3308
+ screen,
3309
+ canvasPosition,
3310
+ dropCanvasPosition: canvasPosition,
3311
+ });
3312
+ };
3313
+
3314
+ window.addEventListener("keydown", handleKeyDown, true);
3315
+ return () => {
3316
+ window.removeEventListener("keydown", handleKeyDown, true);
3317
+ };
3318
+ }, [onDuplicate, screens]);
3319
+
3320
+ const scale = canvasZoom / 100;
3321
+ const chromeScale = scale > 0 ? 1 / scale : 1;
3322
+ const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
3323
+ const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
3324
+ const penActive = effectiveTool === "pen";
3325
+ const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
3326
+ const canvasGestureActive = isDragging || isPanning;
3327
+ const displayedPenPath = penGesturePreview
3328
+ ? penGesturePreview
3329
+ : activePenPath && penPointer && activePenPath.nodes.length > 0
3330
+ ? penCloseHover
3331
+ ? closePenPath(activePenPath)
3332
+ : appendPenNode(activePenPath, createCornerNode(penPointer))
3333
+ : activePenPath;
3334
+ const selectedIdSet = new Set(selectedIds);
3335
+ const fullViewIdSet = new Set(fullViewScreenIds ?? []);
3336
+ const selectedDraftIdSet = new Set(selectedDraftIds);
3337
+ const surfaceCursor = isPanning
3338
+ ? "grabbing"
3339
+ : dragCursor
3340
+ ? dragCursor
2512
3341
  : isDragging && marquee
2513
3342
  ? "crosshair"
2514
3343
  : penActive || getDraftCreationTool(effectiveTool)
@@ -2516,10 +3345,29 @@ export function MultiScreenCanvas({
2516
3345
  : effectiveTool === "hand"
2517
3346
  ? "grab"
2518
3347
  : "default";
2519
- const canvasFrames = screens.map((screen, index) => ({
2520
- screen,
2521
- geometry: frameGeometry[screen.id] ?? getInitialFrameGeometry(index),
2522
- }));
3348
+ const canvasFrames = screens.map((screen, index) => {
3349
+ const metadata = getResolvedMetadata(screen);
3350
+ return {
3351
+ screen,
3352
+ metadata,
3353
+ geometry:
3354
+ frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata),
3355
+ };
3356
+ });
3357
+ const screenContentById = useMemo(() => {
3358
+ if (!renderScreenContent) return new Map<string, ReactNode>();
3359
+ return new Map(
3360
+ screens.map((screen, index) => {
3361
+ const metadata = getResolvedMetadata(screen);
3362
+ const geometry =
3363
+ frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata);
3364
+ return [
3365
+ screen.id,
3366
+ renderScreenContent(screen, metadata, geometry),
3367
+ ] as const;
3368
+ }),
3369
+ );
3370
+ }, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
2523
3371
  const selectedFrameEntries = canvasFrames
2524
3372
  .filter(({ screen }) => selectedIdSet.has(screen.id))
2525
3373
  .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
@@ -2535,16 +3383,25 @@ export function MultiScreenCanvas({
2535
3383
  selectedDraftEntries.length > 1
2536
3384
  ? getFrameGroupBounds(selectedDraftEntries)
2537
3385
  : null;
3386
+ const singleSelectedFrame =
3387
+ selectedFrameEntries.length === 1 && !selectedGroupBounds
3388
+ ? selectedFrameEntries[0]
3389
+ : null;
3390
+ const singleSelectedDraft =
3391
+ selectedDraftEntries.length === 1 && !selectedDraftGroupBounds
3392
+ ? selectedDraftEntries[0]
3393
+ : null;
2538
3394
  return (
2539
3395
  <div
2540
3396
  ref={surfaceRef}
2541
- className="relative h-full w-full select-none overflow-hidden bg-background"
3397
+ className="relative h-full w-full select-none overflow-hidden"
2542
3398
  onMouseDown={handleMouseDown}
2543
3399
  onMouseMove={handleMouseMove}
2544
3400
  style={{ cursor: surfaceCursor, touchAction: "none" }}
2545
3401
  >
2546
3402
  {showPixelGrid ? (
2547
3403
  <div
3404
+ ref={pixelGridRef}
2548
3405
  className="pointer-events-none absolute inset-0 opacity-60"
2549
3406
  style={{
2550
3407
  backgroundImage:
@@ -2556,40 +3413,61 @@ export function MultiScreenCanvas({
2556
3413
  ) : null}
2557
3414
 
2558
3415
  <div
3416
+ ref={worldRef}
2559
3417
  className="pointer-events-none absolute"
2560
3418
  style={{
2561
- left: pan.x,
2562
- top: pan.y,
2563
- transform: `scale(${scale})`,
3419
+ left: 0,
3420
+ top: 0,
3421
+ // Plain 2D transform — NO will-change / translate3d. Forcing a
3422
+ // compositor layer pins a low-res cached raster that the GPU stretches
3423
+ // when you zoom in, leaving screen content permanently blurry. A 2D
3424
+ // transform lets the browser re-rasterize crisply at rest. Zoom smoothness
3425
+ // comes from never re-rendering React during the gesture (see
3426
+ // flushPendingWheelGesture / applyViewToDom), not from layer promotion —
3427
+ // the trace proved paint/composite was never the bottleneck.
3428
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
2564
3429
  transformOrigin: "top left",
2565
3430
  }}
2566
3431
  >
2567
- {canvasFrames.map(({ screen, geometry }) => {
2568
- const metadata = resolveScreenMetadata(
2569
- screen,
2570
- metadataById?.[screen.id],
2571
- getScreenMetadata?.(screen),
2572
- );
3432
+ {canvasFrames.map(({ screen, metadata, geometry }) => {
2573
3433
  return (
2574
3434
  <Screen
2575
3435
  key={screen.id}
2576
3436
  screen={screen}
2577
3437
  metadata={metadata}
2578
3438
  geometry={geometry}
2579
- screenContent={renderScreenContent?.(screen, metadata, geometry)}
3439
+ screenContent={screenContentById.get(screen.id)}
2580
3440
  isActive={screen.id === activeId}
2581
3441
  isSelected={selectedIdSet.has(screen.id)}
3442
+ showFullView={fullViewIdSet.has(screen.id)}
3443
+ isDirectlyHovered={screen.id === directlyHoveredScreenId}
3444
+ hasHoveredChild={
3445
+ (screen.id === activeId && activeScreenHasHoveredChild) ||
3446
+ screen.id === hoveredChildScreenId
3447
+ }
2582
3448
  groupSelected={hasGroupSelection}
2583
3449
  handlesEnabled={!hasGroupSelection}
2584
3450
  penActive={penActive}
2585
3451
  creationToolActive={creationToolActive}
3452
+ canvasGestureActive={canvasGestureActive}
2586
3453
  chromeScale={chromeScale}
3454
+ chromeSettling={chromeSettling}
2587
3455
  onPick={handleFrameClick}
2588
3456
  onEdit={handleFrameDoubleClick}
2589
3457
  onStartFrameDrag={beginFrameDrag}
2590
3458
  onStartResize={beginResize}
2591
3459
  onStartRotate={beginRotate}
2592
3460
  onStartDuplicateGesture={beginDuplicateGesture}
3461
+ onAddBreakpoint={
3462
+ onAddBreakpoint
3463
+ ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
3464
+ : undefined
3465
+ }
3466
+ onActiveBreakpointChange={
3467
+ onActiveBreakpointChange
3468
+ ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
3469
+ : undefined
3470
+ }
2593
3471
  />
2594
3472
  );
2595
3473
  })}
@@ -2602,6 +3480,7 @@ export function MultiScreenCanvas({
2602
3480
  groupSelected={Boolean(selectedDraftGroupBounds)}
2603
3481
  penActive={penActive}
2604
3482
  chromeScale={chromeScale}
3483
+ chromeSettling={chromeSettling}
2605
3484
  onClick={handleDraftClick}
2606
3485
  onStartDrag={beginDraftDrag}
2607
3486
  onStartResize={beginDraftResize}
@@ -2616,6 +3495,7 @@ export function MultiScreenCanvas({
2616
3495
  groupSelected={false}
2617
3496
  penActive={penActive}
2618
3497
  chromeScale={chromeScale}
3498
+ chromeSettling={chromeSettling}
2619
3499
  onClick={() => {}}
2620
3500
  onStartDrag={() => {}}
2621
3501
  onStartResize={() => {}}
@@ -2626,10 +3506,39 @@ export function MultiScreenCanvas({
2626
3506
  <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
2627
3507
  ) : null}
2628
3508
 
3509
+ {singleSelectedFrame ? (
3510
+ <SelectionBox
3511
+ geometry={singleSelectedFrame.geometry}
3512
+ chromeScale={chromeScale}
3513
+ chromeSettling={chromeSettling}
3514
+ showRotate
3515
+ onStartResize={(handle, event) =>
3516
+ beginResize(singleSelectedFrame.id, handle, event)
3517
+ }
3518
+ onStartRotate={(event) =>
3519
+ beginRotate(singleSelectedFrame.id, event)
3520
+ }
3521
+ />
3522
+ ) : null}
3523
+
3524
+ {singleSelectedDraft ? (
3525
+ <SelectionBox
3526
+ geometry={singleSelectedDraft.geometry}
3527
+ chromeScale={chromeScale}
3528
+ chromeSettling={chromeSettling}
3529
+ showRotate={false}
3530
+ onStartResize={(handle, event) =>
3531
+ beginDraftResize(singleSelectedDraft.id, handle, event)
3532
+ }
3533
+ onStartRotate={() => {}}
3534
+ />
3535
+ ) : null}
3536
+
2629
3537
  {selectedGroupBounds ? (
2630
3538
  <GroupSelectionBox
2631
3539
  bounds={selectedGroupBounds}
2632
3540
  chromeScale={chromeScale}
3541
+ chromeSettling={chromeSettling}
2633
3542
  onStartResize={beginGroupResize}
2634
3543
  />
2635
3544
  ) : null}
@@ -2638,6 +3547,7 @@ export function MultiScreenCanvas({
2638
3547
  <GroupSelectionBox
2639
3548
  bounds={selectedDraftGroupBounds}
2640
3549
  chromeScale={chromeScale}
3550
+ chromeSettling={chromeSettling}
2641
3551
  onStartResize={beginDraftGroupResize}
2642
3552
  />
2643
3553
  ) : null}
@@ -2663,20 +3573,46 @@ export function MultiScreenCanvas({
2663
3573
  }
2664
3574
  />
2665
3575
  ))}
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
3576
  </div>
2679
3577
 
3578
+ {marquee ? (
3579
+ <span
3580
+ className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
3581
+ style={{
3582
+ // Convert canvas-space marquee to surface-space so this overlay
3583
+ // is never clipped or hidden by the canvas transform container.
3584
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3585
+ left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
3586
+ top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
3587
+ width: Math.max(1, marquee.width * scale),
3588
+ height: Math.max(1, marquee.height * scale),
3589
+ }}
3590
+ />
3591
+ ) : null}
3592
+
3593
+ {primitiveDropTarget ? (
3594
+ <span
3595
+ data-primitive-drop-target
3596
+ className="pointer-events-none absolute z-40 rounded-sm"
3597
+ style={{
3598
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3599
+ left:
3600
+ pan.x +
3601
+ (SURFACE_PADDING + primitiveDropTarget.boardRect.x) * scale,
3602
+ top:
3603
+ pan.y +
3604
+ (SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
3605
+ width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
3606
+ height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
3607
+ // Match the in-screen inside-guide style: 2px accent border + 14%
3608
+ // accent fill. Uses the same CSS variable as the DesignCanvas guide.
3609
+ border: "2px solid var(--design-editor-accent-color)",
3610
+ background:
3611
+ "color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
3612
+ }}
3613
+ />
3614
+ ) : null}
3615
+
2680
3616
  {duplicatePreview ? (
2681
3617
  <div
2682
3618
  className={cn(
@@ -2712,12 +3648,53 @@ export function MultiScreenCanvas({
2712
3648
 
2713
3649
  {transformBadge ? (
2714
3650
  <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"
3651
+ data-transform-badge
3652
+ 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
3653
  style={{ left: transformBadge.x, top: transformBadge.y }}
2717
3654
  >
2718
3655
  {transformBadge.text}
2719
3656
  </div>
2720
3657
  ) : null}
3658
+
3659
+ {/* Cross-screen element drag: highlight the candidate target screen frame.
3660
+ Uses the same style as the primitiveDropTarget overlay (2px accent border
3661
+ + 14% accent fill) so it is visually consistent. */}
3662
+ {crossScreenTarget ? (
3663
+ <span
3664
+ data-cross-screen-drop-target
3665
+ className="pointer-events-none absolute z-40 rounded-sm"
3666
+ style={{
3667
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3668
+ left:
3669
+ pan.x + (SURFACE_PADDING + crossScreenTarget.geometry.x) * scale,
3670
+ top:
3671
+ pan.y + (SURFACE_PADDING + crossScreenTarget.geometry.y) * scale,
3672
+ width: Math.max(1, crossScreenTarget.geometry.width * scale),
3673
+ height: Math.max(1, crossScreenTarget.geometry.height * scale),
3674
+ border: "2px solid var(--design-editor-accent-color)",
3675
+ background:
3676
+ "color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
3677
+ }}
3678
+ />
3679
+ ) : null}
3680
+
3681
+ {/* Cross-screen element drag: small ghost following the board-space cursor. */}
3682
+ {crossScreenGhost ? (
3683
+ <span
3684
+ data-cross-screen-drag-ghost
3685
+ className="pointer-events-none absolute z-50 rounded border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)]/20 shadow"
3686
+ style={{
3687
+ // A fixed-size ghost (16×16 canvas-px) centred on the board point.
3688
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3689
+ left:
3690
+ pan.x + (SURFACE_PADDING + crossScreenGhost.boardX) * scale - 8,
3691
+ top:
3692
+ pan.y + (SURFACE_PADDING + crossScreenGhost.boardY) * scale - 8,
3693
+ width: 16,
3694
+ height: 16,
3695
+ }}
3696
+ />
3697
+ ) : null}
2721
3698
  </div>
2722
3699
  );
2723
3700
  }
@@ -2728,6 +3705,7 @@ function DraftPrimitiveLayer({
2728
3705
  groupSelected,
2729
3706
  penActive,
2730
3707
  chromeScale,
3708
+ chromeSettling,
2731
3709
  preview = false,
2732
3710
  onClick,
2733
3711
  onStartDrag,
@@ -2738,6 +3716,7 @@ function DraftPrimitiveLayer({
2738
3716
  groupSelected: boolean;
2739
3717
  penActive: boolean;
2740
3718
  chromeScale: number;
3719
+ chromeSettling: boolean;
2741
3720
  preview?: boolean;
2742
3721
  onClick: (id: string, e: React.MouseEvent) => void;
2743
3722
  onStartDrag: (id: string, e: React.MouseEvent) => void;
@@ -2755,7 +3734,7 @@ function DraftPrimitiveLayer({
2755
3734
  type="button"
2756
3735
  className={cn(
2757
3736
  "group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
2758
- preview || penActive ? "cursor-crosshair" : "cursor-move",
3737
+ preview || penActive ? "cursor-crosshair" : "cursor-pointer",
2759
3738
  )}
2760
3739
  style={{
2761
3740
  left: SURFACE_PADDING + geometry.x,
@@ -2777,23 +3756,32 @@ function DraftPrimitiveLayer({
2777
3756
  }}
2778
3757
  >
2779
3758
  <DraftPrimitiveContent draft={draft} preview={preview} />
3759
+ {/* B3 fix: for the creation preview the outline must sit flush with the
3760
+ geometry box (inset: 0) so the blue accent border lands exactly on the
3761
+ shape edge with no visible gap between the gray content border and the
3762
+ blue selection outline. For placed / hovered draft-primitives the
3763
+ existing -5px inset is intentional (matches the screen-frame chrome). */}
2780
3764
  <span
2781
3765
  className={cn(
2782
3766
  "pointer-events-none absolute rounded-sm border transition-opacity",
2783
- selected
3767
+ preview
2784
3768
  ? "border-[var(--design-editor-accent-color)] opacity-100"
2785
- : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
3769
+ : selected
3770
+ ? "border-transparent opacity-0"
3771
+ : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
2786
3772
  )}
2787
3773
  style={{
2788
- inset: -5 * chromeScale,
3774
+ inset: preview ? 0 : -5 * chromeScale,
2789
3775
  borderWidth: 1.5 * chromeScale,
3776
+ transition: getChromeBorderTransition(chromeSettling),
2790
3777
  }}
2791
3778
  />
2792
3779
  <ResizeHandles
2793
- active={selected || preview}
2794
- enabled={!penActive && ((isSelected && !groupSelected) || preview)}
3780
+ active={preview}
3781
+ enabled={!penActive && preview}
2795
3782
  showRotate={false}
2796
3783
  chromeScale={chromeScale}
3784
+ chromeSettling={chromeSettling}
2797
3785
  onStartResize={(handle, event) =>
2798
3786
  onStartResize(draft.id, handle, event)
2799
3787
  }
@@ -2856,12 +3844,19 @@ function DraftPrimitiveContent({
2856
3844
  }
2857
3845
 
2858
3846
  if (draft.kind === "text") {
3847
+ // B5 fix: use canonical style so preview matches the committed element.
3848
+ const textStyle = canvasPrimitiveReactStyle("text", {
3849
+ fill: draft.fill,
3850
+ stroke: draft.stroke,
3851
+ strokeWidth: draft.strokeWidth,
3852
+ });
2859
3853
  return (
2860
3854
  <div
2861
3855
  className={cn(
2862
- "flex size-full items-start rounded-sm border border-dashed border-primary/60 bg-primary/5 px-2 py-1 text-sm font-medium text-foreground",
3856
+ "flex size-full items-start px-2 py-1 text-sm font-medium text-foreground",
2863
3857
  muted,
2864
3858
  )}
3859
+ style={textStyle}
2865
3860
  >
2866
3861
  <span className="truncate">{draft.text}</span>
2867
3862
  </div>
@@ -2869,27 +3864,24 @@ function DraftPrimitiveContent({
2869
3864
  }
2870
3865
 
2871
3866
  if (draft.kind === "frame") {
2872
- return (
2873
- <div
2874
- className={cn(
2875
- "size-full rounded-sm border-2 border-dashed border-primary/70 bg-primary/5",
2876
- muted,
2877
- )}
2878
- />
2879
- );
3867
+ // B5 fix: use canonical style so preview matches the committed element.
3868
+ const frameStyle = canvasPrimitiveReactStyle("frame", {
3869
+ fill: draft.fill,
3870
+ stroke: draft.stroke,
3871
+ strokeWidth: draft.strokeWidth,
3872
+ });
3873
+ return <div className={cn("size-full", muted)} style={frameStyle} />;
2880
3874
  }
2881
3875
 
2882
3876
  if (draft.kind === "ellipse") {
2883
- return (
2884
- <div
2885
- className={cn("size-full rounded-full border", muted)}
2886
- style={{
2887
- background: draft.fill ?? "hsl(var(--primary) / 0.12)",
2888
- borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
2889
- borderWidth: draft.strokeWidth ?? 1,
2890
- }}
2891
- />
2892
- );
3877
+ // B5/B6 fix: use canonical style — ellipse gets borderRadius:50% in both
3878
+ // the preview and the committed path, and the same calm neutral fill.
3879
+ const ellipseStyle = canvasPrimitiveReactStyle("ellipse", {
3880
+ fill: draft.fill,
3881
+ stroke: draft.stroke,
3882
+ strokeWidth: draft.strokeWidth,
3883
+ });
3884
+ return <div className={cn("size-full", muted)} style={ellipseStyle} />;
2893
3885
  }
2894
3886
 
2895
3887
  if (draft.kind === "polygon" || draft.kind === "star") {
@@ -2916,16 +3908,13 @@ function DraftPrimitiveContent({
2916
3908
  );
2917
3909
  }
2918
3910
 
2919
- return (
2920
- <div
2921
- className={cn("size-full rounded-sm border", muted)}
2922
- style={{
2923
- background: draft.fill ?? "hsl(var(--primary) / 0.12)",
2924
- borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
2925
- borderWidth: draft.strokeWidth ?? 1,
2926
- }}
2927
- />
2928
- );
3911
+ // B5 fix: rect/rectangle — use canonical style so preview matches committed.
3912
+ const rectStyle = canvasPrimitiveReactStyle("rect", {
3913
+ fill: draft.fill,
3914
+ stroke: draft.stroke,
3915
+ strokeWidth: draft.strokeWidth,
3916
+ });
3917
+ return <div className={cn("size-full", muted)} style={rectStyle} />;
2929
3918
  }
2930
3919
 
2931
3920
  function PenPathOverlay({
@@ -3034,35 +4023,37 @@ function isPoint(point: Point | undefined): point is Point {
3034
4023
  return !!point;
3035
4024
  }
3036
4025
 
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
- }: {
4026
+ /** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
4027
+ const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
4028
+
4029
+ /** Derive the Tailwind prefix for a given frame width. */
4030
+ function breakpointLabel(widthPx: number): string {
4031
+ if (widthPx <= 640) return "Mobile";
4032
+ if (widthPx <= 1024) return "Tablet";
4033
+ return "Desktop";
4034
+ }
4035
+
4036
+ /** Suggest the next standard breakpoint not yet in the set. */
4037
+ function nextBreakpointWidth(existing: number[]): number | undefined {
4038
+ return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
4039
+ }
4040
+
4041
+ interface ScreenProps {
3056
4042
  screen: ScreenFile;
3057
4043
  metadata: ResolvedScreenMetadata;
3058
4044
  geometry: FrameGeometry;
3059
4045
  isActive: boolean;
3060
4046
  isSelected: boolean;
4047
+ showFullView: boolean;
4048
+ isDirectlyHovered: boolean;
4049
+ hasHoveredChild: boolean;
3061
4050
  groupSelected: boolean;
3062
4051
  handlesEnabled: boolean;
3063
4052
  penActive: boolean;
3064
4053
  creationToolActive: boolean;
4054
+ canvasGestureActive: boolean;
3065
4055
  chromeScale: number;
4056
+ chromeSettling: boolean;
3066
4057
  screenContent?: ReactNode;
3067
4058
  onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
3068
4059
  onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
@@ -3078,15 +4069,81 @@ function Screen({
3078
4069
  display: string,
3079
4070
  e: React.MouseEvent<HTMLElement>,
3080
4071
  ) => void;
3081
- }) {
4072
+ onAddBreakpoint?: (widthPx: number) => void;
4073
+ onActiveBreakpointChange?: (widthPx: number | undefined) => void;
4074
+ }
4075
+
4076
+ const Screen = memo(function Screen({
4077
+ screen,
4078
+ metadata,
4079
+ geometry,
4080
+ isActive,
4081
+ isSelected,
4082
+ showFullView,
4083
+ isDirectlyHovered,
4084
+ hasHoveredChild,
4085
+ groupSelected,
4086
+ handlesEnabled,
4087
+ penActive,
4088
+ creationToolActive,
4089
+ canvasGestureActive,
4090
+ chromeScale,
4091
+ chromeSettling,
4092
+ onPick,
4093
+ onEdit,
4094
+ onStartFrameDrag,
4095
+ onStartResize,
4096
+ onStartRotate,
4097
+ onStartDuplicateGesture,
4098
+ screenContent,
4099
+ onAddBreakpoint,
4100
+ onActiveBreakpointChange,
4101
+ }: ScreenProps) {
3082
4102
  const t = useT();
3083
4103
  const display = metadata.title ?? prettyScreenName(screen.filename);
3084
4104
  const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
4105
+ const previewViewport = getScreenPreviewViewport(metadata, geometry);
3085
4106
  const suppressNextClick = useRef(false);
3086
- const emphasized = isActive || isSelected;
4107
+ const [directlyHovered, setDirectlyHovered] = useState(false);
4108
+ const frameDirectlyHovered =
4109
+ (directlyHovered || isDirectlyHovered) &&
4110
+ !creationToolActive &&
4111
+ !canvasGestureActive;
4112
+ const emphasized = isSelected || frameDirectlyHovered;
4113
+ const fullViewVisible = emphasized || showFullView;
4114
+ const activeOrEmphasized = isActive || emphasized;
3087
4115
  const selectionOutlined = isSelected && !groupSelected;
3088
- const showHoverOutline =
3089
- !creationToolActive && (!isSelected || !groupSelected);
4116
+ const showHoverChrome =
4117
+ frameDirectlyHovered && !isSelected && !groupSelected && !hasHoveredChild;
4118
+ const screenContentInteractive =
4119
+ Boolean(screenContent) &&
4120
+ !penActive &&
4121
+ !creationToolActive &&
4122
+ !canvasGestureActive;
4123
+ const updateDirectHover = useCallback((next: boolean) => {
4124
+ setDirectlyHovered((current) => (current === next ? current : next));
4125
+ }, []);
4126
+ const frameLabelHeight = FRAME_LABEL_HEIGHT * chromeScale;
4127
+ const frameScreenWidth = geometry.width / Math.max(chromeScale, 0.001);
4128
+ const fullViewOutsideFrame =
4129
+ frameScreenWidth < FRAME_HEADER_BUTTON_OUTSIDE_WIDTH;
4130
+ const labelInfoMaxWidth = Math.max(
4131
+ 64,
4132
+ frameScreenWidth - (fullViewOutsideFrame ? 8 : FRAME_HEADER_BUTTON_RESERVE),
4133
+ );
4134
+ const estimatedTitleWidth = clamp(
4135
+ display.length * FRAME_HEADER_TITLE_CHAR_WIDTH,
4136
+ FRAME_HEADER_MIN_TITLE_WIDTH,
4137
+ FRAME_HEADER_MAX_TITLE_WIDTH,
4138
+ );
4139
+ const showDimensions =
4140
+ frameScreenWidth >=
4141
+ estimatedTitleWidth +
4142
+ FRAME_HEADER_DIMENSIONS_WIDTH +
4143
+ (fullViewOutsideFrame ? 16 : FRAME_HEADER_BUTTON_RESERVE);
4144
+ const fullViewMaxWidth = fullViewOutsideFrame
4145
+ ? 120
4146
+ : Math.max(84, Math.min(180, frameScreenWidth * 0.46));
3090
4147
 
3091
4148
  return (
3092
4149
  <div
@@ -3094,17 +4151,32 @@ function Screen({
3094
4151
  className="group/frame pointer-events-auto absolute"
3095
4152
  style={{
3096
4153
  left: SURFACE_PADDING + geometry.x,
3097
- top: SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT,
4154
+ top: SURFACE_PADDING + geometry.y - frameLabelHeight,
3098
4155
  width: geometry.width,
3099
4156
  transform: geometry.rotation
3100
4157
  ? `rotate(${geometry.rotation}deg)`
3101
4158
  : undefined,
3102
- transformOrigin: `${geometry.width / 2}px ${FRAME_LABEL_HEIGHT + geometry.height / 2}px`,
4159
+ transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
3103
4160
  zIndex: geometry.z,
3104
4161
  }}
3105
4162
  >
3106
4163
  <div
3107
- className="flex h-7 w-full cursor-default items-center justify-between gap-2 px-1"
4164
+ className="relative w-full cursor-default"
4165
+ style={{ height: frameLabelHeight }}
4166
+ onClick={(e) => {
4167
+ e.stopPropagation();
4168
+ if (suppressNextClick.current) {
4169
+ suppressNextClick.current = false;
4170
+ return;
4171
+ }
4172
+ if (e.detail > 1) return;
4173
+ onPick(screen.id, e);
4174
+ }}
4175
+ onDoubleClick={(e) => {
4176
+ e.preventDefault();
4177
+ e.stopPropagation();
4178
+ onEdit(screen.id, e);
4179
+ }}
3108
4180
  onMouseDown={(e) => {
3109
4181
  if (e.button !== 0) return;
3110
4182
  if (penActive || creationToolActive) return;
@@ -3114,13 +4186,23 @@ function Screen({
3114
4186
  }
3115
4187
  if (e.shiftKey) {
3116
4188
  e.stopPropagation();
3117
- onPick(screen.id, e);
3118
4189
  return;
3119
4190
  }
3120
4191
  onStartFrameDrag(screen.id, e);
3121
4192
  }}
3122
4193
  >
3123
- <div className="flex min-w-0 items-center gap-1.5">
4194
+ <div
4195
+ data-frame-label
4196
+ className="absolute left-1 top-1/2 flex min-w-0 items-center gap-1.5"
4197
+ onMouseEnter={() => updateDirectHover(true)}
4198
+ onMouseLeave={() => updateDirectHover(false)}
4199
+ style={{
4200
+ maxWidth: labelInfoMaxWidth,
4201
+ transform: `translateY(-50%) scale(${chromeScale})`,
4202
+ transformOrigin: "left center",
4203
+ transition: getChromeLabelTransition(chromeSettling),
4204
+ }}
4205
+ >
3124
4206
  <span
3125
4207
  className={cn(
3126
4208
  "h-1.5 w-1.5 shrink-0 rounded-full",
@@ -3128,26 +4210,45 @@ function Screen({
3128
4210
  )}
3129
4211
  />
3130
4212
  <span
4213
+ data-frame-title
3131
4214
  className={cn(
3132
- "truncate text-[11px] font-medium",
3133
- emphasized ? "text-foreground" : "text-muted-foreground",
4215
+ "min-w-0 truncate text-[11px] font-medium",
4216
+ emphasized
4217
+ ? "text-[var(--design-editor-accent-color)]"
4218
+ : activeOrEmphasized
4219
+ ? "text-foreground"
4220
+ : "text-muted-foreground",
3134
4221
  )}
3135
4222
  title={screen.filename}
3136
4223
  >
3137
4224
  {display}
3138
4225
  </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>
4226
+ {showDimensions ? (
4227
+ <span
4228
+ data-frame-dimensions
4229
+ className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline"
4230
+ >
4231
+ {previewViewport.displayWidth} x {previewViewport.displayHeight}
4232
+ </span>
4233
+ ) : null}
3142
4234
  </div>
3143
4235
  <button
3144
4236
  type="button"
4237
+ data-frame-full-view
3145
4238
  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",
4239
+ "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
4240
  "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
- emphasized && "opacity-100",
4241
+ fullViewVisible && "opacity-100",
4242
+ fullViewOutsideFrame ? "left-full" : "right-1",
3150
4243
  )}
4244
+ style={{
4245
+ maxWidth: fullViewMaxWidth,
4246
+ transform: `translateY(-50%) scale(${chromeScale})`,
4247
+ transformOrigin: fullViewOutsideFrame
4248
+ ? "left center"
4249
+ : "right center",
4250
+ transition: getChromeLabelTransition(chromeSettling),
4251
+ }}
3151
4252
  aria-label={t("multiScreenCanvas.fullView")}
3152
4253
  title={t("multiScreenCanvas.fullView")}
3153
4254
  onClick={(event) => onEdit(screen.id, event)}
@@ -3155,6 +4256,8 @@ function Screen({
3155
4256
  event.preventDefault();
3156
4257
  event.stopPropagation();
3157
4258
  }}
4259
+ onMouseEnter={() => updateDirectHover(true)}
4260
+ onMouseLeave={() => updateDirectHover(false)}
3158
4261
  >
3159
4262
  <IconMaximize className="size-3 shrink-0" />
3160
4263
  <span className="truncate">{t("multiScreenCanvas.fullView")}</span>
@@ -3165,7 +4268,10 @@ function Screen({
3165
4268
  role="button"
3166
4269
  tabIndex={0}
3167
4270
  onClick={(e) => {
3168
- if (isInteractiveScreenContentTarget(e.target)) return;
4271
+ if (isInteractiveScreenContentTarget(e.target)) {
4272
+ e.stopPropagation();
4273
+ return;
4274
+ }
3169
4275
  e.stopPropagation();
3170
4276
  if (suppressNextClick.current) {
3171
4277
  suppressNextClick.current = false;
@@ -3175,13 +4281,19 @@ function Screen({
3175
4281
  onPick(screen.id, e);
3176
4282
  }}
3177
4283
  onDoubleClick={(e) => {
3178
- if (isInteractiveScreenContentTarget(e.target)) return;
4284
+ if (isInteractiveScreenContentTarget(e.target)) {
4285
+ e.stopPropagation();
4286
+ return;
4287
+ }
3179
4288
  e.preventDefault();
3180
4289
  e.stopPropagation();
3181
4290
  }}
3182
4291
  onMouseDown={(e) => {
4292
+ if (isInteractiveScreenContentTarget(e.target)) {
4293
+ e.stopPropagation();
4294
+ return;
4295
+ }
3183
4296
  if (creationToolActive) return;
3184
- if (isInteractiveScreenContentTarget(e.target)) return;
3185
4297
  if (e.detail > 1) {
3186
4298
  e.stopPropagation();
3187
4299
  return;
@@ -3200,46 +4312,38 @@ function Screen({
3200
4312
  onStartFrameDrag(screen.id, e);
3201
4313
  }
3202
4314
  }}
4315
+ onMouseMove={(e) => {
4316
+ updateDirectHover(
4317
+ isDirectScreenHoverTarget(e.target, e.currentTarget),
4318
+ );
4319
+ }}
4320
+ onMouseLeave={() => updateDirectHover(false)}
3203
4321
  className={cn(
3204
4322
  "group/artboard relative block overflow-visible rounded-lg bg-background text-left outline-none transition-colors",
3205
4323
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
3206
4324
  emphasized
3207
4325
  ? "text-foreground"
3208
- : "text-muted-foreground hover:text-foreground",
4326
+ : cn(
4327
+ "text-muted-foreground",
4328
+ showHoverChrome && "hover:text-foreground",
4329
+ ),
3209
4330
  )}
3210
4331
  style={{
3211
4332
  width: geometry.width,
3212
4333
  height: geometry.height,
3213
- cursor:
3214
- penActive || creationToolActive
3215
- ? "crosshair"
3216
- : isSelected
3217
- ? "move"
3218
- : "pointer",
4334
+ cursor: penActive || creationToolActive ? "crosshair" : "pointer",
3219
4335
  touchAction: "none",
3220
4336
  }}
3221
4337
  >
3222
4338
  <span
3223
- className={cn(
3224
- "pointer-events-none absolute border transition-opacity",
3225
- selectionOutlined
3226
- ? "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",
3230
- )}
3231
- style={{
3232
- inset: -5 * chromeScale,
3233
- borderRadius: 13 * chromeScale,
3234
- borderWidth: 1.5 * chromeScale,
3235
- }}
3236
- />
3237
- <span
4339
+ data-screen-content
3238
4340
  className={cn(
3239
4341
  "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",
4342
+ showHoverChrome
4343
+ ? "border-[var(--design-editor-accent-color)]"
4344
+ : "border-border",
3241
4345
  )}
3242
- style={{ pointerEvents: penActive ? "none" : undefined }}
4346
+ style={{ pointerEvents: screenContentInteractive ? "auto" : "none" }}
3243
4347
  >
3244
4348
  {screenContent ?? (
3245
4349
  <iframe
@@ -3249,12 +4353,24 @@ function Screen({
3249
4353
  loading="lazy"
3250
4354
  className="pointer-events-none border-0"
3251
4355
  style={{
3252
- width: metadata.width,
3253
- height: metadata.height,
3254
- transform: `scale(${geometry.width / metadata.width}, ${
3255
- geometry.height / metadata.height
3256
- })`,
4356
+ width: previewViewport.viewportWidth,
4357
+ height: previewViewport.viewportHeight,
4358
+ // Untouched same-aspect overview thumbnails may scale uniformly.
4359
+ // User-resized frames use their real iframe viewport so
4360
+ // responsive layouts recompute instead of getting stretched.
4361
+ transform:
4362
+ previewViewport.scale === 1
4363
+ ? undefined
4364
+ : `scale(${previewViewport.scale})`,
3257
4365
  transformOrigin: "top left",
4366
+ backgroundColor: "white",
4367
+ colorScheme: "light",
4368
+ // Prevent the browser from discarding the composited layer at
4369
+ // fractional zoom levels, which causes the iframe to go black.
4370
+ // backface-visibility:hidden forces the browser to keep the
4371
+ // backing store alive even when the effective scale is very small
4372
+ // (e.g. 0.25 iframe scale × 0.5 canvas zoom = 0.125 total).
4373
+ backfaceVisibility: "hidden",
3258
4374
  }}
3259
4375
  title={screen.filename}
3260
4376
  />
@@ -3265,50 +4381,320 @@ function Screen({
3265
4381
  aria-hidden="true"
3266
4382
  />
3267
4383
  ) : null}
4384
+ {canvasGestureActive && screenContent ? (
4385
+ <span
4386
+ data-screen-interaction-shield
4387
+ className="pointer-events-auto absolute inset-0 z-30"
4388
+ aria-hidden="true"
4389
+ />
4390
+ ) : null}
3268
4391
  <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
3269
4392
  </span>
3270
4393
  <ResizeHandles
3271
- active={selectionOutlined}
3272
- enabled={!penActive && !creationToolActive && handlesEnabled}
4394
+ active={false}
4395
+ enabled={
4396
+ !selectionOutlined &&
4397
+ !penActive &&
4398
+ !creationToolActive &&
4399
+ handlesEnabled
4400
+ }
4401
+ showOnHover={false}
3273
4402
  showRotate
3274
4403
  chromeScale={chromeScale}
4404
+ chromeSettling={chromeSettling}
3275
4405
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
3276
4406
  onStartRotate={(e) => onStartRotate(screen.id, e)}
3277
4407
  />
3278
4408
  </div>
4409
+
4410
+ {/* Multi-breakpoint preview row (§6.4 — Framer/Figma-Sites style).
4411
+ Rendered as a sibling row to the right of the primary frame when
4412
+ the screen has breakpointWidths set. Each frame shares the same
4413
+ srcdoc content at a different viewport width. The active breakpoint
4414
+ is highlighted and clicking a frame header sets the edit scope. */}
4415
+ {screen.breakpointWidths && screen.breakpointWidths.length > 0 ? (
4416
+ <BreakpointPreviewRow
4417
+ screen={screen}
4418
+ primaryGeometry={geometry}
4419
+ previewUrl={previewUrl}
4420
+ activeBreakpointWidth={screen.activeBreakpointWidth}
4421
+ penActive={penActive}
4422
+ creationToolActive={creationToolActive}
4423
+ chromeScale={chromeScale}
4424
+ onActiveBreakpointChange={onActiveBreakpointChange}
4425
+ onAddBreakpoint={onAddBreakpoint}
4426
+ />
4427
+ ) : null}
3279
4428
  </div>
3280
4429
  );
4430
+ }, areScreenPropsEqual);
4431
+
4432
+ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
4433
+ return (
4434
+ prev.screen === next.screen &&
4435
+ prev.screenContent === next.screenContent &&
4436
+ sameResolvedMetadata(prev.metadata, next.metadata) &&
4437
+ sameFrameGeometry(prev.geometry, next.geometry) &&
4438
+ prev.isActive === next.isActive &&
4439
+ prev.isSelected === next.isSelected &&
4440
+ prev.showFullView === next.showFullView &&
4441
+ prev.isDirectlyHovered === next.isDirectlyHovered &&
4442
+ prev.hasHoveredChild === next.hasHoveredChild &&
4443
+ prev.groupSelected === next.groupSelected &&
4444
+ prev.handlesEnabled === next.handlesEnabled &&
4445
+ prev.penActive === next.penActive &&
4446
+ prev.creationToolActive === next.creationToolActive &&
4447
+ prev.canvasGestureActive === next.canvasGestureActive &&
4448
+ prev.chromeScale === next.chromeScale &&
4449
+ prev.chromeSettling === next.chromeSettling &&
4450
+ prev.onPick === next.onPick &&
4451
+ prev.onEdit === next.onEdit &&
4452
+ prev.onStartFrameDrag === next.onStartFrameDrag &&
4453
+ prev.onStartResize === next.onStartResize &&
4454
+ prev.onStartRotate === next.onStartRotate &&
4455
+ prev.onStartDuplicateGesture === next.onStartDuplicateGesture
4456
+ );
4457
+ }
4458
+
4459
+ // ── Breakpoint preview row (§6.4) ────────────────────────────────────────────
4460
+
4461
+ /** Gap between adjacent breakpoint frames in canvas pixels. */
4462
+ const BREAKPOINT_FRAME_GAP = 24;
4463
+
4464
+ function BreakpointPreviewRow({
4465
+ screen,
4466
+ primaryGeometry,
4467
+ previewUrl,
4468
+ activeBreakpointWidth,
4469
+ penActive,
4470
+ creationToolActive,
4471
+ chromeScale,
4472
+ onActiveBreakpointChange,
4473
+ onAddBreakpoint,
4474
+ }: {
4475
+ screen: ScreenFile;
4476
+ primaryGeometry: FrameGeometry;
4477
+ previewUrl: string | undefined;
4478
+ activeBreakpointWidth: number | undefined;
4479
+ penActive: boolean;
4480
+ creationToolActive: boolean;
4481
+ chromeScale: number;
4482
+ onActiveBreakpointChange?: (widthPx: number | undefined) => void;
4483
+ onAddBreakpoint?: (widthPx: number) => void;
4484
+ }) {
4485
+ const breakpointWidths = screen.breakpointWidths ?? [];
4486
+ // Scale factor used to shrink additional frames to match the primary frame height
4487
+ const scaleY = primaryGeometry.height / (primaryGeometry.width || 1);
4488
+ // Place additional frames to the right of the primary, starting after the gap
4489
+ let offsetX = primaryGeometry.width + BREAKPOINT_FRAME_GAP;
4490
+
4491
+ const nextWidth = nextBreakpointWidth(breakpointWidths);
4492
+
4493
+ return (
4494
+ <>
4495
+ {breakpointWidths.map((widthPx) => {
4496
+ // Scale the additional frame proportionally (same height as primary)
4497
+ const frameWidth = Math.round(widthPx * scaleY);
4498
+ const frameHeight = primaryGeometry.height;
4499
+ const isActive = activeBreakpointWidth === widthPx;
4500
+ const currentOffsetX = offsetX;
4501
+ offsetX += frameWidth + BREAKPOINT_FRAME_GAP;
4502
+
4503
+ return (
4504
+ <div
4505
+ key={widthPx}
4506
+ data-frame-shell
4507
+ data-breakpoint-frame
4508
+ className="pointer-events-auto absolute"
4509
+ // Positioned relative to the parent Screen wrapper, which is already
4510
+ // absolute at left: SURFACE_PADDING + geometry.x / top:
4511
+ // SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT. Re-adding those
4512
+ // surface/primary terms here would double-offset every breakpoint
4513
+ // frame (~240px+ down-right), so we offset only within the wrapper.
4514
+ style={{
4515
+ left: currentOffsetX,
4516
+ top: -FRAME_LABEL_HEIGHT,
4517
+ width: frameWidth,
4518
+ zIndex: primaryGeometry.z,
4519
+ }}
4520
+ >
4521
+ {/* Frame label row */}
4522
+ <div
4523
+ className={cn(
4524
+ "flex h-7 w-full items-center justify-between gap-1 px-1 cursor-pointer select-none",
4525
+ )}
4526
+ onClick={(e) => {
4527
+ e.stopPropagation();
4528
+ onActiveBreakpointChange?.(isActive ? undefined : widthPx);
4529
+ }}
4530
+ >
4531
+ <div className="flex min-w-0 items-center gap-1.5">
4532
+ <span
4533
+ className={cn(
4534
+ "h-1.5 w-1.5 shrink-0 rounded-full",
4535
+ isActive
4536
+ ? "bg-[var(--design-editor-accent-color)]"
4537
+ : "bg-muted-foreground/40",
4538
+ )}
4539
+ />
4540
+ <span
4541
+ className={cn(
4542
+ "truncate text-[11px] font-medium",
4543
+ isActive
4544
+ ? "text-[var(--design-editor-accent-color)]"
4545
+ : "text-muted-foreground",
4546
+ )}
4547
+ >
4548
+ {breakpointLabel(widthPx)}
4549
+ </span>
4550
+ <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
4551
+ {widthPx}px
4552
+ </span>
4553
+ </div>
4554
+ </div>
4555
+ {/* Frame card */}
4556
+ <div
4557
+ className={cn(
4558
+ "relative block overflow-hidden rounded-lg border bg-white shadow-lg transition-colors",
4559
+ isActive
4560
+ ? "border-[var(--design-editor-accent-color)]"
4561
+ : "border-border",
4562
+ )}
4563
+ style={{ width: frameWidth, height: frameHeight }}
4564
+ >
4565
+ <iframe
4566
+ src={previewUrl}
4567
+ srcDoc={previewUrl ? undefined : screen.content}
4568
+ sandbox="allow-scripts"
4569
+ loading="lazy"
4570
+ className="pointer-events-none border-0"
4571
+ style={{
4572
+ width: widthPx,
4573
+ height: Math.round(
4574
+ widthPx * (primaryGeometry.height / primaryGeometry.width),
4575
+ ),
4576
+ transform: `scale(${frameWidth / widthPx}, ${frameHeight / Math.round(widthPx * (primaryGeometry.height / primaryGeometry.width))})`,
4577
+ transformOrigin: "top left",
4578
+ }}
4579
+ title={`${screen.filename} — ${breakpointLabel(widthPx)}`}
4580
+ />
4581
+ {creationToolActive || penActive ? (
4582
+ <span className="absolute inset-0 z-20 cursor-crosshair" />
4583
+ ) : null}
4584
+ <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
4585
+ </div>
4586
+ </div>
4587
+ );
4588
+ })}
4589
+
4590
+ {/* + affordance: add the next standard breakpoint */}
4591
+ {onAddBreakpoint && nextWidth !== undefined ? (
4592
+ <div
4593
+ className="pointer-events-auto absolute flex items-center"
4594
+ // Same wrapper-relative coordinate space as the breakpoint frames
4595
+ // above — no SURFACE_PADDING / primaryGeometry.x/y terms or it would
4596
+ // double-offset.
4597
+ style={{
4598
+ left: offsetX,
4599
+ top: -FRAME_LABEL_HEIGHT + primaryGeometry.height / 2,
4600
+ zIndex: primaryGeometry.z,
4601
+ }}
4602
+ >
4603
+ <button
4604
+ type="button"
4605
+ className={cn(
4606
+ "flex size-7 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-sm transition-colors",
4607
+ "hover:border-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
4608
+ )}
4609
+ style={{
4610
+ transform: `scale(${chromeScale})`,
4611
+ transformOrigin: "center",
4612
+ }}
4613
+ title={`Add ${breakpointLabel(nextWidth)} breakpoint (${nextWidth}px)`}
4614
+ onClick={(e) => {
4615
+ e.stopPropagation();
4616
+ onAddBreakpoint(nextWidth);
4617
+ }}
4618
+ >
4619
+ <IconPlus className="size-3.5" />
4620
+ </button>
4621
+ </div>
4622
+ ) : null}
4623
+ </>
4624
+ );
3281
4625
  }
3282
4626
 
3283
4627
  function GroupSelectionBox({
3284
4628
  bounds,
3285
4629
  chromeScale,
4630
+ chromeSettling,
3286
4631
  onStartResize,
3287
4632
  }: {
3288
4633
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
3289
4634
  chromeScale: number;
4635
+ chromeSettling: boolean;
4636
+ onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
4637
+ }) {
4638
+ return (
4639
+ <SelectionBox
4640
+ geometry={{
4641
+ x: bounds.left,
4642
+ y: bounds.top,
4643
+ width: bounds.width,
4644
+ height: bounds.height,
4645
+ }}
4646
+ chromeScale={chromeScale}
4647
+ chromeSettling={chromeSettling}
4648
+ showRotate={false}
4649
+ onStartResize={onStartResize}
4650
+ onStartRotate={() => {}}
4651
+ />
4652
+ );
4653
+ }
4654
+
4655
+ function SelectionBox({
4656
+ geometry,
4657
+ chromeScale,
4658
+ chromeSettling,
4659
+ showRotate = true,
4660
+ onStartResize,
4661
+ onStartRotate,
4662
+ }: {
4663
+ geometry: FrameGeometry;
4664
+ chromeScale: number;
4665
+ chromeSettling: boolean;
4666
+ showRotate?: boolean;
3290
4667
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
4668
+ onStartRotate: (e: React.MouseEvent) => void;
3291
4669
  }) {
3292
4670
  return (
3293
4671
  <div
4672
+ data-frame-selection-box
3294
4673
  data-frame-shell
3295
- className="pointer-events-none absolute z-30 border border-[var(--design-editor-accent-color)]"
4674
+ className="pointer-events-none absolute border border-[var(--design-editor-accent-color)]"
3296
4675
  style={{
3297
- left: SURFACE_PADDING + bounds.left,
3298
- top: SURFACE_PADDING + bounds.top,
3299
- width: bounds.width,
3300
- height: bounds.height,
4676
+ left: SURFACE_PADDING + geometry.x,
4677
+ top: SURFACE_PADDING + geometry.y,
4678
+ width: geometry.width,
4679
+ height: geometry.height,
3301
4680
  borderRadius: 13 * chromeScale,
3302
4681
  borderWidth: 1.5 * chromeScale,
4682
+ transition: getSelectionBoxTransition(chromeSettling),
4683
+ transform: geometry.rotation
4684
+ ? `rotate(${geometry.rotation}deg)`
4685
+ : undefined,
4686
+ transformOrigin: `${geometry.width / 2}px ${geometry.height / 2}px`,
4687
+ zIndex: 1_000_000,
3303
4688
  }}
3304
4689
  >
3305
4690
  <ResizeHandles
3306
4691
  active
3307
4692
  enabled
3308
- showRotate={false}
4693
+ showRotate={showRotate}
3309
4694
  chromeScale={chromeScale}
4695
+ chromeSettling={chromeSettling}
3310
4696
  onStartResize={onStartResize}
3311
- onStartRotate={() => {}}
4697
+ onStartRotate={onStartRotate}
3312
4698
  />
3313
4699
  </div>
3314
4700
  );
@@ -3317,15 +4703,19 @@ function GroupSelectionBox({
3317
4703
  function ResizeHandles({
3318
4704
  active,
3319
4705
  enabled,
4706
+ showOnHover = true,
3320
4707
  showRotate = true,
3321
4708
  chromeScale = 1,
4709
+ chromeSettling = false,
3322
4710
  onStartResize,
3323
4711
  onStartRotate,
3324
4712
  }: {
3325
4713
  active: boolean;
3326
4714
  enabled: boolean;
4715
+ showOnHover?: boolean;
3327
4716
  showRotate?: boolean;
3328
4717
  chromeScale?: number;
4718
+ chromeSettling?: boolean;
3329
4719
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
3330
4720
  onStartRotate: (e: React.MouseEvent) => void;
3331
4721
  }) {
@@ -3335,7 +4725,11 @@ function ResizeHandles({
3335
4725
  "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
4726
  active
3337
4727
  ? "opacity-100"
3338
- : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4728
+ : cn(
4729
+ "opacity-0",
4730
+ showOnHover &&
4731
+ "group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4732
+ ),
3339
4733
  );
3340
4734
  const edgeHandleClass =
3341
4735
  "pointer-events-auto absolute z-10 bg-transparent opacity-0";
@@ -3345,18 +4739,28 @@ function ResizeHandles({
3345
4739
  {EDGE_RESIZE_HANDLE_CONFIGS.map((config) => (
3346
4740
  <span
3347
4741
  key={config.handle}
3348
- data-resize-handle
4742
+ data-resize-handle={config.handle}
3349
4743
  className={edgeHandleClass}
3350
- style={edgeHandleStyle(config.handle, config.cursor, chromeScale)}
4744
+ style={edgeHandleStyle(
4745
+ config.handle,
4746
+ config.cursor,
4747
+ chromeScale,
4748
+ chromeSettling,
4749
+ )}
3351
4750
  onMouseDown={(e) => onStartResize(config.handle, e)}
3352
4751
  />
3353
4752
  ))}
3354
4753
  {CORNER_RESIZE_HANDLE_CONFIGS.map((config) => (
3355
4754
  <span
3356
4755
  key={config.handle}
3357
- data-resize-handle
4756
+ data-resize-handle={config.handle}
3358
4757
  className={visibleHandleClass}
3359
- style={cornerHandleStyle(config.handle, config.cursor, chromeScale)}
4758
+ style={cornerHandleStyle(
4759
+ config.handle,
4760
+ config.cursor,
4761
+ chromeScale,
4762
+ chromeSettling,
4763
+ )}
3360
4764
  onMouseDown={(e) => onStartResize(config.handle, e)}
3361
4765
  />
3362
4766
  ))}
@@ -3369,9 +4773,17 @@ function ResizeHandles({
3369
4773
  "pointer-events-auto absolute z-10 size-5 rounded-full transition-opacity active:cursor-grabbing",
3370
4774
  active
3371
4775
  ? "opacity-100"
3372
- : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4776
+ : cn(
4777
+ "opacity-0",
4778
+ showOnHover &&
4779
+ "group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
4780
+ ),
4781
+ )}
4782
+ style={rotateHandleStyle(
4783
+ config.corner,
4784
+ chromeScale,
4785
+ chromeSettling,
3373
4786
  )}
3374
- style={rotateHandleStyle(config.corner, chromeScale)}
3375
4787
  onMouseDown={onStartRotate}
3376
4788
  />
3377
4789
  ))
@@ -3413,12 +4825,14 @@ function edgeHandleStyle(
3413
4825
  handle: ResizeHandle,
3414
4826
  cursor: string,
3415
4827
  chromeScale: number,
4828
+ chromeSettling: boolean,
3416
4829
  ): CSSProperties {
3417
4830
  const size = 14 * chromeScale;
3418
4831
  const offset = -size / 2;
3419
4832
  if (handle === "n" || handle === "s") {
3420
4833
  return {
3421
4834
  cursor,
4835
+ transition: getChromeHandleTransition(chromeSettling),
3422
4836
  left: 0,
3423
4837
  right: 0,
3424
4838
  height: size,
@@ -3427,6 +4841,7 @@ function edgeHandleStyle(
3427
4841
  }
3428
4842
  return {
3429
4843
  cursor,
4844
+ transition: getChromeHandleTransition(chromeSettling),
3430
4845
  top: 0,
3431
4846
  bottom: 0,
3432
4847
  width: size,
@@ -3438,11 +4853,13 @@ function cornerHandleStyle(
3438
4853
  handle: ResizeHandle,
3439
4854
  cursor: string,
3440
4855
  chromeScale: number,
4856
+ chromeSettling: boolean,
3441
4857
  ): CSSProperties {
3442
4858
  const size = 10 * chromeScale;
3443
4859
  const offset = -size / 2;
3444
4860
  return {
3445
4861
  cursor,
4862
+ transition: getChromeHandleTransition(chromeSettling),
3446
4863
  width: size,
3447
4864
  height: size,
3448
4865
  borderWidth: Math.max(1, 1.25 * chromeScale),
@@ -3451,11 +4868,16 @@ function cornerHandleStyle(
3451
4868
  };
3452
4869
  }
3453
4870
 
3454
- function rotateHandleStyle(corner: string, chromeScale: number): CSSProperties {
4871
+ function rotateHandleStyle(
4872
+ corner: string,
4873
+ chromeScale: number,
4874
+ chromeSettling: boolean,
4875
+ ): CSSProperties {
3455
4876
  const size = 28 * chromeScale;
3456
4877
  const offset = -34 * chromeScale;
3457
4878
  return {
3458
4879
  cursor: "grab",
4880
+ transition: getChromeHandleTransition(chromeSettling),
3459
4881
  width: size,
3460
4882
  height: size,
3461
4883
  ...(corner.includes("n") ? { top: offset } : { bottom: offset }),
@@ -3475,7 +4897,15 @@ interface BoundsRect {
3475
4897
  bottom: number;
3476
4898
  }
3477
4899
 
3478
- function getInitialFrameGeometry(index: number): FrameGeometry {
4900
+ interface ScreenViewportSize {
4901
+ width: number;
4902
+ height: number;
4903
+ }
4904
+
4905
+ function getInitialFrameGeometry(
4906
+ index: number,
4907
+ metadata?: ScreenViewportSize,
4908
+ ): FrameGeometry {
3479
4909
  // Seed default frames with the actual screen dimensions and the 3-column grid
3480
4910
  // the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
3481
4911
  // a design without persisted geometry opens centered. (The larger
@@ -3483,22 +4913,80 @@ function getInitialFrameGeometry(index: number): FrameGeometry {
3483
4913
  // editor's default placement.)
3484
4914
  const column = index % 3;
3485
4915
  const row = Math.floor(index / 3);
4916
+ const height = getOverviewFrameHeight(SCREEN_WIDTH, metadata);
3486
4917
  return {
3487
4918
  x: column * (SCREEN_WIDTH + SCREEN_GAP),
3488
- y: row * (SCREEN_CARD_HEIGHT + SCREEN_GAP),
4919
+ y: row * (height + FRAME_LABEL_HEIGHT + SCREEN_GAP),
3489
4920
  width: SCREEN_WIDTH,
3490
- height: SCREEN_HEIGHT,
4921
+ height,
3491
4922
  };
3492
4923
  }
3493
4924
 
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
- }));
4925
+ function getOverviewFrameHeight(width: number, metadata?: ScreenViewportSize) {
4926
+ const sourceWidth =
4927
+ metadata?.width && metadata.width > 0 ? metadata.width : 1280;
4928
+ const sourceHeight =
4929
+ metadata?.height && metadata.height > 0 ? metadata.height : 2560;
4930
+ return Math.max(80, Math.round((width * sourceHeight) / sourceWidth));
4931
+ }
4932
+
4933
+ export function getPreviewDeviceFrameGeometry({
4934
+ currentGeometry,
4935
+ metadata,
4936
+ previewDeviceFrame,
4937
+ }: {
4938
+ currentGeometry: FrameGeometry;
4939
+ metadata?: { width: number; height: number };
4940
+ previewDeviceFrame: DeviceFrameType;
4941
+ }): FrameGeometry {
4942
+ if (previewDeviceFrame === "none") {
4943
+ return {
4944
+ ...currentGeometry,
4945
+ height: getOverviewFrameHeight(currentGeometry.width, metadata),
4946
+ };
4947
+ }
4948
+
4949
+ const viewport = DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
4950
+ return {
4951
+ ...currentGeometry,
4952
+ width: Math.max(1, Math.round(metadata?.width ?? viewport.width)),
4953
+ height: Math.max(1, Math.round(metadata?.height ?? viewport.height)),
4954
+ };
4955
+ }
4956
+
4957
+ function getScreenPreviewViewport(
4958
+ metadata: ScreenViewportSize,
4959
+ geometry: ScreenViewportSize,
4960
+ ) {
4961
+ const metadataWidth = Math.max(1, Math.round(metadata.width));
4962
+ const metadataHeight = Math.max(1, Math.round(metadata.height));
4963
+ const geometryWidth = Math.max(1, Math.round(geometry.width));
4964
+ const geometryHeight = Math.max(1, Math.round(geometry.height));
4965
+ const metadataAspect = metadataWidth / metadataHeight;
4966
+ const geometryAspect = geometryWidth / geometryHeight;
4967
+ const aspectMatches = Math.abs(metadataAspect - geometryAspect) < 0.005;
4968
+
4969
+ if (aspectMatches) {
4970
+ return {
4971
+ viewportWidth: metadataWidth,
4972
+ viewportHeight: metadataHeight,
4973
+ displayWidth: metadataWidth,
4974
+ displayHeight: metadataHeight,
4975
+ scale:
4976
+ Math.abs(metadataWidth - geometryWidth) < 0.5 &&
4977
+ Math.abs(metadataHeight - geometryHeight) < 0.5
4978
+ ? 1
4979
+ : geometryWidth / metadataWidth,
4980
+ };
4981
+ }
4982
+
4983
+ return {
4984
+ viewportWidth: geometryWidth,
4985
+ viewportHeight: geometryHeight,
4986
+ displayWidth: geometryWidth,
4987
+ displayHeight: geometryHeight,
4988
+ scale: 1,
4989
+ };
3502
4990
  }
3503
4991
 
3504
4992
  function dedupeIds(ids: string[]) {
@@ -3556,6 +5044,22 @@ function isInteractiveScreenContentTarget(target: EventTarget | null) {
3556
5044
  );
3557
5045
  }
3558
5046
 
5047
+ function mutePreviewIframePointerEvents(root: HTMLElement | null) {
5048
+ if (!root) return () => {};
5049
+ const previous = new Map<HTMLIFrameElement, string>();
5050
+ root
5051
+ .querySelectorAll<HTMLIFrameElement>("[data-design-preview-iframe]")
5052
+ .forEach((iframe) => {
5053
+ previous.set(iframe, iframe.style.pointerEvents);
5054
+ iframe.style.pointerEvents = "none";
5055
+ });
5056
+ return () => {
5057
+ previous.forEach((pointerEvents, iframe) => {
5058
+ if (iframe.isConnected) iframe.style.pointerEvents = pointerEvents;
5059
+ });
5060
+ };
5061
+ }
5062
+
3559
5063
  function frameBoundsToGeometry(bounds: {
3560
5064
  left: number;
3561
5065
  top: number;
@@ -3697,6 +5201,20 @@ function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
3697
5201
  );
3698
5202
  }
3699
5203
 
5204
+ function sameResolvedMetadata(
5205
+ a: ResolvedScreenMetadata,
5206
+ b: ResolvedScreenMetadata,
5207
+ ) {
5208
+ return (
5209
+ a.source === b.source &&
5210
+ a.previewState === b.previewState &&
5211
+ a.title === b.title &&
5212
+ a.width === b.width &&
5213
+ a.height === b.height &&
5214
+ a.previewUrl === b.previewUrl
5215
+ );
5216
+ }
5217
+
3700
5218
  function cloneFrameGeometryById(
3701
5219
  geometryById: FrameGeometryById,
3702
5220
  ): FrameGeometryById {
@@ -4124,24 +5642,37 @@ function resolveScreenMetadata(
4124
5642
  screen: ScreenFile,
4125
5643
  keyedMetadata?: ScreenMetadata,
4126
5644
  getterMetadata?: ScreenMetadata,
5645
+ previewDeviceFrame: DeviceFrameType = "none",
4127
5646
  ): ResolvedScreenMetadata {
4128
- const metadata = { ...screen, ...keyedMetadata, ...getterMetadata };
5647
+ // Normalize content to a string up front: a screen whose content has not yet
5648
+ // loaded (or is otherwise not a plain string) must not crash the overview
5649
+ // render via the content.trim()/slice() helpers below.
5650
+ const safeScreen: ScreenFile =
5651
+ typeof screen.content === "string" ? screen : { ...screen, content: "" };
5652
+ const metadata = { ...safeScreen, ...keyedMetadata, ...getterMetadata };
4129
5653
  const previewUrl =
4130
5654
  metadata.url ??
4131
5655
  metadata.previewUrl ??
4132
- screen.previewUrl ??
4133
- getPreviewUrl(screen.content);
4134
- const width = metadata.width && metadata.width > 0 ? metadata.width : 1280;
5656
+ safeScreen.previewUrl ??
5657
+ getPreviewUrl(safeScreen.content);
5658
+ const deviceViewport =
5659
+ previewDeviceFrame === "none"
5660
+ ? undefined
5661
+ : DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
5662
+ const width =
5663
+ deviceViewport?.width ??
5664
+ (metadata.width && metadata.width > 0 ? metadata.width : 1280);
4135
5665
  const height =
4136
- metadata.height && metadata.height > 0 ? metadata.height : 2560;
5666
+ deviceViewport?.height ??
5667
+ (metadata.height && metadata.height > 0 ? metadata.height : 2560);
4137
5668
  return {
4138
5669
  source:
4139
5670
  normalizeSource(metadata.sourceType ?? metadata.source) ??
4140
- deriveSource(screen, previewUrl),
5671
+ deriveSource(safeScreen, previewUrl),
4141
5672
  previewState:
4142
5673
  normalizePreviewState(
4143
5674
  metadata.lod ?? metadata.previewState ?? metadata.status,
4144
- ) ?? derivePreviewState(screen, previewUrl),
5675
+ ) ?? derivePreviewState(safeScreen, previewUrl),
4145
5676
  title: metadata.title,
4146
5677
  width,
4147
5678
  height,
@@ -4217,7 +5748,11 @@ function derivePreviewState(
4217
5748
  }
4218
5749
 
4219
5750
  function getPreviewUrl(content: string) {
4220
- return getUrl(content.trim())?.toString();
5751
+ // Tolerate non-string content (e.g. a screen whose content has not loaded):
5752
+ // a missing URL is correct here, a crash is not.
5753
+ return getUrl(
5754
+ typeof content === "string" ? content.trim() : undefined,
5755
+ )?.toString();
4221
5756
  }
4222
5757
 
4223
5758
  function getUrl(value: string | undefined) {
@@ -4231,3 +5766,276 @@ function getUrl(value: string | undefined) {
4231
5766
  return undefined;
4232
5767
  }
4233
5768
  }
5769
+
5770
+ function clampNumber(value: number, min: number, max: number) {
5771
+ return Math.min(max, Math.max(min, value));
5772
+ }
5773
+
5774
+ // ---------------------------------------------------------------------------
5775
+ // Primitive-into-primitive drop target detection
5776
+ // ---------------------------------------------------------------------------
5777
+
5778
+ /** A committed container primitive that can accept a dropped primitive. */
5779
+ export interface PrimitiveDropTarget {
5780
+ /** The node's data-agent-native-node-id value. */
5781
+ nodeId: string;
5782
+ /** The screen frame id (ScreenFile.id) that owns this primitive. */
5783
+ screenId: string;
5784
+ /** The primitive's bounding rect in board/canvas space. */
5785
+ boardRect: FrameGeometry;
5786
+ }
5787
+
5788
+ /**
5789
+ * Parsed representation of a committed primitive found in a screen's HTML.
5790
+ * Geometry is in screen-local CSS pixels (as written by appendCanvasPrimitiveToHtml).
5791
+ */
5792
+ export interface ParsedScreenPrimitive {
5793
+ nodeId: string;
5794
+ screenId: string;
5795
+ /** Position relative to screen body (CSS px). */
5796
+ localLeft: number;
5797
+ localTop: number;
5798
+ localWidth: number;
5799
+ localHeight: number;
5800
+ isContainer: boolean;
5801
+ }
5802
+
5803
+ /** Fast djb2-variant hash of a string. Runs in O(n) but is inlined for the
5804
+ * JIT — no allocations, no imports. Produces a 32-bit unsigned integer as a
5805
+ * hex string. Used only for cache-key disambiguation, not cryptography. */
5806
+ function hashString(s: string): string {
5807
+ let h = 5381;
5808
+ for (let i = 0; i < s.length; i++) {
5809
+ // h = h * 33 ^ charCode (djb2 xor variant)
5810
+ h = ((h << 5) + h) ^ s.charCodeAt(i);
5811
+ h = h >>> 0; // keep as unsigned 32-bit
5812
+ }
5813
+ return h.toString(16);
5814
+ }
5815
+
5816
+ /** Simple LRU-style cache to avoid re-parsing the same screen HTML on every
5817
+ * mousemove frame. Keyed by `screenId:contentLength:hash` so that ANY edit
5818
+ * (including changes deep in the middle of a large HTML document) invalidates
5819
+ * the entry. A length:hash pair eliminates the collision zone that existed
5820
+ * when only prefix48+suffix48 were used — edits in `content[48..len-49]` would
5821
+ * produce the same key even though the content differed. */
5822
+ export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
5823
+ const PRIMITIVE_PARSE_CACHE_MAX = 64;
5824
+
5825
+ export function parsePrimitivesFromScreen(
5826
+ screen: ScreenFile,
5827
+ ): ParsedScreenPrimitive[] {
5828
+ const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
5829
+ const cached = primitiveParseCache.get(cacheKey);
5830
+ if (cached) return cached;
5831
+
5832
+ const result: ParsedScreenPrimitive[] = [];
5833
+ if (typeof DOMParser === "undefined" || !screen.content) {
5834
+ return result;
5835
+ }
5836
+
5837
+ try {
5838
+ const doc = new DOMParser().parseFromString(screen.content, "text/html");
5839
+ const nodes = doc.querySelectorAll("[data-agent-native-node-id]");
5840
+ nodes.forEach((el) => {
5841
+ const nodeId = el.getAttribute("data-agent-native-node-id");
5842
+ if (!nodeId) return;
5843
+
5844
+ const htmlEl = el as HTMLElement;
5845
+ const style = htmlEl.style;
5846
+ const tag = el.tagName.toLowerCase();
5847
+
5848
+ // Only block elements with explicit absolute positioning are considered
5849
+ // positioned primitives drawn by appendCanvasPrimitiveToHtml.
5850
+ if (style.position !== "absolute") return;
5851
+
5852
+ const left = parseFloat(style.left) || 0;
5853
+ const top = parseFloat(style.top) || 0;
5854
+ const width = parseFloat(style.width) || 0;
5855
+ const height = parseFloat(style.height) || 0;
5856
+
5857
+ // Validity: must have non-zero size
5858
+ if (width <= 0 || height <= 0) return;
5859
+
5860
+ // Container check:
5861
+ // - Must be a div (not svg/path/circle/polygon/etc.)
5862
+ // - Must NOT be an ellipse (border-radius:50%)
5863
+ // - NOT a text primitive (but text divs could be containers in principle;
5864
+ // we exclude them by checking for display:inline-block autoSize pattern)
5865
+ const isDiv = tag === "div";
5866
+ const isEllipse =
5867
+ style.borderRadius === "50%" ||
5868
+ style.borderRadius === "50% 50% 50% 50%";
5869
+ const isTextAutoSize = style.display === "inline-block";
5870
+ const isContainer = isDiv && !isEllipse && !isTextAutoSize;
5871
+
5872
+ result.push({
5873
+ nodeId,
5874
+ screenId: screen.id,
5875
+ localLeft: left,
5876
+ localTop: top,
5877
+ localWidth: width,
5878
+ localHeight: height,
5879
+ isContainer,
5880
+ });
5881
+ });
5882
+ } catch {
5883
+ // Silently ignore parse errors
5884
+ }
5885
+
5886
+ if (primitiveParseCache.size >= PRIMITIVE_PARSE_CACHE_MAX) {
5887
+ // Evict the oldest entry
5888
+ const firstKey = primitiveParseCache.keys().next().value;
5889
+ if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
5890
+ }
5891
+ primitiveParseCache.set(cacheKey, result);
5892
+ return result;
5893
+ }
5894
+
5895
+ /**
5896
+ * Convert a screen-local primitive rect to board/canvas coordinates.
5897
+ *
5898
+ * appendCanvasPrimitiveToHtml stores positions in screen-local CSS pixels
5899
+ * scaled from the board draft geometry:
5900
+ * localX = (boardX - frame.x) * (metadata.width / frame.width)
5901
+ * Inverting:
5902
+ * boardX = frame.x + localX * (frame.width / metadata.width)
5903
+ */
5904
+ export function primitiveLocalToBoardRect(
5905
+ localLeft: number,
5906
+ localTop: number,
5907
+ localWidth: number,
5908
+ localHeight: number,
5909
+ frameGeometry: FrameGeometry,
5910
+ metadata: { width: number; height: number },
5911
+ ): FrameGeometry {
5912
+ const scaleX = frameGeometry.width / Math.max(1, metadata.width);
5913
+ const scaleY = frameGeometry.height / Math.max(1, metadata.height);
5914
+ return {
5915
+ x: frameGeometry.x + localLeft * scaleX,
5916
+ y: frameGeometry.y + localTop * scaleY,
5917
+ width: Math.max(1, localWidth * scaleX),
5918
+ height: Math.max(1, localHeight * scaleY),
5919
+ };
5920
+ }
5921
+
5922
+ /**
5923
+ * Find the topmost committed container primitive at `point` (canvas coords),
5924
+ * excluding `draggedNodeId` and any of its descendants.
5925
+ *
5926
+ * Descendants are detected geometrically: a primitive whose board rect is
5927
+ * fully enclosed by the dragged node's board rect is treated as a descendant
5928
+ * and excluded. This avoids a circular parent-child relationship on drop.
5929
+ *
5930
+ * Returns null if no valid target found.
5931
+ */
5932
+ export function getPrimitiveDropTargetForPoint(
5933
+ point: Point,
5934
+ draggedNodeId: string | null,
5935
+ screens: ScreenFile[],
5936
+ frameGeometryById: FrameGeometryById,
5937
+ getMetadata: (screen: ScreenFile) => { width: number; height: number },
5938
+ ): PrimitiveDropTarget | null {
5939
+ // Pre-compute the dragged node's board rect so we can exclude its descendants.
5940
+ let draggedBoardRect: FrameGeometry | null = null;
5941
+ if (draggedNodeId) {
5942
+ outer: for (const screen of screens) {
5943
+ const frameGeometry = frameGeometryById[screen.id];
5944
+ if (!frameGeometry) continue;
5945
+ const metadata = getMetadata(screen);
5946
+ const primitives = parsePrimitivesFromScreen(screen);
5947
+ for (const prim of primitives) {
5948
+ if (prim.nodeId === draggedNodeId) {
5949
+ draggedBoardRect = primitiveLocalToBoardRect(
5950
+ prim.localLeft,
5951
+ prim.localTop,
5952
+ prim.localWidth,
5953
+ prim.localHeight,
5954
+ frameGeometry,
5955
+ metadata,
5956
+ );
5957
+ break outer;
5958
+ }
5959
+ }
5960
+ }
5961
+ }
5962
+
5963
+ let best: PrimitiveDropTarget | null = null;
5964
+
5965
+ for (const screen of screens) {
5966
+ const frameGeometry = frameGeometryById[screen.id];
5967
+ if (!frameGeometry) continue;
5968
+
5969
+ const frameBounds = {
5970
+ left: frameGeometry.x,
5971
+ top: frameGeometry.y,
5972
+ right: frameGeometry.x + frameGeometry.width,
5973
+ bottom: frameGeometry.y + frameGeometry.height,
5974
+ };
5975
+ if (!rectContainsPoint(frameBounds, point)) {
5976
+ continue;
5977
+ }
5978
+
5979
+ const metadata = getMetadata(screen);
5980
+ const primitives = parsePrimitivesFromScreen(screen);
5981
+
5982
+ for (const prim of primitives) {
5983
+ if (!prim.isContainer) continue;
5984
+ if (draggedNodeId && prim.nodeId === draggedNodeId) continue;
5985
+
5986
+ const boardRect = primitiveLocalToBoardRect(
5987
+ prim.localLeft,
5988
+ prim.localTop,
5989
+ prim.localWidth,
5990
+ prim.localHeight,
5991
+ frameGeometry,
5992
+ metadata,
5993
+ );
5994
+
5995
+ // Exclude geometric descendants: a primitive whose board rect is fully
5996
+ // contained within the dragged node's board rect is a child/descendant
5997
+ // and cannot be a valid reparent target (would create a cycle).
5998
+ if (
5999
+ draggedBoardRect &&
6000
+ boardRect.x >= draggedBoardRect.x &&
6001
+ boardRect.y >= draggedBoardRect.y &&
6002
+ boardRect.x + boardRect.width <=
6003
+ draggedBoardRect.x + draggedBoardRect.width &&
6004
+ boardRect.y + boardRect.height <=
6005
+ draggedBoardRect.y + draggedBoardRect.height
6006
+ ) {
6007
+ continue;
6008
+ }
6009
+
6010
+ if (
6011
+ point.x >= boardRect.x &&
6012
+ point.x <= boardRect.x + boardRect.width &&
6013
+ point.y >= boardRect.y &&
6014
+ point.y <= boardRect.y + boardRect.height
6015
+ ) {
6016
+ // Later in the DOM = higher paint order = topmost visually.
6017
+ // We take the last match within each screen (DOM order).
6018
+ best = { nodeId: prim.nodeId, screenId: screen.id, boardRect };
6019
+ }
6020
+ }
6021
+ }
6022
+
6023
+ return best;
6024
+ }
6025
+
6026
+ /**
6027
+ * Resolve which screen (ScreenFile.id) owns a committed primitive nodeId by
6028
+ * scanning all screen HTML for the given data-agent-native-node-id value.
6029
+ */
6030
+ export function resolveNodeScreenId(
6031
+ nodeId: string,
6032
+ screens: ScreenFile[],
6033
+ ): string | null {
6034
+ for (const screen of screens) {
6035
+ const primitives = parsePrimitivesFromScreen(screen);
6036
+ if (primitives.some((p) => p.nodeId === nodeId)) {
6037
+ return screen.id;
6038
+ }
6039
+ }
6040
+ return null;
6041
+ }