@agent-native/core 0.169.1 → 0.171.0

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 (463) hide show
  1. package/agent-native.eject.json +4 -1
  2. package/bin/agent-native.js +23 -5
  3. package/corpus/README.md +1 -1
  4. package/corpus/templates/analytics/.agents/skills/secrets/SKILL.md +11 -4
  5. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
  6. package/corpus/templates/analytics/app/i18n-data.ts +28 -0
  7. package/corpus/templates/analytics/app/pages/Agents.tsx +25 -1
  8. package/corpus/templates/analytics/app/routes/$.tsx +10 -0
  9. package/corpus/templates/analytics/app/routes.ts +3 -1
  10. package/corpus/templates/analytics/changelog/2026-08-22-fixed-the-agent-native-templates-first-party-dashboard-panel.md +6 -0
  11. package/corpus/templates/analytics/changelog/2026-08-22-org-admin-panels-no-longer-report-a-database-error-as-a-per.md +9 -0
  12. package/corpus/templates/analytics/changelog/2026-08-22-slack-requests-report-invalid-inputs.md +6 -0
  13. package/corpus/templates/analytics/changelog/2026-08-22-unknown-analytics-links-now-return-a-real-not-found-status.md +6 -0
  14. package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +2 -1
  15. package/corpus/templates/analytics/docs/schemas/tracked-events.md +2 -0
  16. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +29 -7
  17. package/corpus/templates/analytics/server/handlers/slack.ts +59 -5
  18. package/corpus/templates/analytics/server/lib/db-admin-connections.ts +11 -6
  19. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +132 -2
  20. package/corpus/templates/analytics/server/lib/slack.ts +4 -2
  21. package/corpus/templates/analytics/vitest.config.ts +6 -1
  22. package/corpus/templates/assets/.agents/skills/secrets/SKILL.md +11 -4
  23. package/corpus/templates/assets/app/routes/library.tsx +5 -3
  24. package/corpus/templates/assets/app/routes.ts +3 -1
  25. package/corpus/templates/assets/changelog/2026-08-22-library-page-loads-again-instead-of-crashing-on-open.md +6 -0
  26. package/corpus/templates/assets/vitest.config.ts +6 -1
  27. package/corpus/templates/brain/.agents/skills/secrets/SKILL.md +11 -4
  28. package/corpus/templates/brain/app/components/ui/pointer-lock.ts +1 -0
  29. package/corpus/templates/brain/app/routes/sources.tsx +8 -3
  30. package/corpus/templates/brain/changelog/2026-08-22-tuning-or-adding-a-source-no-longer-leaves-the-sources-page.md +6 -0
  31. package/corpus/templates/brain/vitest.config.ts +1 -0
  32. package/corpus/templates/calendar/.agents/skills/secrets/SKILL.md +11 -4
  33. package/corpus/templates/calendar/actions/list-booking-links.ts +83 -4
  34. package/corpus/templates/calendar/actions/update-booking-link.ts +4 -3
  35. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +11 -3
  36. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +1 -1
  37. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +103 -44
  38. package/corpus/templates/calendar/app/components/calendar/OutOfOfficeEvent.tsx +28 -24
  39. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +9 -2
  40. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +47 -58
  41. package/corpus/templates/calendar/app/hooks/use-booking-links.ts +5 -1
  42. package/corpus/templates/calendar/app/lib/event-layout.ts +52 -26
  43. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +126 -98
  44. package/corpus/templates/calendar/app/pages/CalendarView.tsx +1 -0
  45. package/corpus/templates/calendar/app/pages/Settings.tsx +88 -81
  46. package/corpus/templates/calendar/app/routes.ts +3 -1
  47. package/corpus/templates/calendar/changelog/2026-08-22-booking-link-visibility-respects-shared-access.md +6 -0
  48. package/corpus/templates/calendar/changelog/2026-08-22-clicking-an-out-of-office-event-s-marker-no-longer-creates-a.md +6 -0
  49. package/corpus/templates/calendar/changelog/2026-08-22-event-detail-views-now-show-your-calendar-timezone.md +6 -0
  50. package/corpus/templates/calendar/changelog/2026-08-22-google-calendar-can-now-connect-with-the-shared-google-oauth.md +6 -0
  51. package/corpus/templates/calendar/changelog/2026-08-22-overlapping-events-now-show-each-others-boundaries.md +6 -0
  52. package/corpus/templates/calendar/changelog/2026-08-22-public-booking-pages-now-offer-time-slots-from-the-host-s-we.md +6 -0
  53. package/corpus/templates/calendar/server/handlers/bookings.ts +5 -7
  54. package/corpus/templates/calendar/server/handlers/google-auth.ts +10 -2
  55. package/corpus/templates/calendar/server/lib/google-calendar.ts +139 -14
  56. package/corpus/templates/calendar/server/onboarding.ts +85 -0
  57. package/corpus/templates/calendar/server/plugins/agent-chat.ts +2 -0
  58. package/corpus/templates/calendar/shared/api.ts +9 -1
  59. package/corpus/templates/chat/.agents/skills/secrets/SKILL.md +11 -4
  60. package/corpus/templates/chat/app/routes.ts +3 -1
  61. package/corpus/templates/chat/vitest.config.ts +8 -0
  62. package/corpus/templates/clips/.agents/skills/secrets/SKILL.md +11 -4
  63. package/corpus/templates/clips/actions/get-meeting.ts +9 -4
  64. package/corpus/templates/clips/actions/list-recordings.ts +16 -3
  65. package/corpus/templates/clips/actions/search-meetings.ts +1 -0
  66. package/corpus/templates/clips/actions/view-screen.ts +8 -1
  67. package/corpus/templates/clips/app/components/meetings/meeting-history-row.tsx +32 -1
  68. package/corpus/templates/clips/app/components/player/delete-recording-menu.tsx +1 -1
  69. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +29 -1
  70. package/corpus/templates/clips/app/i18n/en-US.ts +5 -0
  71. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +33 -4
  72. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +1 -0
  73. package/corpus/templates/clips/app/routes/access-request.approve.tsx +16 -9
  74. package/corpus/templates/clips/app/routes/embed.$shareId.tsx +17 -7
  75. package/corpus/templates/clips/app/routes/share.$shareId.tsx +17 -7
  76. package/corpus/templates/clips/changelog/2026-08-21-camera-bubble-black-circle.md +6 -0
  77. package/corpus/templates/clips/changelog/2026-08-22-screen-recordings-respect-the-microphone-toggle.md +6 -0
  78. package/corpus/templates/clips/changelog/2026-08-22-the-recordings-library-no-longer-fails-right-after-server-start.md +6 -0
  79. package/corpus/templates/clips/changelog/2026-08-23-the-agent-can-see-your-library-right-after-server-start.md +6 -0
  80. package/corpus/templates/clips/chrome-extension/src/background.ts +1 -2
  81. package/corpus/templates/clips/chrome-extension/src/native-transcription.ts +39 -7
  82. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +21 -25
  83. package/corpus/templates/clips/desktop/src/app.tsx +53 -24
  84. package/corpus/templates/clips/desktop/src/lib/bubble-playback.ts +68 -0
  85. package/corpus/templates/clips/desktop/src/lib/recorder.ts +3 -0
  86. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +72 -14
  87. package/corpus/templates/clips/desktop/src/lib/updater.ts +8 -3
  88. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +162 -20
  89. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +43 -6
  90. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +150 -13
  91. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +12 -0
  92. package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +28 -3
  93. package/corpus/templates/clips/server/lib/emails.ts +17 -33
  94. package/corpus/templates/content/.agents/skills/secrets/SKILL.md +11 -4
  95. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +140 -15
  96. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +12 -14
  97. package/corpus/templates/content/app/components/ui/pointer-lock.ts +1 -0
  98. package/corpus/templates/content/app/hooks/use-content-database.ts +18 -2
  99. package/corpus/templates/content/app/hooks/use-local-storage.ts +38 -22
  100. package/corpus/templates/content/app/i18n-data.ts +25 -0
  101. package/corpus/templates/content/app/routes.ts +3 -1
  102. package/corpus/templates/content/changelog/2026-08-22-code-blocks-inserted-from-the-slash-menu-no-longer-get-stuck.md +6 -0
  103. package/corpus/templates/content/changelog/2026-08-22-the-sidebar-page-tree-no-longer-flashes-empty-after-creating-a-page.md +6 -0
  104. package/corpus/templates/content/docs/solutions/2026-08-21-content-mermaid-rendering-shape.md +233 -0
  105. package/corpus/templates/content/server/plugins/agent-chat.ts +9 -2
  106. package/corpus/templates/content/vitest.config.ts +7 -1
  107. package/corpus/templates/crm/.agents/skills/secrets/SKILL.md +11 -4
  108. package/corpus/templates/design/.agents/skills/secrets/SKILL.md +11 -4
  109. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1383 -88
  110. package/corpus/templates/design/actions/generate-design.ts +98 -47
  111. package/corpus/templates/design/actions/get-design-system.ts +6 -1
  112. package/corpus/templates/design/actions/refresh-design-system-with-builder.ts +146 -0
  113. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +70 -26
  114. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +284 -136
  115. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +2057 -141
  116. package/corpus/templates/design/app/components/design/editor/DesignBottomToolbar.tsx +6 -2
  117. package/corpus/templates/design/app/components/design/editor/DesignWorkspaceRail.tsx +4 -4
  118. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +10 -1
  119. package/corpus/templates/design/app/components/design/multi-screen/alt-hover-measurement.ts +19 -2
  120. package/corpus/templates/design/app/components/design/multi-screen/types.ts +9 -2
  121. package/corpus/templates/design/app/components/layout/Header.tsx +15 -0
  122. package/corpus/templates/design/app/components/layout/Layout.tsx +7 -2
  123. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +11 -4
  124. package/corpus/templates/design/app/i18n-data.ts +1 -0
  125. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +12 -0
  126. package/corpus/templates/design/app/lib/design-system-data.ts +68 -0
  127. package/corpus/templates/design/app/pages/DesignEditor.tsx +77 -35
  128. package/corpus/templates/design/app/pages/DesignSystems.tsx +136 -42
  129. package/corpus/templates/design/app/pages/Index.tsx +2 -0
  130. package/corpus/templates/design/app/pages/Templates.tsx +0 -10
  131. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +10 -0
  132. package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +50 -0
  133. package/corpus/templates/design/app/pages/design-editor/commands/apply-design-editor-command.ts +1 -1
  134. package/corpus/templates/design/app/pages/design-editor/commands/apply-file-content-update.ts +10 -8
  135. package/corpus/templates/design/app/pages/design-editor/commands/apply-local-content-update.ts +6 -6
  136. package/corpus/templates/design/app/pages/design-editor/commands/apply-pending-visual-styles-with-agent.ts +1 -1
  137. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +18 -7
  138. package/corpus/templates/design/app/pages/design-editor/commands/escape-hotkey.ts +4 -90
  139. package/corpus/templates/design/app/pages/design-editor/commands/layer-marquee-selection-change.ts +2 -1
  140. package/corpus/templates/design/app/pages/design-editor/commands/layer-selection-change.ts +4 -3
  141. package/corpus/templates/design/app/pages/design-editor/commands/screen-element-select.ts +12 -2
  142. package/corpus/templates/design/app/pages/design-editor/commands/screen-visual-style-change.ts +9 -2
  143. package/corpus/templates/design/app/pages/design-editor/commands/send-runtime-layer-move-semantic-handoff.ts +1 -1
  144. package/corpus/templates/design/app/pages/design-editor/commands/send-runtime-layer-semantic-handoff.ts +1 -1
  145. package/corpus/templates/design/app/pages/design-editor/commands/send-runtime-layer-state-semantic-handoff.ts +1 -1
  146. package/corpus/templates/design/app/pages/design-editor/commands/start-sidebar-resize.ts +1 -1
  147. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +11 -1
  148. package/corpus/templates/design/app/pages/design-editor/effects/adopt-db-file-content.ts +11 -19
  149. package/corpus/templates/design/app/pages/design-editor/effects/observe-collab-text.ts +6 -6
  150. package/corpus/templates/design/app/pages/design-editor/effects/publish-agent-selection-context.ts +1 -1
  151. package/corpus/templates/design/app/pages/design-editor/effects/seed-collab-content.ts +12 -15
  152. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -59
  153. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +8 -0
  154. package/corpus/templates/design/app/pages/design-editor/types.ts +3 -3
  155. package/corpus/templates/design/app/routes.ts +3 -1
  156. package/corpus/templates/design/changelog/2026-08-20-canvas-edits-patch-the-frame-instead-of-rebuilding-it.md +6 -0
  157. package/corpus/templates/design/changelog/2026-08-21-builder-design-system-imports-reconcile-after-indexing.md +6 -0
  158. package/corpus/templates/design/changelog/2026-08-21-draw-mode-needs-shift-y.md +6 -0
  159. package/corpus/templates/design/changelog/2026-08-21-eyedropper-shortcut.md +6 -0
  160. package/corpus/templates/design/changelog/2026-08-21-figma-style-alignment-guides-and-snapping.md +6 -0
  161. package/corpus/templates/design/changelog/2026-08-21-group-transforms-keep-the-selection.md +6 -0
  162. package/corpus/templates/design/changelog/2026-08-21-inspector-fields-keep-focus-on-enter.md +6 -0
  163. package/corpus/templates/design/changelog/2026-08-21-pen-alt-keeps-the-incoming-handle.md +6 -0
  164. package/corpus/templates/design/changelog/2026-08-21-pen-on-the-board-creates-one-vector.md +6 -0
  165. package/corpus/templates/design/changelog/2026-08-21-scale-a-multi-selection-from-the-group-handles.md +6 -0
  166. package/corpus/templates/design/changelog/2026-08-21-scale-resizes-text-with-the-object.md +6 -0
  167. package/corpus/templates/design/changelog/2026-08-21-selecting-another-object-keeps-the-scale-tool-armed.md +6 -0
  168. package/corpus/templates/design/changelog/2026-08-21-the-shape-tool-remembers-the-shape-you-picked.md +6 -0
  169. package/corpus/templates/design/changelog/2026-08-21-vectors-drawn-in-a-frame-land-where-you-drew-them.md +6 -0
  170. package/corpus/templates/design/changelog/2026-08-22-multi-screen-generation-no-longer-loses-a-saved-screens-layo.md +6 -0
  171. package/corpus/templates/design/changelog/2026-08-22-rename-and-search-fields-are-labeled-for-screen-readers.md +6 -0
  172. package/corpus/templates/design/package.json +2 -0
  173. package/corpus/templates/design/server/lib/builder-design-system-proxy.ts +311 -0
  174. package/corpus/templates/design/shared/canvas-math.ts +612 -206
  175. package/corpus/templates/design/shared/pen-path.ts +37 -0
  176. package/corpus/templates/design/vitest.config.ts +7 -1
  177. package/corpus/templates/dispatch/.agents/skills/secrets/SKILL.md +11 -4
  178. package/corpus/templates/dispatch/actions/list-workspace-connections.ts +34 -3
  179. package/corpus/templates/dispatch/app/routes/integrations.tsx +55 -21
  180. package/corpus/templates/dispatch/app/routes.ts +3 -1
  181. package/corpus/templates/factory/.agents/skills/secrets/SKILL.md +11 -4
  182. package/corpus/templates/factory/.env.example +2 -7
  183. package/corpus/templates/factory/AGENTS.md +3 -0
  184. package/corpus/templates/factory/README.md +5 -3
  185. package/corpus/templates/factory/actions/create-factory.ts +16 -3
  186. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +2 -0
  187. package/corpus/templates/factory/actions/get-triage-item.ts +2 -0
  188. package/corpus/templates/factory/actions/list-factory-audit.ts +116 -36
  189. package/corpus/templates/factory/actions/list-factory-automations.ts +2 -1
  190. package/corpus/templates/factory/actions/list-triage-items.ts +22 -19
  191. package/corpus/templates/factory/actions/run-factory-automation.ts +7 -3
  192. package/corpus/templates/factory/actions/save-factory-automation.ts +5 -2
  193. package/corpus/templates/factory/actions/save-triage-config.ts +2 -8
  194. package/corpus/templates/factory/actions/start-builder-for-item.ts +21 -0
  195. package/corpus/templates/factory/app/components/factory/FactoryAuditView.tsx +229 -297
  196. package/corpus/templates/factory/app/components/factory/FactorySettingsView.tsx +397 -247
  197. package/corpus/templates/factory/app/components/factory/FactorySourceSettingsGroup.tsx +33 -0
  198. package/corpus/templates/factory/app/components/factory/NewFactoryForm.tsx +202 -162
  199. package/corpus/templates/factory/app/i18n/en-US.ts +32 -4
  200. package/corpus/templates/factory/app/routes/factory.tsx +84 -11
  201. package/corpus/templates/factory/app/routes.ts +3 -1
  202. package/corpus/templates/factory/changelog/2026-08-21-activity-runs-use-automation-display-names.md +6 -0
  203. package/corpus/templates/factory/changelog/2026-08-21-create-and-settings-source-cards.md +6 -0
  204. package/corpus/templates/factory/changelog/2026-08-21-keep-automations-visible-while-running.md +6 -0
  205. package/corpus/templates/factory/changelog/2026-08-21-nested-factory-automations-can-poll.md +6 -0
  206. package/corpus/templates/factory/changelog/2026-08-21-settings-sticky-unsaved-bar.md +6 -0
  207. package/corpus/templates/factory/server/connectors/credentials.ts +10 -5
  208. package/corpus/templates/factory/server/lib/factory-audit-report.ts +412 -0
  209. package/corpus/templates/factory/server/lib/factory-config-reconcile.ts +321 -0
  210. package/corpus/templates/factory/server/lib/factory-scope.ts +25 -2
  211. package/corpus/templates/factory/server/lib/require-factory-automation.ts +13 -4
  212. package/corpus/templates/factory/server/plugins/factory-migrations.ts +148 -4
  213. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +4 -6
  214. package/corpus/templates/factory/vitest.config.ts +17 -2
  215. package/corpus/templates/forms/.agents/skills/secrets/SKILL.md +11 -4
  216. package/corpus/templates/forms/app/routes/_app.settings.tsx +4 -1
  217. package/corpus/templates/forms/changelog/2026-08-22-the-extensions-link-now-opens-the-extensions-tab-instead-o.md +6 -0
  218. package/corpus/templates/forms/server/lib/public-form-ssr.ts +11 -1
  219. package/corpus/templates/forms/vitest.config.ts +1 -0
  220. package/corpus/templates/macros/.agents/skills/secrets/SKILL.md +11 -4
  221. package/corpus/templates/macros/app/routes.ts +3 -1
  222. package/corpus/templates/macros/vitest.config.ts +6 -1
  223. package/corpus/templates/mail/.agents/skills/secrets/SKILL.md +11 -4
  224. package/corpus/templates/mail/actions/manage-gmail-filters.ts +10 -2
  225. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +100 -37
  226. package/corpus/templates/mail/app/components/email/IntegrationsSidebar.tsx +14 -2
  227. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +66 -36
  228. package/corpus/templates/mail/app/hooks/use-google-auth.ts +2 -0
  229. package/corpus/templates/mail/app/lib/google-oauth-setup.ts +17 -0
  230. package/corpus/templates/mail/app/pages/InboxPage.tsx +69 -9
  231. package/corpus/templates/mail/app/routes/$.tsx +9 -0
  232. package/corpus/templates/mail/app/routes/$view.$threadId.tsx +23 -1
  233. package/corpus/templates/mail/app/routes/$view.tsx +47 -1
  234. package/corpus/templates/mail/app/routes.ts +3 -1
  235. package/corpus/templates/mail/changelog/2026-08-22-gmail-can-now-connect-with-the-shared-google-oauth.md +6 -0
  236. package/corpus/templates/mail/changelog/2026-08-22-gmail-filters-now-shows-the-real-error.md +6 -0
  237. package/corpus/templates/mail/changelog/2026-08-22-unknown-mail-urls-now-show-the-404-page.md +6 -0
  238. package/corpus/templates/mail/server/handlers/google-auth.ts +11 -2
  239. package/corpus/templates/mail/server/lib/google-auth.ts +92 -12
  240. package/corpus/templates/mail/server/onboarding.ts +66 -18
  241. package/corpus/templates/mail/vitest.config.ts +6 -1
  242. package/corpus/templates/plan/.agents/skills/secrets/SKILL.md +11 -4
  243. package/corpus/templates/plan/app/routes/settings.tsx +4 -1
  244. package/corpus/templates/plan/app/routes.ts +3 -1
  245. package/corpus/templates/plan/changelog/2026-08-22-the-extensions-link-now-opens-the-extensions-tab-instead-o.md +6 -0
  246. package/corpus/templates/plan/netlify.toml +1 -1
  247. package/corpus/templates/slides/.agents/skills/secrets/SKILL.md +11 -4
  248. package/corpus/templates/slides/actions/patch-deck.ts +79 -10
  249. package/corpus/templates/slides/actions/update-slide.ts +11 -1
  250. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +14 -4
  251. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +10 -52
  252. package/corpus/templates/slides/app/components/editor/GoogleDocImportHint.tsx +8 -53
  253. package/corpus/templates/slides/app/components/editor/GoogleDriveConnectionCta.tsx +9 -75
  254. package/corpus/templates/slides/app/components/editor/GoogleSlidesReferenceImport.tsx +8 -60
  255. package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +10 -2
  256. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +19 -7
  257. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +49 -36
  258. package/corpus/templates/slides/app/routes/access-request.approve.tsx +16 -9
  259. package/corpus/templates/slides/app/routes.ts +3 -1
  260. package/corpus/templates/slides/changelog/2026-08-22-already-uploaded-chat-images-are-no-longer-dropped.md +6 -0
  261. package/corpus/templates/slides/changelog/2026-08-22-google-drive-and-slides-can-now-connect-with-the-shared-google-oauth.md +6 -0
  262. package/corpus/templates/slides/changelog/2026-08-22-multi-slide-repairs-now-report-slides-that-still-overflow.md +6 -0
  263. package/corpus/templates/slides/changelog/2026-08-22-update-slide-now-reports-which-batched-edit-was-skipped.md +6 -0
  264. package/corpus/templates/slides/server/handlers/google-docs-auth.ts +52 -1
  265. package/corpus/templates/slides/server/lib/chat-attachments.ts +29 -3
  266. package/corpus/templates/slides/server/lib/google-docs-oauth.ts +66 -16
  267. package/corpus/templates/slides/server/lib/slide-content-patch.ts +13 -2
  268. package/corpus/templates/slides/server/plugins/agent-chat.ts +5 -2
  269. package/corpus/templates/slides/tsconfig.json +5 -0
  270. package/corpus/templates/slides/vitest.config.ts +6 -1
  271. package/corpus/templates/tasks/.agents/skills/secrets/SKILL.md +11 -4
  272. package/corpus/templates/tasks/app/routes/settings.tsx +4 -1
  273. package/corpus/templates/tasks/app/routes.ts +3 -1
  274. package/corpus/templates/tasks/changelog/2026-08-22-the-extensions-link-now-opens-the-extensions-tab-instead-o.md +6 -0
  275. package/corpus/templates/tasks/vitest.config.ts +7 -1
  276. package/dist/action.d.ts +91 -0
  277. package/dist/action.js +50 -0
  278. package/dist/agent/abort-reasons.d.ts +38 -0
  279. package/dist/agent/abort-reasons.js +49 -0
  280. package/dist/agent/engine/ai-sdk-engine.js +8 -5
  281. package/dist/agent/engine/anthropic-engine.js +7 -5
  282. package/dist/agent/engine/first-event-timeout.d.ts +42 -17
  283. package/dist/agent/engine/first-event-timeout.js +67 -25
  284. package/dist/agent/production-agent.d.ts +23 -0
  285. package/dist/agent/production-agent.js +107 -102
  286. package/dist/agent/run-loop-with-resume.d.ts +2 -28
  287. package/dist/agent/run-loop-with-resume.js +25 -40
  288. package/dist/agent/run-manager.js +5 -4
  289. package/dist/agent/run-store.d.ts +55 -0
  290. package/dist/agent/run-store.js +124 -1
  291. package/dist/agent/tool-search.js +2 -1
  292. package/dist/agent/types.d.ts +9 -4
  293. package/dist/app-config/auth.d.ts +1 -0
  294. package/dist/app-config/auth.js +7 -0
  295. package/dist/app-config/configuration-error.d.ts +11 -0
  296. package/dist/app-config/configuration-error.js +14 -0
  297. package/dist/app-config/index.d.ts +1 -0
  298. package/dist/app-config/index.js +1 -0
  299. package/dist/app-config/integrations.d.ts +1 -0
  300. package/dist/app-config/integrations.js +10 -0
  301. package/dist/app-config/plugins.d.ts +34 -0
  302. package/dist/app-config/plugins.js +39 -0
  303. package/dist/app-config/run-lifecycle-invariants.d.ts +18 -9
  304. package/dist/app-config/run-lifecycle-invariants.js +18 -25
  305. package/dist/app-config/schema.d.ts +17 -0
  306. package/dist/app-config/schema.js +2 -0
  307. package/dist/chat-threads/schema-migrations.js +20 -0
  308. package/dist/chat-threads/store.js +21 -4
  309. package/dist/cli/native-dependencies.d.ts +4 -0
  310. package/dist/cli/native-dependencies.js +15 -0
  311. package/dist/client/EnvironmentBadge.d.ts +1 -1
  312. package/dist/client/EnvironmentBadge.js +40 -5
  313. package/dist/client/MultiTabAssistantChat.d.ts +1 -1
  314. package/dist/client/MultiTabAssistantChat.js +35 -14
  315. package/dist/client/agent-chat/index.d.ts +1 -1
  316. package/dist/client/agent-chat/index.js +1 -1
  317. package/dist/client/agent-page/use-jobs.d.ts +2 -1
  318. package/dist/client/agent-page/use-jobs.js +3 -5
  319. package/dist/client/agent-sidebar-state.js +3 -1
  320. package/dist/client/blocks/library/code-tabs.js +5 -0
  321. package/dist/client/blocks/library/code.js +1 -0
  322. package/dist/client/chat-first/apps-rail.js +2 -2
  323. package/dist/client/chat-model-groups.d.ts +12 -0
  324. package/dist/client/chat-model-groups.js +3 -2
  325. package/dist/client/integrations/GoogleProductLogo.d.ts +5 -0
  326. package/dist/client/integrations/GoogleProductLogo.js +45 -0
  327. package/dist/client/integrations/index.d.ts +2 -0
  328. package/dist/client/integrations/index.js +2 -0
  329. package/dist/client/integrations/workspace-provider-oauth.d.ts +8 -0
  330. package/dist/client/integrations/workspace-provider-oauth.js +13 -0
  331. package/dist/client/resources/mcp-integration-catalog.js +0 -29
  332. package/dist/client/settings/SettingsTabsPage.js +10 -6
  333. package/dist/client/sharing/ShareButton.js +11 -2
  334. package/dist/client/use-chat-models.d.ts +6 -0
  335. package/dist/client/use-chat-models.js +10 -0
  336. package/dist/client/use-db-sync.js +27 -9
  337. package/dist/collab/routes.d.ts +1 -1
  338. package/dist/collab/struct-routes.d.ts +1 -1
  339. package/dist/connections/catalog.d.ts +111 -9
  340. package/dist/connections/catalog.js +86 -30
  341. package/dist/db/create-get-db.js +18 -0
  342. package/dist/db/ddl-guard.d.ts +0 -4
  343. package/dist/db/ddl-guard.js +46 -0
  344. package/dist/deploy/build.d.ts +19 -0
  345. package/dist/deploy/build.js +190 -16
  346. package/dist/deploy/function-bundle.d.ts +45 -0
  347. package/dist/deploy/function-bundle.js +337 -0
  348. package/dist/deploy/workspace-deploy.js +19 -1
  349. package/dist/eject/workspace-connections.js +4 -0
  350. package/dist/email-catalog/registry.d.ts +4 -0
  351. package/dist/email-catalog/registry.js +79 -14
  352. package/dist/extensions/content-patch.js +14 -2
  353. package/dist/integrations/plugin.d.ts +10 -0
  354. package/dist/integrations/plugin.js +40 -6
  355. package/dist/jobs/actions/run-automation-now.d.ts +2 -1
  356. package/dist/jobs/actions/run-automation-now.js +11 -5
  357. package/dist/jobs/frontmatter.js +53 -2
  358. package/dist/jobs/run-now.d.ts +11 -1
  359. package/dist/jobs/run-now.js +42 -7
  360. package/dist/jobs/scheduler.d.ts +1 -0
  361. package/dist/jobs/scheduler.js +2 -1
  362. package/dist/localization/core-messages/ar-SA.js +3 -0
  363. package/dist/localization/core-messages/de-DE.js +3 -0
  364. package/dist/localization/core-messages/en-US.d.ts +3 -0
  365. package/dist/localization/core-messages/en-US.js +3 -0
  366. package/dist/localization/core-messages/es-ES.js +3 -0
  367. package/dist/localization/core-messages/fr-FR.js +3 -0
  368. package/dist/localization/core-messages/hi-IN.js +3 -0
  369. package/dist/localization/core-messages/ja-JP.js +3 -0
  370. package/dist/localization/core-messages/ko-KR.js +3 -0
  371. package/dist/localization/core-messages/pt-BR.js +3 -0
  372. package/dist/localization/core-messages/zh-CN.js +3 -0
  373. package/dist/localization/core-messages/zh-TW.js +3 -0
  374. package/dist/localization/core-messages.d.ts +3 -0
  375. package/dist/localization/default-messages.d.ts +2 -5
  376. package/dist/localization/default-messages.js +2 -5
  377. package/dist/mcp/build-server.d.ts +9 -0
  378. package/dist/mcp/build-server.js +37 -8
  379. package/dist/mcp-client/oauth-client.d.ts +7 -0
  380. package/dist/mcp-client/oauth-client.js +12 -1
  381. package/dist/oauth-tokens/index.d.ts +1 -1
  382. package/dist/oauth-tokens/index.js +1 -1
  383. package/dist/oauth-tokens/lifecycle.d.ts +11 -0
  384. package/dist/oauth-tokens/lifecycle.js +17 -0
  385. package/dist/oauth-tokens/store.js +7 -0
  386. package/dist/observability/posthog-ai.d.ts +15 -1
  387. package/dist/observability/posthog-ai.js +24 -12
  388. package/dist/observability/traces.js +158 -28
  389. package/dist/onboarding/app-profile.js +2 -2
  390. package/dist/onboarding/plugin.js +10 -2
  391. package/dist/onboarding/types.d.ts +2 -0
  392. package/dist/provider-api/actions/custom-provider-registration.d.ts +1 -1
  393. package/dist/provider-api/actions/provider-api.d.ts +2 -2
  394. package/dist/provider-api/index.js +2 -0
  395. package/dist/resources/handlers.d.ts +2 -2
  396. package/dist/resources/store.js +13 -4
  397. package/dist/scripts/agent-engines/list-agent-engines.js +29 -3
  398. package/dist/secrets/register-framework-secrets.js +1 -1
  399. package/dist/server/action-discovery.js +5 -0
  400. package/dist/server/agent-chat/action-filters-a2a.d.ts +27 -3
  401. package/dist/server/agent-chat/action-filters-a2a.js +48 -9
  402. package/dist/server/agent-chat-plugin.js +36 -9
  403. package/dist/server/agent-discovery.js +43 -20
  404. package/dist/server/auth.js +18 -16
  405. package/dist/server/beta-opt-out-html.js +45 -4
  406. package/dist/server/better-auth-instance.d.ts +2 -1
  407. package/dist/server/better-auth-instance.js +37 -7
  408. package/dist/server/builder-design-systems.d.ts +4 -1
  409. package/dist/server/builder-design-systems.js +87 -6
  410. package/dist/server/builder-oauth.d.ts +1 -7
  411. package/dist/server/builder-oauth.js +80 -163
  412. package/dist/server/core-routes-plugin.d.ts +29 -0
  413. package/dist/server/core-routes-plugin.js +100 -20
  414. package/dist/server/default-plugins.d.ts +18 -0
  415. package/dist/server/default-plugins.js +22 -0
  416. package/dist/server/edge.d.ts +1 -0
  417. package/dist/server/edge.js +1 -0
  418. package/dist/server/framework-request-handler.js +31 -3
  419. package/dist/server/google-oauth.d.ts +3 -0
  420. package/dist/server/google-oauth.js +3 -0
  421. package/dist/server/index.d.ts +4 -4
  422. package/dist/server/index.js +3 -3
  423. package/dist/server/onboarding-html.js +37 -3
  424. package/dist/server/realtime-token.d.ts +1 -1
  425. package/dist/server/workspace-provider-oauth.d.ts +6 -1
  426. package/dist/server/workspace-provider-oauth.js +72 -20
  427. package/dist/shared/agent-sidebar-url.d.ts +1 -0
  428. package/dist/shared/agent-sidebar-url.js +1 -0
  429. package/dist/shared/environment-lanes.d.ts +2 -0
  430. package/dist/shared/environment-lanes.js +3 -1
  431. package/dist/shared/index.d.ts +3 -1
  432. package/dist/shared/index.js +3 -1
  433. package/dist/shared/surface-visibility.d.ts +31 -0
  434. package/dist/shared/surface-visibility.js +57 -0
  435. package/dist/templates/chat/.agents/skills/secrets/SKILL.md +11 -4
  436. package/dist/templates/chat/app/routes.ts +3 -1
  437. package/dist/templates/chat/vitest.config.ts +8 -0
  438. package/dist/templates/default/.agents/skills/secrets/SKILL.md +11 -4
  439. package/dist/templates/default/app/routes.ts +3 -1
  440. package/dist/templates/headless/.agents/skills/secrets/SKILL.md +11 -4
  441. package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +11 -4
  442. package/dist/tracking/registry.d.ts +11 -0
  443. package/dist/tracking/registry.js +5 -2
  444. package/dist/triggers/actions.js +10 -3
  445. package/dist/usage/store.js +23 -3
  446. package/dist/user-profile/actions/get-auth-methods.js +18 -5
  447. package/dist/vite/client.js +33 -7
  448. package/docs/content/actions-access-control.mdx +43 -4
  449. package/docs/content/actions-defining.mdx +16 -0
  450. package/docs/content/authentication.mdx +40 -21
  451. package/docs/content/environment-variables.mdx +1 -0
  452. package/docs/content/template-clips-capture-everywhere.mdx +29 -30
  453. package/docs/content/template-clips-developers.mdx +4 -4
  454. package/docs/content/template-clips.mdx +3 -1
  455. package/package.json +2 -2
  456. package/src/eject/workspace-connections.ts +4 -0
  457. package/src/templates/chat/.agents/skills/secrets/SKILL.md +11 -4
  458. package/src/templates/chat/app/routes.ts +3 -1
  459. package/src/templates/chat/vitest.config.ts +8 -0
  460. package/src/templates/default/.agents/skills/secrets/SKILL.md +11 -4
  461. package/src/templates/default/app/routes.ts +3 -1
  462. package/src/templates/headless/.agents/skills/secrets/SKILL.md +11 -4
  463. package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +11 -4
