@agent-native/core 0.90.5 → 0.90.7

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 (117) hide show
  1. package/README.md +34 -7
  2. package/corpus/README.md +1 -1
  3. package/corpus/core/CHANGELOG.md +13 -0
  4. package/corpus/core/docs/content/agent-native-toolkit.mdx +1 -1
  5. package/corpus/core/docs/content/locales/ar-SA/agent-native-toolkit.mdx +1 -1
  6. package/corpus/core/docs/content/locales/de-DE/agent-native-toolkit.mdx +1 -1
  7. package/corpus/core/docs/content/locales/es-ES/agent-native-toolkit.mdx +1 -1
  8. package/corpus/core/docs/content/locales/fr-FR/agent-native-toolkit.mdx +1 -1
  9. package/corpus/core/docs/content/locales/hi-IN/agent-native-toolkit.mdx +1 -1
  10. package/corpus/core/docs/content/locales/ja-JP/agent-native-toolkit.mdx +1 -1
  11. package/corpus/core/docs/content/locales/ko-KR/agent-native-toolkit.mdx +1 -1
  12. package/corpus/core/docs/content/locales/pt-BR/agent-native-toolkit.mdx +1 -1
  13. package/corpus/core/docs/content/locales/zh-CN/agent-native-toolkit.mdx +1 -1
  14. package/corpus/core/docs/content/locales/zh-TW/agent-native-toolkit.mdx +1 -1
  15. package/corpus/core/docs/content/toolkit-agent-ux.mdx +1 -1
  16. package/corpus/core/docs/content/toolkit-app-adapters.mdx +1 -1
  17. package/corpus/core/docs/content/toolkit-collaboration-ui.mdx +1 -1
  18. package/corpus/core/docs/content/toolkit-collaboration.mdx +1 -1
  19. package/corpus/core/docs/content/toolkit-command-navigation.mdx +2 -2
  20. package/corpus/core/docs/content/toolkit-comments-review.mdx +1 -1
  21. package/corpus/core/docs/content/toolkit-history.mdx +1 -1
  22. package/corpus/core/docs/content/toolkit-observability.mdx +1 -1
  23. package/corpus/core/docs/content/toolkit-org-team.mdx +1 -1
  24. package/corpus/core/docs/content/toolkit-resources.mdx +1 -1
  25. package/corpus/core/docs/content/toolkit-settings.mdx +1 -1
  26. package/corpus/core/docs/content/toolkit-setup-connections.mdx +1 -1
  27. package/corpus/core/docs/content/toolkit-sharing-ui.mdx +1 -1
  28. package/corpus/core/docs/content/toolkit-sharing.mdx +1 -1
  29. package/corpus/core/docs/content/toolkit-shell-hooks.mdx +1 -1
  30. package/corpus/core/docs/content/toolkit-ui.mdx +1 -1
  31. package/corpus/core/package.json +1 -1
  32. package/corpus/core/src/client/use-db-sync.ts +99 -1
  33. package/corpus/core/src/collab/client.ts +49 -4
  34. package/corpus/core/src/deploy/build.ts +103 -0
  35. package/corpus/core/src/server/core-routes-plugin.ts +137 -8
  36. package/corpus/templates/clips/changelog/2026-07-07-faster-local-whisper-dictation-start.md +6 -0
  37. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +311 -31
  38. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +2 -0
  39. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +9 -0
  40. package/corpus/templates/design/actions/read-source-file.ts +28 -0
  41. package/corpus/templates/design/actions/update-file.ts +76 -7
  42. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +181 -1
  43. package/corpus/templates/design/app/components/design/EditPanel.tsx +4 -0
  44. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +387 -25
  45. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1110 -88
  46. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +368 -16
  47. package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +24 -0
  48. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +27 -6
  49. package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +18 -0
  50. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +5 -4
  51. package/corpus/templates/design/app/components/design/multi-screen/handle-hit-zones.ts +124 -0
  52. package/corpus/templates/design/app/components/design/multi-screen/types.ts +23 -0
  53. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  54. package/corpus/templates/design/app/i18n-data.ts +19 -0
  55. package/corpus/templates/design/changelog/2026-07-07-dragging-a-free-floating-element-into-a-layout-column-or-row.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-07-dragging-a-repeated-list-item-like-a-to-do-card-generated-fr.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-07-dragging-an-element-onto-a-button-or-chip-s-middle-no-longer.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-07-elements-dropped-into-another-board-shape-now-stay-exactly-w.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-07-fixed-drag-reordering-elements-within-a-screen-no-longer-sil.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-07-fixed-dragging-an-element-from-a-screen-onto-the-canvas-now-.md +6 -0
  61. package/corpus/templates/design/changelog/2026-07-07-fixed-dragging-elements-between-screens-into-ai-generated-co.md +6 -0
  62. package/corpus/templates/design/changelog/2026-07-07-fixed-moving-elements-between-screens-no-longer-bakes-intern.md +6 -0
  63. package/corpus/templates/design/changelog/2026-07-07-fixed-the-cross-screen-drop-indicator-now-points-at-the-corr.md +6 -0
  64. package/corpus/templates/design/changelog/2026-07-07-fixed-the-zoom-menu-no-longer-leaves-the-canvas-unresponsive.md +6 -0
  65. package/corpus/templates/design/changelog/2026-07-07-text-drawn-on-the-canvas-is-now-readable-white-instead-of-in.md +6 -0
  66. package/corpus/templates/design/server/source-workspace.ts +27 -3
  67. package/corpus/templates/design/shared/board-file.ts +359 -0
  68. package/corpus/templates/design/shared/breakpoint-media.ts +21 -3
  69. package/corpus/templates/design/shared/code-layer.ts +145 -10
  70. package/dist/client/use-db-sync.d.ts +18 -0
  71. package/dist/client/use-db-sync.d.ts.map +1 -1
  72. package/dist/client/use-db-sync.js +88 -1
  73. package/dist/client/use-db-sync.js.map +1 -1
  74. package/dist/collab/awareness.d.ts +2 -2
  75. package/dist/collab/awareness.d.ts.map +1 -1
  76. package/dist/collab/client.d.ts.map +1 -1
  77. package/dist/collab/client.js +43 -4
  78. package/dist/collab/client.js.map +1 -1
  79. package/dist/deploy/build.d.ts +1 -0
  80. package/dist/deploy/build.d.ts.map +1 -1
  81. package/dist/deploy/build.js +57 -0
  82. package/dist/deploy/build.js.map +1 -1
  83. package/dist/notifications/routes.d.ts +3 -3
  84. package/dist/observability/routes.d.ts +3 -3
  85. package/dist/progress/routes.d.ts +1 -1
  86. package/dist/server/core-routes-plugin.d.ts +10 -0
  87. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  88. package/dist/server/core-routes-plugin.js +103 -9
  89. package/dist/server/core-routes-plugin.js.map +1 -1
  90. package/docs/content/agent-native-toolkit.mdx +1 -1
  91. package/docs/content/locales/ar-SA/agent-native-toolkit.mdx +1 -1
  92. package/docs/content/locales/de-DE/agent-native-toolkit.mdx +1 -1
  93. package/docs/content/locales/es-ES/agent-native-toolkit.mdx +1 -1
  94. package/docs/content/locales/fr-FR/agent-native-toolkit.mdx +1 -1
  95. package/docs/content/locales/hi-IN/agent-native-toolkit.mdx +1 -1
  96. package/docs/content/locales/ja-JP/agent-native-toolkit.mdx +1 -1
  97. package/docs/content/locales/ko-KR/agent-native-toolkit.mdx +1 -1
  98. package/docs/content/locales/pt-BR/agent-native-toolkit.mdx +1 -1
  99. package/docs/content/locales/zh-CN/agent-native-toolkit.mdx +1 -1
  100. package/docs/content/locales/zh-TW/agent-native-toolkit.mdx +1 -1
  101. package/docs/content/toolkit-agent-ux.mdx +1 -1
  102. package/docs/content/toolkit-app-adapters.mdx +1 -1
  103. package/docs/content/toolkit-collaboration-ui.mdx +1 -1
  104. package/docs/content/toolkit-collaboration.mdx +1 -1
  105. package/docs/content/toolkit-command-navigation.mdx +2 -2
  106. package/docs/content/toolkit-comments-review.mdx +1 -1
  107. package/docs/content/toolkit-history.mdx +1 -1
  108. package/docs/content/toolkit-observability.mdx +1 -1
  109. package/docs/content/toolkit-org-team.mdx +1 -1
  110. package/docs/content/toolkit-resources.mdx +1 -1
  111. package/docs/content/toolkit-settings.mdx +1 -1
  112. package/docs/content/toolkit-setup-connections.mdx +1 -1
  113. package/docs/content/toolkit-sharing-ui.mdx +1 -1
  114. package/docs/content/toolkit-sharing.mdx +1 -1
  115. package/docs/content/toolkit-shell-hooks.mdx +1 -1
  116. package/docs/content/toolkit-ui.mdx +1 -1
  117. package/package.json +1 -1
