@agent-native/core 0.92.7 → 0.92.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/docs/content/a2a-protocol.mdx +12 -0
  4. package/corpus/core/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
  5. package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
  6. package/corpus/core/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
  7. package/corpus/core/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
  8. package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
  9. package/corpus/core/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
  10. package/corpus/core/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
  11. package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
  12. package/corpus/core/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
  13. package/corpus/core/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
  14. package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
  15. package/corpus/core/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
  16. package/corpus/core/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
  17. package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
  18. package/corpus/core/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
  19. package/corpus/core/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
  20. package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
  21. package/corpus/core/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
  22. package/corpus/core/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
  23. package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
  24. package/corpus/core/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
  25. package/corpus/core/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
  26. package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
  27. package/corpus/core/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
  28. package/corpus/core/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
  29. package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
  30. package/corpus/core/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
  31. package/corpus/core/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
  32. package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
  33. package/corpus/core/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
  34. package/corpus/core/docs/content/pr-visual-recap.mdx +3 -3
  35. package/corpus/core/docs/content/pure-agent-apps.mdx +22 -0
  36. package/corpus/core/package.json +1 -1
  37. package/corpus/core/src/agent/engine/builtin.ts +1 -1
  38. package/corpus/core/src/agent/model-config.ts +30 -30
  39. package/corpus/core/src/agent/production-agent.ts +129 -7
  40. package/corpus/core/src/cli/design-connect.ts +438 -83
  41. package/corpus/core/src/cli/recap.ts +17 -3
  42. package/corpus/core/src/client/AgentPanel.tsx +8 -1
  43. package/corpus/core/src/client/agent-chat-adapter.ts +12 -7
  44. package/corpus/core/src/client/composer/TiptapComposer.tsx +1 -1
  45. package/corpus/core/src/client/index.ts +4 -0
  46. package/corpus/core/src/client/org/TeamPage.tsx +41 -26
  47. package/corpus/core/src/client/use-action.ts +113 -1
  48. package/corpus/core/src/db/client.ts +12 -2
  49. package/corpus/core/src/db/create-get-db.ts +4 -1
  50. package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +1 -1
  51. package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +1 -1
  52. package/corpus/core/src/server/ssr-handler.ts +7 -0
  53. package/corpus/core/src/server/transcribe-voice.ts +1 -1
  54. package/corpus/core/src/usage/store.ts +13 -4
  55. package/corpus/core/src/vite/client.ts +38 -1
  56. package/corpus/templates/analytics/AGENTS.md +3 -0
  57. package/corpus/templates/analytics/actions/get-data-program.ts +78 -0
  58. package/corpus/templates/analytics/actions/list-data-programs.ts +49 -0
  59. package/corpus/templates/analytics/app/global.css +11 -0
  60. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +163 -17
  61. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  62. package/corpus/templates/analytics/app/i18n-data.ts +20 -0
  63. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +96 -3
  64. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -1
  65. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +61 -4
  66. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +40 -2
  67. package/corpus/templates/analytics/changelog/2026-07-09-analytics-no-longer-forgets-successful-data-queries-when-a-l.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-keep-consistent-spacing-when-they-stack-ver.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-tables-and-extensions-can-be-selected-and-d.md +6 -0
  70. package/corpus/templates/analytics/changelog/2026-07-09-long-running-analytics-requests-now-recover-automatically-wh.md +6 -0
  71. package/corpus/templates/analytics/server/db/index.ts +6 -0
  72. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +1 -7
  73. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +102 -15
  74. package/corpus/templates/content/app/hooks/use-content-database.ts +52 -0
  75. package/corpus/templates/content/app/hooks/use-document-properties.ts +50 -6
  76. package/corpus/templates/content/app/i18n/zh-TW.ts +2 -0
  77. package/corpus/templates/content/app/i18n-data.ts +28 -0
  78. package/corpus/templates/content/changelog/2026-07-09-notion-conflict-warnings-no-longer-flash-during-normal-synce.md +6 -0
  79. package/corpus/templates/content/changelog/2026-07-09-property-menus-and-delete-confirmations-now-open-visibly-and.md +6 -0
  80. package/corpus/templates/content/server/lib/notion-sync.ts +130 -28
  81. package/corpus/templates/design/actions/add-breakpoint.ts +48 -68
  82. package/corpus/templates/design/actions/add-localhost-screens.ts +496 -61
  83. package/corpus/templates/design/actions/apply-component-prop-edit.ts +2 -18
  84. package/corpus/templates/design/actions/apply-design-token-edit.ts +51 -82
  85. package/corpus/templates/design/actions/apply-tweaks.ts +63 -41
  86. package/corpus/templates/design/actions/capture-design-state.ts +2 -2
  87. package/corpus/templates/design/actions/connect-localhost.ts +58 -12
  88. package/corpus/templates/design/actions/create-component.ts +2 -18
  89. package/corpus/templates/design/actions/create-design-branch.ts +38 -18
  90. package/corpus/templates/design/actions/create-fusion-app.ts +15 -11
  91. package/corpus/templates/design/actions/delete-file.ts +19 -34
  92. package/corpus/templates/design/actions/deploy-design-preview.ts +59 -27
  93. package/corpus/templates/design/actions/deploy-fusion-app.ts +22 -15
  94. package/corpus/templates/design/actions/export-pdf.ts +2 -1
  95. package/corpus/templates/design/actions/export-zip.ts +9 -3
  96. package/corpus/templates/design/actions/generate-design.ts +107 -70
  97. package/corpus/templates/design/actions/get-component-details.ts +2 -18
  98. package/corpus/templates/design/actions/get-design-branch-diff.ts +2 -3
  99. package/corpus/templates/design/actions/get-design-surface-index.ts +2 -18
  100. package/corpus/templates/design/actions/get-design.ts +2 -1
  101. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +7 -1
  102. package/corpus/templates/design/actions/import-design-tokens.ts +114 -76
  103. package/corpus/templates/design/actions/index-components.ts +2 -18
  104. package/corpus/templates/design/actions/list-design-source-capabilities.ts +23 -28
  105. package/corpus/templates/design/actions/list-local-files.ts +9 -2
  106. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +82 -98
  107. package/corpus/templates/design/actions/open-component-source.ts +2 -18
  108. package/corpus/templates/design/actions/open-visual-edit.ts +19 -1
  109. package/corpus/templates/design/actions/present-design-variants.ts +70 -70
  110. package/corpus/templates/design/actions/preview-component-prop-edit.ts +4 -14
  111. package/corpus/templates/design/actions/read-local-file.ts +9 -2
  112. package/corpus/templates/design/actions/remove-breakpoint.ts +40 -61
  113. package/corpus/templates/design/actions/rename-screen.ts +425 -0
  114. package/corpus/templates/design/actions/request-localhost-write-consent.ts +12 -2
  115. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +9 -2
  116. package/corpus/templates/design/actions/sync-fusion-app.ts +56 -39
  117. package/corpus/templates/design/actions/update-design.ts +376 -61
  118. package/corpus/templates/design/actions/update-file.ts +455 -188
  119. package/corpus/templates/design/actions/write-local-file.ts +10 -2
  120. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +685 -608
  121. package/corpus/templates/design/app/components/design/LayersPanel.tsx +43 -15
  122. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +576 -2015
  123. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +62 -14
  124. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +263 -25
  125. package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +34 -0
  126. package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +40 -0
  127. package/corpus/templates/design/app/components/design/design-canvas/creation.ts +47 -0
  128. package/corpus/templates/design/app/components/design/design-canvas/element-payload.ts +27 -0
  129. package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +62 -0
  130. package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +137 -0
  131. package/corpus/templates/design/app/components/design/design-canvas/file-drop.ts +10 -0
  132. package/corpus/templates/design/app/components/design/design-canvas/hit-test.ts +23 -0
  133. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +23 -0
  134. package/corpus/templates/design/app/components/design/design-canvas/iframe-pan.ts +177 -0
  135. package/corpus/templates/design/app/components/design/design-canvas/motion-types.ts +7 -0
  136. package/corpus/templates/design/app/components/design/design-canvas/pending-text-edit.ts +62 -0
  137. package/corpus/templates/design/app/components/design/index.ts +2 -2
  138. package/corpus/templates/design/app/components/design/multi-screen/canvas-tools.ts +100 -0
  139. package/corpus/templates/design/app/components/design/multi-screen/coordinate-transforms.ts +81 -0
  140. package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +23 -13
  141. package/corpus/templates/design/app/components/design/multi-screen/culling.ts +238 -19
  142. package/corpus/templates/design/app/components/design/multi-screen/draft-primitives.ts +424 -0
  143. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +341 -0
  144. package/corpus/templates/design/app/components/design/multi-screen/iframe-targeting.ts +52 -0
  145. package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +62 -0
  146. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +283 -0
  147. package/corpus/templates/design/app/components/design/multi-screen/screen-content-cache.ts +272 -0
  148. package/corpus/templates/design/app/components/design/multi-screen/types.ts +5 -1
  149. package/corpus/templates/design/app/components/design/multi-screen/wheel-gesture-state.ts +12 -0
  150. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +325 -84
  151. package/corpus/templates/design/app/hooks/use-navigation-state.ts +15 -1
  152. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +119 -47
  153. package/corpus/templates/design/app/i18n/zh-TW.ts +9 -0
  154. package/corpus/templates/design/app/i18n-data.ts +223 -0
  155. package/corpus/templates/design/app/lib/design-save-outbox.ts +379 -0
  156. package/corpus/templates/design/app/lib/png-clipboard.ts +113 -0
  157. package/corpus/templates/design/app/pages/design-editor/canvas-primitives.ts +229 -0
  158. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +918 -0
  159. package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +43 -0
  160. package/corpus/templates/design/app/pages/design-editor/cross-screen-text-color.ts +336 -0
  161. package/corpus/templates/design/app/pages/design-editor/data-operations.ts +270 -0
  162. package/corpus/templates/design/app/pages/design-editor/dom-guards.ts +24 -0
  163. package/corpus/templates/design/app/pages/design-editor/dom-utils.ts +11 -0
  164. package/corpus/templates/design/app/pages/design-editor/editor-session.ts +20 -0
  165. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +408 -0
  166. package/corpus/templates/design/app/pages/design-editor/export-capture.ts +115 -0
  167. package/corpus/templates/design/app/pages/design-editor/geometry-persistence.ts +93 -0
  168. package/corpus/templates/design/app/pages/design-editor/history.ts +169 -0
  169. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +185 -0
  170. package/corpus/templates/design/app/pages/design-editor/motion-state.ts +309 -0
  171. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +438 -0
  172. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +510 -0
  173. package/corpus/templates/design/app/pages/design-editor/portable-style.ts +115 -0
  174. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +337 -0
  175. package/corpus/templates/design/app/pages/design-editor/style-utils.ts +5 -0
  176. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +92 -0
  177. package/corpus/templates/design/app/pages/design-editor/tweak-save.ts +87 -0
  178. package/corpus/templates/design/app/pages/design-editor/types.ts +36 -0
  179. package/corpus/templates/design/app/routes/design.$id.tsx +11 -1
  180. package/corpus/templates/design/changelog/2026-07-09-design-edits-canvas-positions-tools-and-tab-context-now-pers.md +6 -0
  181. package/corpus/templates/design/changelog/2026-07-09-figma-style-shortcuts-drawing-annotations-and-cross-screen-d.md +6 -0
  182. package/corpus/templates/design/changelog/2026-07-09-hand-and-scale-tools-now-keep-their-active-toolbar-identity-.md +6 -0
  183. package/corpus/templates/design/changelog/2026-07-09-large-multi-screen-designs-now-stay-responsive-during-long-c.md +6 -0
  184. package/corpus/templates/design/changelog/2026-07-09-layer-drags-now-land-exactly-where-the-insertion-indicator-s.md +6 -0
  185. package/corpus/templates/design/server/db/index.ts +2 -52
  186. package/corpus/templates/design/server/db/schema.ts +16 -0
  187. package/corpus/templates/design/server/lib/design-data-access.ts +70 -0
  188. package/corpus/templates/design/server/lib/design-data-mutation.ts +249 -0
  189. package/corpus/templates/design/server/lib/fusion-screens.ts +89 -76
  190. package/corpus/templates/design/server/lib/import-design-files.ts +76 -35
  191. package/corpus/templates/design/server/lib/verify-write-grant.ts +7 -2
  192. package/corpus/templates/design/server/plugins/db.ts +19 -0
  193. package/corpus/templates/design/server/source-workspace.ts +12 -13
  194. package/corpus/templates/design/shared/resolve-tweaks.ts +15 -0
  195. package/corpus/templates/design/shared/screen-rename.ts +42 -0
  196. package/corpus/templates/design/shared/source-mode.ts +84 -4
  197. package/dist/agent/engine/builder-engine.d.ts +1 -1
  198. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  199. package/dist/agent/engine/builtin.js +1 -1
  200. package/dist/agent/engine/builtin.js.map +1 -1
  201. package/dist/agent/model-config.d.ts +9 -9
  202. package/dist/agent/model-config.d.ts.map +1 -1
  203. package/dist/agent/model-config.js +30 -30
  204. package/dist/agent/model-config.js.map +1 -1
  205. package/dist/agent/production-agent.d.ts +60 -1
  206. package/dist/agent/production-agent.d.ts.map +1 -1
  207. package/dist/agent/production-agent.js +106 -7
  208. package/dist/agent/production-agent.js.map +1 -1
  209. package/dist/cli/design-connect.d.ts +30 -1
  210. package/dist/cli/design-connect.d.ts.map +1 -1
  211. package/dist/cli/design-connect.js +359 -75
  212. package/dist/cli/design-connect.js.map +1 -1
  213. package/dist/cli/recap.d.ts.map +1 -1
  214. package/dist/cli/recap.js +13 -3
  215. package/dist/cli/recap.js.map +1 -1
  216. package/dist/client/AgentPanel.d.ts.map +1 -1
  217. package/dist/client/AgentPanel.js +6 -1
  218. package/dist/client/AgentPanel.js.map +1 -1
  219. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  220. package/dist/client/agent-chat-adapter.js +10 -5
  221. package/dist/client/agent-chat-adapter.js.map +1 -1
  222. package/dist/client/composer/TiptapComposer.js +1 -1
  223. package/dist/client/composer/TiptapComposer.js.map +1 -1
  224. package/dist/client/index.d.ts +1 -1
  225. package/dist/client/index.d.ts.map +1 -1
  226. package/dist/client/index.js +1 -1
  227. package/dist/client/index.js.map +1 -1
  228. package/dist/client/org/TeamPage.d.ts.map +1 -1
  229. package/dist/client/org/TeamPage.js +6 -5
  230. package/dist/client/org/TeamPage.js.map +1 -1
  231. package/dist/client/use-action.d.ts +32 -0
  232. package/dist/client/use-action.d.ts.map +1 -1
  233. package/dist/client/use-action.js +68 -1
  234. package/dist/client/use-action.js.map +1 -1
  235. package/dist/collab/struct-routes.d.ts +1 -1
  236. package/dist/db/client.d.ts +3 -0
  237. package/dist/db/client.d.ts.map +1 -1
  238. package/dist/db/client.js +7 -2
  239. package/dist/db/client.js.map +1 -1
  240. package/dist/db/create-get-db.d.ts.map +1 -1
  241. package/dist/db/create-get-db.js +2 -2
  242. package/dist/db/create-get-db.js.map +1 -1
  243. package/dist/notifications/routes.d.ts +3 -3
  244. package/dist/observability/routes.d.ts +1 -1
  245. package/dist/resources/handlers.d.ts +3 -3
  246. package/dist/scripts/agent-engines/manage-agent-engine.js +1 -1
  247. package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
  248. package/dist/scripts/agent-engines/set-agent-engine.js +1 -1
  249. package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
  250. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  251. package/dist/server/ssr-handler.d.ts.map +1 -1
  252. package/dist/server/ssr-handler.js +7 -0
  253. package/dist/server/ssr-handler.js.map +1 -1
  254. package/dist/server/transcribe-voice.js +1 -1
  255. package/dist/server/transcribe-voice.js.map +1 -1
  256. package/dist/usage/store.d.ts.map +1 -1
  257. package/dist/usage/store.js +13 -4
  258. package/dist/usage/store.js.map +1 -1
  259. package/dist/vite/client.d.ts.map +1 -1
  260. package/dist/vite/client.js +36 -1
  261. package/dist/vite/client.js.map +1 -1
  262. package/docs/content/a2a-protocol.mdx +12 -0
  263. package/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
  264. package/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
  265. package/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
  266. package/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
  267. package/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
  268. package/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
  269. package/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
  270. package/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
  271. package/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
  272. package/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
  273. package/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
  274. package/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
  275. package/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
  276. package/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
  277. package/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
  278. package/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
  279. package/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
  280. package/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
  281. package/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
  282. package/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
  283. package/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
  284. package/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
  285. package/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
  286. package/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
  287. package/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
  288. package/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
  289. package/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
  290. package/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
  291. package/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
  292. package/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
  293. package/docs/content/pr-visual-recap.mdx +3 -3
  294. package/docs/content/pure-agent-apps.mdx +22 -0
  295. package/package.json +2 -2