@@ -63,6 +63,39 @@ export interface CanvasSnapOptions {
63
63
  thresholdScreenPx?: number;
64
64
  zoom: number;
65
65
  bypass?: boolean;
66
+ /** Axes the caller has already pinned (a Shift dominant-axis drag). Both the
67
+ * offset and the guides skip these, so the chrome never describes a
68
+ * position the object is not at. */
69
+ lockedAxes?: { x?: boolean; y?: boolean };
70
+ /** Pre-resolved AABBs, so a per-rAF caller does not rebuild them for each
71
+ * stage of the drag-snap stack. */
72
+ stationaryBounds?: FrameBounds[];
73
+ }
74
+
75
+ export type SpacingSnapOptions = CanvasSnapOptions;
76
+
77
+ export interface DragSnapOptions extends CanvasSnapOptions {
78
+ /** Figma's "snap to pixel grid": land on whole canvas pixels. */
79
+ pixelGrid?: boolean;
80
+ /** How far a neighbour can be, in screen px, and still get a distance
81
+ * readout. Beyond this the measurement is noise stretched across empty
82
+ * canvas rather than something the user is positioning against. */
83
+ proximityRangeScreenPx?: number;
84
+ }
85
+
86
+ /** The distance from the moving frame to its nearest neighbour on one axis. */
87
+ export interface ProximityMeasurement {
88
+ orientation: "vertical" | "horizontal";
89
+ gap: number;
90
+ band: DistanceGuideBand;
91
+ }
92
+
93
+ export interface DragSnapResult {
94
+ dx: number;
95
+ dy: number;
96
+ guides: AlignmentGuide[];
97
+ spacingGuides: EqualGapGuide[];
98
+ measurements: ProximityMeasurement[];
66
99
  }