@@ -239,6 +239,10 @@ import {
239
239
  getChromeLabelTransition,
240
240
  getSelectionBoxTransition,
241
241
  } from "./multi-screen/chrome-transitions";
242
+ import {
243
+ getCornerHandleGeometry,
244
+ getEdgeHandleHitGeometry,
245
+ } from "./multi-screen/handle-hit-zones";
242
246
 
243
247
  export function isDirectScreenHoverTarget(
244
248
  target: EventTarget | null,
@@ -274,6 +278,38 @@ export function isOsFileDrag(event: {
274
278
  return false;
275
279
  }
276
280
 
281
+ // PERF9-WHEEL: module-scoped "a wheel/pinch camera gesture is in flight"
282
+ // flag, set by markWheelGestureActive on the first applied flush and cleared
283
+ // by commitView once the gesture settles (or on canvas unmount). Lives at
284
+ // module scope (not React state) because its consumers are event handlers —
285
+ // DesignEditor's hover callbacks — that must read the CURRENT value without
286
+ // any render round-trip: flipping React state for this at gesture start
287
+ // re-rendered every Screen and measurably stalled the first wheel ticks.
288
+ //
289
+ // Single-canvas-instance assumption: this flag is process-wide, not scoped
290
+ // to a particular MultiScreenCanvas mount. The editor only ever renders one
291
+ // MultiScreenCanvas at a time, so that's fine in practice — but if a second
292
+ // instance were ever mounted concurrently (e.g. a future side-by-side or
293
+ // picture-in-picture view), both would read/write the SAME flag and could
294
+ // stomp each other's gesture state. The unmount cleanup effect below clears
295
+ // it UNCONDITIONALLY (no ownership/identity check), so an unmounting second
296
+ // instance would also incorrectly clear a still-active gesture that belongs
297
+ // to the other, still-mounted instance. Revisit this if that assumption ever
298
+ // changes.
299
+ let wheelCameraGestureActive = false;
300
+
301
+ /** True while a MultiScreenCanvas wheel/pinch camera gesture is in flight
302
+ * (first applied wheel flush → settled debounced commit). DesignEditor's
303
+ * hover handlers consult this to skip hover setState mid-gesture: muting
304
+ * the content layers at gesture start flips the element under the cursor,
305
+ * firing a hover-clear that would otherwise cost a full-tree React render
306
+ * exactly when the pan needs the main thread most. Hover state simply goes
307
+ * stale for the gesture's duration (same contract as a space-drag pan) and
308
+ * recomputes from the next real pointer event after settle. */
309
+ export function isWheelCameraGestureActive(): boolean {
310
+ return wheelCameraGestureActive;
311
+ }
312
+
277
313
  export function shouldBoardSurfaceCapturePointerEvents(args: {
278
314
  tool: MultiScreenCanvasTool | string;
279
315
  gestureActive?: boolean;
@@ -296,6 +332,80 @@ export function shouldShowFrameFullViewButton(args: {
296
332
  return args.emphasized || !!args.showFullView || !!args.childHoverActive;
297
333
  }
298
334
 
335
+ /** How long an armed duplicate-commit suppression stays valid while the
336
+ * created file(s) round-trip through the server before appearing in
337
+ * `screens`. Generous vs. the usual create-file latency, but short enough
338
+ * that an armed flag left behind by a failed mutation can't swallow a much
339
+ * later, unrelated screen-count recenter. */
340
+ export const LINEUP_RECENTER_SUPPRESS_MAX_AGE_MS = 10_000;
341
+
342
+ /** Armed by a duplicate commit (alt-drag drop or Cmd+D) so the
343
+ * lineup-recenter effect can recognise — and skip — exactly the screen-count
344
+ * transition(s) that duplicate is about to produce. */
345
+ export interface LineupRecenterDuplicateArm {
346
+ atMs: number;
347
+ /** `screens.length` at the moment the duplicate was dispatched. */
348
+ fromCount: number;
349
+ /** How many duplicates were dispatched (alt-drag: 1; Cmd+D: one per
350
+ * selected frame — they land one server round-trip at a time). */
351
+ addedCount: number;
352
+ }
353
+
354
+ /**
355
+ * Whether the "center the lineup when the screen footprint changes" effect
356
+ * must SKIP its camera move for this run. A duplicate (alt-drag drop OR
357
+ * Cmd+D) places the clone deliberately relative to content the user is
358
+ * already looking at — Figma keeps the camera still on that commit, but the
359
+ * recenter effect (keyed on `screens.length`) would otherwise pan/zoom the
360
+ * whole board the moment each new screen lands, making every untouched
361
+ * sibling visibly shift. The duplicate commit arms `armed` with a timestamp,
362
+ * the pre-duplicate screen count, and how many clones were dispatched; the
363
+ * effect suppresses only transitions inside `(fromCount, fromCount +
364
+ * addedCount]`, only while fresh, and never for device-frame-preview-driven
365
+ * runs — so initial-load fit, explicit Zoom-to-fit (cameraCommand, a
366
+ * different path entirely), toolbar add-screen recenters, and device-frame
367
+ * changes all keep their behavior. Pure/exported for unit tests.
368
+ */
369
+ export function shouldSuppressLineupRecenter(args: {
370
+ armed: LineupRecenterDuplicateArm | null;
371
+ nowMs: number;
372
+ screenCount: number;
373
+ deviceFrameChanged: boolean;
374
+ maxAgeMs?: number;
375
+ }): boolean {
376
+ if (!args.armed) return false;
377
+ if (args.deviceFrameChanged) return false;
378
+ const age = args.nowMs - args.armed.atMs;
379
+ if (age < 0 || age > (args.maxAgeMs ?? LINEUP_RECENTER_SUPPRESS_MAX_AGE_MS)) {
380
+ return false;
381
+ }
382
+ return (
383
+ args.screenCount > args.armed.fromCount && // i18n-ignore — pure boolean expression, guard misreads the parenthesized return in this .tsx as JSX text
384
+ args.screenCount <= args.armed.fromCount + args.armed.addedCount
385
+ );
386
+ }
387
+
388
+ /**
389
+ * True when this lineup-recenter run was caused purely by the screen count
390
+ * SHRINKING (delete, or undo of a duplicate/add). Nothing new needs to
391
+ * become reachable when screens disappear, and Figma keeps the camera still
392
+ * on delete/undo — so the recenter effect skips these runs entirely (undo
393
+ * after a duplicate previously yanked the camera a second time).
394
+ * Device-frame-preview changes still recenter regardless of count direction.
395
+ * Pure/exported for unit tests.
396
+ */
397
+ export function isLineupShrinkOnlyChange(args: {
398
+ previousCount: number | null;
399
+ screenCount: number;
400
+ deviceFrameChanged: boolean;
401
+ }): boolean {
402
+ return (
403
+ !args.deviceFrameChanged &&
404
+ args.previousCount !== null &&
405
+ args.screenCount < args.previousCount
406
+ );
407
+ }
408
+
299
409
  /**
300
410
  * Item 8b — whether an overview breakpoint frame's "…" menu affordance
301
411
  * (Change width / Remove) should render: gated by edit permission AND at
@@ -569,10 +679,37 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
569
679
  const dragState = useRef<DragState | null>(null);
570
680
  const dragCleanup = useRef<(() => void) | null>(null);
571
681
  const duplicateCleanup = useRef<(() => void) | null>(null);
682
+ // Armed by a duplicate commit (alt-drag drop or Cmd+D) so the
683
+ // lineup-recenter effect keeps the camera still when the
684
+ // deliberately-placed clone(s) land in `screens` — see
685
+ // shouldSuppressLineupRecenter.
686
+ const lineupRecenterSuppressRef = useRef<LineupRecenterDuplicateArm | null>(
687
+ null,
688
+ );
689
+ const lineupRecenterDeviceFrameRef = useRef(previewDeviceFrame);
690
+ const lineupRecenterPrevCountRef = useRef<number | null>(null);
572
691
  const handledSelectAllRequestRef = useRef(selectAllRequest);
573
692
  const handledClearSelectionRequestRef = useRef(clearSelectionRequest);
574
693
  const [isDragging, setIsDragging] = useState(false);
575
694
  const [isPanning, setIsPanning] = useState(false);
695
+ // PERF9-WHEEL: an in-flight wheel/pinch gesture imperatively mutes pointer
696
+ // events on every live preview iframe (screens + board surface), restoring
697
+ // each element's prior inline value once the gesture settles (commitView).
698
+ // Without this, every wheel-pan tick moves live iframes under a stationary
699
+ // cursor, the browser re-hit-tests into them, and their hover/hit-test
700
+ // bridges post pointermove/element-hover messages (thousands per 240-tick
701
+ // pan) that can setState in DesignEditor — ~20ms full-tree React renders
702
+ // mid-gesture, which is what capped wheel-pan well under 60fps while drags
703
+ // ran at ~98fps on the same board. Done with direct style writes on cached
704
+ // nodes (NOT React state): flipping a canvasGestureActive-style flag at
705
+ // gesture start re-rendered every Screen + mounted interaction shields,
706
+ // costing a measured ~75ms first-tick stall — the exact class of jank
707
+ // PERF9 exists to avoid. Same "imperative now, reconcile once at settle"
708
+ // contract as applyViewToDom/scheduleViewCommit.
709
+ const wheelGestureActiveRef = useRef(false);
710
+ const wheelGestureMutedElementsRef = useRef<Map<HTMLElement, string> | null>(
711
+ null,
712
+ );
576
713
  const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
577
714
  const marqueeRef = useRef<MarqueeRect | null>(marquee);
578
715
  const [alignmentGuides, setAlignmentGuidesRaw] = useState<AlignmentGuide[]>(
@@ -1202,6 +1339,42 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1202
1339
 
1203
1340
  // Center the lineup when the screen footprint changes so new frames stay reachable.
1204
1341
  useEffect(() => {
1342
+ const deviceFrameChanged =
1343
+ lineupRecenterDeviceFrameRef.current !== previewDeviceFrame;
1344
+ lineupRecenterDeviceFrameRef.current = previewDeviceFrame;
1345
+ const previousCount = lineupRecenterPrevCountRef.current;
1346
+ lineupRecenterPrevCountRef.current = screens.length;
1347
+ // Screen-count decreases (delete, undo of a duplicate) never recenter —
1348
+ // see isLineupShrinkOnlyChange.
1349
+ if (
1350
+ isLineupShrinkOnlyChange({
1351
+ previousCount,
1352
+ screenCount: screens.length,
1353
+ deviceFrameChanged,
1354
+ })
1355
+ ) {
1356
+ return;
1357
+ }
1358
+ // A duplicate (alt-drag drop or Cmd+D) placed the new screen(s)
1359
+ // deliberately: keep the camera still for exactly those screen-count
1360
+ // transitions (see shouldSuppressLineupRecenter's doc for what this must
1361
+ // NOT affect).
1362
+ const armed = lineupRecenterSuppressRef.current;
1363
+ if (
1364
+ shouldSuppressLineupRecenter({
1365
+ armed,
1366
+ nowMs: Date.now(),
1367
+ screenCount: screens.length,
1368
+ deviceFrameChanged,
1369
+ })
1370
+ ) {
1371
+ // Disarm only once every dispatched clone has landed — a multi-frame
1372
+ // Cmd+D's duplicates arrive one create-file round-trip at a time.
1373
+ if (armed && screens.length >= armed.fromCount + armed.addedCount) {
1374
+ lineupRecenterSuppressRef.current = null;
1375
+ }
1376
+ return;
1377
+ }
1205
1378
  if (!surfaceRef.current || screens.length === 0) return;
1206
1379
  const rect = surfaceRef.current.getBoundingClientRect();
1207
1380
  const scale = zoomRef.current / 100;
@@ -1278,6 +1451,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1278
1451
  if (chromeSettleTimerRef.current !== null) {
1279
1452
  window.clearTimeout(chromeSettleTimerRef.current);
1280
1453
  }
1454
+ // PERF9-WHEEL: unmounting mid-gesture means the settled commitView
1455
+ // never runs — don't leave the module-scoped gesture flag stuck (the
1456
+ // muted elements unmount with the canvas, so no style restore needed).
1457
+ // Clears unconditionally (single-canvas-instance assumption — see the
1458
+ // module-scope doc comment on wheelCameraGestureActive above).
1459
+ wheelGestureActiveRef.current = false;
1460
+ wheelCameraGestureActive = false;
1461
+ wheelGestureMutedElementsRef.current = null;
1281
1462
  };
1282
1463
  }, []);
1283
1464
 
@@ -1515,6 +1696,27 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1515
1696
  crossScreenLastHitResultRef.current.clear();
1516
1697
  };
1517
1698
 
1699
+ // Single source of truth for a target screen's "viewport" dimensions used
1700
+ // to scale board<->iframe coordinates. MUST prefer the iframe's live
1701
+ // clientWidth/clientHeight over resolveScreenMetadata's DEFAULTED
1702
+ // 1280x2560 fallback (used for screens — e.g. duplicated screens — with
1703
+ // no screenMetadata entry in designs.data). runHitTest and
1704
+ // getTargetLocalPoint already did this correctly; requestCrossScreenDropGuide
1705
+ // previously called getResolvedMetadata directly with no iframe fallback,
1706
+ // so the drawn guide used different (often wrong-by-4x) geometry than the
1707
+ // hit-test that produced it — guides rendered squashed/mispositioned for
1708
+ // any screen missing metadata. Centralizing here keeps all three call
1709
+ // sites in sync going forward.
1710
+ const getTargetViewportMetadata = (
1711
+ targetScreen: (typeof screensRef.current)[number],
1712
+ targetIframe: HTMLIFrameElement | null | undefined,
1713
+ ): { width: number; height: number } => ({
1714
+ width:
1715
+ targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width,
1716
+ height:
1717
+ targetIframe?.clientHeight || getResolvedMetadata(targetScreen).height,
1718
+ });
1719
+
1518
1720
  const runHitTest = (
1519
1721
  candidate: CrossScreenDragTarget,
1520
1722
  boardPoint: Point,
@@ -1531,10 +1733,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1531
1733
  );
1532
1734
  const targetContentWindow = targetIframe?.contentWindow;
1533
1735
  if (!targetContentWindow) return Promise.resolve({});
1534
- const targetViewportWidth =
1535
- targetIframe.clientWidth || getResolvedMetadata(targetScreen).width;
1536
- const targetViewportHeight =
1537
- targetIframe.clientHeight || getResolvedMetadata(targetScreen).height;
1736
+ const { width: targetViewportWidth, height: targetViewportHeight } =
1737
+ getTargetViewportMetadata(targetScreen, targetIframe);
1538
1738
  const scaleX = targetViewportWidth / Math.max(1, targetGeometry.width);
1539
1739
  const scaleY = targetViewportHeight / Math.max(1, targetGeometry.height);
1540
1740
  const localX = (boardPoint.x - targetGeometry.x) * scaleX;
@@ -1573,6 +1773,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1573
1773
  typeof ev.data.anchorNodeId === "string"
1574
1774
  ? ev.data.anchorNodeId
1575
1775
  : undefined,
1776
+ // Id-on-demand handshake passthrough (see the fields' doc
1777
+ // comments on CrossScreenHitTestResult): without these, drops
1778
+ // into id-less AI-generated screens can never flow-insert.
1779
+ pendingNodeId:
1780
+ typeof ev.data.pendingNodeId === "string" && ev.data.pendingNodeId
1781
+ ? ev.data.pendingNodeId
1782
+ : undefined,
1783
+ anchorSelector:
1784
+ typeof ev.data.anchorSelector === "string" &&
1785
+ ev.data.anchorSelector
1786
+ ? ev.data.anchorSelector
1787
+ : undefined,
1576
1788
  placement: isCrossScreenDropPlacement(ev.data.placement)
1577
1789
  ? ev.data.placement
1578
1790
  : undefined,
@@ -1620,10 +1832,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1620
1832
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1621
1833
  `[data-screen-iframe-id="${targetIframeId}"]`,
1622
1834
  );
1623
- const targetViewportWidth =
1624
- targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width;
1625
- const targetViewportHeight =
1626
- targetIframe?.clientHeight || getResolvedMetadata(targetScreen).height;
1835
+ const { width: targetViewportWidth, height: targetViewportHeight } =
1836
+ getTargetViewportMetadata(targetScreen, targetIframe);
1627
1837
  const scaleX =
1628
1838
  targetViewportWidth / Math.max(1, candidate.geometry.width);
1629
1839
  const scaleY =
@@ -1645,11 +1855,22 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1645
1855
  const targetScreen = screensRef.current.find(
1646
1856
  (s) => s.id === candidate.id,
1647
1857
  );
1858
+ const targetIframeId = targetScreen
1859
+ ? CSS.escape(getActiveScreenIframeId(targetScreen))
1860
+ : null;
1861
+ const targetIframe = targetIframeId
1862
+ ? surfaceRef.current?.querySelector<HTMLIFrameElement>(
1863
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1864
+ )
1865
+ : null;
1648
1866
  const guide = targetScreen
1649
1867
  ? getCrossScreenDropGuideForHitTest({
1650
1868
  hit,
1651
1869
  targetGeometry: candidate.geometry,
1652
- targetMetadata: getResolvedMetadata(targetScreen),
1870
+ targetMetadata: getTargetViewportMetadata(
1871
+ targetScreen,
1872
+ targetIframe,
1873
+ ),
1653
1874
  })
1654
1875
  : null;
1655
1876
  setCrossScreenDropGuide(guide);
@@ -1737,7 +1958,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1737
1958
  }
1738
1959
 
1739
1960
  void runHitTest(targetCandidate, lastBoardPoint).then(
1740
- ({ anchorNodeId, placement, dropMode, anchorRect }) => {
1961
+ ({
1962
+ anchorNodeId,
1963
+ pendingNodeId,
1964
+ anchorSelector,
1965
+ placement,
1966
+ dropMode,
1967
+ anchorRect,
1968
+ }) => {
1741
1969
  const targetAnchorPlacement = isCrossScreenDropPlacement(placement)
1742
1970
  ? placement
1743
1971
  : undefined;
@@ -1751,6 +1979,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1751
1979
  sourceScreenId,
1752
1980
  targetScreenId: targetCandidate.id,
1753
1981
  targetAnchorNodeId: anchorNodeId,
1982
+ // Id-on-demand handshake (CrossScreenHitTestResult doc): lets
1983
+ // the drop handler persist the minted pending id into the
1984
+ // stored dest document and flow-insert instead of silently
1985
+ // degrading to absolute placement on id-less screens.
1986
+ targetAnchorPendingNodeId: pendingNodeId,
1987
+ targetAnchorSelector: anchorSelector,
1754
1988
  targetAnchorPlacement,
1755
1989
  targetDropMode: dropMode,
1756
1990
  targetAnchorRect: anchorRect,
@@ -1959,12 +2193,28 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1959
2193
  }
1960
2194
 
1961
2195
  // Remember the latest drag payload for use on "end".
2196
+ //
2197
+ // Pointer-offset pin fix: the bridge recomputes `pointerOffset` on
2198
+ // EVERY "move" post from the dragged element's CURRENT
2199
+ // getBoundingClientRect — which keeps changing while a flow-reorder
2200
+ // drag live-reorders the source element under the still-in-bounds
2201
+ // pointer. Letting a later move's offset win (the previous `??`
2202
+ // fallback here never actually engaged, since the bridge always
2203
+ // supplies a value) made the eventual screen->canvas drop land at
2204
+ // `boardPoint - (whatever slot the element occupied at the LAST
2205
+ // in-source tick)` instead of the true press-time grab offset,
2206
+ // drifting the landing position by up to the element's own size.
2207
+ // Figma keeps the grab point pixel-pinned under the cursor for the
2208
+ // whole gesture — so once "start" has captured a pointer offset,
2209
+ // never let a "move" message's (re-derived, drifting) offset
2210
+ // override it; only fall back to a move-supplied offset on the rare
2211
+ // path where "start" itself didn't have one yet.
1962
2212
  crossScreenDragMsgRef.current = {
1963
2213
  selector: selector ?? "",
1964
2214
  sourceId,
1965
2215
  sourcePointerOffset:
1966
- sourcePointerOffset ??
1967
- crossScreenDragMsgRef.current?.sourcePointerOffset,
2216
+ crossScreenDragMsgRef.current?.sourcePointerOffset ??
2217
+ sourcePointerOffset,
1968
2218
  sourceElementSize:
1969
2219
  sourceElementSize ??
1970
2220
  crossScreenDragMsgRef.current?.sourceElementSize,
@@ -5000,6 +5250,14 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5000
5250
  x: dropCanvasPosition.x - pointerOffset.x,
5001
5251
  y: dropCanvasPosition.y - pointerOffset.y,
5002
5252
  };
5253
+ // The user placed this clone deliberately — suppress the
5254
+ // lineup-recenter camera move when the created screen lands in
5255
+ // `screens` (Figma keeps the camera still on alt-drag duplicate).
5256
+ lineupRecenterSuppressRef.current = {
5257
+ atMs: Date.now(),
5258
+ fromCount: screensRef.current.length,
5259
+ addedCount: 1,
5260
+ };
5003
5261
  onDuplicate(screen.id, {
5004
5262
  mode,
5005
5263
  screen,
@@ -5264,6 +5522,24 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5264
5522
  const shouldSettleChrome = pendingChromeSettleRef.current;
5265
5523
  pendingChromeSettleRef.current = false;
5266
5524
  if (shouldSettleChrome) startChromeSettle();
5525
+ // PERF9-WHEEL: the gesture has settled — restore every muted content
5526
+ // layer's own prior inline pointer-events value (imperative, mirroring
5527
+ // how markWheelGestureActive muted them; restoring the recorded value,
5528
+ // not "", keeps React's inline-style bookkeeping consistent since React
5529
+ // only rewrites style properties it believes changed).
5530
+ if (wheelGestureActiveRef.current) {
5531
+ wheelGestureActiveRef.current = false;
5532
+ wheelCameraGestureActive = false;
5533
+ const muted = wheelGestureMutedElementsRef.current;
5534
+ wheelGestureMutedElementsRef.current = null;
5535
+ if (muted) {
5536
+ muted.forEach((previousPointerEvents, element) => {
5537
+ if (element.isConnected) {
5538
+ element.style.pointerEvents = previousPointerEvents;
5539
+ }
5540
+ });
5541
+ }
5542
+ }
5267
5543
  setCanvasZoom(zoomRef.current);
5268
5544
  setPan(panRef.current);
5269
5545
  onZoomChange?.(zoomRef.current);
@@ -5322,6 +5598,36 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5322
5598
  scheduleViewCommit();
5323
5599
  }, [cameraCommand, applyViewToDom, scheduleViewCommit]);
5324
5600
 
5601
+ // PERF9-WHEEL: the first flush that actually moves the camera mutes
5602
+ // pointer events on every live preview iframe with direct style writes on
5603
+ // a snapshot NodeList (ref-guarded — runs ONCE per gesture, zero React
5604
+ // commits). commitView restores the recorded per-element values once the
5605
+ // gesture has been idle for the debounce beat. A mid-gesture React render
5606
+ // from unrelated state could rewrite an iframe's style and re-enable it
5607
+ // early; that only reverts to pre-fix behavior for the gesture's tail, so
5608
+ // it's an acceptable trade for keeping gesture start/end render-free.
5609
+ const markWheelGestureActive = useCallback(() => {
5610
+ if (wheelGestureActiveRef.current) return;
5611
+ wheelGestureActiveRef.current = true;
5612
+ wheelCameraGestureActive = true;
5613
+ const surface = surfaceRef.current;
5614
+ if (!surface) return;
5615
+ // Mute the same layers a drag's canvasGestureActive gates via props:
5616
+ // every screen's content wrapper and the board-surface layer — this
5617
+ // covers the live iframes AND DesignCanvas's parent-side hover/hit-test
5618
+ // overlays beneath them.
5619
+ const muted = new Map<HTMLElement, string>();
5620
+ surface
5621
+ .querySelectorAll<HTMLElement>(
5622
+ "[data-screen-content], [data-board-surface-layer]",
5623
+ )
5624
+ .forEach((element) => {
5625
+ muted.set(element, element.style.pointerEvents);
5626
+ element.style.pointerEvents = "none";
5627
+ });
5628
+ wheelGestureMutedElementsRef.current = muted;
5629
+ }, []);
5630
+
5325
5631
  const flushPendingWheelGesture = useCallback(() => {
5326
5632
  wheelGestureFrameRef.current = null;
5327
5633
  const gesture = pendingWheelGestureRef.current;
@@ -5350,6 +5656,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5350
5656
  });
5351
5657
  zoomRef.current = nextZoom;
5352
5658
  panRef.current = nextPan;
5659
+ markWheelGestureActive();
5353
5660
  applyViewToDom();
5354
5661
  scheduleViewCommit({ settleChrome: true });
5355
5662
  return;
@@ -5360,9 +5667,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5360
5667
  y: panRef.current.y - gesture.deltaY,
5361
5668
  };
5362
5669
  panRef.current = nextPan;
5670
+ markWheelGestureActive();
5363
5671
  applyViewToDom();
5364
5672
  scheduleViewCommit();
5365
- }, [applyViewToDom, scheduleViewCommit]);
5673
+ }, [applyViewToDom, markWheelGestureActive, scheduleViewCommit]);
5366
5674
 
5367
5675
  const enqueueWheelGesture = useCallback(
5368
5676
  (gesture: PendingWheelGesture) => {
@@ -5404,8 +5712,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5404
5712
  metaKey: boolean;
5405
5713
  shiftKey: boolean;
5406
5714
  }) => {
5407
- const rect = surfaceRef.current?.getBoundingClientRect();
5408
- if (!rect) return;
5409
5715
  const delta = getWheelDeltaFromValues(
5410
5716
  args.deltaX,
5411
5717
  args.deltaY,
@@ -5413,6 +5719,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5413
5719
  );
5414
5720
 
5415
5721
  if (args.ctrlKey || args.metaKey) {
5722
+ // PERF9-WHEEL: only the zoom branch needs the surface rect (cursor
5723
+ // anchoring). Pan ticks skip the per-event getBoundingClientRect —
5724
+ // no forced style/layout read on the hot pan path.
5725
+ const rect = surfaceRef.current?.getBoundingClientRect();
5726
+ if (!rect) return;
5416
5727
  const zoomDeltaY = clamp(
5417
5728
  delta.y,
5418
5729
  -MAX_WHEEL_ZOOM_DELTA,
@@ -5761,6 +6072,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5761
6072
  // is offset relative to its OWN source geometry (not chained off the
5762
6073
  // previous duplicate), mirroring how a multi-select alt-drag would
5763
6074
  // offset each frame independently.
6075
+ let dispatched = 0;
5764
6076
  for (const targetId of frameIds) {
5765
6077
  const screen = screens.find((s) => s.id === targetId);
5766
6078
  if (!screen) continue;
@@ -5778,6 +6090,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5778
6090
  canvasPosition,
5779
6091
  dropCanvasPosition: canvasPosition,
5780
6092
  });
6093
+ dispatched += 1;
6094
+ }
6095
+ if (dispatched > 0) {
6096
+ // Cmd+D places each clone right beside its source — like the
6097
+ // alt-drag drop, the camera must stay still while the clones land
6098
+ // (see shouldSuppressLineupRecenter).
6099
+ lineupRecenterSuppressRef.current = {
6100
+ atMs: Date.now(),
6101
+ fromCount: screensRef.current.length,
6102
+ addedCount: dispatched,
6103
+ };
5781
6104
  }
5782
6105
  };
5783
6106
 
@@ -5811,6 +6134,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5811
6134
  }, [effectiveTool]);
5812
6135
  const penActive = effectiveTool === "pen";
5813
6136
  const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
6137
+ // PERF9-WHEEL: an in-flight wheel pan/zoom also mutes iframe pointer
6138
+ // events, but imperatively (see markWheelGestureActive) rather than via
6139
+ // this flag — flipping React state here at gesture start re-rendered every
6140
+ // Screen and stalled the first wheel tick.
5814
6141
  const canvasGestureActive = isDragging || isPanning;
5815
6142
  const boardSurfaceInteractive = shouldBoardSurfaceCapturePointerEvents({
5816
6143
  tool: effectiveTool,
@@ -6156,6 +6483,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6156
6483
  // iframes (which have their own stacking context above this).
6157
6484
  <div
6158
6485
  className="[&_.design-canvas-iframe-wrapper]:shadow-none [&_.design-canvas-iframe-wrapper]:ring-0"
6486
+ // PERF9-WHEEL: stable hook so markWheelGestureActive can mute
6487
+ // this layer's pointer events imperatively during a wheel
6488
+ // gesture, exactly like the [data-screen-content] wrappers.
6489
+ data-board-surface-layer
6159
6490
  style={getBoardSurfaceLayerStyle({
6160
6491
  geometry: boardGeo,
6161
6492
  interactive: boardSurfaceInteractive,
@@ -6768,6 +7099,8 @@ function DraftPrimitiveLayer({
6768
7099
  chromeScale={chromeScale}
6769
7100
  chromeSettling={chromeSettling}
6770
7101
  rotationDeg={draft.geometry.rotation ?? 0}
7102
+ frameWidth={draft.geometry.width}
7103
+ frameHeight={draft.geometry.height}
6771
7104
  onStartResize={(handle, event) =>
6772
7105
  onStartResize(draft.id, handle, event)
6773
7106
  }
@@ -8029,6 +8362,8 @@ const Screen = memo(function Screen({
8029
8362
  chromeScale={chromeScale}
8030
8363
  chromeSettling={chromeSettling}
8031
8364
  rotationDeg={geometry.rotation ?? 0}
8365
+ frameWidth={geometry.width}
8366
+ frameHeight={geometry.height}
8032
8367
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
8033
8368
  onStartRotate={(e) => onStartRotate(screen.id, e)}
8034
8369
  />
@@ -8751,6 +9086,8 @@ function PassiveSelectionBox({
8751
9086
  config.cursor,
8752
9087
  chromeScale,
8753
9088
  chromeSettling,
9089
+ geometry.width,
9090
+ geometry.height,
8754
9091
  )}
8755
9092
  />
8756
9093
  ))}
@@ -8805,6 +9142,8 @@ function SelectionBox({
8805
9142
  chromeScale={chromeScale}
8806
9143
  chromeSettling={chromeSettling}
8807
9144
  rotationDeg={geometry.rotation ?? 0}
9145
+ frameWidth={geometry.width}
9146
+ frameHeight={geometry.height}
8808
9147
  onStartResize={onStartResize}
8809
9148
  onStartRotate={onStartRotate}
8810
9149
  />
@@ -8820,6 +9159,8 @@ function ResizeHandles({
8820
9159
  chromeScale = 1,
8821
9160
  chromeSettling = false,
8822
9161
  rotationDeg = 0,
9162
+ frameWidth = Number.POSITIVE_INFINITY,
9163
+ frameHeight = Number.POSITIVE_INFINITY,
8823
9164
  onStartResize,
8824
9165
  onStartRotate,
8825
9166
  }: {
@@ -8833,6 +9174,11 @@ function ResizeHandles({
8833
9174
  * visual direction instead of a static unrotated cursor (CSS `cursor` is
8834
9175
  * never itself rotated by a transform on the element). */
8835
9176
  rotationDeg?: number;
9177
+ /** Frame dimensions in local (board) px, used to clamp how far handle hit
9178
+ * zones may reach into the frame body (handle-hit-zones.ts). Omit for the
9179
+ * unclamped historical behavior. */
9180
+ frameWidth?: number;
9181
+ frameHeight?: number;
8836
9182
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
8837
9183
  onStartRotate: (e: React.MouseEvent) => void;
8838
9184
  }) {
@@ -8863,6 +9209,8 @@ function ResizeHandles({
8863
9209
  getResizeCursorForHandle(config.handle, rotationDeg),
8864
9210
  chromeScale,
8865
9211
  chromeSettling,
9212
+ frameWidth,
9213
+ frameHeight,
8866
9214
  )}
8867
9215
  onMouseDown={(e) => onStartResize(config.handle, e)}
8868
9216
  />
@@ -8877,6 +9225,8 @@ function ResizeHandles({
8877
9225
  getResizeCursorForHandle(config.handle, rotationDeg),
8878
9226
  chromeScale,
8879
9227
  chromeSettling,
9228
+ frameWidth,
9229
+ frameHeight,
8880
9230
  )}
8881
9231
  onMouseDown={(e) => onStartResize(config.handle, e)}
8882
9232
  />
@@ -8939,31 +9289,38 @@ const ROTATE_HANDLE_CONFIGS: Array<{
8939
9289
  corner: string;
8940
9290
  }> = [{ corner: "nw" }, { corner: "ne" }, { corner: "se" }, { corner: "sw" }];
8941
9291
 
9292
+ // Edge/corner handle hit zones are clamped against the frame's own dimensions
9293
+ // (handle-hit-zones.ts) so that at low zoom the chromeScale-compensated hit
9294
+ // slop can never cover the whole frame body and steal every press as a
9295
+ // resize — the frame's central 50% band stays grabbable for a move drag at
9296
+ // any zoom. On large frames these produce the historical geometry exactly.
8942
9297
  function edgeHandleStyle(
8943
9298
  handle: ResizeHandle,
8944
9299
  cursor: string,
8945
9300
  chromeScale: number,
8946
9301
  chromeSettling: boolean,
9302
+ frameWidth: number,
9303
+ frameHeight: number,
8947
9304
  ): CSSProperties {
8948
- const size = 14 * chromeScale;
8949
- const offset = -size / 2;
8950
9305
  if (handle === "n" || handle === "s") {
9306
+ const geometry = getEdgeHandleHitGeometry(chromeScale, frameHeight);
8951
9307
  return {
8952
9308
  cursor,
8953
9309
  transition: getChromeHandleTransition(chromeSettling),
8954
9310
  left: 0,
8955
9311
  right: 0,
8956
- height: size,
8957
- [handle === "n" ? "top" : "bottom"]: offset,
9312
+ height: geometry.thickness,
9313
+ [handle === "n" ? "top" : "bottom"]: geometry.outwardOffset,
8958
9314
  };
8959
9315
  }
9316
+ const geometry = getEdgeHandleHitGeometry(chromeScale, frameWidth);
8960
9317
  return {
8961
9318
  cursor,
8962
9319
  transition: getChromeHandleTransition(chromeSettling),
8963
9320
  top: 0,
8964
9321
  bottom: 0,
8965
- width: size,
8966
- [handle === "w" ? "left" : "right"]: offset,
9322
+ width: geometry.thickness,
9323
+ [handle === "w" ? "left" : "right"]: geometry.outwardOffset,
8967
9324
  };
8968
9325
  }
8969
9326
 
@@ -8972,17 +9329,22 @@ function cornerHandleStyle(
8972
9329
  cursor: string,
8973
9330
  chromeScale: number,
8974
9331
  chromeSettling: boolean,
9332
+ frameWidth: number,
9333
+ frameHeight: number,
8975
9334
  ): CSSProperties {
8976
- const size = 10 * chromeScale;
8977
- const offset = -size / 2;
9335
+ const { size, offsetX, offsetY } = getCornerHandleGeometry(
9336
+ chromeScale,
9337
+ frameWidth,
9338
+ frameHeight,
9339
+ );
8978
9340
  return {
8979
9341
  cursor,
8980
9342
  transition: getChromeHandleTransition(chromeSettling),
8981
9343
  width: size,
8982
9344
  height: size,
8983
9345
  borderWidth: Math.max(1, 1.25 * chromeScale),
8984
- ...(handle.includes("n") ? { top: offset } : { bottom: offset }),
8985
- ...(handle.includes("w") ? { left: offset } : { right: offset }),
9346
+ ...(handle.includes("n") ? { top: offsetY } : { bottom: offsetY }),
9347
+ ...(handle.includes("w") ? { left: offsetX } : { right: offsetX }),
8986
9348
  };
8987
9349
  }
8988
9350