@@ -5,8 +5,21 @@ import {
5
5
  getDraftGeometryFromPoints,
6
6
  } from "@shared/canvas-math";
7
7
  import { ensureCodeLayerNodeIdsInHtml } from "@shared/code-layer";
8
+ import {
9
+ appendPenNode,
10
+ clonePenPath,
11
+ closePenPath,
12
+ constrainPointTo45Degrees,
13
+ createCornerNode,
14
+ createSmoothNode,
15
+ isPenCloseTarget,
16
+ serializePenPath,
17
+ type PenPath,
18
+ type PenPoint,
19
+ } from "@shared/pen-path";
8
20
  import { IconPlugConnectedX, IconRefresh } from "@tabler/icons-react";
9
21
  import { useRef, useEffect, useCallback, useMemo, useState } from "react";
22
+ import { toast } from "sonner";
10
23
 
11
24
  import { Button } from "@/components/ui/button";
12
25
  // NOTE: This wires up the NEW shared visual-editor DrawOverlay + comment-pin
@@ -23,7 +36,6 @@ import { cn } from "@/lib/utils";
23
36
 
24
37
  import { editorChromeBridgeScript } from "../../../.generated/bridge/editor-chrome.generated";
25
38
  import { embeddedWheelBridgeScript } from "../../../.generated/bridge/embedded-wheel.generated";
26
- import { hitTestBridgeScript } from "../../../.generated/bridge/hit-test.generated";
27
39
  import { motionPreviewBridgeScript } from "../../../.generated/bridge/motion-preview.generated";
28
40
  import { navBridgeScript } from "../../../.generated/bridge/nav.generated";
29
41
  import { shaderFillPreviewBridgeScript } from "../../../.generated/bridge/shader-fill-preview.generated";
@@ -31,7 +43,50 @@ import { shaderRuntimeBridgeScript } from "../../../.generated/bridge/shader-run
31
43
  import { tweakBridgeScript } from "../../../.generated/bridge/tweak.generated";
32
44
  import { zoomBridgeScript } from "../../../.generated/bridge/zoom.generated";
33
45
  import { isTrustedCanvasBridgeMessage } from "./bridge-security";
46
+ import { submitDesignAnnotations } from "./design-canvas/annotation-submit";
47
+ import {
48
+ getScreenContentPointFromClient,
49
+ getZoomToCursorScrollDelta,
50
+ } from "./design-canvas/coordinate-transforms";
51
+ import {
52
+ collapseDoubleClickPenAnchor,
53
+ type CreatePrimitiveSpec,
54
+ type CreationTool,
55
+ } from "./design-canvas/creation";
56
+ import { isElementInfoPayload } from "./design-canvas/element-payload";
57
+ import {
58
+ embeddedContentOffsetStyle,
59
+ getEmbeddedFrameBackgroundStyle,
60
+ getEmbeddedFrameDocumentContent,
61
+ getEmbeddedIframeBackgroundColor,
62
+ } from "./design-canvas/embedded-frame";
63
+ import {
64
+ getDesignCanvasIframeSandbox,
65
+ getSnapshotRetryDelayMs,
66
+ resolveLiveEditPreviewUrl,
67
+ sanitizeLocalhostSourceSnapshotHtml,
68
+ shouldFetchExternalSourceSnapshot,
69
+ shouldUseIframeLoadReadyFallback,
70
+ } from "./design-canvas/external-preview";
71
+ import { isOsFileDragEvent } from "./design-canvas/file-drop";
72
+ import { LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT } from "./design-canvas/hit-test";
73
+ import type {
74
+ IframeContextMenuPayload,
75
+ IframeFigmaClipboardPastePayload,
76
+ IframeHotkeyPayload,
77
+ } from "./design-canvas/iframe-events";
78
+ import {
79
+ forwardEmbeddedCanvasPanMessage,
80
+ type EmbeddedCanvasPanSession,
81
+ } from "./design-canvas/iframe-pan";
82
+ import type { MotionTrackWire } from "./design-canvas/motion-types";
83
+ import {
84
+ PENDING_TEXT_EDIT_TIMEOUT_MS,
85
+ routePendingTextEditKey,
86
+ schedulePendingTextEditActivation,
87
+ } from "./design-canvas/pending-text-edit";
34
88
  import { DeviceFrame } from "./DeviceFrame";