67
100
 
68
101
  export interface ResizeSnapOptions extends CanvasSnapOptions {
@@ -134,14 +167,12 @@ export interface DistanceGuideBand {
134
167
  * highlights both gaps with the shared distance. */
135
168
  export interface EqualGapGuide {
136
169
  orientation: "vertical" | "horizontal";
137
- /** The shared gap size, in canvas units, both bands agree on. */
170
+ /** The shared gap size, in canvas units, every band agrees on. */
138
171
  gap: number;
139
- bands: [DistanceGuideBand, DistanceGuideBand];
140
- }
141
-
142
- export interface EqualGapOptions {
143
- /** How close two gaps must be (in canvas units) to count as "equal". */
144
- toleranceCanvasPx?: number;
172
+ /** At least two: the gap(s) around the moving frame, plus every other gap
173
+ * in the same row/column that matches, so a run of evenly spaced frames
174
+ * lights up as one rhythm instead of just the pair being snapped. */
175
+ bands: DistanceGuideBand[];
145
176
  }
146
177
 
147
178
  export interface RotateFrameMetadata {
@@ -225,6 +256,7 @@ interface SnapCandidate {
225
256
  }
226
257
 
227
258
  export const DEFAULT_SNAP_THRESHOLD_SCREEN_PX = 6;
259
+ export const DEFAULT_PROXIMITY_RANGE_SCREEN_PX = 160;
228
260
  export const DEFAULT_ROTATION_SNAP_DEGREES = 15;
229
261
  export const DEFAULT_PIXEL_GRID_MIN_ZOOM = 800;
230
262
  export const MIN_CANVAS_FRAME_WIDTH = 120;
@@ -638,76 +670,260 @@ export function computeMoveSnap(
638
670
  return { dx: 0, dy: 0, guides: [] as AlignmentGuide[] };
639
671
  }
640
672
 
641
- let bestX: SnapCandidate | null = null;
642
- let bestY: SnapCandidate | null = null;
643
673
  const threshold = getCanvasSnapThreshold(options);
644
674
  // Use the rotated (world-space) AABB rather than the unrotated local
645
675
  // bounds, so a rotated frame snaps by its visual silhouette instead of an
646
676
  // AABB that doesn't match anything on screen.
647
- const stationaryBounds = stationary.map((entry) => ({
648
- ...entry,
649
- bounds: getRotatedFrameAABB(entry.geometry),
650
- }));
677
+ const movingBounds = moving.map((entry) =>
678
+ getRotatedFrameAABB(entry.geometry),
679
+ );
680
+ const stationaryBounds =
681
+ options.stationaryBounds ??
682
+ stationary.map((entry) => getRotatedFrameAABB(entry.geometry));
683
+
684
+ // A locked axis must be locked before the guides are built: derive both the
685
+ // offset and the guides from the same position, or the lines describe a
686
+ // place the frame is not.
687
+ const dx = options.lockedAxes?.x
688
+ ? null
689
+ : findAxisSnapOffset("x", movingBounds, stationaryBounds, threshold);
690
+ const dy = options.lockedAxes?.y
691
+ ? null
692
+ : findAxisSnapOffset("y", movingBounds, stationaryBounds, threshold);
693
+
694
+ const snappedBounds = movingBounds.map((bounds) =>
695
+ translateBounds(bounds, dx ?? 0, dy ?? 0),
696
+ );
651
697
 
652
- for (const entry of moving) {
653
- const movingBounds = getRotatedFrameAABB(entry.geometry);
654
- for (const stationaryEntry of stationaryBounds) {
655
- bestX = getBestCandidate(
656
- bestX,
657
- getAxisSnapCandidates(
658
- "x",
659
- movingBounds,
660
- stationaryEntry.bounds,
661
- threshold,
662
- ),
663
- );
664
- bestY = getBestCandidate(
665
- bestY,
666
- getAxisSnapCandidates(
667
- "y",
668
- movingBounds,
669
- stationaryEntry.bounds,
670
- threshold,
671
- ),
672
- );
673
- }
674
- }
698
+ return {
699
+ dx: dx ?? 0,
700
+ dy: dy ?? 0,
701
+ guides: [
702
+ ...(options.lockedAxes?.x
703
+ ? []
704
+ : buildAxisGuides("x", snappedBounds, stationaryBounds)),
705
+ ...(options.lockedAxes?.y
706
+ ? []
707
+ : buildAxisGuides("y", snappedBounds, stationaryBounds)),
708
+ ],
709
+ };
710
+ }
711
+
712
+ /**
713
+ * Figma-style "smart spacing" snap: centers the frame between two neighbors,
714
+ * or matches an existing gap in the same row/column. `lockedAxes` are the
715
+ * axes computeMoveSnap already claimed — moving one would pull the frame off
716
+ * the alignment guide the user can already see.
717
+ */
718
+ export function computeSpacingSnap(
719
+ moving: FrameGeometry,
720
+ stationary: FrameEntry[],
721
+ options: SpacingSnapOptions,
722
+ ): {
723
+ dx: number;
724
+ dy: number;
725
+ guides: EqualGapGuide[];
726
+ movedAxes?: { x: boolean; y: boolean };
727
+ } {
728
+ if (options.bypass) return { dx: 0, dy: 0, guides: [] };
675
729
 
730
+ const tolerance = getCanvasSnapThreshold(options);
731
+ const spacingMatchTolerance = getCanvasSnapThreshold({
732
+ ...options,
733
+ thresholdScreenPx: SPACING_MATCH_SCREEN_PX,
734
+ });
735
+ const bounds = getRotatedFrameAABB(moving);
736
+ const stationaryBounds =
737
+ options.stationaryBounds ??
738
+ stationary.map((entry) => getRotatedFrameAABB(entry.geometry));
739
+
740
+ const idle = { offset: 0, side: null } as const;
741
+ const x = options.lockedAxes?.x
742
+ ? idle
743
+ : findSpacingSnapOffset("x", bounds, stationaryBounds, tolerance);
744
+ const y = options.lockedAxes?.y
745
+ ? idle
746
+ : findSpacingSnapOffset("y", bounds, stationaryBounds, tolerance);
747
+
748
+ // Guides are drawn at the tight tolerance, not the snap pull: a near-equal
749
+ // pair must not get labelled as an equal gap it does not actually have.
750
+ const snapped = translateBounds(bounds, x.offset, y.offset);
676
751
  return {
677
- dx: bestX?.offset ?? 0,
678
- dy: bestY?.offset ?? 0,
679
- guides: [bestX?.guide, bestY?.guide].filter(Boolean) as AlignmentGuide[],
752
+ dx: x.offset,
753
+ dy: y.offset,
754
+ guides: [
755
+ ...(options.lockedAxes?.x
756
+ ? []
757
+ : buildSpacingGuides(
758
+ "x",
759
+ snapped,
760
+ stationaryBounds,
761
+ spacingMatchTolerance,
762
+ x.side,
763
+ )),
764
+ ...(options.lockedAxes?.y
765
+ ? []
766
+ : buildSpacingGuides(
767
+ "y",
768
+ snapped,
769
+ stationaryBounds,
770
+ spacingMatchTolerance,
771
+ y.side,
772
+ )),
773
+ ],
774
+ movedAxes: { x: x.offset !== 0, y: y.offset !== 0 },
680
775
  };
681
776
  }
682
777
 
683
778
  /**
684
- * Figma-style "smart spacing" guides (CV11): when a single moving frame sits
685
- * between two stationary neighbors with matching gaps on either side — or
686
- * continues that same gap on just one side — highlight both gaps with their
687
- * shared distance. This is purely a *display* aid (unlike computeMoveSnap,
688
- * it never adjusts the frame's position) so callers should compute it
689
- * independently of, and after, any snap offset has already been applied to
690
- * the moving frame's geometry.
691
- *
692
- * Only single-frame moves are supported (matching how Figma's own equal-gap
693
- * guides only appear while dragging one object) — pass the already-resolved
694
- * single moving frame, not a multi-select group.
779
+ * Distance to the nearest neighbour on each axis, for the frame's settled
780
+ * position. Stock Figma only surfaces a distance when it matches an existing
781
+ * gap; showing the nearest one unconditionally is a deliberate divergence,
782
+ * range-limited so a lone frame across the canvas does not draw a measurement
783
+ * over empty space.
695
784
  */
696
- export function computeEqualGapGuides(
785
+ export function computeProximityMeasurements(
697
786
  moving: FrameGeometry,
698
787
  stationary: FrameEntry[],
699
- { toleranceCanvasPx = 1 }: EqualGapOptions = {},
700
- ): EqualGapGuide[] {
701
- const movingBounds = getRotatedFrameAABB(moving);
702
- const guides: EqualGapGuide[] = [];
788
+ options: {
789
+ zoom: number;
790
+ rangeScreenPx?: number;
791
+ bypass?: boolean;
792
+ stationaryBounds?: FrameBounds[];
793
+ },
794
+ ): ProximityMeasurement[] {
795
+ if (options.bypass) return [];
796
+ const range =
797
+ (options.rangeScreenPx ?? DEFAULT_PROXIMITY_RANGE_SCREEN_PX) /
798
+ getCameraScale(options.zoom);
799
+ const bounds = getRotatedFrameAABB(moving);
800
+ const stationaryBounds =
801
+ options.stationaryBounds ??
802
+ stationary.map((entry) => getRotatedFrameAABB(entry.geometry));
803
+
804
+ const measurements: ProximityMeasurement[] = [];
805
+ for (const axis of ["x", "y"] as const) {
806
+ const candidates = collectAxisGapCandidates(axis, bounds, stationaryBounds);
807
+ const nearest = candidates.reduce<GapCandidate | null>(
808
+ (best, candidate) =>
809
+ !best || candidate.gap < best.gap ? candidate : best,
810
+ null,
811
+ );
812
+ if (!nearest || nearest.gap > range) continue;
813
+ measurements.push({
814
+ orientation: axis === "x" ? "vertical" : "horizontal",
815
+ gap: nearest.gap,
816
+ band: gapCandidateBand(nearest),
817
+ });
818
+ }
819
+ return measurements;
820
+ }
821
+
822
+ /**
823
+ * The whole Figma move-snap stack in one call, in precedence order: an
824
+ * edge/center alignment wins, spacing only gets the axes alignment left free,
825
+ * and the pixel grid only gets the axes with no guide drawn on them at all —
826
+ * rounding an axis that has a visible guide would move the frame off the line
827
+ * the user is looking at.
828
+ */
829
+ export function computeDragSnap(
830
+ moving: FrameEntry[],
831
+ stationary: FrameEntry[],
832
+ options: DragSnapOptions,
833
+ ): DragSnapResult {
834
+ // One AABB pass feeds all three stages; this runs every rAF of a drag.
835
+ const stationaryBounds = stationary.map((entry) =>
836
+ getRotatedFrameAABB(entry.geometry),
837
+ );
838
+ const alignment = computeMoveSnap(moving, stationary, {
839
+ ...options,
840
+ stationaryBounds,
841
+ });
842
+ const claimedX = alignment.guides.some((g) => g.orientation === "vertical");
843
+ const claimedY = alignment.guides.some((g) => g.orientation === "horizontal");
703
844
 
704
- const horizontal = collectAxisGapCandidates("x", movingBounds, stationary);
705
- guides.push(...pairUpEqualGaps("vertical", horizontal, toleranceCanvasPx));
845
+ // Figma drops spacing guides for a multi-select drag; so do we.
846
+ const single = moving.length === 1 ? moving[0] : null;
847
+ const spacing = single
848
+ ? computeSpacingSnap(
849
+ {
850
+ ...single.geometry,
851
+ x: single.geometry.x + alignment.dx,
852
+ y: single.geometry.y + alignment.dy,
853
+ },
854
+ stationary,
855
+ {
856
+ zoom: options.zoom,
857
+ bypass: options.bypass,
858
+ thresholdScreenPx: options.thresholdScreenPx,
859
+ stationaryBounds,
860
+ lockedAxes: {
861
+ x: claimedX || options.lockedAxes?.x,
862
+ y: claimedY || options.lockedAxes?.y,
863
+ },
864
+ },
865
+ )
866
+ : {
867
+ dx: 0,
868
+ dy: 0,
869
+ guides: [] as EqualGapGuide[],
870
+ movedAxes: { x: false, y: false },
871
+ };
872
+
873
+ let dx = alignment.dx + spacing.dx;
874
+ let dy = alignment.dy + spacing.dy;
875
+
876
+ const anchor = moving[0];
877
+ if (options.pixelGrid && anchor && !options.bypass) {
878
+ // Keyed on what actually moved the frame, not on what got drawn: a
879
+ // spacing snap that produced no chrome is still a position the user
880
+ // chose, and rounding it away undoes the snap they just felt.
881
+ const claimed = (orientation: "vertical" | "horizontal") =>
882
+ alignment.guides.some((g) => g.orientation === orientation) ||
883
+ spacing.guides.some((g) => g.orientation === orientation) ||
884
+ (orientation === "vertical"
885
+ ? spacing.movedAxes?.x
886
+ : spacing.movedAxes?.y);
887
+ // Only the anchor rounds; the rest of a multi-select rides the same
888
+ // delta, so a group keeps its internal fractional offsets.
889
+ if (!claimed("vertical") && !options.lockedAxes?.x) {
890
+ dx = Math.round(anchor.geometry.x + dx) - anchor.geometry.x;
891
+ }
892
+ if (!claimed("horizontal") && !options.lockedAxes?.y) {
893
+ dy = Math.round(anchor.geometry.y + dy) - anchor.geometry.y;
894
+ }
895
+ }
706
896
 
707
- const vertical = collectAxisGapCandidates("y", movingBounds, stationary);
708
- guides.push(...pairUpEqualGaps("horizontal", vertical, toleranceCanvasPx));
897
+ // An axis whose spacing guide already prints the gap does not also need a
898
+ // proximity readout of the same number.
899
+ const settled = single
900
+ ? {
901
+ ...single.geometry,
902
+ x: single.geometry.x + dx,
903
+ y: single.geometry.y + dy,
904
+ }
905
+ : null;
906
+ const measurements = settled
907
+ ? computeProximityMeasurements(settled, stationary, {
908
+ zoom: options.zoom,
909
+ rangeScreenPx: options.proximityRangeScreenPx,
910
+ bypass: options.bypass,
911
+ stationaryBounds,
912
+ }).filter(
913
+ (measurement) =>
914
+ !spacing.guides.some(
915
+ (guide) => guide.orientation === measurement.orientation,
916
+ ),
917
+ )
918
+ : [];
709
919
 
710
- return guides;
920
+ return {
921
+ dx,
922
+ dy,
923
+ guides: alignment.guides,
924
+ spacingGuides: spacing.guides,
925
+ measurements,
926
+ };
711
927
  }
712
928
 
713
929
  interface GapCandidate {
@@ -724,122 +940,102 @@ interface GapCandidate {
724
940
  function collectAxisGapCandidates(
725
941
  axis: "x" | "y",
726
942
  movingBounds: FrameBounds,
727
- stationary: FrameEntry[],
943
+ stationary: FrameBounds[],
728
944
  ): GapCandidate[] {
729
945
  const candidates: GapCandidate[] = [];
730
- for (const entry of stationary) {
731
- const bounds = getRotatedFrameAABB(entry.geometry);
946
+ for (const bounds of stationary) {
732
947
  // Only frames that overlap the moving frame's extent on the OTHER axis
733
948
  // produce a meaningful "gap between them" — otherwise the empty space
734
949
  // isn't really a corridor connecting the two shapes.
735
- const crossOverlaps =
736
- axis === "x"
737
- ? bounds.top < movingBounds.bottom && bounds.bottom > movingBounds.top
738
- : bounds.left < movingBounds.right && bounds.right > movingBounds.left;
739
- if (!crossOverlaps) continue;
950
+ if (!crossAxisOverlaps(axis, bounds, movingBounds)) continue;
740
951
 
741
- const crossStart =
742
- axis === "x"
743
- ? Math.max(bounds.top, movingBounds.top)
744
- : Math.max(bounds.left, movingBounds.left);
745
- const crossEnd =
746
- axis === "x"
747
- ? Math.min(bounds.bottom, movingBounds.bottom)
748
- : Math.min(bounds.right, movingBounds.right);
749
-
750
- if (axis === "x") {
751
- if (bounds.right <= movingBounds.left) {
752
- candidates.push({
753
- side: "before",
754
- gap: movingBounds.left - bounds.right,
755
- gapStart: bounds.right,
756
- gapEnd: movingBounds.left,
757
- crossStart,
758
- crossEnd,
759
- });
760
- } else if (bounds.left >= movingBounds.right) {
761
- candidates.push({
762
- side: "after",
763
- gap: bounds.left - movingBounds.right,
764
- gapStart: movingBounds.right,
765
- gapEnd: bounds.left,
766
- crossStart,
767
- crossEnd,
768
- });
769
- }
770
- } else {
771
- if (bounds.bottom <= movingBounds.top) {
772
- candidates.push({
773
- side: "before",
774
- gap: movingBounds.top - bounds.bottom,
775
- gapStart: bounds.bottom,
776
- gapEnd: movingBounds.top,
777
- crossStart,
778
- crossEnd,
779
- });
780
- } else if (bounds.top >= movingBounds.bottom) {
781
- candidates.push({
782
- side: "after",
783
- gap: bounds.top - movingBounds.bottom,
784
- gapStart: movingBounds.bottom,
785
- gapEnd: bounds.top,
786
- crossStart,
787
- crossEnd,
788
- });
789
- }
952
+ const crossStart = Math.max(
953
+ getCrossStart(bounds, axis),
954
+ getCrossStart(movingBounds, axis),
955
+ );
956
+ const crossEnd = Math.min(
957
+ getCrossEnd(bounds, axis),
958
+ getCrossEnd(movingBounds, axis),
959
+ );
960
+
961
+ if (getAxisEnd(bounds, axis) <= getAxisStart(movingBounds, axis)) {
962
+ candidates.push({
963
+ side: "before",
964
+ gap: getAxisStart(movingBounds, axis) - getAxisEnd(bounds, axis),
965
+ gapStart: getAxisEnd(bounds, axis),
966
+ gapEnd: getAxisStart(movingBounds, axis),
967
+ crossStart,
968
+ crossEnd,
969
+ });
970
+ } else if (getAxisStart(bounds, axis) >= getAxisEnd(movingBounds, axis)) {
971
+ candidates.push({
972
+ side: "after",
973
+ gap: getAxisStart(bounds, axis) - getAxisEnd(movingBounds, axis),
974
+ gapStart: getAxisEnd(movingBounds, axis),
975
+ gapEnd: getAxisStart(bounds, axis),
976
+ crossStart,
977
+ crossEnd,
978
+ });
790
979
  }
791
980
  }
792
981
  return candidates;
793
982
  }
794
983
 
795
- function pairUpEqualGaps(
796
- orientation: "vertical" | "horizontal",
984
+ // Closest gap on each side is the one a user is most likely dragging toward,
985
+ // so only the single closest candidate per side is ever paired — otherwise a
986
+ // busy canvas surfaces every combinatorial pair of same-ish gaps at once.
987
+ function closestGapCandidate(
797
988
  candidates: GapCandidate[],
798
- toleranceCanvasPx: number,
799
- ): EqualGapGuide[] {
800
- const before = candidates.filter((c) => c.side === "before");
801
- const after = candidates.filter((c) => c.side === "after");
802
- const guides: EqualGapGuide[] = [];
803
-
804
- // Closest gap on each side is the one a user is most likely dragging
805
- // toward, so only pair the single closest "before" candidate against the
806
- // single closest "after" candidate — otherwise a busy canvas would surface
807
- // every combinatorial pair of same-ish gaps at once.
808
- const closestBefore = before.reduce<GapCandidate | null>(
809
- (best, c) => (!best || c.gap < best.gap ? c : best),
810
- null,
811
- );
812
- const closestAfter = after.reduce<GapCandidate | null>(
813
- (best, c) => (!best || c.gap < best.gap ? c : best),
989
+ side: "before" | "after",
990
+ ): GapCandidate | null {
991
+ return candidates.reduce<GapCandidate | null>(
992
+ (best, candidate) =>
993
+ candidate.side === side && (!best || candidate.gap < best.gap)
994
+ ? candidate
995
+ : best,
814
996
  null,
815
997
  );
998
+ }
816
999
 
817
- if (
818
- closestBefore &&
819
- closestAfter &&
820
- Math.abs(closestBefore.gap - closestAfter.gap) <= toleranceCanvasPx
821
- ) {
822
- guides.push({
823
- orientation,
824
- gap: (closestBefore.gap + closestAfter.gap) / 2,
825
- bands: [
826
- {
827
- gapStart: closestBefore.gapStart,
828
- gapEnd: closestBefore.gapEnd,
829
- crossStart: closestBefore.crossStart,
830
- crossEnd: closestBefore.crossEnd,
831
- },
832
- {
833
- gapStart: closestAfter.gapStart,
834
- gapEnd: closestAfter.gapEnd,
835
- crossStart: closestAfter.crossStart,
836
- crossEnd: closestAfter.crossEnd,
837
- },
838
- ],
839
- });
840
- }
1000
+ function gapCandidateBand(candidate: GapCandidate): DistanceGuideBand {
1001
+ return {
1002
+ gapStart: candidate.gapStart,
1003
+ gapEnd: candidate.gapEnd,
1004
+ crossStart: candidate.crossStart,
1005
+ crossEnd: candidate.crossEnd,
1006
+ };
1007
+ }
841
1008
 
842
- return guides;
1009
+ function buildEqualGapPair(
1010
+ axis: "x" | "y",
1011
+ movingBounds: FrameBounds,
1012
+ stationary: FrameBounds[],
1013
+ toleranceCanvasPx: number,
1014
+ ): EqualGapGuide[] {
1015
+ const candidates = collectAxisGapCandidates(axis, movingBounds, stationary);
1016
+ const before = closestGapCandidate(candidates, "before");
1017
+ const after = closestGapCandidate(candidates, "after");
1018
+ if (!before || !after) return [];
1019
+ if (Math.abs(before.gap - after.gap) > toleranceCanvasPx) return [];
1020
+ return [
1021
+ {
1022
+ orientation: axis === "x" ? "vertical" : "horizontal",
1023
+ gap: (before.gap + after.gap) / 2,
1024
+ bands: [gapCandidateBand(before), gapCandidateBand(after)],
1025
+ },
1026
+ ];
1027
+ }
1028
+
1029
+ /** Every gap already in the row/column that matches `gap`. Figma and tldraw
1030
+ * both light the whole run, not only the pair the snap landed on. */
1031
+ function matchingRhythmBands(
1032
+ rhythms: { gap: number; band: DistanceGuideBand }[],
1033
+ gap: number,
1034
+ tolerance: number,
1035
+ ): DistanceGuideBand[] {
1036
+ return rhythms
1037
+ .filter((rhythm) => Math.abs(rhythm.gap - gap) <= tolerance)
1038
+ .map((rhythm) => rhythm.band);
843
1039
  }
844
1040
 
845
1041
  export function resizeFrameFromDelta(
@@ -2001,54 +2197,264 @@ function getCanvasSnapThreshold({
2001
2197
  return thresholdScreenPx / scale;
2002
2198
  }
2003
2199
 
2004
- function getAxisSnapCandidates(
2200
+ const SNAP_ALIGN_EPSILON = 1e-6;
2201
+ /** Screen px. Every other tolerance in this module is screen-constant via
2202
+ * getCanvasSnapThreshold; a fixed canvas-px value would make the equal-gap
2203
+ * claim 16x stricter at 25% zoom than at 400%. */
2204
+ const SPACING_MATCH_SCREEN_PX = 0.5;
2205
+
2206
+ function getAxisSnapValues(bounds: FrameBounds, axis: "x" | "y") {
2207
+ return axis === "x"
2208
+ ? [bounds.left, bounds.centerX, bounds.right]
2209
+ : [bounds.top, bounds.centerY, bounds.bottom];
2210
+ }
2211
+
2212
+ function getAxisStart(bounds: FrameBounds, axis: "x" | "y") {
2213
+ return axis === "x" ? bounds.left : bounds.top;
2214
+ }
2215
+
2216
+ function getAxisEnd(bounds: FrameBounds, axis: "x" | "y") {
2217
+ return axis === "x" ? bounds.right : bounds.bottom;
2218
+ }
2219
+
2220
+ function getCrossStart(bounds: FrameBounds, axis: "x" | "y") {
2221
+ return axis === "x" ? bounds.top : bounds.left;
2222
+ }
2223
+
2224
+ function getCrossEnd(bounds: FrameBounds, axis: "x" | "y") {
2225
+ return axis === "x" ? bounds.bottom : bounds.right;
2226
+ }
2227
+
2228
+ function crossAxisOverlaps(
2005
2229
  axis: "x" | "y",
2006
- movingBounds: FrameBounds,
2007
- stationaryBounds: FrameBounds,
2230
+ a: FrameBounds,
2231
+ b: FrameBounds,
2232
+ ): boolean {
2233
+ return (
2234
+ getCrossStart(a, axis) < getCrossEnd(b, axis) &&
2235
+ getCrossEnd(a, axis) > getCrossStart(b, axis)
2236
+ );
2237
+ }
2238
+
2239
+ function translateBounds(
2240
+ bounds: FrameBounds,
2241
+ dx: number,
2242
+ dy: number,
2243
+ ): FrameBounds {
2244
+ return {
2245
+ left: bounds.left + dx,
2246
+ right: bounds.right + dx,
2247
+ centerX: bounds.centerX + dx,
2248
+ top: bounds.top + dy,
2249
+ bottom: bounds.bottom + dy,
2250
+ centerY: bounds.centerY + dy,
2251
+ width: bounds.width,
2252
+ height: bounds.height,
2253
+ };
2254
+ }
2255
+
2256
+ function findAxisSnapOffset(
2257
+ axis: "x" | "y",
2258
+ moving: FrameBounds[],
2259
+ stationary: FrameBounds[],
2008
2260
  threshold: number,
2009
- ): SnapCandidate[] {
2010
- const movingValues =
2011
- axis === "x"
2012
- ? [movingBounds.left, movingBounds.centerX, movingBounds.right]
2013
- : [movingBounds.top, movingBounds.centerY, movingBounds.bottom];
2014
- const stationaryValues =
2015
- axis === "x"
2016
- ? [
2017
- stationaryBounds.left,
2018
- stationaryBounds.centerX,
2019
- stationaryBounds.right,
2020
- ]
2021
- : [
2022
- stationaryBounds.top,
2023
- stationaryBounds.centerY,
2024
- stationaryBounds.bottom,
2025
- ];
2026
-
2027
- return movingValues.flatMap((movingValue) =>
2028
- stationaryValues
2029
- .map((stationaryValue) => {
2030
- const offset = stationaryValue - movingValue;
2031
- const distance = Math.abs(offset);
2032
- if (distance > threshold) return null;
2033
- return {
2034
- distance,
2035
- offset,
2036
- guide:
2037
- axis === "x"
2038
- ? getVerticalGuide(
2039
- stationaryValue,
2040
- movingBounds,
2041
- stationaryBounds,
2042
- )
2043
- : getHorizontalGuide(
2044
- stationaryValue,
2045
- movingBounds,
2046
- stationaryBounds,
2047
- ),
2048
- };
2049
- })
2050
- .filter(Boolean),
2051
- ) as SnapCandidate[];
2261
+ ): number | null {
2262
+ let offset: number | null = null;
2263
+ let bestDistance = Infinity;
2264
+ for (const movingBounds of moving) {
2265
+ for (const movingValue of getAxisSnapValues(movingBounds, axis)) {
2266
+ for (const stationaryBounds of stationary) {
2267
+ for (const stationaryValue of getAxisSnapValues(
2268
+ stationaryBounds,
2269
+ axis,
2270
+ )) {
2271
+ const candidate = stationaryValue - movingValue;
2272
+ const distance = Math.abs(candidate);
2273
+ if (distance > threshold || distance >= bestDistance) continue;
2274
+ bestDistance = distance;
2275
+ offset = candidate;
2276
+ }
2277
+ }
2278
+ }
2279
+ }
2280
+ return offset;
2281
+ }
2282
+
2283
+ /** Every guide line the snapped position actually sits on, not just the one
2284
+ * that won the snap: three frames sharing a left edge draw one line through
2285
+ * all three, and an edge match plus a center match on the same axis draw
2286
+ * both, matching Figma. Call with post-snap bounds — the line's extent is
2287
+ * the union of the frames it connects. */
2288
+ function buildAxisGuides(
2289
+ axis: "x" | "y",
2290
+ moving: FrameBounds[],
2291
+ stationary: FrameBounds[],
2292
+ ): AlignmentGuide[] {
2293
+ const spans = new Map<number, { start: number; end: number }>();
2294
+ for (const movingBounds of moving) {
2295
+ for (const movingValue of getAxisSnapValues(movingBounds, axis)) {
2296
+ for (const stationaryBounds of stationary) {
2297
+ for (const stationaryValue of getAxisSnapValues(
2298
+ stationaryBounds,
2299
+ axis,
2300
+ )) {
2301
+ if (Math.abs(stationaryValue - movingValue) > SNAP_ALIGN_EPSILON) {
2302
+ continue;
2303
+ }
2304
+ const existing = spans.get(stationaryValue);
2305
+ const start = Math.min(
2306
+ existing?.start ?? Infinity,
2307
+ getCrossStart(movingBounds, axis),
2308
+ getCrossStart(stationaryBounds, axis),
2309
+ );
2310
+ const end = Math.max(
2311
+ existing?.end ?? -Infinity,
2312
+ getCrossEnd(movingBounds, axis),
2313
+ getCrossEnd(stationaryBounds, axis),
2314
+ );
2315
+ spans.set(stationaryValue, { start, end });
2316
+ }
2317
+ }
2318
+ }
2319
+ }
2320
+
2321
+ const orientation = axis === "x" ? "vertical" : "horizontal";
2322
+ return Array.from(spans, ([position, span]) => ({
2323
+ orientation,
2324
+ position,
2325
+ start: span.start,
2326
+ end: span.end,
2327
+ })) as AlignmentGuide[];
2328
+ }
2329
+
2330
+ /** Gaps that already exist between two side-by-side stationary frames in the
2331
+ * moving frame's own row/column — the rhythm a Figma spacing snap matches. */
2332
+ function collectRhythmGaps(
2333
+ axis: "x" | "y",
2334
+ movingBounds: FrameBounds,
2335
+ stationary: FrameBounds[],
2336
+ ): { gap: number; band: DistanceGuideBand }[] {
2337
+ const row = stationary
2338
+ .filter(
2339
+ (bounds) =>
2340
+ crossAxisOverlaps(axis, bounds, movingBounds) &&
2341
+ // A frame that spans the whole moving frame — a parent content box,
2342
+ // a backdrop — is a wrapper, not a neighbour in the rhythm, and its
2343
+ // span would swallow every real gap in the row.
2344
+ !(
2345
+ getAxisStart(bounds, axis) <= getAxisStart(movingBounds, axis) &&
2346
+ getAxisEnd(bounds, axis) >= getAxisEnd(movingBounds, axis)
2347
+ ),
2348
+ )
2349
+ .sort((a, b) => getAxisStart(a, axis) - getAxisStart(b, axis));
2350
+
2351
+ const gaps: { gap: number; band: DistanceGuideBand }[] = [];
2352
+ let previous: FrameBounds | null = null;
2353
+ for (const bounds of row) {
2354
+ const gap = previous
2355
+ ? getAxisStart(bounds, axis) - getAxisEnd(previous, axis)
2356
+ : 0;
2357
+ if (previous && gap > 0 && crossAxisOverlaps(axis, previous, bounds)) {
2358
+ gaps.push({
2359
+ gap,
2360
+ band: {
2361
+ gapStart: getAxisEnd(previous, axis),
2362
+ gapEnd: getAxisStart(bounds, axis),
2363
+ crossStart: Math.max(
2364
+ getCrossStart(previous, axis),
2365
+ getCrossStart(bounds, axis),
2366
+ ),
2367
+ crossEnd: Math.min(
2368
+ getCrossEnd(previous, axis),
2369
+ getCrossEnd(bounds, axis),
2370
+ ),
2371
+ },
2372
+ });
2373
+ }
2374
+ if (!previous || getAxisEnd(bounds, axis) > getAxisEnd(previous, axis)) {
2375
+ previous = bounds;
2376
+ }
2377
+ }
2378
+ return gaps;
2379
+ }
2380
+
2381
+ /** The offset plus which neighbour it was measured against, so the guide is
2382
+ * drawn on the side the snap actually used. Picking a side independently
2383
+ * builds chrome for a gap that was never matched. */
2384
+ function findSpacingSnapOffset(
2385
+ axis: "x" | "y",
2386
+ movingBounds: FrameBounds,
2387
+ stationary: FrameBounds[],
2388
+ tolerance: number,
2389
+ ): { offset: number; side: "before" | "after" | "both" | null } {
2390
+ const candidates = collectAxisGapCandidates(axis, movingBounds, stationary);
2391
+ const before = closestGapCandidate(candidates, "before");
2392
+ const after = closestGapCandidate(candidates, "after");
2393
+ if (!before && !after) return { offset: 0, side: null };
2394
+
2395
+ let offset = 0;
2396
+ let side: "before" | "after" | "both" | null = null;
2397
+ let bestDistance = Infinity;
2398
+ const consider = (value: number, matched: "before" | "after" | "both") => {
2399
+ const distance = Math.abs(value);
2400
+ if (distance > tolerance || distance >= bestDistance) return;
2401
+ bestDistance = distance;
2402
+ offset = value;
2403
+ side = matched;
2404
+ };
2405
+
2406
+ if (before && after) consider((after.gap - before.gap) / 2, "both");
2407
+ for (const rhythm of collectRhythmGaps(axis, movingBounds, stationary)) {
2408
+ if (before) consider(rhythm.gap - before.gap, "before");
2409
+ if (after) consider(after.gap - rhythm.gap, "after");
2410
+ }
2411
+ return { offset, side };
2412
+ }
2413
+
2414
+ /** The pair of gap bands to draw once `findSpacingSnapOffset` has landed:
2415
+ * the two equal gaps around the frame, or the frame's gap plus the existing
2416
+ * gap it matched. Call with post-snap bounds. */
2417
+ function buildSpacingGuides(
2418
+ axis: "x" | "y",
2419
+ movingBounds: FrameBounds,
2420
+ stationary: FrameBounds[],
2421
+ tolerance: number,
2422
+ matchedSide?: "before" | "after" | "both" | null,
2423
+ ): EqualGapGuide[] {
2424
+ const rhythms = collectRhythmGaps(axis, movingBounds, stationary);
2425
+ const pair = buildEqualGapPair(axis, movingBounds, stationary, tolerance);
2426
+ if (pair.length) {
2427
+ const guide = pair[0];
2428
+ return [
2429
+ {
2430
+ ...guide,
2431
+ bands: [
2432
+ ...guide.bands,
2433
+ ...matchingRhythmBands(rhythms, guide.gap, tolerance),
2434
+ ],
2435
+ },
2436
+ ];
2437
+ }
2438
+
2439
+ const candidates = collectAxisGapCandidates(axis, movingBounds, stationary);
2440
+ const neighbor =
2441
+ matchedSide === "after"
2442
+ ? closestGapCandidate(candidates, "after")
2443
+ : matchedSide === "before"
2444
+ ? closestGapCandidate(candidates, "before")
2445
+ : (closestGapCandidate(candidates, "before") ??
2446
+ closestGapCandidate(candidates, "after"));
2447
+ if (!neighbor) return [];
2448
+
2449
+ const matched = matchingRhythmBands(rhythms, neighbor.gap, tolerance);
2450
+ if (!matched.length) return [];
2451
+ return [
2452
+ {
2453
+ orientation: axis === "x" ? "vertical" : "horizontal",
2454
+ gap: neighbor.gap,
2455
+ bands: [gapCandidateBand(neighbor), ...matched],
2456
+ },
2457
+ ];
2052
2458
  }
2053
2459
 
2054
2460
  function getBestCandidate(