89
+ import { shapeClosingHandles } from "./multi-screen/draft-primitives";
35
90
  import type {
36
91
  ElementInfo,
37
92
  ElementSelectionIntent,
@@ -77,25 +132,6 @@ function isAllowedFusionOrigin(
77
132
  return host === "builder.io" || host.endsWith(".builder.io");
78
133
  }
79
134
 
80
- /**
81
- * Wire shape for a single motion track sent via the `motion-load-tracks`
82
- * postMessage. Matches the serialisable subset of `MotionTrack` from
83
- * `shared/motion-timeline.ts` without requiring an import at the UI layer.
84
- */
85
- export interface MotionTrackWire {
86
- targetNodeId: string;
87
- property: string;
88
- /**
89
- * Keyframes normalised to the TRACK's own span; `ease` accepts CSS timing
90
- * functions plus `spring(bounce[, settle])` tokens and `linear(...)` lists.
91
- */
92
- keyframes: Array<{ t: number; value: string; ease?: string }>;
93
- /** Track start offset within the timeline, ms (staggering). */
94
- delayMs?: number;
95
- /** Per-track animation span, ms. Omitted = whole timeline. */
96
- durationMs?: number;
97
- }
98
-
99
135
  /**
100
136
  * Motion-preview bridge. Injected alongside the other bridge scripts so the
101
137
  * MotionDock's scrubbing preview works in ALL editor modes without writing
@@ -192,59 +228,6 @@ ${navBridgeScript}
192
228
  </script>
193
229
  `;
194
230
 
195
- /**
196
- * Lightweight hit-test bridge: injected into every screen srcdoc iframe so
197
- * MultiScreenCanvas can ask "what container is under this point?" during a
198
- * cross-screen drag without needing to synthesise DOM events.
199
- *
200
- * Protocol (parent → iframe via postMessage):
201
- * { type: 'agent-native:hit-test', correlationId: string, x: number, y: number }
202
- * where x/y are in this iframe's viewport coordinate space.
203
- * When preview is true, the iframe also renders its local insertion guide.
204
- * { type: 'agent-native:hit-test-preview-clear' } hides that guide.
205
- *
206
- * Reply (iframe → window.parent):
207
- * { type: 'agent-native:hit-test-result', correlationId: string,
208
- * anchorNodeId: string, placement: 'before'|'after'|'inside',
209
- * axis: 'x'|'y',
210
- * anchorRect: { left: number, top: number, width: number, height: number } }
211
- *
212
- * Reads DOM only — no mutations, no event interception. The container-drop and
213
- * placement logic is intentionally kept in sync with the corresponding helpers
214
- * inside editor-chrome.bridge.ts (search for "// keep in sync with
215
- * hit-test.bridge.ts" comments there).
216
- *
217
- * Source: app/components/design/bridge/hit-test.bridge.ts
218
- * Compiled: .generated/bridge/hit-test.generated.ts (run bridge/codegen.ts to update)
219
- */
220
- export const LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT = `
221
- <script data-agent-native-hit-test-bridge>
222
- ${hitTestBridgeScript}
223
- </script>
224
- `;
225
-
226
- /**
227
- * Append the LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT into a complete HTML string.
228
- * Injects before </body> when present, before </html> as a fallback, or appends
229
- * at the end. This is the seam MultiScreenCanvas uses to add the hit-test
230
- * responder to each screen srcdoc without rebuilding the entire document.
231
- */
232
- export function appendHitTestResponder(html: string): string {
233
- if (html.includes("</body>")) {
234
- return html.replace(
235
- "</body>", // i18n-ignore generated iframe HTML marker
236
- LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT + "</body>", // i18n-ignore generated iframe HTML injection
237
- );
238
- }
239
- if (html.includes("</html>")) {
240
- return html.replace(
241
- "</html>", // i18n-ignore generated iframe HTML marker
242
- LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT + "</html>", // i18n-ignore generated iframe HTML injection
243
- );
244
- }
245
- return html + LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT;
246
- }
247
-
248
231
  const EDITOR_BRIDGE_VAR_NAMES = [
249
232
  "--design-editor-accent-color",
250
233
  "--design-editor-accent-hover-color",
@@ -305,8 +288,10 @@ interface DesignCanvasProps {
305
288
  /**
306
289
  * The runtime source tier for this canvas.
307
290
  *
308
- * - `"inline"` (default) — HTML/Alpine `srcdoc` iframe; same-origin null
309
- * origin; all bridge scripts injected by DesignCanvas.
291
+ * - `"inline"` (default) — HTML/Alpine `srcdoc` iframe; all bridge scripts
292
+ * injected by DesignCanvas. Read-only frames get an opaque origin;
293
+ * editable frames temporarily retain the parent origin for the live-DOM
294
+ * editor features documented by getDesignCanvasIframeSandbox.
310
295
  * - `"localhost"` — `src=devServerUrl`; dev server is same-origin in most
311
296
  * setups; bridge trust: origin must match parent or be "null".
312
297
  * - `"fusion"` — `src=builderHostedUrl`; cross-origin Builder-hosted app;
@@ -351,7 +336,9 @@ interface DesignCanvasProps {
351
336
  * For `"inline"` and `"localhost"` sources this prop is ignored.
352
337
  */
353
338
  fusionUrl?: string;
354
- bridgeToken?: string;
339
+ /** Read-only localhost bridge credential. Filesystem write tokens never enter
340
+ * this browser component. */
341
+ previewToken?: string;
355
342
  zoom: number;
356
343
  onZoomChange?: (zoom: number) => void;
357
344
  deviceFrame: DeviceFrameType;
@@ -680,138 +667,6 @@ interface DesignCanvasProps {
680
667
  spacePanActive?: boolean;
681
668
  }
682
669
 
683
- /** Creation tools supported by the single-screen click-to-place overlay. */
684
- export type CreationTool =
685
- | "rectangle"
686
- | "ellipse"
687
- | "line"
688
- | "arrow"
689
- | "text"
690
- | "pen"
691
- // Figma parity: the frame tool (F/A) used inside a focused screen places a
692
- // bare container <div> (a nested "frame") instead of forcing a jump out to
693
- // overview to draw a new screen.
694
- | "frame";
695
-
696
- /**
697
- * Geometry payload emitted by the single-screen creation overlay. Coordinates
698
- * are always in screen-content space (see `activeCreationTool` doc above).
699
- *
700
- * - `rect` is present for rectangle/ellipse/text (a click uses a sensible
701
- * default size; a drag uses the dragged bounds via
702
- * `getDraftGeometryFromPoints`, matching MultiScreenCanvas).
703
- * - `points` is present for line/arrow (always exactly 2 points) and for a
704
- * pen click (a single start point — see the deferred multi-click pen note
705
- * on the overlay component itself).
706
- * - `fromClick` distinguishes a negligible-movement click (true) from a
707
- * drag past the movement threshold (false), mirroring MultiScreenCanvas's
708
- * own click-vs-drag draft creation semantics.
709
- */
710
- export interface CreatePrimitiveSpec {
711
- tool: CreationTool;
712
- rect?: { x: number; y: number; width: number; height: number };
713
- points?: Array<{ x: number; y: number }>;
714
- fromClick: boolean;
715
- }
716
-
717
- /**
718
- * Converts a pointer position in viewport (client) coordinates into
719
- * screen-content coordinates — the screen's own pixel coordinate system, as
720
- * used by `getDraftGeometryFromPoints` / `draftPrimitiveToInsert` in
721
- * MultiScreenCanvas.
722
- *
723
- * The iframe's rendered rect (`getBoundingClientRect()`) already reflects any
724
- * outer zoom `transform: scale(...)` wrapper, while `iframe.clientWidth` /
725
- * `iframe.clientHeight` are the raw (unscaled) layout size that equals the
726
- * screen's own content pixel dimensions. Dividing out that ratio undoes the
727
- * zoom scale, matching the scaleX/scaleY convention already used by the
728
- * `element-contextmenu` bridge handler below.
729
- */
730
- export function getScreenContentPointFromClient(
731
- clientX: number,
732
- clientY: number,
733
- iframeRect: { left: number; top: number; width: number; height: number },
734
- iframeContentSize: { width: number; height: number },
735
- ): { x: number; y: number } {
736
- const scaleX =
737
- iframeContentSize.width > 0
738
- ? iframeRect.width / iframeContentSize.width
739
- : 1;
740
- const scaleY =
741
- iframeContentSize.height > 0
742
- ? iframeRect.height / iframeContentSize.height
743
- : 1;
744
- return {
745
- x: (clientX - iframeRect.left) / (scaleX || 1),
746
- y: (clientY - iframeRect.top) / (scaleY || 1),
747
- };
748
- }
749
-
750
- /**
751
- * Computes the scroll-container delta needed to keep a given viewport
752
- * (client) point anchored to the same content underneath it while zooming.
753
- *
754
- * This is the cursor-anchor math used by the `pinch-zoom-wheel` bridge
755
- * handler (forwarded from inside the iframe, since wheel events don't bubble
756
- * out of iframes — see `zoom.bridge.ts`) to mirror `usePinchZoom`'s own
757
- * zoom-to-cursor behavior for gestures that occur over the iframe itself.
758
- *
759
- * IMPORTANT — this math is only correct when the scaled layer's
760
- * `transform-origin` is `top left` (or equivalent), so that the layer's own
761
- * top-left corner is a fixed point in scroll-content space regardless of
762
- * zoom. If the scaled layer is instead centered via `transform-origin:
763
- * center center` plus flex/grid centering, the *painted* box re-centers
764
- * around the container's center on every zoom change — the flex-centering
765
- * repositions the *layout* box so its center lands at the container's
766
- * center, and `transform-origin: center` then scales around that already-
767
- * centered point. That is a well-defined anchor too, but it is NOT the one
768
- * this function (or usePinchZoom) computes, so cursor-following would drift.
769
- * See the long comment on the "none"-mode zoom layer in the component render
770
- * for how DesignCanvas keeps this invariant true while still looking
771
- * centered at rest.
772
- *
773
- * @param anchorClient The viewport point to keep stationary (e.g. cursor
774
- * position converted from iframe-document space to viewport space).
775
- * @param containerRect The scroll container's `getBoundingClientRect()`.
776
- * @param scrollOffset The scroll container's current `{ scrollLeft,
777
- * scrollTop }`.
778
- * @param zoomRatio `nextZoom / currentZoom` — how much the zoom is about to
779
- * change by by (>1 zooming in, <1 zooming out).
780
- */
781
- export function getZoomToCursorScrollDelta(
782
- anchorClient: { x: number; y: number },
783
- containerRect: { left: number; top: number },
784
- scrollOffset: { scrollLeft: number; scrollTop: number },
785
- zoomRatio: number,
786
- ): { dx: number; dy: number } {
787
- const cx = anchorClient.x - containerRect.left + scrollOffset.scrollLeft;
788
- const cy = anchorClient.y - containerRect.top + scrollOffset.scrollTop;
789
- return {
790
- dx: cx * (zoomRatio - 1),
791
- dy: cy * (zoomRatio - 1),
792
- };
793
- }
794
-
795
- /**
796
- * True when a drag event is carrying native OS files (e.g. dragged in from
797
- * Finder/Explorer) rather than an internal HTML5 DOM drag (element reorder,
798
- * the extensions-panel native-asset drag, etc). A real OS file drag always
799
- * includes the "Files" type in `dataTransfer.types` — reliably present during
800
- * dragenter/dragover even before drop, when `dataTransfer.files` itself is
801
- * empty for security reasons — so this is the correct check to gate
802
- * preventDefault()/overlay logic on, not `files.length`.
803
- */
804
- export function isOsFileDragEvent(event: {
805
- dataTransfer: { types: readonly string[] | DOMStringList } | null;
806
- }): boolean {
807
- const types = event.dataTransfer?.types;
808
- if (!types) return false;
809
- for (let i = 0; i < types.length; i += 1) {
810
- if (types[i] === "Files") return true;
811
- }
812
- return false;
813
- }
814
-
815
670
  function getExternalPreviewUrl(content: string): string | null {
816
671
  const trimmed = content.trim();
817
672
  if (!/^https?:\/\//i.test(trimmed)) return null;
@@ -824,60 +679,12 @@ function getExternalPreviewUrl(content: string): string | null {
824
679
  }
825
680
  }
826
681
 
827
- function escapeHtmlAttribute(value: string): string {
828
- return value
829
- .replace(/&/g, "&amp;")
830
- .replace(/"/g, "&quot;")
831
- .replace(/</g, "&lt;")
832
- .replace(/>/g, "&gt;");
833
- }
834
-
835
- function addSnapshotBaseHref(html: string, href: string): string {
836
- if (!html.trim()) return html;
837
- if (/<base\b/i.test(html)) return html;
838
- const baseTag = `<base href="${escapeHtmlAttribute(href)}">`;
839
- if (/<head\b[^>]*>/i.test(html)) {
840
- return html.replace(/<head\b[^>]*>/i, (match) => `${match}${baseTag}`);
841
- }
842
- if (/<html\b[^>]*>/i.test(html)) {
843
- return html.replace(
844
- /<html\b[^>]*>/i,
845
- (match) => `${match}<head>${baseTag}</head>`,
846
- );
847
- }
848
- return `<!DOCTYPE html><html><head>${baseTag}<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${html}</body></html>`;
849
- }
850
-
851
- function prepareStaticSnapshotHtml(html: string, href: string): string {
852
- const withBase = addSnapshotBaseHref(html, href);
853
- if (typeof DOMParser === "undefined") {
854
- return withBase.replace(/<script\b[^>]*>.*?<\/script>/gis, "");
855
- }
856
-
857
- const document = new DOMParser().parseFromString(withBase, "text/html");
858
- document.querySelectorAll("script").forEach((script) => script.remove());
859
- return `<!DOCTYPE html>${document.documentElement.outerHTML}`;
860
- }
861
-
862
682
  function snapshotEndpointUrl(bridgeUrl: string, previewUrl: string): string {
863
683
  const endpoint = new URL("/snapshot", bridgeUrl);
864
684
  endpoint.searchParams.set("url", previewUrl);
865
685
  return endpoint.toString();
866
686
  }
867
687
 
868
- export function liveEditEndpointUrl(
869
- bridgeUrl: string,
870
- previewUrl: string,
871
- options?: { includeEditorBridge?: boolean },
872
- ): string {
873
- const endpoint = new URL("/live-edit", bridgeUrl);
874
- endpoint.searchParams.set("url", previewUrl);
875
- if (options?.includeEditorBridge === false) {
876
- endpoint.searchParams.set("bridge", "0");
877
- }
878
- return endpoint.toString();
879
- }
880
-
881
688
  function originFromUrl(value: string | undefined): string | null {
882
689
  if (!value) return null;
883
690
  try {
@@ -920,215 +727,6 @@ function contentHash(value: string): string {
920
727
  return `${value.length}:${hash >>> 0}`;
921
728
  }
922
729
 
923
- function isPlainRecord(value: unknown): value is Record<string, unknown> {
924
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
925
- }
926
-
927
- function isFiniteRect(value: unknown): value is ElementInfo["boundingRect"] {
928
- if (!isPlainRecord(value)) return false;
929
- return (
930
- Number.isFinite(value.x) &&
931
- Number.isFinite(value.y) &&
932
- Number.isFinite(value.width) &&
933
- Number.isFinite(value.height)
934
- );
935
- }
936
-
937
- export function isElementInfoPayload(value: unknown): value is ElementInfo {
938
- if (!isPlainRecord(value)) return false;
939
- return (
940
- typeof value.tagName === "string" &&
941
- Array.isArray(value.classes) &&
942
- isPlainRecord(value.computedStyles) &&
943
- isFiniteRect(value.boundingRect) &&
944
- typeof value.isFlexChild === "boolean" &&
945
- typeof value.isFlexContainer === "boolean"
946
- );
947
- }
948
-
949
- /**
950
- * Exponential backoff delay (ms) for the localhost bridge snapshot auto-retry
951
- * loop, keyed by the zero-based retry attempt number (0 = first retry after
952
- * the initial failed fetch). Doubles each attempt starting at 1.5s, capped at
953
- * ~15s so a genuinely-down dev server settles into a slow, low-noise poll
954
- * instead of hammering the bridge every 1.5s forever.
955
- */
956
- const SNAPSHOT_RETRY_BASE_DELAY_MS = 1500;
957
- const SNAPSHOT_RETRY_MAX_DELAY_MS = 15000;
958
- export function getSnapshotRetryDelayMs(attempt: number): number {
959
- const safeAttempt = Number.isFinite(attempt) ? Math.max(0, attempt) : 0;
960
- const delay = SNAPSHOT_RETRY_BASE_DELAY_MS * 2 ** safeAttempt;
961
- return Math.min(SNAPSHOT_RETRY_MAX_DELAY_MS, delay);
962
- }
963
-
964
- const TRANSPARENT_EMBEDDED_FRAME_STYLE =
965
- "<style data-agent-native-transparent-frame>html,body{background:transparent!important;}body{background-color:transparent!important;}</style>";
966
-
967
- function embeddedFrameBackgroundStyle(background: string | undefined): string {
968
- const trimmed = background?.trim();
969
- if (!trimmed || /[;{}<>]/.test(trimmed)) return "";
970
- return `<style data-agent-native-frame-background>html,body{background:${trimmed}!important;}body{background-color:${trimmed}!important;}</style>`;
971
- }
972
-
973
- export function getEmbeddedFrameBackgroundStyle(args: {
974
- embeddedFrameBackground?: string;
975
- transparentBackground?: boolean;
976
- }): string {
977
- return args.transparentBackground
978
- ? TRANSPARENT_EMBEDDED_FRAME_STYLE
979
- : embeddedFrameBackgroundStyle(args.embeddedFrameBackground);
980
- }
981
-
982
- export function getEmbeddedIframeBackgroundColor(args: {
983
- embeddedFrameBackground?: string;
984
- transparentBackground?: boolean;
985
- }): string {
986
- return args.transparentBackground
987
- ? "transparent"
988
- : (args.embeddedFrameBackground ?? "transparent");
989
- }
990
-
991
- /**
992
- * CSS rule that shifts embedded-frame (board) content so board-coordinate
993
- * (0,0) lands at the center of the board iframe's oversized surface.
994
- *
995
- * The selector is deliberately scoped to DIRECT `<body>` children only.
996
- * `translate` composes per element, so a blanket
997
- * `[data-agent-native-node-id]{translate:…}` rule shifted every NESTED
998
- * node-id-bearing descendant by the surface offset AGAIN (+65536px per
999
- * nesting level) — any board child nested inside another board primitive
1000
- * rendered tens of thousands of px outside its parent (i.e. visually
1001
- * vanished) even when its persisted parent-relative left/top were perfectly
1002
- * correct, and every rect-based coordinate computation on nested board
1003
- * content was poisoned by the extra offset.
1004
- *
1005
- * Scoping (rather than wrapping content in a single offset container) is the
1006
- * robust fix here because board elements are DOCUMENTED direct `<body>`
1007
- * children (see shared/board-file.ts module doc + emptyBoardHtml): every
1008
- * creation path appends at body level (appendCanvasPrimitiveToHtml,
1009
- * boardObjectEntryToHtmlFragment, moveNodeBetweenDocuments' body-append),
1010
- * and several consumers key on that structure with `body > …` selectors
1011
- * (BOARD_SURFACE_RENDER_STYLE) and depth-1 walks
1012
- * (backfillBoardPrimitiveMarkers, getBoardContentLayerSignature). A wrapper
1013
- * element would silently break all of those structural contracts.
1014
- *
1015
- * With this rule, the offset applies exactly once — to top-level board
1016
- * elements — and nested children position purely via parent-relative
1017
- * left/top inside their (absolutely positioned) parent, like any normal
1018
- * CSS containing-block hierarchy.
1019
- */
1020
- function embeddedContentOffsetStyle(x: number, y: number): string {
1021
- if (x === 0 && y === 0) return "";
1022
- return `<style data-agent-native-content-offset>body > [data-agent-native-node-id]{translate:${Math.round(x)}px ${Math.round(y)}px;}</style>`;
1023
- }
1024
-
1025
- function injectEmbeddedFrameStyle(content: string, style: string): string {
1026
- if (!style) return content;
1027
- if (/<\/head>/i.test(content)) {
1028
- return content.replace(/<\/head>/i, `${style}</head>`);
1029
- }
1030
- if (/<body\b/i.test(content)) {
1031
- return content.replace(/<body\b/i, `${style}<body`);
1032
- }
1033
- return `${style}${content}`;
1034
- }
1035
-
1036
- export function getEmbeddedFrameDocumentContent(args: {
1037
- content: string;
1038
- embeddedFrameBackground?: string;
1039
- transparentBackground?: boolean;
1040
- contentOffsetX?: number;
1041
- contentOffsetY?: number;
1042
- }): string {
1043
- const frameStyle = [
1044
- getEmbeddedFrameBackgroundStyle({
1045
- embeddedFrameBackground: args.embeddedFrameBackground,
1046
- transparentBackground: args.transparentBackground,
1047
- }),
1048
- embeddedContentOffsetStyle(
1049
- args.contentOffsetX ?? 0,
1050
- args.contentOffsetY ?? 0,
1051
- ),
1052
- ].join("");
1053
- return injectEmbeddedFrameStyle(args.content, frameStyle);
1054
- }
1055
-
1056
- export interface IframeHotkeyPayload {
1057
- key: string;
1058
- code: string;
1059
- metaKey: boolean;
1060
- ctrlKey: boolean;
1061
- shiftKey: boolean;
1062
- altKey: boolean;
1063
- repeat: boolean;
1064
- }
1065
-
1066
- export interface IframeFigmaClipboardPastePayload {
1067
- content: string;
1068
- }
1069
-
1070
- export interface IframeContextMenuPayload {
1071
- clientX: number;
1072
- clientY: number;
1073
- viewportClientX?: number;
1074
- viewportClientY?: number;
1075
- info?: ElementInfo | null;
1076
- }
1077
-
1078
- /**
1079
- * Creation-race keystroke routing: after the host calls `beginTextEdit(nodeId)`
1080
- * for a JUST-created text element, there is a window (persist round-trip +
1081
- * bridge activation) where the browser focus still sits on the HOST document.
1082
- * Keystrokes typed in that window used to fall into host keyboard shortcuts —
1083
- * letters switched tools, digits zoomed, and Delete/Backspace DELETED the
1084
- * selected layer/screen — while the characters themselves were silently lost.
1085
- *
1086
- * While a text edit is pending, every host keydown is routed through this
1087
- * pure helper:
1088
- * - printable characters are BUFFERED (replayed into the editable once the
1089
- * bridge reports the session active) and swallowed,
1090
- * - Backspace edits the buffer, Delete/Enter/Tab/arrows are swallowed so they
1091
- * can never reach host layer-deletion/navigation,
1092
- * - Escape clears the pending buffer (user aborted) and is swallowed,
1093
- * - IME composition and Cmd/Ctrl chords (undo!) pass through untouched.
1094
- *
1095
- * Exported for unit tests (DesignCanvas.pending-text-edit.test.ts).
1096
- */
1097
- export type PendingTextEditKeyAction =
1098
- | { action: "buffer"; char: string }
1099
- | { action: "drop-last" }
1100
- | { action: "swallow" }
1101
- | { action: "clear-and-swallow" }
1102
- | { action: "pass" };
1103
-
1104
- export function routePendingTextEditKey(e: {
1105
- key: string;
1106
- metaKey?: boolean;
1107
- ctrlKey?: boolean;
1108
- altKey?: boolean;
1109
- isComposing?: boolean;
1110
- }): PendingTextEditKeyAction {
1111
- if (e.isComposing) return { action: "pass" };
1112
- if (e.metaKey || e.ctrlKey) return { action: "pass" };
1113
- if (e.key === "Escape") return { action: "clear-and-swallow" };
1114
- if (e.key === "Backspace") return { action: "drop-last" };
1115
- if (
1116
- e.key === "Delete" ||
1117
- e.key === "Enter" ||
1118
- e.key === "Tab" ||
1119
- e.key.startsWith("Arrow")
1120
- ) {
1121
- return { action: "swallow" };
1122
- }
1123
- if (e.key.length === 1) return { action: "buffer", char: e.key };
1124
- return { action: "pass" };
1125
- }
1126
-
1127
- /** How long a pending text edit may wait for bridge activation before the
1128
- * keystroke routing above stands down (matches the bridge's own ~2s
1129
- * begin-text-edit retry window, plus round-trip slack). */
1130
- export const PENDING_TEXT_EDIT_TIMEOUT_MS = 3000;
1131
-
1132
730
  export function DesignCanvas({
1133
731
  content,
1134
732
  contentKey,
@@ -1137,7 +735,7 @@ export function DesignCanvas({
1137
735
  externalSnapshotHtml,
1138
736
  onExternalContentSnapshot,
1139
737
  fusionUrl,
1140
- bridgeToken,
738
+ previewToken,
1141
739
  zoom,
1142
740
  onZoomChange,
1143
741
  deviceFrame,
@@ -1209,6 +807,9 @@ export function DesignCanvas({
1209
807
  }: DesignCanvasProps) {
1210
808
  const t = useT();
1211
809
  const iframeRef = useRef<HTMLIFrameElement>(null);
810
+ const embeddedCanvasPanSessionRef = useRef<EmbeddedCanvasPanSession | null>(
811
+ null,
812
+ );
1212
813
  const scrollContainerRef = useRef<HTMLDivElement>(null);
1213
814
  const zoomLayerRef = useRef<HTMLDivElement>(null);
1214
815
  const zoomRef = useRef(zoom);
@@ -1246,6 +847,7 @@ export function DesignCanvas({
1246
847
  // fallback for older/interact-mode documents that never inject the chrome
1247
848
  // bridge and thus never post ready) and flush in order.
1248
849
  const bridgeReadyRef = useRef(false);
850
+ const previousIframeDocumentIdentityRef = useRef<string | null>(null);
1249
851
  const pendingOneShotMessagesRef = useRef<unknown[]>([]);
1250
852
  const flushPendingOneShotMessages = useCallback(() => {
1251
853
  const iframe = iframeRef.current;
@@ -1331,24 +933,34 @@ export function DesignCanvas({
1331
933
  }),
1332
934
  [boardSurface, contentKey, editMode, readOnly, screenId],
1333
935
  );
1334
- const embeddedWheelBridgeForCurrentState = useMemo(
936
+ const embeddedGestureBridgeForCurrentState = useMemo(
1335
937
  () =>
1336
938
  EMBEDDED_WHEEL_BRIDGE_SCRIPT.replace(
1337
939
  "__EMBEDDED_WHEEL_FORWARDING_ENABLED__",
1338
940
  isEmbeddedFrame ? "true" : "false",
941
+ ).replace(
942
+ "__EMBEDDED_SPACE_KEY_FORWARDING_ENABLED__",
943
+ interactMode || readOnly ? "true" : "false",
1339
944
  ),
1340
- [isEmbeddedFrame],
945
+ [interactMode, isEmbeddedFrame, readOnly],
1341
946
  );
947
+ const includeLiveEditEditorChrome = !interactMode && !readOnly;
1342
948
  const liveEditBridgeScript = useMemo(
1343
949
  () =>
1344
- MOTION_PREVIEW_BRIDGE_SCRIPT +
1345
- SHADER_FILL_PREVIEW_BRIDGE_SCRIPT +
1346
- TWEAK_BRIDGE_SCRIPT +
1347
- ZOOM_BRIDGE_SCRIPT +
1348
- LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT +
1349
- embeddedWheelBridgeForCurrentState +
950
+ includeLiveEditEditorChrome
951
+ ? MOTION_PREVIEW_BRIDGE_SCRIPT +
952
+ SHADER_FILL_PREVIEW_BRIDGE_SCRIPT +
953
+ TWEAK_BRIDGE_SCRIPT +
954
+ ZOOM_BRIDGE_SCRIPT +
955
+ LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT +
956
+ embeddedGestureBridgeForCurrentState +
957
+ editorChromeBridgeForCurrentState
958
+ : embeddedGestureBridgeForCurrentState,
959
+ [
1350
960
  editorChromeBridgeForCurrentState,
1351
- [editorChromeBridgeForCurrentState, embeddedWheelBridgeForCurrentState],
961
+ embeddedGestureBridgeForCurrentState,
962
+ includeLiveEditEditorChrome,
963
+ ],
1352
964
  );
1353
965
  const liveEditBridgeKey = useMemo(
1354
966
  () => contentHash(liveEditBridgeScript),
@@ -1356,38 +968,52 @@ export function DesignCanvas({
1356
968
  );
1357
969
  const hasLiveEditExternalFrame =
1358
970
  sourceType === "localhost" && Boolean(bridgeUrl && rawExternalPreviewUrl);
1359
- const usesLiveEditEditorBridge =
971
+ const hasAuthenticatedLiveEditExternalFrame =
972
+ hasLiveEditExternalFrame && Boolean(previewToken);
973
+ const usesLiveEditInjectedBridge =
1360
974
  sourceType === "localhost" &&
1361
- Boolean(bridgeUrl && rawExternalPreviewUrl) &&
1362
- !interactMode &&
1363
- !readOnly;
975
+ Boolean(bridgeUrl && previewToken && rawExternalPreviewUrl);
976
+ const usesLiveEditEditorBridge =
977
+ usesLiveEditInjectedBridge && includeLiveEditEditorChrome;
1364
978
  const liveEditBridgeRegistered =
1365
- usesLiveEditEditorBridge &&
979
+ usesLiveEditInjectedBridge &&
1366
980
  registeredLiveEditBridgeKey === liveEditBridgeKey;
1367
- const requiresEditableExternalSnapshot = false;
1368
- const liveEditExternalPreviewUrl =
1369
- hasLiveEditExternalFrame &&
1370
- bridgeUrl &&
1371
- rawExternalPreviewUrl &&
1372
- interactMode
1373
- ? liveEditEndpointUrl(bridgeUrl, rawExternalPreviewUrl, {
1374
- includeEditorBridge: false,
1375
- })
1376
- : liveEditBridgeRegistered && bridgeUrl && rawExternalPreviewUrl
1377
- ? liveEditEndpointUrl(bridgeUrl, rawExternalPreviewUrl)
1378
- : null;
981
+ // The live iframe and the editable source model are deliberately separate:
982
+ // render exactly one authenticated /live-edit document while fetching
983
+ // /snapshot in parallel so DesignEditor patches real HTML rather than the
984
+ // persisted URL string. The snapshot never replaces the live iframe.
985
+ const requiresExternalSourceSnapshot = shouldFetchExternalSourceSnapshot({
986
+ sourceType,
987
+ bridgeUrl,
988
+ previewToken,
989
+ previewUrl: rawExternalPreviewUrl,
990
+ hasSnapshotConsumer: Boolean(onExternalContentSnapshot),
991
+ });
992
+ const liveEditExternalPreviewUrl = resolveLiveEditPreviewUrl({
993
+ sourceType,
994
+ bridgeUrl,
995
+ previewToken,
996
+ previewUrl: rawExternalPreviewUrl,
997
+ bridgeKey: liveEditBridgeKey,
998
+ registeredBridgeKey: registeredLiveEditBridgeKey,
999
+ });
1379
1000
  const iframeRenderContent =
1380
- activeExternalSnapshotHtml ??
1381
- (requiresEditableExternalSnapshot ? "" : renderedContent);
1001
+ !hasAuthenticatedLiveEditExternalFrame && activeExternalSnapshotHtml
1002
+ ? activeExternalSnapshotHtml
1003
+ : renderedContent;
1382
1004
  const externalPreviewUrl =
1383
1005
  liveEditExternalPreviewUrl ??
1384
- (activeExternalSnapshotHtml || requiresEditableExternalSnapshot
1006
+ (hasLiveEditExternalFrame
1385
1007
  ? null
1386
- : rawExternalPreviewUrl);
1387
- const waitingForEditableExternalSnapshot =
1388
- requiresEditableExternalSnapshot && !activeExternalSnapshotHtml;
1008
+ : activeExternalSnapshotHtml
1009
+ ? null
1010
+ : rawExternalPreviewUrl);
1011
+ // Source hydration is parallel and must never block or replace the one live
1012
+ // iframe. Apply-to-source remains guarded until the callback has populated
1013
+ // DesignEditor's source snapshot.
1014
+ const waitingForEditableExternalSnapshot = false;
1389
1015
  const waitingForLiveEditBridge =
1390
- usesLiveEditEditorBridge && !liveEditBridgeRegistered;
1016
+ usesLiveEditInjectedBridge && !liveEditBridgeRegistered;
1391
1017
  zoomRef.current = zoom;
1392
1018
  runtimeReplacementContentRef.current = runtimeReplacementContent;
1393
1019
  runtimeReplacementKeyRef.current = runtimeReplacementKey;
@@ -1400,7 +1026,7 @@ export function DesignCanvas({
1400
1026
  // it gets injected into the proxied preview. Re-runs only when the script
1401
1027
  // content (liveEditBridgeKey) or the bridge target changes.
1402
1028
  useEffect(() => {
1403
- if (!usesLiveEditEditorBridge || !bridgeUrl) {
1029
+ if (!usesLiveEditInjectedBridge || !bridgeUrl || !previewToken) {
1404
1030
  setRegisteredLiveEditBridgeKey(null);
1405
1031
  return;
1406
1032
  }
@@ -1415,9 +1041,12 @@ export function DesignCanvas({
1415
1041
  method: "POST",
1416
1042
  headers: {
1417
1043
  "content-type": "application/json",
1418
- ...(bridgeToken ? { "x-bridge-token": bridgeToken } : {}),
1044
+ "x-design-preview-token": previewToken,
1419
1045
  },
1420
- body: JSON.stringify({ script: liveEditBridgeScript }),
1046
+ body: JSON.stringify({
1047
+ script: liveEditBridgeScript,
1048
+ bridgeKey: liveEditBridgeKey,
1049
+ }),
1421
1050
  });
1422
1051
  if (!response.ok) {
1423
1052
  throw new Error(`Bridge registration failed (${response.status})`);
@@ -1435,18 +1064,19 @@ export function DesignCanvas({
1435
1064
  };
1436
1065
  }, [
1437
1066
  bridgeUrl,
1438
- bridgeToken,
1439
1067
  liveEditBridgeKey,
1440
1068
  liveEditBridgeScript,
1441
- usesLiveEditEditorBridge,
1069
+ previewToken,
1070
+ usesLiveEditInjectedBridge,
1442
1071
  ]);
1443
1072
 
1444
1073
  useEffect(() => {
1445
1074
  const previewUrl = rawExternalPreviewUrl;
1446
1075
  if (
1447
- !requiresEditableExternalSnapshot ||
1076
+ !requiresExternalSourceSnapshot ||
1448
1077
  sourceType !== "localhost" ||
1449
1078
  !bridgeUrl ||
1079
+ !previewToken ||
1450
1080
  !previewUrl
1451
1081
  ) {
1452
1082
  snapshotRetryAttemptRef.current = 0;
@@ -1456,17 +1086,12 @@ export function DesignCanvas({
1456
1086
  setExternalSnapshotState(null);
1457
1087
  return;
1458
1088
  }
1459
- if (externalSnapshotHtml) {
1460
- snapshotRetryAttemptRef.current = 0;
1461
- setExternalSnapshotState(null);
1462
- return;
1463
- }
1464
1089
  let cancelled = false;
1465
1090
  const controller = new AbortController();
1466
1091
  let retryTimer: number | undefined;
1467
1092
  const endpoint = snapshotEndpointUrl(bridgeUrl, previewUrl);
1468
1093
  const scheduleRetry = () => {
1469
- if (!requiresEditableExternalSnapshot || cancelled) return;
1094
+ if (!requiresExternalSourceSnapshot || cancelled) return;
1470
1095
  const delay = getSnapshotRetryDelayMs(snapshotRetryAttemptRef.current);
1471
1096
  snapshotRetryAttemptRef.current += 1;
1472
1097
  retryTimer = window.setTimeout(() => {
@@ -1478,7 +1103,10 @@ export function DesignCanvas({
1478
1103
  try {
1479
1104
  const response = await fetch(endpoint, {
1480
1105
  method: "GET",
1481
- headers: { accept: "application/json" },
1106
+ headers: {
1107
+ accept: "application/json",
1108
+ "x-design-preview-token": previewToken,
1109
+ },
1482
1110
  signal: controller.signal,
1483
1111
  });
1484
1112
  const payload = (await response.json().catch(() => null)) as {
@@ -1502,8 +1130,10 @@ export function DesignCanvas({
1502
1130
  return;
1503
1131
  }
1504
1132
  const sourceUrl = payload.url || previewUrl;
1505
- const html = prepareStaticSnapshotHtml(payload.html ?? "", sourceUrl);
1506
- const stamped = ensureCodeLayerNodeIdsInHtml(html, {
1133
+ const sourceHtml = sanitizeLocalhostSourceSnapshotHtml(
1134
+ payload.html ?? "",
1135
+ );
1136
+ const stamped = ensureCodeLayerNodeIdsInHtml(sourceHtml, {
1507
1137
  source: {
1508
1138
  kind: "remote-url",
1509
1139
  sourceType: "localhost",
@@ -1540,11 +1170,11 @@ export function DesignCanvas({
1540
1170
  };
1541
1171
  }, [
1542
1172
  bridgeUrl,
1543
- externalSnapshotHtml,
1544
1173
  externalSnapshotRetryNonce,
1545
1174
  rawExternalPreviewUrl,
1546
- requiresEditableExternalSnapshot,
1175
+ requiresExternalSourceSnapshot,
1547
1176
  sourceType,
1177
+ previewToken,
1548
1178
  ]);
1549
1179
 
1550
1180
  // Manual retry (offline-state "Retry" button): reset the backoff so the
@@ -1666,6 +1296,13 @@ export function DesignCanvas({
1666
1296
  // never rebuilds srcdoc / reloads every screen iframe.
1667
1297
  const srcdoc = useMemo(() => {
1668
1298
  if (externalPreviewUrl) return undefined;
1299
+ // Never boot the raw localhost URL (or an editor-enabled srcdoc) while
1300
+ // the keyed live-edit bridge is still registering. The opaque placeholder
1301
+ // stays behind the visible loading surface; the real iframe mounts once,
1302
+ // directly at the authenticated proxied document.
1303
+ if (waitingForLiveEditBridge) {
1304
+ return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
1305
+ }
1669
1306
  const editorChromeBridge = interactMode
1670
1307
  ? ""
1671
1308
  : createEditorBridgeThemeScript(readEditorBridgeThemeVars()) +
@@ -1690,10 +1327,6 @@ export function DesignCanvas({
1690
1327
  "__DESIGN_CANVAS_BOARD_SURFACE__",
1691
1328
  boardSurface ? "true" : "false",
1692
1329
  );
1693
- const embeddedWheelBridge = EMBEDDED_WHEEL_BRIDGE_SCRIPT.replace(
1694
- "__EMBEDDED_WHEEL_FORWARDING_ENABLED__",
1695
- isEmbeddedFrame ? "true" : "false",
1696
- );
1697
1330
  // ALWAYS injected (like the other always-on bridges above) so
1698
1331
  // MultiScreenCanvas's cross-screen drag hit-testing
1699
1332
  // (agent-native:hit-test / agent-native:hit-test-result) resolves an
@@ -1708,7 +1341,7 @@ export function DesignCanvas({
1708
1341
  ZOOM_BRIDGE_SCRIPT +
1709
1342
  NAV_BRIDGE_SCRIPT +
1710
1343
  LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT +
1711
- embeddedWheelBridge +
1344
+ embeddedGestureBridgeForCurrentState +
1712
1345
  editorChromeBridge;
1713
1346
  const frameContent = getEmbeddedFrameDocumentContent({
1714
1347
  content: iframeRenderContent,
@@ -1750,10 +1383,22 @@ export function DesignCanvas({
1750
1383
  embeddedFrame?.contentOffsetX,
1751
1384
  embeddedFrame?.contentOffsetY,
1752
1385
  embeddedFrameBackground,
1386
+ embeddedGestureBridgeForCurrentState,
1753
1387
  iframeRenderContent,
1754
1388
  transparentBackground,
1389
+ waitingForLiveEditBridge,
1755
1390
  ]);
1756
1391
 
1392
+ const iframeDocumentIdentity = externalPreviewUrl
1393
+ ? `src:${externalPreviewUrl}`
1394
+ : waitingForLiveEditBridge
1395
+ ? `live-edit-pending:${liveEditBridgeKey}`
1396
+ : `srcdoc:${contentKey ?? ""}:${contentHash(srcdoc ?? "")}`;
1397
+ if (previousIframeDocumentIdentityRef.current !== iframeDocumentIdentity) {
1398
+ previousIframeDocumentIdentityRef.current = iframeDocumentIdentity;
1399
+ bridgeReadyRef.current = false;
1400
+ }
1401
+
1757
1402
  // Listen for messages from the iframe
1758
1403
  useEffect(() => {
1759
1404
  function handleMessage(e: MessageEvent) {
@@ -1768,7 +1413,7 @@ export function DesignCanvas({
1768
1413
  // somehow shares our window reference still can't be trusted.
1769
1414
  const trusted =
1770
1415
  sourceType === "fusion"
1771
- ? iframeWindow != null &&
1416
+ ? iframeWindow !== null &&
1772
1417
  e.source === iframeWindow &&
1773
1418
  isAllowedFusionOrigin(e.origin, fusionUrl)
1774
1419
  : isTrustedCanvasBridgeMessage({
@@ -2088,6 +1733,21 @@ export function DesignCanvas({
2088
1733
  }
2089
1734
  return;
2090
1735
  }
1736
+ if (e.data.type === "embedded-canvas-pan") {
1737
+ const iframe = iframeRef.current;
1738
+ if (!iframe) return;
1739
+ const result = forwardEmbeddedCanvasPanMessage({
1740
+ data: e.data,
1741
+ iframe,
1742
+ hostWindow: window,
1743
+ session: embeddedCanvasPanSessionRef.current,
1744
+ });
1745
+ embeddedCanvasPanSessionRef.current = result.session;
1746
+ // The message type belongs exclusively to the gesture bridge. Even a
1747
+ // malformed/reordered packet must not fall through to another bridge
1748
+ // protocol handler after validation rejects it.
1749
+ return;
1750
+ }
2091
1751
  if (e.data.type === "embedded-canvas-wheel") {
2092
1752
  if (!isEmbeddedFrame) return;
2093
1753
  const iframe = iframeRef.current;
@@ -2211,6 +1871,13 @@ export function DesignCanvas({
2211
1871
  const replayIframeEditorState = useCallback(() => {
2212
1872
  const iframe = iframeRef.current;
2213
1873
  if (!iframe) return;
1874
+ iframe.contentWindow?.postMessage(
1875
+ {
1876
+ type: "embedded-canvas-pan-mode",
1877
+ leftButtonEnabled: handToolActive || spacePanActive,
1878
+ },
1879
+ "*",
1880
+ );
2214
1881
  iframe.contentWindow?.postMessage(
2215
1882
  { type: "tweak-values", values: tweakValues },
2216
1883
  "*",
@@ -2283,6 +1950,7 @@ export function DesignCanvas({
2283
1950
  );
2284
1951
  }
2285
1952
  }, [
1953
+ handToolActive,
2286
1954
  hoveredSelector,
2287
1955
  hoveredSelectorCandidates,
2288
1956
  hiddenSelectors,
@@ -2295,6 +1963,7 @@ export function DesignCanvas({
2295
1963
  selectedSelectorCandidates,
2296
1964
  selectedSelectorGroups,
2297
1965
  shaderFillPreview,
1966
+ spacePanActive,
2298
1967
  tweakValues,
2299
1968
  ]);
2300
1969
 
@@ -2309,6 +1978,22 @@ export function DesignCanvas({
2309
1978
  return () => iframe.removeEventListener("load", replayIframeEditorState);
2310
1979
  }, [replayIframeEditorState]);
2311
1980
 
1981
+ // A real top-level focus loss (Cmd+Tab, switching windows, browser chrome)
1982
+ // does not reliably produce pointercancel inside every browser's iframe.
1983
+ // Explicitly reset the child bridge as well as the host-side packet session
1984
+ // so the next middle/hand drag can always start cleanly after focus returns.
1985
+ useEffect(() => {
1986
+ const handleHostWindowBlur = () => {
1987
+ embeddedCanvasPanSessionRef.current = null;
1988
+ iframeRef.current?.contentWindow?.postMessage(
1989
+ { type: "embedded-canvas-pan-cancel" },
1990
+ "*",
1991
+ );
1992
+ };
1993
+ window.addEventListener("blur", handleHostWindowBlur);
1994
+ return () => window.removeEventListener("blur", handleHostWindowBlur);
1995
+ }, []);
1996
+
2312
1997
  // Fallback for documents that never inject the editor-chrome bridge (e.g.
2313
1998
  // interactMode, or an external-preview iframe where srcdoc is undefined):
2314
1999
  // those never post agent-native:editor-chrome-ready, so nothing would ever
@@ -2322,13 +2007,14 @@ export function DesignCanvas({
2322
2007
  const iframe = iframeRef.current;
2323
2008
  if (!iframe) return;
2324
2009
  function handleLoadReadyFallback() {
2010
+ if (!shouldUseIframeLoadReadyFallback(usesLiveEditEditorBridge)) return;
2325
2011
  if (bridgeReadyRef.current) return;
2326
2012
  bridgeReadyRef.current = true;
2327
2013
  flushPendingOneShotMessages();
2328
2014
  }
2329
2015
  iframe.addEventListener("load", handleLoadReadyFallback);
2330
2016
  return () => iframe.removeEventListener("load", handleLoadReadyFallback);
2331
- }, [flushPendingOneShotMessages]);
2017
+ }, [flushPendingOneShotMessages, usesLiveEditEditorBridge]);
2332
2018
 
2333
2019
  useEffect(() => {
2334
2020
  if (clearSelectionRequest === undefined) return;
@@ -2502,7 +2188,7 @@ export function DesignCanvas({
2502
2188
  * The bridge enters the same contenteditable text-editing path used on dblclick.
2503
2189
  */
2504
2190
  const beginTextEdit = useCallback(
2505
- (nodeId: string) => {
2191
+ (nodeId: string, options?: { afterPointerGesture?: boolean }) => {
2506
2192
  const iframe = iframeRef.current;
2507
2193
  if (!iframe?.contentWindow || !nodeId) return;
2508
2194
  // Arm the creation-race keystroke buffer (see routePendingTextEditKey):
@@ -2517,11 +2203,16 @@ export function DesignCanvas({
2517
2203
  startedAt: Date.now(),
2518
2204
  };
2519
2205
  }
2520
- postOneShotBridgeMessage({
2521
- type: "begin-text-edit",
2522
- nodeId,
2523
- force: true,
2524
- });
2206
+ schedulePendingTextEditActivation(
2207
+ () => {
2208
+ postOneShotBridgeMessage({
2209
+ type: "begin-text-edit",
2210
+ nodeId,
2211
+ force: true,
2212
+ });
2213
+ },
2214
+ { afterPointerGesture: options?.afterPointerGesture },
2215
+ );
2525
2216
  },
2526
2217
  [postOneShotBridgeMessage],
2527
2218
  );
@@ -2623,7 +2314,11 @@ export function DesignCanvas({
2623
2314
 
2624
2315
  const lastStyleBaselineResetRequestRef = useRef<number | null>(null);
2625
2316
  useEffect(() => {
2626
- if (styleBaselineResetRequest == null) return;
2317
+ if (
2318
+ styleBaselineResetRequest === null ||
2319
+ styleBaselineResetRequest === undefined
2320
+ )
2321
+ return;
2627
2322
  if (
2628
2323
  lastStyleBaselineResetRequestRef.current === styleBaselineResetRequest
2629
2324
  ) {
@@ -2919,7 +2614,9 @@ export function DesignCanvas({
2919
2614
  // the deviceFrame width so the caller can render the same source at an
2920
2615
  // explicit viewport width (e.g. 390 / 768 / 1280 side-by-side breakpoints).
2921
2616
  const resolvedWidth =
2922
- previewWidthPx != null ? `${previewWidthPx}px` : iframeWidth;
2617
+ previewWidthPx !== null && previewWidthPx !== undefined
2618
+ ? `${previewWidthPx}px`
2619
+ : iframeWidth;
2923
2620
  const focusScrollSurface = useCallback(() => {
2924
2621
  const surface = scrollContainerRef.current;
2925
2622
  if (!surface || document.activeElement === surface) return;
@@ -2937,15 +2634,11 @@ export function DesignCanvas({
2937
2634
  // plain `mousemove`/`mouseup` window listeners for the drag's lifetime,
2938
2635
  // same pattern as its `installDragListeners(handleMouseMove, handlePanEnd)`.
2939
2636
  //
2940
- // These handlers live on the scroll container itself, so they fire for
2941
- // drags that start over the empty canvas background or the creation
2942
- // overlay (both real DOM nodes in this document). A drag that starts
2943
- // *inside* the iframe's own document does not reach these handlers —
2944
- // mousedown does not cross a same-origin/cross-document boundary any more
2945
- // than wheel events do (see the pinch-zoom-wheel bridge message above for
2946
- // the equivalent problem with wheel) — so panning from inside interactive
2947
- // iframe content is a follow-up that would need its own bridge message,
2948
- // not a gap introduced by this change.
2637
+ // Drags that begin inside the iframe arrive through the generated embedded
2638
+ // gesture bridge as a synthetic mousedown on the parent iframe element,
2639
+ // followed by window mousemove/mouseup events. That deliberately reuses
2640
+ // this exact path, keeping empty-canvas, inline HTML/Alpine, and localhost
2641
+ // live-edit panning behavior identical without an iframe-local pan model.
2949
2642
  const [isPanningState, setIsPanningState] = useState(false);
2950
2643
 
2951
2644
  const handleScrollSurfaceMouseDown = useCallback(
@@ -2972,13 +2665,15 @@ export function DesignCanvas({
2972
2665
  scroll.scrollLeft -= dx;
2973
2666
  scroll.scrollTop -= dy;
2974
2667
  };
2975
- const handleMouseUp = () => {
2668
+ const handlePanEnd = () => {
2976
2669
  setIsPanningState(false);
2977
2670
  window.removeEventListener("mousemove", handleMouseMove);
2978
- window.removeEventListener("mouseup", handleMouseUp);
2671
+ window.removeEventListener("mouseup", handlePanEnd);
2672
+ window.removeEventListener("blur", handlePanEnd);
2979
2673
  };
2980
2674
  window.addEventListener("mousemove", handleMouseMove);
2981
- window.addEventListener("mouseup", handleMouseUp);
2675
+ window.addEventListener("mouseup", handlePanEnd);
2676
+ window.addEventListener("blur", handlePanEnd);
2982
2677
  },
2983
2678
  [handToolActive, spacePanActive],
2984
2679
  );
@@ -3104,7 +2799,9 @@ export function DesignCanvas({
3104
2799
  // layer's own top-left corner stays a fixed point in scroll-content
3105
2800
  // space — is untouched. Base editing (previewWidthPx unset) keeps
3106
2801
  // the original inline-block full-width layout unchanged.
3107
- ...(previewWidthPx != null && !embeddedFrame
2802
+ ...(previewWidthPx !== null &&
2803
+ previewWidthPx !== undefined &&
2804
+ !embeddedFrame
3108
2805
  ? {
3109
2806
  display: "block",
3110
2807
  marginLeft: "auto",
@@ -3114,36 +2811,14 @@ export function DesignCanvas({
3114
2811
  }}
3115
2812
  >
3116
2813
  <iframe
2814
+ key={iframeDocumentIdentity}
3117
2815
  ref={iframeRef}
3118
2816
  src={externalPreviewUrl ?? undefined}
3119
2817
  srcDoc={externalPreviewUrl ? undefined : srcdoc}
3120
- sandbox={
3121
- externalPreviewUrl
3122
- ? "allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin"
3123
- : // NOTE(security): allow-same-origin is still present on this
3124
- // branch (the inline srcdoc iframe, where agent/user-generated
3125
- // design HTML executes) even though allow-scripts +
3126
- // allow-same-origin together defeat the sandbox in principle.
3127
- // This is a deliberate, currently-blocked tradeoff, not an
3128
- // oversight — see the long comment above DesignCanvasProps /
3129
- // sourceType for the security model, and the PNG/SVG export
3130
- // handlers in DesignEditor.tsx (handleDownloadPng /
3131
- // handleDownloadSvg). Those handlers read this same iframe's
3132
- // `contentDocument` directly and hand the LIVE document element
3133
- // to html2canvas, and read live CSSOM (getComputedStyle,
3134
- // doc.styleSheets) for SVG serialization. That cannot be
3135
- // expressed over the postMessage bridge without moving
3136
- // html2canvas execution and CSSOM-dependent serialization logic
3137
- // INTO the sandboxed iframe itself — a separate, larger rewrite,
3138
- // not a bridge-handler addition. All other former
3139
- // `contentDocument`/`contentWindow.document` reads on this
3140
- // iframe (e.g. text-edit-status) now go through bridge
3141
- // request/response postMessage handlers in
3142
- // editor-chrome.bridge.ts and no longer need allow-same-origin.
3143
- // Do not remove this flag until PNG/SVG export is rebuilt to run
3144
- // inside the iframe.
3145
- "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
3146
- }
2818
+ sandbox={getDesignCanvasIframeSandbox({
2819
+ externalPreview: Boolean(externalPreviewUrl),
2820
+ readOnly,
2821
+ })}
3147
2822
  data-design-preview-iframe
3148
2823
  data-screen-iframe-id={
3149
2824
  boardSurface ? undefined : (screenId ?? undefined)
@@ -3285,19 +2960,25 @@ export function DesignCanvas({
3285
2960
  "",
3286
2961
  instruction || "Apply these annotations to the design.",
3287
2962
  ];
3288
- try {
3289
- sendToDesignAgentChat({
3290
- message: lines.join("\n"),
3291
- submit: true,
3292
- openSidebar: true,
3293
- });
3294
- } catch (err) {
3295
- console.error("[DesignCanvas] failed to submit drawing:", err);
3296
- }
3297
- if (queuedAnnotationPins.length > 0) {
3298
- setPinSubmitSignal((signal) => signal + 1);
3299
- }
3300
- onExitDrawMode?.();
2963
+ submitDesignAnnotations({
2964
+ message: lines.join("\n"),
2965
+ hasQueuedPins: queuedAnnotationPins.length > 0,
2966
+ send: (message) => {
2967
+ sendToDesignAgentChat({
2968
+ message,
2969
+ submit: true,
2970
+ openSidebar: true,
2971
+ });
2972
+ },
2973
+ markQueuedPinsSubmitted: () => {
2974
+ setPinSubmitSignal((signal) => signal + 1);
2975
+ },
2976
+ exitDrawMode: () => onExitDrawMode?.(),
2977
+ onError: (error) => {
2978
+ console.error("[DesignCanvas] failed to submit drawing:", error);
2979
+ toast.error(t("designEditor.toasts.annotationSendError"));
2980
+ },
2981
+ });
3301
2982
  }}
3302
2983
  />
3303
2984
  </div>
@@ -3492,6 +3173,17 @@ interface CreationDragState {
3492
3173
  moved: boolean;
3493
3174
  }
3494
3175
 
3176
+ interface SingleScreenPenGestureState {
3177
+ pointerId: number;
3178
+ originClient: { x: number; y: number };
3179
+ anchor: PenPoint;
3180
+ pathBefore: PenPath | null;
3181
+ moved: boolean;
3182
+ closing: boolean;
3183
+ }
3184
+
3185
+ const SINGLE_SCREEN_PEN_HIT_RADIUS_PX = 10;
3186
+
3495
3187
  interface SingleScreenCreationOverlayProps {
3496
3188
  tool: CreationTool;
3497
3189
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -3508,15 +3200,11 @@ interface SingleScreenCreationOverlayProps {
3508
3200
  * is treated as a drag (draws a rect/line via `getDraftGeometryFromPoints`,
3509
3201
  * the same helper MultiScreenCanvas uses for overview drafts); a
3510
3202
  * pointerdown→up with negligible movement is treated as a click (places a
3511
- * default-sized primitive, or for pen, starts a path at that point).
3512
- *
3513
- * Deferred: multi-click pen path authoring. In single-screen mode a single
3514
- * click emits `points: [point], fromClick: true` so the parent can start a
3515
- * pen path (matching MultiScreenCanvas's click-to-start behavior), but there
3516
- * is no in-overlay support yet for adding further anchor points before
3517
- * committing — that's a follow-up once the parent's pen-path commit flow is
3518
- * wired for single-screen. This intentionally never force-switches to
3519
- * overview to get full pen authoring.
3203
+ * default-sized primitive). Pen owns a stateful path until the user presses
3204
+ * Enter/Escape, double-clicks, closes on the first anchor, or chooses another
3205
+ * tool. Clicks add corner anchors; pointer drags add Bezier handles; primary
3206
+ * undo/Delete/Backspace remove the latest active segment. This mirrors the
3207
+ * overview pen workflow without forcing a mode switch.
3520
3208
  */
3521
3209
  function SingleScreenCreationOverlay({
3522
3210
  tool,
@@ -3525,6 +3213,14 @@ function SingleScreenCreationOverlay({
3525
3213
  }: SingleScreenCreationOverlayProps) {
3526
3214
  const [drag, setDrag] = useState<CreationDragState | null>(null);
3527
3215
  const dragRef = useRef<CreationDragState | null>(null);
3216
+ const [penPath, setPenPath] = useState<PenPath | null>(null);
3217
+ const penPathRef = useRef<PenPath | null>(null);
3218
+ const [penGesturePreview, setPenGesturePreview] = useState<PenPath | null>(
3219
+ null,
3220
+ );
3221
+ const penGestureRef = useRef<SingleScreenPenGestureState | null>(null);
3222
+ const [penPointer, setPenPointer] = useState<PenPoint | null>(null);
3223
+ const [penCloseHover, setPenCloseHover] = useState(false);
3528
3224
 
3529
3225
  const toContentPoint = useCallback(
3530
3226
  (clientX: number, clientY: number) => {
@@ -3539,6 +3235,91 @@ function SingleScreenCreationOverlay({
3539
3235
  [iframeRef],
3540
3236
  );
3541
3237
 
3238
+ const penHitRadius = useCallback(() => {
3239
+ const iframe = iframeRef.current;
3240
+ const rect = iframe?.getBoundingClientRect();
3241
+ if (!iframe || !rect || iframe.clientWidth <= 0) {
3242
+ return SINGLE_SCREEN_PEN_HIT_RADIUS_PX;
3243
+ }
3244
+ const scale = rect.width / iframe.clientWidth;
3245
+ return SINGLE_SCREEN_PEN_HIT_RADIUS_PX / (scale || 1);
3246
+ }, [iframeRef]);
3247
+
3248
+ const updatePenPath = useCallback((nextPath: PenPath | null) => {
3249
+ penPathRef.current = nextPath;
3250
+ setPenPath(nextPath);
3251
+ }, []);
3252
+
3253
+ const clearPenPath = useCallback(() => {
3254
+ updatePenPath(null);
3255
+ penGestureRef.current = null;
3256
+ setPenGesturePreview(null);
3257
+ setPenPointer(null);
3258
+ setPenCloseHover(false);
3259
+ }, [updatePenPath]);
3260
+
3261
+ const finishPenPath = useCallback(
3262
+ (
3263
+ path: PenPath | null = penPathRef.current,
3264
+ options?: { preserveActiveTool?: boolean },
3265
+ ) => {
3266
+ const committed = path ? clonePenPath(path) : null;
3267
+ clearPenPath();
3268
+ if (!committed || committed.nodes.length < 2 || !onCreatePrimitive) {
3269
+ return;
3270
+ }
3271
+ onCreatePrimitive({
3272
+ tool: "pen",
3273
+ points: committed.nodes.map((node) => node.point),
3274
+ penPath: committed,
3275
+ fromClick: false,
3276
+ preserveActiveTool: options?.preserveActiveTool,
3277
+ });
3278
+ },
3279
+ [clearPenPath, onCreatePrimitive],
3280
+ );
3281
+
3282
+ const getPenAnchor = useCallback(
3283
+ (clientX: number, clientY: number, shiftKey: boolean) => {
3284
+ const point = toContentPoint(clientX, clientY);
3285
+ const path = penPathRef.current;
3286
+ const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
3287
+ return shiftKey && lastAnchor
3288
+ ? constrainPointTo45Degrees(lastAnchor, point)
3289
+ : point;
3290
+ },
3291
+ [toContentPoint],
3292
+ );
3293
+
3294
+ const updatePenPointer = useCallback(
3295
+ (clientX: number, clientY: number, shiftKey: boolean) => {
3296
+ const path = penPathRef.current;
3297
+ if (!path || path.closed) {
3298
+ setPenPointer(null);
3299
+ setPenCloseHover(false);
3300
+ return;
3301
+ }
3302
+ const rawPoint = toContentPoint(clientX, clientY);
3303
+ const closeHover = isPenCloseTarget(path, rawPoint, penHitRadius());
3304
+ setPenCloseHover(closeHover);
3305
+ setPenPointer(
3306
+ closeHover
3307
+ ? path.nodes[0]!.point
3308
+ : getPenAnchor(clientX, clientY, shiftKey),
3309
+ );
3310
+ },
3311
+ [getPenAnchor, penHitRadius, toContentPoint],
3312
+ );
3313
+
3314
+ const previousToolRef = useRef(tool);
3315
+ useEffect(() => {
3316
+ const previousTool = previousToolRef.current;
3317
+ previousToolRef.current = tool;
3318
+ if (previousTool === "pen" && tool !== "pen") {
3319
+ finishPenPath(penPathRef.current, { preserveActiveTool: true });
3320
+ }
3321
+ }, [finishPenPath, tool]);
3322
+
3542
3323
  const emit = useCallback(
3543
3324
  (state: CreationDragState) => {
3544
3325
  if (!onCreatePrimitive) return;
@@ -3559,17 +3340,8 @@ function SingleScreenCreationOverlay({
3559
3340
  return;
3560
3341
  }
3561
3342
 
3562
- if (activeTool === "pen") {
3563
- // Deferred: multi-click pen authoring (see component doc comment).
3564
- // A click starts a path; a drag is treated the same as a click for
3565
- // now — only the start point is emitted.
3566
- onCreatePrimitive({
3567
- tool: "pen",
3568
- points: [startContent],
3569
- fromClick: true,
3570
- });
3571
- return;
3572
- }
3343
+ // Pen commits through finishPenPath after collecting multiple gestures.
3344
+ if (activeTool === "pen") return;
3573
3345
 
3574
3346
  if (!moved) {
3575
3347
  const size = CREATION_DEFAULT_SIZE[activeTool];
@@ -3608,6 +3380,36 @@ function SingleScreenCreationOverlay({
3608
3380
  const handlePointerDown = useCallback(
3609
3381
  (e: React.PointerEvent<HTMLDivElement>) => {
3610
3382
  if (e.button !== 0) return;
3383
+ if (tool === "pen") {
3384
+ e.preventDefault();
3385
+ e.stopPropagation();
3386
+ const currentPath = penPathRef.current?.closed
3387
+ ? null
3388
+ : penPathRef.current;
3389
+ const pathBefore = currentPath ? clonePenPath(currentPath) : null;
3390
+ const rawPoint = toContentPoint(e.clientX, e.clientY);
3391
+ const closing = isPenCloseTarget(pathBefore, rawPoint, penHitRadius());
3392
+ const anchor = closing
3393
+ ? pathBefore!.nodes[0]!.point
3394
+ : getPenAnchor(e.clientX, e.clientY, e.shiftKey);
3395
+ penGestureRef.current = {
3396
+ pointerId: e.pointerId,
3397
+ originClient: { x: e.clientX, y: e.clientY },
3398
+ anchor,
3399
+ pathBefore,
3400
+ moved: false,
3401
+ closing,
3402
+ };
3403
+ const initialPath = closing
3404
+ ? closePenPath(pathBefore!)
3405
+ : appendPenNode(pathBefore, createCornerNode(anchor));
3406
+ updatePenPath(initialPath);
3407
+ setPenGesturePreview(initialPath);
3408
+ setPenPointer(null);
3409
+ setPenCloseHover(closing);
3410
+ e.currentTarget.setPointerCapture(e.pointerId);
3411
+ return;
3412
+ }
3611
3413
  const point = toContentPoint(e.clientX, e.clientY);
3612
3414
  const next: CreationDragState = {
3613
3415
  tool,
@@ -3621,11 +3423,49 @@ function SingleScreenCreationOverlay({
3621
3423
  setDrag(next);
3622
3424
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
3623
3425
  },
3624
- [tool, toContentPoint],
3426
+ [
3427
+ finishPenPath,
3428
+ getPenAnchor,
3429
+ penHitRadius,
3430
+ tool,
3431
+ toContentPoint,
3432
+ updatePenPath,
3433
+ ],
3625
3434
  );
3626
3435
 
3627
3436
  const handlePointerMove = useCallback(
3628
3437
  (e: React.PointerEvent<HTMLDivElement>) => {
3438
+ if (tool === "pen") {
3439
+ const gesture = penGestureRef.current;
3440
+ if (!gesture || gesture.pointerId !== e.pointerId) {
3441
+ updatePenPointer(e.clientX, e.clientY, e.shiftKey);
3442
+ return;
3443
+ }
3444
+ const moved =
3445
+ gesture.moved ||
3446
+ Math.hypot(
3447
+ e.clientX - gesture.originClient.x,
3448
+ e.clientY - gesture.originClient.y,
3449
+ ) > CREATION_CLICK_MOVE_THRESHOLD_PX;
3450
+ gesture.moved = moved;
3451
+ const rawHandle = toContentPoint(e.clientX, e.clientY);
3452
+ const handleOut = e.shiftKey
3453
+ ? constrainPointTo45Degrees(gesture.anchor, rawHandle)
3454
+ : rawHandle;
3455
+ const nextPath = gesture.closing
3456
+ ? shapeClosingHandles(gesture.pathBefore!, moved ? handleOut : null)
3457
+ : appendPenNode(
3458
+ gesture.pathBefore,
3459
+ moved
3460
+ ? createSmoothNode(gesture.anchor, handleOut, {
3461
+ breakSymmetry: e.altKey,
3462
+ })
3463
+ : createCornerNode(gesture.anchor),
3464
+ );
3465
+ updatePenPath(nextPath);
3466
+ setPenGesturePreview(nextPath);
3467
+ return;
3468
+ }
3629
3469
  const current = dragRef.current;
3630
3470
  if (!current || current.pointerId !== e.pointerId) return;
3631
3471
  // Movement threshold measured in client (viewport) space — a constant
@@ -3646,32 +3486,138 @@ function SingleScreenCreationOverlay({
3646
3486
  dragRef.current = next;
3647
3487
  setDrag(next);
3648
3488
  },
3649
- [toContentPoint],
3489
+ [tool, toContentPoint, updatePenPath, updatePenPointer],
3650
3490
  );
3651
3491
 
3652
3492
  const handlePointerUp = useCallback(
3653
3493
  (e: React.PointerEvent<HTMLDivElement>) => {
3494
+ if (tool === "pen") {
3495
+ const gesture = penGestureRef.current;
3496
+ if (!gesture || gesture.pointerId !== e.pointerId) return;
3497
+ e.preventDefault();
3498
+ e.stopPropagation();
3499
+ const moved =
3500
+ gesture.moved ||
3501
+ Math.hypot(
3502
+ e.clientX - gesture.originClient.x,
3503
+ e.clientY - gesture.originClient.y,
3504
+ ) > CREATION_CLICK_MOVE_THRESHOLD_PX;
3505
+ const rawHandle = toContentPoint(e.clientX, e.clientY);
3506
+ const handleOut = e.shiftKey
3507
+ ? constrainPointTo45Degrees(gesture.anchor, rawHandle)
3508
+ : rawHandle;
3509
+ const nextPath = gesture.closing
3510
+ ? shapeClosingHandles(gesture.pathBefore!, moved ? handleOut : null)
3511
+ : appendPenNode(
3512
+ gesture.pathBefore,
3513
+ moved
3514
+ ? createSmoothNode(gesture.anchor, handleOut, {
3515
+ breakSymmetry: e.altKey,
3516
+ })
3517
+ : createCornerNode(gesture.anchor),
3518
+ );
3519
+ penGestureRef.current = null;
3520
+ setPenGesturePreview(null);
3521
+ setPenPointer(null);
3522
+ setPenCloseHover(false);
3523
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
3524
+ e.currentTarget.releasePointerCapture(e.pointerId);
3525
+ }
3526
+ if (gesture.closing) {
3527
+ finishPenPath(nextPath);
3528
+ } else {
3529
+ updatePenPath(nextPath);
3530
+ }
3531
+ return;
3532
+ }
3654
3533
  const current = dragRef.current;
3655
3534
  if (!current || current.pointerId !== e.pointerId) return;
3656
3535
  dragRef.current = null;
3657
3536
  setDrag(null);
3658
3537
  emit(current);
3659
3538
  },
3660
- [emit],
3539
+ [emit, finishPenPath, tool, toContentPoint, updatePenPath],
3661
3540
  );
3662
3541
 
3663
3542
  const handlePointerCancel = useCallback(
3664
3543
  (e: React.PointerEvent<HTMLDivElement>) => {
3544
+ if (tool === "pen") {
3545
+ const gesture = penGestureRef.current;
3546
+ if (!gesture || gesture.pointerId !== e.pointerId) return;
3547
+ penGestureRef.current = null;
3548
+ updatePenPath(gesture.pathBefore);
3549
+ setPenGesturePreview(null);
3550
+ setPenPointer(null);
3551
+ setPenCloseHover(false);
3552
+ return;
3553
+ }
3665
3554
  const current = dragRef.current;
3666
3555
  if (!current || current.pointerId !== e.pointerId) return;
3667
3556
  dragRef.current = null;
3668
3557
  setDrag(null);
3669
3558
  },
3670
- [],
3559
+ [tool, updatePenPath],
3671
3560
  );
3672
3561
 
3562
+ useEffect(() => {
3563
+ if (tool !== "pen") return;
3564
+ const handleKeyDown = (event: KeyboardEvent) => {
3565
+ const path = penPathRef.current;
3566
+ if (!path) return;
3567
+ const target = event.target;
3568
+ if (
3569
+ target instanceof HTMLElement &&
3570
+ (target.isContentEditable ||
3571
+ target.tagName === "INPUT" ||
3572
+ target.tagName === "TEXTAREA")
3573
+ ) {
3574
+ return;
3575
+ }
3576
+
3577
+ const primary = event.metaKey || event.ctrlKey;
3578
+ const undo =
3579
+ primary && !event.shiftKey && event.key.toLowerCase() === "z";
3580
+ const removeSegment =
3581
+ !primary && (event.key === "Delete" || event.key === "Backspace");
3582
+ if (undo || removeSegment) {
3583
+ event.preventDefault();
3584
+ event.stopPropagation();
3585
+ event.stopImmediatePropagation();
3586
+ if (penGestureRef.current) return;
3587
+ const remainingNodes = path.nodes.slice(0, -1);
3588
+ if (remainingNodes.length === 0) {
3589
+ clearPenPath();
3590
+ } else {
3591
+ updatePenPath({ nodes: remainingNodes, closed: false });
3592
+ setPenGesturePreview(null);
3593
+ setPenPointer(null);
3594
+ setPenCloseHover(false);
3595
+ }
3596
+ return;
3597
+ }
3598
+
3599
+ if (primary || (event.key !== "Enter" && event.key !== "Escape")) {
3600
+ return;
3601
+ }
3602
+ event.preventDefault();
3603
+ event.stopPropagation();
3604
+ event.stopImmediatePropagation();
3605
+ finishPenPath(path);
3606
+ };
3607
+
3608
+ window.addEventListener("keydown", handleKeyDown, true);
3609
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
3610
+ }, [clearPenPath, finishPenPath, tool, updatePenPath]);
3611
+
3673
3612
  const cursorClass = tool === "text" ? "cursor-text" : "cursor-crosshair";
3674
3613
  const isLineTool = tool === "line" || tool === "arrow";
3614
+ const displayedPenPath =
3615
+ penGesturePreview ??
3616
+ (penPath && penPointer
3617
+ ? penCloseHover
3618
+ ? closePenPath(penPath)
3619
+ : appendPenNode(penPath, createCornerNode(penPointer))
3620
+ : penPath);
3675
3621
 
3676
3622
  // Live drag preview, converted back from screen-content space to the
3677
3623
  // overlay's own (unscaled, layout-space) coordinates. The overlay div is
@@ -3700,7 +3646,26 @@ function SingleScreenCreationOverlay({
3700
3646
  onPointerMove={handlePointerMove}
3701
3647
  onPointerUp={handlePointerUp}
3702
3648
  onPointerCancel={handlePointerCancel}
3649
+ onDoubleClick={(event) => {
3650
+ if (tool !== "pen" || !penPathRef.current) return;
3651
+ event.preventDefault();
3652
+ event.stopPropagation();
3653
+ finishPenPath(collapseDoubleClickPenAnchor(penPathRef.current));
3654
+ }}
3655
+ onPointerLeave={() => {
3656
+ if (tool === "pen" && !penGestureRef.current) {
3657
+ setPenPointer(null);
3658
+ setPenCloseHover(false);
3659
+ }
3660
+ }}
3703
3661
  >
3662
+ {tool === "pen" && displayedPenPath ? (
3663
+ <SingleScreenPenPathOverlay
3664
+ path={displayedPenPath}
3665
+ closeHover={penCloseHover}
3666
+ iframeRef={iframeRef}
3667
+ />
3668
+ ) : null}
3704
3669
  {previewRect ? (
3705
3670
  <div
3706
3671
  data-creation-preview-rect
@@ -3733,3 +3698,115 @@ function SingleScreenCreationOverlay({
3733
3698
  </div>
3734
3699
  );
3735
3700
  }
3701
+
3702
+ function SingleScreenPenPathOverlay({
3703
+ path,
3704
+ closeHover,
3705
+ iframeRef,
3706
+ }: {
3707
+ path: PenPath;
3708
+ closeHover: boolean;
3709
+ iframeRef: React.RefObject<HTMLIFrameElement | null>;
3710
+ }) {
3711
+ const iframe = iframeRef.current;
3712
+ const rect = iframe?.getBoundingClientRect();
3713
+ const renderedScale =
3714
+ iframe && rect && iframe.clientWidth > 0
3715
+ ? rect.width / iframe.clientWidth
3716
+ : 1;
3717
+ const chromeScale = 1 / (renderedScale || 1);
3718
+ const anchorSize = 8 * chromeScale;
3719
+ const handleSize = 6 * chromeScale;
3720
+ const pathData = serializePenPath(path);
3721
+
3722
+ return (
3723
+ <div
3724
+ data-pen-path-overlay
3725
+ className="pointer-events-none absolute inset-0 z-[90]"
3726
+ >
3727
+ <svg className="absolute inset-0 size-full overflow-visible">
3728
+ {path.nodes.map((node, index) => (
3729
+ <g key={`single-screen-pen-handles-${index}`}>
3730
+ {node.handleIn ? (
3731
+ <line
3732
+ x1={node.point.x}
3733
+ y1={node.point.y}
3734
+ x2={node.handleIn.x}
3735
+ y2={node.handleIn.y}
3736
+ stroke="var(--design-editor-accent-color)"
3737
+ strokeDasharray="3 3"
3738
+ strokeWidth={Math.max(1, chromeScale)}
3739
+ />
3740
+ ) : null}
3741
+ {node.handleOut ? (
3742
+ <line
3743
+ x1={node.point.x}
3744
+ y1={node.point.y}
3745
+ x2={node.handleOut.x}
3746
+ y2={node.handleOut.y}
3747
+ stroke="var(--design-editor-accent-color)"
3748
+ strokeDasharray="3 3"
3749
+ strokeWidth={Math.max(1, chromeScale)}
3750
+ />
3751
+ ) : null}
3752
+ </g>
3753
+ ))}
3754
+ <path
3755
+ d={pathData}
3756
+ fill="none"
3757
+ stroke="rgba(255,255,255,0.95)"
3758
+ strokeLinecap="round"
3759
+ strokeLinejoin="round"
3760
+ strokeWidth={5 * chromeScale}
3761
+ />
3762
+ <path
3763
+ d={pathData}
3764
+ fill="none"
3765
+ stroke="var(--design-editor-accent-color)"
3766
+ strokeLinecap="round"
3767
+ strokeLinejoin="round"
3768
+ strokeWidth={2 * chromeScale}
3769
+ />
3770
+ </svg>
3771
+ {path.nodes.map((node, index) => (
3772
+ <span
3773
+ key={`single-screen-pen-anchor-${index}`}
3774
+ data-pen-anchor
3775
+ className={cn(
3776
+ "absolute rounded-[2px] border shadow-sm",
3777
+ index === 0 && closeHover
3778
+ ? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
3779
+ : "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
3780
+ )}
3781
+ style={{
3782
+ left: node.point.x - anchorSize / 2,
3783
+ top: node.point.y - anchorSize / 2,
3784
+ width: anchorSize,
3785
+ height: anchorSize,
3786
+ borderWidth: Math.max(1, chromeScale),
3787
+ }}
3788
+ />
3789
+ ))}
3790
+ {path.nodes.flatMap((node, index) =>
3791
+ [node.handleIn, node.handleOut].flatMap((handle, handleIndex) =>
3792
+ handle
3793
+ ? [
3794
+ <span
3795
+ key={`single-screen-pen-handle-${index}-${handleIndex}`}
3796
+ data-pen-handle
3797
+ className="absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
3798
+ style={{
3799
+ left: handle.x - handleSize / 2,
3800
+ top: handle.y - handleSize / 2,
3801
+ width: handleSize,
3802
+ height: handleSize,
3803
+ borderWidth: Math.max(1, chromeScale),
3804
+ }}
3805
+ />,
3806
+ ]
3807
+ : [],
3808
+ ),
3809
+ )}
3810
+ </div>
3811
+ );
3812
+ }