@agent-native/core 0.80.9 → 0.80.11

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 (148) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/cli/design-connect.ts +28 -2
  6. package/corpus/core/src/cli/skills.ts +45 -24
  7. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  8. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  9. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  10. package/corpus/templates/assets/AGENTS.md +4 -0
  11. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  12. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  13. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  14. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  15. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  16. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  17. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  18. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  19. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  20. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  21. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  22. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  23. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  24. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  25. package/corpus/templates/assets/shared/api.ts +4 -0
  26. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  27. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  28. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  29. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  30. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  31. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  32. package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
  33. package/corpus/templates/design/actions/apply-design-state.ts +213 -0
  34. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  35. package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
  36. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  37. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  38. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  39. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  40. package/corpus/templates/design/actions/create-component.ts +447 -0
  41. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  42. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  43. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  44. package/corpus/templates/design/actions/delete-design.ts +20 -0
  45. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  46. package/corpus/templates/design/actions/get-component-details.ts +251 -0
  47. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  48. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  49. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  50. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  51. package/corpus/templates/design/actions/index-components.ts +258 -0
  52. package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
  53. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  54. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  55. package/corpus/templates/design/actions/list-design-states.ts +90 -0
  56. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  57. package/corpus/templates/design/actions/open-component-source.ts +242 -0
  58. package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
  59. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  60. package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
  61. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  62. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  63. package/corpus/templates/design/actions/run-design-audit.ts +436 -0
  64. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  65. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
  67. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
  68. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  69. package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
  70. package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
  71. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  72. package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
  73. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
  74. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
  75. package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
  76. package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
  77. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  78. package/corpus/templates/design/app/components/design/index.ts +16 -0
  79. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  80. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  81. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  82. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  83. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  84. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  85. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  86. package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
  87. package/corpus/templates/design/app/i18n-data.ts +280 -0
  88. package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
  89. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  90. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  91. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  92. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  93. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  94. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  95. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  96. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  97. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  98. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  99. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  100. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  101. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  102. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  103. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  104. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  105. package/corpus/templates/design/e2e/helpers.ts +10 -4
  106. package/corpus/templates/design/server/db/schema.ts +123 -0
  107. package/corpus/templates/design/server/plugins/db.ts +90 -0
  108. package/corpus/templates/design/shared/builder-app.ts +297 -0
  109. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  110. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  111. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  112. package/corpus/templates/design/shared/component-model.ts +274 -0
  113. package/corpus/templates/design/shared/design-review.ts +275 -0
  114. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  115. package/corpus/templates/design/shared/design-state.ts +112 -0
  116. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  117. package/corpus/templates/design/shared/motion-compiler.ts +349 -0
  118. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  119. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  120. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  121. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  122. package/corpus/templates/design/shared/source-mode.ts +245 -0
  123. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  124. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  125. package/dist/agent/production-agent.d.ts.map +1 -1
  126. package/dist/agent/production-agent.js +94 -10
  127. package/dist/agent/production-agent.js.map +1 -1
  128. package/dist/cli/design-connect.d.ts.map +1 -1
  129. package/dist/cli/design-connect.js +28 -2
  130. package/dist/cli/design-connect.js.map +1 -1
  131. package/dist/cli/skills.d.ts.map +1 -1
  132. package/dist/cli/skills.js +37 -21
  133. package/dist/cli/skills.js.map +1 -1
  134. package/dist/client/AssistantChat.d.ts +14 -0
  135. package/dist/client/AssistantChat.d.ts.map +1 -1
  136. package/dist/client/AssistantChat.js +115 -22
  137. package/dist/client/AssistantChat.js.map +1 -1
  138. package/dist/client/sse-event-processor.d.ts.map +1 -1
  139. package/dist/client/sse-event-processor.js +10 -0
  140. package/dist/client/sse-event-processor.js.map +1 -1
  141. package/dist/collab/routes.d.ts +1 -1
  142. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  143. package/dist/notifications/routes.d.ts +2 -2
  144. package/dist/observability/routes.d.ts +8 -8
  145. package/dist/resources/handlers.d.ts +2 -2
  146. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  147. package/dist/server/transcribe-voice.d.ts +1 -1
  148. package/package.json +1 -1
@@ -32,7 +32,7 @@ import {
32
32
  type PenNode,
33
33
  type PenPath,
34
34
  } from "@shared/pen-path";
35
- import { IconCopy, IconMaximize } from "@tabler/icons-react";
35
+ import { IconCopy, IconMaximize, IconPlus } from "@tabler/icons-react";
36
36
  import {
37
37
  memo,
38
38
  useRef,
@@ -47,6 +47,7 @@ import {
47
47
  import { prettyScreenName } from "@/lib/screen-names";
48
48
  import { cn } from "@/lib/utils";
49
49
 
50
+ import { canvasPrimitiveReactStyle } from "./canvas-primitive-style";
50
51
  import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "./types";
51
52
 
52
53
  interface ScreenFile {
@@ -64,6 +65,14 @@ interface ScreenFile {
64
65
  height?: number;
65
66
  url?: string;
66
67
  previewUrl?: string;
68
+ /**
69
+ * When set, renders multiple side-by-side breakpoint frames (mobile-first,
70
+ * §6.4). Each entry is a pixel width; the active breakpoint determines the
71
+ * edit scope (Tailwind prefix: base / md: / lg: / xl:).
72
+ */
73
+ breakpointWidths?: number[];
74
+ /** Id of the currently active breakpoint frame for this screen. */
75
+ activeBreakpointWidth?: number;
67
76
  }
68
77
 
69
78
  type ScreenSourceType = "localhost" | "fusion" | "inline";
@@ -135,6 +144,7 @@ interface MultiScreenCanvasProps {
135
144
  zoom: number;
136
145
  activeId?: string | null;
137
146
  selectedScreenIds?: string[];
147
+ fullViewScreenIds?: string[];
138
148
  activeScreenHasHoveredChild?: boolean;
139
149
  hoveredChildScreenId?: string | null;
140
150
  directlyHoveredScreenId?: string | null;
@@ -158,6 +168,13 @@ interface MultiScreenCanvasProps {
158
168
  primitive: CanvasPrimitiveInsert,
159
169
  ) => boolean | string;
160
170
  onPrimitiveCreated?: (screenId: string, nodeId: string) => void;
171
+ onPrimitiveReparent?: (args: {
172
+ sourceNodeId: string;
173
+ sourceScreenId: string;
174
+ targetNodeId: string;
175
+ targetScreenId: string;
176
+ placement: "inside";
177
+ }) => void;
161
178
  onCreateScreenFrame?: (geometry: FrameGeometry) => void;
162
179
  onDeleteSelection?: (ids: string[]) => boolean | void;
163
180
  onZoomChange?: (zoom: number) => void;
@@ -169,7 +186,33 @@ interface MultiScreenCanvasProps {
169
186
  onScreenSelectionChange?: (ids: string[]) => void;
170
187
  selectAllRequest?: number;
171
188
  clearSelectionRequest?: number;
189
+ /**
190
+ * Called when the user clicks the + affordance on a screen's breakpoint
191
+ * row to add the next standard breakpoint width (390 / 768 / 1280).
192
+ */
193
+ onAddBreakpoint?: (screenId: string, widthPx: number) => void;
194
+ /**
195
+ * Called when the user clicks a breakpoint frame header to make it the
196
+ * active edit scope.
197
+ */
198
+ onActiveBreakpointChange?: (
199
+ screenId: string,
200
+ widthPx: number | undefined,
201
+ ) => void;
172
202
  onSelectionChange?: (selectedIds: string[]) => void;
203
+ /**
204
+ * Called when the user drags an element out of the active screen's iframe
205
+ * and drops it onto a different screen. The bridge in the source iframe
206
+ * posts { type:"agent-native:cross-screen-drag" } messages; the host
207
+ * translates them to board coords, finds the target frame, and calls this
208
+ * prop with the resolved ids.
209
+ */
210
+ onCrossScreenElementDrop?: (args: {
211
+ sourceSelector: string;
212
+ sourceNodeId?: string;
213
+ sourceScreenId: string;
214
+ targetScreenId: string;
215
+ }) => void;
173
216
  }
174
217
 
175
218
  /**
@@ -482,6 +525,7 @@ export function MultiScreenCanvas({
482
525
  zoom,
483
526
  activeId,
484
527
  selectedScreenIds,
528
+ fullViewScreenIds,
485
529
  activeScreenHasHoveredChild = false,
486
530
  hoveredChildScreenId,
487
531
  directlyHoveredScreenId,
@@ -499,6 +543,7 @@ export function MultiScreenCanvas({
499
543
  onGeometryCommit,
500
544
  onCreatePrimitive,
501
545
  onPrimitiveCreated,
546
+ onPrimitiveReparent,
502
547
  onCreateScreenFrame,
503
548
  onDeleteSelection,
504
549
  onZoomChange,
@@ -506,7 +551,10 @@ export function MultiScreenCanvas({
506
551
  onScreenSelectionChange,
507
552
  selectAllRequest,
508
553
  clearSelectionRequest,
554
+ onAddBreakpoint,
555
+ onActiveBreakpointChange,
509
556
  onSelectionChange,
557
+ onCrossScreenElementDrop,
510
558
  }: MultiScreenCanvasProps) {
511
559
  const t = useT();
512
560
  const surfaceRef = useRef<HTMLDivElement>(null);
@@ -518,6 +566,7 @@ export function MultiScreenCanvas({
518
566
  const frameGeometryRef = useRef(frameGeometry);
519
567
  const onGeometryChangeRef = useRef(onGeometryChange);
520
568
  const onGeometryCommitRef = useRef(onGeometryCommit);
569
+ const screensRef = useRef(screens);
521
570
  const [draftPrimitives, setDraftPrimitives] = useState<DraftPrimitive[]>([]);
522
571
  const draftPrimitivesRef = useRef(draftPrimitives);
523
572
  const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
@@ -552,6 +601,34 @@ export function MultiScreenCanvas({
552
601
  null,
553
602
  );
554
603
  const [dragCursor, setDragCursor] = useState<string | null>(null);
604
+ const [primitiveDropTarget, setPrimitiveDropTarget] =
605
+ useState<PrimitiveDropTarget | null>(null);
606
+ const primitiveDropTargetRef = useRef<PrimitiveDropTarget | null>(null);
607
+ const onPrimitiveReparentRef = useRef(onPrimitiveReparent);
608
+
609
+ // Cross-screen element drag state — driven by postMessage from the source iframe.
610
+ interface CrossScreenDragGhost {
611
+ /** Board-space point where the ghost is shown (follows the cursor). */
612
+ boardX: number;
613
+ boardY: number;
614
+ }
615
+ interface CrossScreenDragTarget {
616
+ /** The screen frame that is the candidate drop target. */
617
+ id: string;
618
+ geometry: FrameGeometry;
619
+ }
620
+ const [crossScreenGhost, setCrossScreenGhost] =
621
+ useState<CrossScreenDragGhost | null>(null);
622
+ const [crossScreenTarget, setCrossScreenTarget] =
623
+ useState<CrossScreenDragTarget | null>(null);
624
+ /** Ref kept in sync with state so the message handler can read without closures. */
625
+ const crossScreenTargetRef = useRef<CrossScreenDragTarget | null>(null);
626
+ /** The most-recent drag message payload — kept for use in the "end" handler. */
627
+ const crossScreenDragMsgRef = useRef<{
628
+ selector: string;
629
+ sourceId?: string;
630
+ } | null>(null);
631
+ const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
555
632
  const suppressNextPick = useRef(false);
556
633
  const feedbackTimerRef = useRef<number | null>(null);
557
634
  const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
@@ -583,6 +660,18 @@ export function MultiScreenCanvas({
583
660
  onGeometryCommitRef.current = onGeometryCommit;
584
661
  }, [onGeometryCommit]);
585
662
 
663
+ useEffect(() => {
664
+ onPrimitiveReparentRef.current = onPrimitiveReparent;
665
+ }, [onPrimitiveReparent]);
666
+
667
+ useEffect(() => {
668
+ onCrossScreenElementDropRef.current = onCrossScreenElementDrop;
669
+ }, [onCrossScreenElementDrop]);
670
+
671
+ useEffect(() => {
672
+ screensRef.current = screens;
673
+ }, [screens]);
674
+
586
675
  useEffect(() => {
587
676
  activePenPathRef.current = activePenPath;
588
677
  }, [activePenPath]);
@@ -734,15 +823,13 @@ export function MultiScreenCanvas({
734
823
  const metadata = getResolvedMetadata(screen);
735
824
  const currentGeometry =
736
825
  current[screen.id] ?? getInitialFrameGeometry(index, metadata);
737
- const nextHeight = getOverviewFrameHeight(
738
- currentGeometry.width,
826
+ const nextGeometry = getPreviewDeviceFrameGeometry({
827
+ currentGeometry,
739
828
  metadata,
740
- );
741
- if (currentGeometry.height === nextHeight) return;
742
- next[screen.id] = {
743
- ...currentGeometry,
744
- height: nextHeight,
745
- };
829
+ previewDeviceFrame,
830
+ });
831
+ if (sameFrameGeometry(currentGeometry, nextGeometry)) return;
832
+ next[screen.id] = nextGeometry;
746
833
  changed = true;
747
834
  });
748
835
 
@@ -787,9 +874,17 @@ export function MultiScreenCanvas({
787
874
  if (!surfaceRef.current || screens.length === 0) return;
788
875
  const rect = surfaceRef.current.getBoundingClientRect();
789
876
  const scale = zoomRef.current / 100;
790
- const frames = screens.map((screen, index) =>
791
- getInitialFrameGeometry(index, getResolvedMetadata(screen)),
792
- );
877
+ const frames = screens.map((screen, index) => {
878
+ const metadata = getResolvedMetadata(screen);
879
+ const currentGeometry =
880
+ frameGeometryRef.current[screen.id] ??
881
+ getInitialFrameGeometry(index, metadata);
882
+ return getPreviewDeviceFrameGeometry({
883
+ currentGeometry,
884
+ metadata,
885
+ previewDeviceFrame,
886
+ });
887
+ });
793
888
  const bounds = getFrameGroupBounds(
794
889
  frames.map((geometry, index) => ({
795
890
  id: screens[index]?.id ?? String(index),
@@ -908,6 +1003,155 @@ export function MultiScreenCanvas({
908
1003
  [getCurrentFrameEntries],
909
1004
  );
910
1005
 
1006
+ // ── Cross-screen element drag receiver ────────────────────────────────────
1007
+ // The source iframe (the active interactive screen) posts
1008
+ // { type: "agent-native:cross-screen-drag", phase, selector, sourceId,
1009
+ // iframeX, iframeY, viewportW, viewportH }
1010
+ // during element drags that the bridge wants the host to handle.
1011
+ useEffect(() => {
1012
+ if (!onCrossScreenElementDrop) return;
1013
+
1014
+ const clearCrossScreenDrag = () => {
1015
+ setCrossScreenGhost(null);
1016
+ setCrossScreenTarget(null);
1017
+ crossScreenTargetRef.current = null;
1018
+ crossScreenDragMsgRef.current = null;
1019
+ };
1020
+
1021
+ const handleMessage = (event: MessageEvent) => {
1022
+ if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
1023
+ return;
1024
+ }
1025
+ const msg = event.data as {
1026
+ type: string;
1027
+ phase: "move" | "end" | "cancel";
1028
+ selector?: string;
1029
+ sourceId?: string;
1030
+ iframeX?: number;
1031
+ iframeY?: number;
1032
+ viewportW?: number;
1033
+ viewportH?: number;
1034
+ };
1035
+
1036
+ if (msg.phase === "cancel") {
1037
+ clearCrossScreenDrag();
1038
+ return;
1039
+ }
1040
+
1041
+ // The drag originates from the active screen (only the active screen has
1042
+ // a live interactive iframe posting these messages).
1043
+ const sourceScreenId = activeId;
1044
+ if (!sourceScreenId) {
1045
+ // Always clear visual state (ghost + highlight) when we have no active
1046
+ // screen to attribute the drag to — regardless of the phase. Without
1047
+ // this, a "move" message arriving after activeId became null would leave
1048
+ // stale ghost/target state visible on the canvas.
1049
+ clearCrossScreenDrag();
1050
+ return;
1051
+ }
1052
+
1053
+ if (msg.phase === "move") {
1054
+ const { iframeX, iframeY, viewportW, viewportH, selector, sourceId } =
1055
+ msg;
1056
+ if (
1057
+ iframeX === undefined ||
1058
+ iframeY === undefined ||
1059
+ viewportW === undefined ||
1060
+ viewportH === undefined
1061
+ ) {
1062
+ return;
1063
+ }
1064
+
1065
+ // Remember the latest drag payload for use on "end".
1066
+ crossScreenDragMsgRef.current = {
1067
+ selector: selector ?? "",
1068
+ sourceId,
1069
+ };
1070
+
1071
+ // Pointer is inside the source iframe — let the bridge handle it.
1072
+ if (
1073
+ iframeX >= 0 &&
1074
+ iframeY >= 0 &&
1075
+ iframeX <= viewportW &&
1076
+ iframeY <= viewportH
1077
+ ) {
1078
+ clearCrossScreenDrag();
1079
+ return;
1080
+ }
1081
+
1082
+ // Translate iframe coords → board coords using source frame geometry.
1083
+ // Board math mirrors primitiveLocalToBoardRect:
1084
+ // boardX = frame.x + iframeX * (frame.width / metadata.width)
1085
+ // boardY = frame.y + iframeY * (frame.height / metadata.height)
1086
+ const sourceScreen = screensRef.current.find(
1087
+ (s) => s.id === sourceScreenId,
1088
+ );
1089
+ const sourceGeometry = frameGeometryRef.current[sourceScreenId];
1090
+ if (!sourceScreen || !sourceGeometry) {
1091
+ clearCrossScreenDrag();
1092
+ return;
1093
+ }
1094
+ const sourceMetadata = getResolvedMetadata(sourceScreen);
1095
+ const scaleX = sourceGeometry.width / Math.max(1, sourceMetadata.width);
1096
+ const scaleY =
1097
+ sourceGeometry.height / Math.max(1, sourceMetadata.height);
1098
+ const boardX = sourceGeometry.x + iframeX * scaleX;
1099
+ const boardY = sourceGeometry.y + iframeY * scaleY;
1100
+ const boardPoint = { x: boardX, y: boardY };
1101
+
1102
+ setCrossScreenGhost({ boardX, boardY });
1103
+
1104
+ // Find which screen frame the board point falls in.
1105
+ const target = getFrameEntryAtPoint(boardPoint);
1106
+ if (target && target.id !== sourceScreenId) {
1107
+ const nextTarget = { id: target.id, geometry: target.geometry };
1108
+ crossScreenTargetRef.current = nextTarget;
1109
+ setCrossScreenTarget(nextTarget);
1110
+ } else {
1111
+ crossScreenTargetRef.current = null;
1112
+ setCrossScreenTarget(null);
1113
+ }
1114
+ return;
1115
+ }
1116
+
1117
+ if (msg.phase === "end") {
1118
+ const candidate = crossScreenTargetRef.current;
1119
+ // Use the saved payload from the last "move" as the primary source of
1120
+ // truth; fall back to the "end" message's own fields in case the ref
1121
+ // was cleared (e.g. a brief re-entry into the source iframe nulled it
1122
+ // via clearCrossScreenDrag while pointerOutsideIframe remained true).
1123
+ const payload = crossScreenDragMsgRef.current ?? {
1124
+ selector: msg.selector ?? "",
1125
+ sourceId: msg.sourceId,
1126
+ };
1127
+ clearCrossScreenDrag();
1128
+ // Guard: require at least one stable identifier (selector or sourceId)
1129
+ // so the drop handler in DesignEditor can resolve the node. An empty
1130
+ // selector AND a missing sourceId would produce nodeAttrId="" and
1131
+ // moveNodeBetweenDocuments would fail with a generic error toast.
1132
+ const hasIdentifier = !!(payload.selector || payload.sourceId);
1133
+ if (candidate && hasIdentifier && sourceScreenId) {
1134
+ onCrossScreenElementDropRef.current?.({
1135
+ sourceSelector: payload.selector,
1136
+ sourceNodeId: payload.sourceId,
1137
+ sourceScreenId,
1138
+ targetScreenId: candidate.id,
1139
+ });
1140
+ }
1141
+ }
1142
+ };
1143
+
1144
+ window.addEventListener("message", handleMessage);
1145
+ return () => {
1146
+ window.removeEventListener("message", handleMessage);
1147
+ };
1148
+ }, [
1149
+ activeId,
1150
+ getFrameEntryAtPoint,
1151
+ getResolvedMetadata,
1152
+ onCrossScreenElementDrop,
1153
+ ]);
1154
+
911
1155
  const deleteSelectedItems = useCallback(() => {
912
1156
  const frameIds = selectedIdsRef.current.filter(
913
1157
  (id) => frameGeometryRef.current[id],
@@ -1036,6 +1280,31 @@ export function MultiScreenCanvas({
1036
1280
  [],
1037
1281
  );
1038
1282
 
1283
+ const updatePrimitiveDropTarget = useCallback(
1284
+ (target: PrimitiveDropTarget | null) => {
1285
+ primitiveDropTargetRef.current = target;
1286
+ setPrimitiveDropTarget(target);
1287
+ },
1288
+ [],
1289
+ );
1290
+
1291
+ const findPrimitiveDropTarget = useCallback(
1292
+ (
1293
+ point: Point,
1294
+ draggedNodeId: string | null,
1295
+ ): PrimitiveDropTarget | null => {
1296
+ if (!onPrimitiveReparentRef.current) return null;
1297
+ return getPrimitiveDropTargetForPoint(
1298
+ point,
1299
+ draggedNodeId,
1300
+ screensRef.current,
1301
+ frameGeometryRef.current,
1302
+ getResolvedMetadata,
1303
+ );
1304
+ },
1305
+ [getResolvedMetadata],
1306
+ );
1307
+
1039
1308
  const finishDrag = useCallback(() => {
1040
1309
  if (feedbackTimerRef.current !== null) {
1041
1310
  window.clearTimeout(feedbackTimerRef.current);
@@ -1049,6 +1318,8 @@ export function MultiScreenCanvas({
1049
1318
  setAlignmentGuides([]);
1050
1319
  setTransformBadge(null);
1051
1320
  setDragCursor(null);
1321
+ primitiveDropTargetRef.current = null;
1322
+ setPrimitiveDropTarget(null);
1052
1323
  dragCleanup.current?.();
1053
1324
  }, []);
1054
1325
 
@@ -1533,11 +1804,15 @@ export function MultiScreenCanvas({
1533
1804
 
1534
1805
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
1535
1806
  const originFrameId = getFrameEntryAtPoint(originCanvas)?.id;
1536
- const initialGeometry = getDraftGeometryForTool(
1537
- tool,
1538
- originCanvas,
1539
- originCanvas,
1540
- );
1807
+ // B1 fix: seed a zero-size preview at the click origin instead of
1808
+ // getDraftGeometryForTool(tool, origin, origin) which returns the
1809
+ // default 160x120 size and causes a visible flash before the drag delta
1810
+ // applies. The real size only matters at mouseup (createDraftPrimitive)
1811
+ // which already handles the "no drag" click-to-place case correctly.
1812
+ const initialGeometry =
1813
+ tool === "pen" || tool === "line" || tool === "arrow"
1814
+ ? getDraftGeometryForTool(tool, originCanvas, originCanvas)
1815
+ : { x: originCanvas.x, y: originCanvas.y, width: 0, height: 0 };
1541
1816
  const initialPoints =
1542
1817
  tool === "pen"
1543
1818
  ? [originCanvas]
@@ -1773,40 +2048,94 @@ export function MultiScreenCanvas({
1773
2048
  ev.clientX,
1774
2049
  ev.clientY,
1775
2050
  );
2051
+
2052
+ // Primitive drop-into-container detection: check if the dragged draft
2053
+ // is hovering over a committed container primitive on any screen.
2054
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
2055
+ const primitiveTarget = findPrimitiveDropTarget(canvasPoint, null);
2056
+ updatePrimitiveDropTarget(primitiveTarget);
1776
2057
  };
1777
2058
 
1778
2059
  const handleMouseUp = () => {
1779
2060
  const state = dragState.current;
2061
+ const dropTarget = primitiveDropTargetRef.current;
1780
2062
  if (state?.type === "draft-move" && state.hasMoved) {
1781
- const persisted: Array<{
1782
- draftId: string;
1783
- frameId: string;
1784
- nodeId: string;
1785
- }> = [];
1786
- draftPrimitivesRef.current.forEach((draft) => {
1787
- if (!state.targetIds.includes(draft.id)) return;
1788
- const result = persistDraftPrimitive(draft);
1789
- if (result) {
1790
- persisted.push({
1791
- draftId: draft.id,
1792
- frameId: result.frameId,
1793
- nodeId: result.nodeId,
2063
+ if (dropTarget) {
2064
+ // Drop into a container primitive: persist the draft into the
2065
+ // target's screen, then call onPrimitiveReparent to nest it.
2066
+ const persisted: Array<{
2067
+ draftId: string;
2068
+ frameId: string;
2069
+ nodeId: string;
2070
+ }> = [];
2071
+ draftPrimitivesRef.current.forEach((draft) => {
2072
+ if (!state.targetIds.includes(draft.id)) return;
2073
+ // Persist into the target's screen (not just any containing frame)
2074
+ const result = persistDraftPrimitive(draft, dropTarget.screenId);
2075
+ if (result) {
2076
+ persisted.push({
2077
+ draftId: draft.id,
2078
+ frameId: result.frameId,
2079
+ nodeId: result.nodeId,
2080
+ });
2081
+ }
2082
+ });
2083
+
2084
+ if (persisted.length > 0) {
2085
+ const persistedDraftIds = new Set(
2086
+ persisted.map((entry) => entry.draftId),
2087
+ );
2088
+ updateDraftPrimitives((current) =>
2089
+ current.filter((draft) => !persistedDraftIds.has(draft.id)),
2090
+ );
2091
+ updateSelectedDraftIds((current) =>
2092
+ current.filter((draftId) => !persistedDraftIds.has(draftId)),
2093
+ );
2094
+ // Reparent each persisted node into the container primitive.
2095
+ persisted.forEach((entry) => {
2096
+ onPrimitiveReparentRef.current?.({
2097
+ sourceNodeId: entry.nodeId,
2098
+ sourceScreenId: entry.frameId,
2099
+ targetNodeId: dropTarget.nodeId,
2100
+ targetScreenId: dropTarget.screenId,
2101
+ placement: "inside",
2102
+ });
1794
2103
  });
2104
+ const lastNodeId = persisted[persisted.length - 1]?.nodeId;
2105
+ if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
2106
+ }
2107
+ } else {
2108
+ // Normal drop: persist into whichever screen contains the draft.
2109
+ const persisted: Array<{
2110
+ draftId: string;
2111
+ frameId: string;
2112
+ nodeId: string;
2113
+ }> = [];
2114
+ draftPrimitivesRef.current.forEach((draft) => {
2115
+ if (!state.targetIds.includes(draft.id)) return;
2116
+ const result = persistDraftPrimitive(draft);
2117
+ if (result) {
2118
+ persisted.push({
2119
+ draftId: draft.id,
2120
+ frameId: result.frameId,
2121
+ nodeId: result.nodeId,
2122
+ });
2123
+ }
2124
+ });
2125
+
2126
+ if (persisted.length > 0) {
2127
+ const persistedDraftIds = new Set(
2128
+ persisted.map((entry) => entry.draftId),
2129
+ );
2130
+ updateDraftPrimitives((current) =>
2131
+ current.filter((draft) => !persistedDraftIds.has(draft.id)),
2132
+ );
2133
+ updateSelectedDraftIds((current) =>
2134
+ current.filter((draftId) => !persistedDraftIds.has(draftId)),
2135
+ );
2136
+ const lastNodeId = persisted[persisted.length - 1]?.nodeId;
2137
+ if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
1795
2138
  }
1796
- });
1797
-
1798
- if (persisted.length > 0) {
1799
- const persistedDraftIds = new Set(
1800
- persisted.map((entry) => entry.draftId),
1801
- );
1802
- updateDraftPrimitives((current) =>
1803
- current.filter((draft) => !persistedDraftIds.has(draft.id)),
1804
- );
1805
- updateSelectedDraftIds((current) =>
1806
- current.filter((draftId) => !persistedDraftIds.has(draftId)),
1807
- );
1808
- const lastNodeId = persisted[persisted.length - 1]?.nodeId;
1809
- if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
1810
2139
  }
1811
2140
  }
1812
2141
  finishDrag();
@@ -1815,12 +2144,15 @@ export function MultiScreenCanvas({
1815
2144
  installDragListeners(handleMouseMove, handleMouseUp);
1816
2145
  },
1817
2146
  [
2147
+ findPrimitiveDropTarget,
1818
2148
  finishDrag,
2149
+ getCanvasPoint,
1819
2150
  getCurrentCanvasEntries,
1820
2151
  installDragListeners,
1821
2152
  persistDraftPrimitive,
1822
2153
  showTransformFeedback,
1823
2154
  updateDraftPrimitives,
2155
+ updatePrimitiveDropTarget,
1824
2156
  updateSelectedDraftIds,
1825
2157
  updateSelectedIds,
1826
2158
  ],
@@ -2068,11 +2400,51 @@ export function MultiScreenCanvas({
2068
2400
  ev.clientX,
2069
2401
  ev.clientY,
2070
2402
  );
2403
+
2404
+ // When all dragged ids are committed primitive nodeIds (not screen
2405
+ // frames), check for a container primitive drop target to highlight.
2406
+ const currentFrameIds = Object.keys(frameGeometryRef.current);
2407
+ const allCommitted = state.targetIds.every(
2408
+ (targetId) => !currentFrameIds.includes(targetId),
2409
+ );
2410
+ if (allCommitted) {
2411
+ const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
2412
+ updatePrimitiveDropTarget(
2413
+ findPrimitiveDropTarget(canvasPoint, state.primaryId),
2414
+ );
2415
+ }
2071
2416
  };
2072
2417
 
2073
2418
  const handleMouseUp = () => {
2074
2419
  const state = dragState.current;
2420
+ const dropTarget = primitiveDropTargetRef.current;
2075
2421
  if (state?.type === "move" && state.hasMoved) {
2422
+ // If all dragged ids are committed primitive nodeIds (not screen
2423
+ // frames), attempt a primitive reparent on drop.
2424
+ const currentFrameIds = Object.keys(frameGeometryRef.current);
2425
+ const allCommitted = state.targetIds.every(
2426
+ (targetId) => !currentFrameIds.includes(targetId),
2427
+ );
2428
+ if (allCommitted && dropTarget) {
2429
+ const sourceScreenId = resolveNodeScreenId(
2430
+ state.primaryId,
2431
+ screensRef.current,
2432
+ );
2433
+ if (sourceScreenId) {
2434
+ onPrimitiveReparentRef.current?.({
2435
+ sourceNodeId: state.primaryId,
2436
+ sourceScreenId,
2437
+ targetNodeId: dropTarget.nodeId,
2438
+ targetScreenId: dropTarget.screenId,
2439
+ placement: "inside",
2440
+ });
2441
+ suppressNextPick.current = true;
2442
+ finishDrag();
2443
+ return;
2444
+ }
2445
+ }
2446
+
2447
+ // Normal screen-frame geometry commit.
2076
2448
  const after = cloneFrameGeometryById(frameGeometryRef.current);
2077
2449
  onGeometryCommitRef.current?.(
2078
2450
  frameGeometryWithOverrides(after, state.originFrames),
@@ -2087,12 +2459,15 @@ export function MultiScreenCanvas({
2087
2459
  },
2088
2460
  [
2089
2461
  activeId,
2462
+ findPrimitiveDropTarget,
2090
2463
  finishDrag,
2464
+ getCanvasPoint,
2091
2465
  getCurrentFrameEntries,
2092
2466
  installDragListeners,
2093
2467
  onPick,
2094
2468
  showTransformFeedback,
2095
2469
  updateFrameGeometry,
2470
+ updatePrimitiveDropTarget,
2096
2471
  updateSelectedDraftIds,
2097
2472
  updateSelectedIds,
2098
2473
  ],
@@ -2957,6 +3332,7 @@ export function MultiScreenCanvas({
2957
3332
  : appendPenNode(activePenPath, createCornerNode(penPointer))
2958
3333
  : activePenPath;
2959
3334
  const selectedIdSet = new Set(selectedIds);
3335
+ const fullViewIdSet = new Set(fullViewScreenIds ?? []);
2960
3336
  const selectedDraftIdSet = new Set(selectedDraftIds);
2961
3337
  const surfaceCursor = isPanning
2962
3338
  ? "grabbing"
@@ -3063,6 +3439,7 @@ export function MultiScreenCanvas({
3063
3439
  screenContent={screenContentById.get(screen.id)}
3064
3440
  isActive={screen.id === activeId}
3065
3441
  isSelected={selectedIdSet.has(screen.id)}
3442
+ showFullView={fullViewIdSet.has(screen.id)}
3066
3443
  isDirectlyHovered={screen.id === directlyHoveredScreenId}
3067
3444
  hasHoveredChild={
3068
3445
  (screen.id === activeId && activeScreenHasHoveredChild) ||
@@ -3081,6 +3458,16 @@ export function MultiScreenCanvas({
3081
3458
  onStartResize={beginResize}
3082
3459
  onStartRotate={beginRotate}
3083
3460
  onStartDuplicateGesture={beginDuplicateGesture}
3461
+ onAddBreakpoint={
3462
+ onAddBreakpoint
3463
+ ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
3464
+ : undefined
3465
+ }
3466
+ onActiveBreakpointChange={
3467
+ onActiveBreakpointChange
3468
+ ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
3469
+ : undefined
3470
+ }
3084
3471
  />
3085
3472
  );
3086
3473
  })}
@@ -3203,6 +3590,29 @@ export function MultiScreenCanvas({
3203
3590
  />
3204
3591
  ) : null}
3205
3592
 
3593
+ {primitiveDropTarget ? (
3594
+ <span
3595
+ data-primitive-drop-target
3596
+ className="pointer-events-none absolute z-40 rounded-sm"
3597
+ style={{
3598
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3599
+ left:
3600
+ pan.x +
3601
+ (SURFACE_PADDING + primitiveDropTarget.boardRect.x) * scale,
3602
+ top:
3603
+ pan.y +
3604
+ (SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
3605
+ width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
3606
+ height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
3607
+ // Match the in-screen inside-guide style: 2px accent border + 14%
3608
+ // accent fill. Uses the same CSS variable as the DesignCanvas guide.
3609
+ border: "2px solid var(--design-editor-accent-color)",
3610
+ background:
3611
+ "color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
3612
+ }}
3613
+ />
3614
+ ) : null}
3615
+
3206
3616
  {duplicatePreview ? (
3207
3617
  <div
3208
3618
  className={cn(
@@ -3245,6 +3655,46 @@ export function MultiScreenCanvas({
3245
3655
  {transformBadge.text}
3246
3656
  </div>
3247
3657
  ) : null}
3658
+
3659
+ {/* Cross-screen element drag: highlight the candidate target screen frame.
3660
+ Uses the same style as the primitiveDropTarget overlay (2px accent border
3661
+ + 14% accent fill) so it is visually consistent. */}
3662
+ {crossScreenTarget ? (
3663
+ <span
3664
+ data-cross-screen-drop-target
3665
+ className="pointer-events-none absolute z-40 rounded-sm"
3666
+ style={{
3667
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3668
+ left:
3669
+ pan.x + (SURFACE_PADDING + crossScreenTarget.geometry.x) * scale,
3670
+ top:
3671
+ pan.y + (SURFACE_PADDING + crossScreenTarget.geometry.y) * scale,
3672
+ width: Math.max(1, crossScreenTarget.geometry.width * scale),
3673
+ height: Math.max(1, crossScreenTarget.geometry.height * scale),
3674
+ border: "2px solid var(--design-editor-accent-color)",
3675
+ background:
3676
+ "color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
3677
+ }}
3678
+ />
3679
+ ) : null}
3680
+
3681
+ {/* Cross-screen element drag: small ghost following the board-space cursor. */}
3682
+ {crossScreenGhost ? (
3683
+ <span
3684
+ data-cross-screen-drag-ghost
3685
+ className="pointer-events-none absolute z-50 rounded border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)]/20 shadow"
3686
+ style={{
3687
+ // A fixed-size ghost (16×16 canvas-px) centred on the board point.
3688
+ // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
3689
+ left:
3690
+ pan.x + (SURFACE_PADDING + crossScreenGhost.boardX) * scale - 8,
3691
+ top:
3692
+ pan.y + (SURFACE_PADDING + crossScreenGhost.boardY) * scale - 8,
3693
+ width: 16,
3694
+ height: 16,
3695
+ }}
3696
+ />
3697
+ ) : null}
3248
3698
  </div>
3249
3699
  );
3250
3700
  }
@@ -3306,6 +3756,11 @@ function DraftPrimitiveLayer({
3306
3756
  }}
3307
3757
  >
3308
3758
  <DraftPrimitiveContent draft={draft} preview={preview} />
3759
+ {/* B3 fix: for the creation preview the outline must sit flush with the
3760
+ geometry box (inset: 0) so the blue accent border lands exactly on the
3761
+ shape edge with no visible gap between the gray content border and the
3762
+ blue selection outline. For placed / hovered draft-primitives the
3763
+ existing -5px inset is intentional (matches the screen-frame chrome). */}
3309
3764
  <span
3310
3765
  className={cn(
3311
3766
  "pointer-events-none absolute rounded-sm border transition-opacity",
@@ -3316,7 +3771,7 @@ function DraftPrimitiveLayer({
3316
3771
  : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
3317
3772
  )}
3318
3773
  style={{
3319
- inset: -5 * chromeScale,
3774
+ inset: preview ? 0 : -5 * chromeScale,
3320
3775
  borderWidth: 1.5 * chromeScale,
3321
3776
  transition: getChromeBorderTransition(chromeSettling),
3322
3777
  }}
@@ -3389,12 +3844,19 @@ function DraftPrimitiveContent({
3389
3844
  }
3390
3845
 
3391
3846
  if (draft.kind === "text") {
3847
+ // B5 fix: use canonical style so preview matches the committed element.
3848
+ const textStyle = canvasPrimitiveReactStyle("text", {
3849
+ fill: draft.fill,
3850
+ stroke: draft.stroke,
3851
+ strokeWidth: draft.strokeWidth,
3852
+ });
3392
3853
  return (
3393
3854
  <div
3394
3855
  className={cn(
3395
- "flex size-full items-start rounded-sm border border-dashed border-primary/60 bg-primary/5 px-2 py-1 text-sm font-medium text-foreground",
3856
+ "flex size-full items-start px-2 py-1 text-sm font-medium text-foreground",
3396
3857
  muted,
3397
3858
  )}
3859
+ style={textStyle}
3398
3860
  >
3399
3861
  <span className="truncate">{draft.text}</span>
3400
3862
  </div>
@@ -3402,27 +3864,24 @@ function DraftPrimitiveContent({
3402
3864
  }
3403
3865
 
3404
3866
  if (draft.kind === "frame") {
3405
- return (
3406
- <div
3407
- className={cn(
3408
- "size-full rounded-sm border-2 border-dashed border-primary/70 bg-primary/5",
3409
- muted,
3410
- )}
3411
- />
3412
- );
3867
+ // B5 fix: use canonical style so preview matches the committed element.
3868
+ const frameStyle = canvasPrimitiveReactStyle("frame", {
3869
+ fill: draft.fill,
3870
+ stroke: draft.stroke,
3871
+ strokeWidth: draft.strokeWidth,
3872
+ });
3873
+ return <div className={cn("size-full", muted)} style={frameStyle} />;
3413
3874
  }
3414
3875
 
3415
3876
  if (draft.kind === "ellipse") {
3416
- return (
3417
- <div
3418
- className={cn("size-full rounded-full border", muted)}
3419
- style={{
3420
- background: draft.fill ?? "hsl(var(--primary) / 0.12)",
3421
- borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
3422
- borderWidth: draft.strokeWidth ?? 1,
3423
- }}
3424
- />
3425
- );
3877
+ // B5/B6 fix: use canonical style — ellipse gets borderRadius:50% in both
3878
+ // the preview and the committed path, and the same calm neutral fill.
3879
+ const ellipseStyle = canvasPrimitiveReactStyle("ellipse", {
3880
+ fill: draft.fill,
3881
+ stroke: draft.stroke,
3882
+ strokeWidth: draft.strokeWidth,
3883
+ });
3884
+ return <div className={cn("size-full", muted)} style={ellipseStyle} />;
3426
3885
  }
3427
3886
 
3428
3887
  if (draft.kind === "polygon" || draft.kind === "star") {
@@ -3449,16 +3908,13 @@ function DraftPrimitiveContent({
3449
3908
  );
3450
3909
  }
3451
3910
 
3452
- return (
3453
- <div
3454
- className={cn("size-full rounded-sm border", muted)}
3455
- style={{
3456
- background: draft.fill ?? "hsl(var(--primary) / 0.12)",
3457
- borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
3458
- borderWidth: draft.strokeWidth ?? 1,
3459
- }}
3460
- />
3461
- );
3911
+ // B5 fix: rect/rectangle — use canonical style so preview matches committed.
3912
+ const rectStyle = canvasPrimitiveReactStyle("rect", {
3913
+ fill: draft.fill,
3914
+ stroke: draft.stroke,
3915
+ strokeWidth: draft.strokeWidth,
3916
+ });
3917
+ return <div className={cn("size-full", muted)} style={rectStyle} />;
3462
3918
  }
3463
3919
 
3464
3920
  function PenPathOverlay({
@@ -3567,12 +4023,28 @@ function isPoint(point: Point | undefined): point is Point {
3567
4023
  return !!point;
3568
4024
  }
3569
4025
 
4026
+ /** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
4027
+ const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
4028
+
4029
+ /** Derive the Tailwind prefix for a given frame width. */
4030
+ function breakpointLabel(widthPx: number): string {
4031
+ if (widthPx <= 640) return "Mobile";
4032
+ if (widthPx <= 1024) return "Tablet";
4033
+ return "Desktop";
4034
+ }
4035
+
4036
+ /** Suggest the next standard breakpoint not yet in the set. */
4037
+ function nextBreakpointWidth(existing: number[]): number | undefined {
4038
+ return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
4039
+ }
4040
+
3570
4041
  interface ScreenProps {
3571
4042
  screen: ScreenFile;
3572
4043
  metadata: ResolvedScreenMetadata;
3573
4044
  geometry: FrameGeometry;
3574
4045
  isActive: boolean;
3575
4046
  isSelected: boolean;
4047
+ showFullView: boolean;
3576
4048
  isDirectlyHovered: boolean;
3577
4049
  hasHoveredChild: boolean;
3578
4050
  groupSelected: boolean;
@@ -3597,6 +4069,8 @@ interface ScreenProps {
3597
4069
  display: string,
3598
4070
  e: React.MouseEvent<HTMLElement>,
3599
4071
  ) => void;
4072
+ onAddBreakpoint?: (widthPx: number) => void;
4073
+ onActiveBreakpointChange?: (widthPx: number | undefined) => void;
3600
4074
  }
3601
4075
 
3602
4076
  const Screen = memo(function Screen({
@@ -3605,6 +4079,7 @@ const Screen = memo(function Screen({
3605
4079
  geometry,
3606
4080
  isActive,
3607
4081
  isSelected,
4082
+ showFullView,
3608
4083
  isDirectlyHovered,
3609
4084
  hasHoveredChild,
3610
4085
  groupSelected,
@@ -3621,6 +4096,8 @@ const Screen = memo(function Screen({
3621
4096
  onStartRotate,
3622
4097
  onStartDuplicateGesture,
3623
4098
  screenContent,
4099
+ onAddBreakpoint,
4100
+ onActiveBreakpointChange,
3624
4101
  }: ScreenProps) {
3625
4102
  const t = useT();
3626
4103
  const display = metadata.title ?? prettyScreenName(screen.filename);
@@ -3633,6 +4110,7 @@ const Screen = memo(function Screen({
3633
4110
  !creationToolActive &&
3634
4111
  !canvasGestureActive;
3635
4112
  const emphasized = isSelected || frameDirectlyHovered;
4113
+ const fullViewVisible = emphasized || showFullView;
3636
4114
  const activeOrEmphasized = isActive || emphasized;
3637
4115
  const selectionOutlined = isSelected && !groupSelected;
3638
4116
  const showHoverChrome =
@@ -3760,7 +4238,7 @@ const Screen = memo(function Screen({
3760
4238
  className={cn(
3761
4239
  "absolute top-1/2 z-40 flex h-5 shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
3762
4240
  "hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
3763
- emphasized && "opacity-100",
4241
+ fullViewVisible && "opacity-100",
3764
4242
  fullViewOutsideFrame ? "left-full" : "right-1",
3765
4243
  )}
3766
4244
  style={{
@@ -3857,27 +4335,13 @@ const Screen = memo(function Screen({
3857
4335
  touchAction: "none",
3858
4336
  }}
3859
4337
  >
3860
- <span
3861
- data-screen-hover-outline
3862
- className={cn(
3863
- "pointer-events-none absolute border transition-opacity",
3864
- showHoverChrome
3865
- ? "border-[var(--design-editor-accent-color)] opacity-100"
3866
- : "border-transparent opacity-0",
3867
- )}
3868
- style={{
3869
- inset: -5 * chromeScale,
3870
- borderRadius: 13 * chromeScale,
3871
- borderWidth: 1.5 * chromeScale,
3872
- transition: getChromeBorderTransition(chromeSettling),
3873
- }}
3874
- />
3875
4338
  <span
3876
4339
  data-screen-content
3877
4340
  className={cn(
3878
4341
  "relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
3879
- "border-border",
3880
- showHoverChrome && "border-muted-foreground/60",
4342
+ showHoverChrome
4343
+ ? "border-[var(--design-editor-accent-color)]"
4344
+ : "border-border",
3881
4345
  )}
3882
4346
  style={{ pointerEvents: screenContentInteractive ? "auto" : "none" }}
3883
4347
  >
@@ -3913,7 +4377,7 @@ const Screen = memo(function Screen({
3913
4377
  )}
3914
4378
  {creationToolActive ? (
3915
4379
  <span
3916
- className="absolute inset-0 z-20 cursor-crosshair"
4380
+ className="pointer-events-auto absolute inset-0 z-20 cursor-crosshair"
3917
4381
  aria-hidden="true"
3918
4382
  />
3919
4383
  ) : null}
@@ -3942,6 +4406,25 @@ const Screen = memo(function Screen({
3942
4406
  onStartRotate={(e) => onStartRotate(screen.id, e)}
3943
4407
  />
3944
4408
  </div>
4409
+
4410
+ {/* Multi-breakpoint preview row (§6.4 — Framer/Figma-Sites style).
4411
+ Rendered as a sibling row to the right of the primary frame when
4412
+ the screen has breakpointWidths set. Each frame shares the same
4413
+ srcdoc content at a different viewport width. The active breakpoint
4414
+ is highlighted and clicking a frame header sets the edit scope. */}
4415
+ {screen.breakpointWidths && screen.breakpointWidths.length > 0 ? (
4416
+ <BreakpointPreviewRow
4417
+ screen={screen}
4418
+ primaryGeometry={geometry}
4419
+ previewUrl={previewUrl}
4420
+ activeBreakpointWidth={screen.activeBreakpointWidth}
4421
+ penActive={penActive}
4422
+ creationToolActive={creationToolActive}
4423
+ chromeScale={chromeScale}
4424
+ onActiveBreakpointChange={onActiveBreakpointChange}
4425
+ onAddBreakpoint={onAddBreakpoint}
4426
+ />
4427
+ ) : null}
3945
4428
  </div>
3946
4429
  );
3947
4430
  }, areScreenPropsEqual);
@@ -3954,6 +4437,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
3954
4437
  sameFrameGeometry(prev.geometry, next.geometry) &&
3955
4438
  prev.isActive === next.isActive &&
3956
4439
  prev.isSelected === next.isSelected &&
4440
+ prev.showFullView === next.showFullView &&
3957
4441
  prev.isDirectlyHovered === next.isDirectlyHovered &&
3958
4442
  prev.hasHoveredChild === next.hasHoveredChild &&
3959
4443
  prev.groupSelected === next.groupSelected &&
@@ -3972,6 +4456,174 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
3972
4456
  );
3973
4457
  }
3974
4458
 
4459
+ // ── Breakpoint preview row (§6.4) ────────────────────────────────────────────
4460
+
4461
+ /** Gap between adjacent breakpoint frames in canvas pixels. */
4462
+ const BREAKPOINT_FRAME_GAP = 24;
4463
+
4464
+ function BreakpointPreviewRow({
4465
+ screen,
4466
+ primaryGeometry,
4467
+ previewUrl,
4468
+ activeBreakpointWidth,
4469
+ penActive,
4470
+ creationToolActive,
4471
+ chromeScale,
4472
+ onActiveBreakpointChange,
4473
+ onAddBreakpoint,
4474
+ }: {
4475
+ screen: ScreenFile;
4476
+ primaryGeometry: FrameGeometry;
4477
+ previewUrl: string | undefined;
4478
+ activeBreakpointWidth: number | undefined;
4479
+ penActive: boolean;
4480
+ creationToolActive: boolean;
4481
+ chromeScale: number;
4482
+ onActiveBreakpointChange?: (widthPx: number | undefined) => void;
4483
+ onAddBreakpoint?: (widthPx: number) => void;
4484
+ }) {
4485
+ const breakpointWidths = screen.breakpointWidths ?? [];
4486
+ // Scale factor used to shrink additional frames to match the primary frame height
4487
+ const scaleY = primaryGeometry.height / (primaryGeometry.width || 1);
4488
+ // Place additional frames to the right of the primary, starting after the gap
4489
+ let offsetX = primaryGeometry.width + BREAKPOINT_FRAME_GAP;
4490
+
4491
+ const nextWidth = nextBreakpointWidth(breakpointWidths);
4492
+
4493
+ return (
4494
+ <>
4495
+ {breakpointWidths.map((widthPx) => {
4496
+ // Scale the additional frame proportionally (same height as primary)
4497
+ const frameWidth = Math.round(widthPx * scaleY);
4498
+ const frameHeight = primaryGeometry.height;
4499
+ const isActive = activeBreakpointWidth === widthPx;
4500
+ const currentOffsetX = offsetX;
4501
+ offsetX += frameWidth + BREAKPOINT_FRAME_GAP;
4502
+
4503
+ return (
4504
+ <div
4505
+ key={widthPx}
4506
+ data-frame-shell
4507
+ data-breakpoint-frame
4508
+ className="pointer-events-auto absolute"
4509
+ // Positioned relative to the parent Screen wrapper, which is already
4510
+ // absolute at left: SURFACE_PADDING + geometry.x / top:
4511
+ // SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT. Re-adding those
4512
+ // surface/primary terms here would double-offset every breakpoint
4513
+ // frame (~240px+ down-right), so we offset only within the wrapper.
4514
+ style={{
4515
+ left: currentOffsetX,
4516
+ top: -FRAME_LABEL_HEIGHT,
4517
+ width: frameWidth,
4518
+ zIndex: primaryGeometry.z,
4519
+ }}
4520
+ >
4521
+ {/* Frame label row */}
4522
+ <div
4523
+ className={cn(
4524
+ "flex h-7 w-full items-center justify-between gap-1 px-1 cursor-pointer select-none",
4525
+ )}
4526
+ onClick={(e) => {
4527
+ e.stopPropagation();
4528
+ onActiveBreakpointChange?.(isActive ? undefined : widthPx);
4529
+ }}
4530
+ >
4531
+ <div className="flex min-w-0 items-center gap-1.5">
4532
+ <span
4533
+ className={cn(
4534
+ "h-1.5 w-1.5 shrink-0 rounded-full",
4535
+ isActive
4536
+ ? "bg-[var(--design-editor-accent-color)]"
4537
+ : "bg-muted-foreground/40",
4538
+ )}
4539
+ />
4540
+ <span
4541
+ className={cn(
4542
+ "truncate text-[11px] font-medium",
4543
+ isActive
4544
+ ? "text-[var(--design-editor-accent-color)]"
4545
+ : "text-muted-foreground",
4546
+ )}
4547
+ >
4548
+ {breakpointLabel(widthPx)}
4549
+ </span>
4550
+ <span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
4551
+ {widthPx}px
4552
+ </span>
4553
+ </div>
4554
+ </div>
4555
+ {/* Frame card */}
4556
+ <div
4557
+ className={cn(
4558
+ "relative block overflow-hidden rounded-lg border bg-white shadow-lg transition-colors",
4559
+ isActive
4560
+ ? "border-[var(--design-editor-accent-color)]"
4561
+ : "border-border",
4562
+ )}
4563
+ style={{ width: frameWidth, height: frameHeight }}
4564
+ >
4565
+ <iframe
4566
+ src={previewUrl}
4567
+ srcDoc={previewUrl ? undefined : screen.content}
4568
+ sandbox="allow-scripts"
4569
+ loading="lazy"
4570
+ className="pointer-events-none border-0"
4571
+ style={{
4572
+ width: widthPx,
4573
+ height: Math.round(
4574
+ widthPx * (primaryGeometry.height / primaryGeometry.width),
4575
+ ),
4576
+ transform: `scale(${frameWidth / widthPx}, ${frameHeight / Math.round(widthPx * (primaryGeometry.height / primaryGeometry.width))})`,
4577
+ transformOrigin: "top left",
4578
+ }}
4579
+ title={`${screen.filename} — ${breakpointLabel(widthPx)}`}
4580
+ />
4581
+ {creationToolActive || penActive ? (
4582
+ <span className="absolute inset-0 z-20 cursor-crosshair" />
4583
+ ) : null}
4584
+ <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
4585
+ </div>
4586
+ </div>
4587
+ );
4588
+ })}
4589
+
4590
+ {/* + affordance: add the next standard breakpoint */}
4591
+ {onAddBreakpoint && nextWidth !== undefined ? (
4592
+ <div
4593
+ className="pointer-events-auto absolute flex items-center"
4594
+ // Same wrapper-relative coordinate space as the breakpoint frames
4595
+ // above — no SURFACE_PADDING / primaryGeometry.x/y terms or it would
4596
+ // double-offset.
4597
+ style={{
4598
+ left: offsetX,
4599
+ top: -FRAME_LABEL_HEIGHT + primaryGeometry.height / 2,
4600
+ zIndex: primaryGeometry.z,
4601
+ }}
4602
+ >
4603
+ <button
4604
+ type="button"
4605
+ className={cn(
4606
+ "flex size-7 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-sm transition-colors",
4607
+ "hover:border-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
4608
+ )}
4609
+ style={{
4610
+ transform: `scale(${chromeScale})`,
4611
+ transformOrigin: "center",
4612
+ }}
4613
+ title={`Add ${breakpointLabel(nextWidth)} breakpoint (${nextWidth}px)`}
4614
+ onClick={(e) => {
4615
+ e.stopPropagation();
4616
+ onAddBreakpoint(nextWidth);
4617
+ }}
4618
+ >
4619
+ <IconPlus className="size-3.5" />
4620
+ </button>
4621
+ </div>
4622
+ ) : null}
4623
+ </>
4624
+ );
4625
+ }
4626
+
3975
4627
  function GroupSelectionBox({
3976
4628
  bounds,
3977
4629
  chromeScale,
@@ -4278,6 +4930,30 @@ function getOverviewFrameHeight(width: number, metadata?: ScreenViewportSize) {
4278
4930
  return Math.max(80, Math.round((width * sourceHeight) / sourceWidth));
4279
4931
  }
4280
4932
 
4933
+ export function getPreviewDeviceFrameGeometry({
4934
+ currentGeometry,
4935
+ metadata,
4936
+ previewDeviceFrame,
4937
+ }: {
4938
+ currentGeometry: FrameGeometry;
4939
+ metadata?: { width: number; height: number };
4940
+ previewDeviceFrame: DeviceFrameType;
4941
+ }): FrameGeometry {
4942
+ if (previewDeviceFrame === "none") {
4943
+ return {
4944
+ ...currentGeometry,
4945
+ height: getOverviewFrameHeight(currentGeometry.width, metadata),
4946
+ };
4947
+ }
4948
+
4949
+ const viewport = DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
4950
+ return {
4951
+ ...currentGeometry,
4952
+ width: Math.max(1, Math.round(metadata?.width ?? viewport.width)),
4953
+ height: Math.max(1, Math.round(metadata?.height ?? viewport.height)),
4954
+ };
4955
+ }
4956
+
4281
4957
  function getScreenPreviewViewport(
4282
4958
  metadata: ScreenViewportSize,
4283
4959
  geometry: ScreenViewportSize,
@@ -4968,12 +5644,17 @@ function resolveScreenMetadata(
4968
5644
  getterMetadata?: ScreenMetadata,
4969
5645
  previewDeviceFrame: DeviceFrameType = "none",
4970
5646
  ): ResolvedScreenMetadata {
4971
- const metadata = { ...screen, ...keyedMetadata, ...getterMetadata };
5647
+ // Normalize content to a string up front: a screen whose content has not yet
5648
+ // loaded (or is otherwise not a plain string) must not crash the overview
5649
+ // render via the content.trim()/slice() helpers below.
5650
+ const safeScreen: ScreenFile =
5651
+ typeof screen.content === "string" ? screen : { ...screen, content: "" };
5652
+ const metadata = { ...safeScreen, ...keyedMetadata, ...getterMetadata };
4972
5653
  const previewUrl =
4973
5654
  metadata.url ??
4974
5655
  metadata.previewUrl ??
4975
- screen.previewUrl ??
4976
- getPreviewUrl(screen.content);
5656
+ safeScreen.previewUrl ??
5657
+ getPreviewUrl(safeScreen.content);
4977
5658
  const deviceViewport =
4978
5659
  previewDeviceFrame === "none"
4979
5660
  ? undefined
@@ -4987,11 +5668,11 @@ function resolveScreenMetadata(
4987
5668
  return {
4988
5669
  source:
4989
5670
  normalizeSource(metadata.sourceType ?? metadata.source) ??
4990
- deriveSource(screen, previewUrl),
5671
+ deriveSource(safeScreen, previewUrl),
4991
5672
  previewState:
4992
5673
  normalizePreviewState(
4993
5674
  metadata.lod ?? metadata.previewState ?? metadata.status,
4994
- ) ?? derivePreviewState(screen, previewUrl),
5675
+ ) ?? derivePreviewState(safeScreen, previewUrl),
4995
5676
  title: metadata.title,
4996
5677
  width,
4997
5678
  height,
@@ -5067,7 +5748,11 @@ function derivePreviewState(
5067
5748
  }
5068
5749
 
5069
5750
  function getPreviewUrl(content: string) {
5070
- return getUrl(content.trim())?.toString();
5751
+ // Tolerate non-string content (e.g. a screen whose content has not loaded):
5752
+ // a missing URL is correct here, a crash is not.
5753
+ return getUrl(
5754
+ typeof content === "string" ? content.trim() : undefined,
5755
+ )?.toString();
5071
5756
  }
5072
5757
 
5073
5758
  function getUrl(value: string | undefined) {
@@ -5085,3 +5770,272 @@ function getUrl(value: string | undefined) {
5085
5770
  function clampNumber(value: number, min: number, max: number) {
5086
5771
  return Math.min(max, Math.max(min, value));
5087
5772
  }
5773
+
5774
+ // ---------------------------------------------------------------------------
5775
+ // Primitive-into-primitive drop target detection
5776
+ // ---------------------------------------------------------------------------
5777
+
5778
+ /** A committed container primitive that can accept a dropped primitive. */
5779
+ export interface PrimitiveDropTarget {
5780
+ /** The node's data-agent-native-node-id value. */
5781
+ nodeId: string;
5782
+ /** The screen frame id (ScreenFile.id) that owns this primitive. */
5783
+ screenId: string;
5784
+ /** The primitive's bounding rect in board/canvas space. */
5785
+ boardRect: FrameGeometry;
5786
+ }
5787
+
5788
+ /**
5789
+ * Parsed representation of a committed primitive found in a screen's HTML.
5790
+ * Geometry is in screen-local CSS pixels (as written by appendCanvasPrimitiveToHtml).
5791
+ */
5792
+ export interface ParsedScreenPrimitive {
5793
+ nodeId: string;
5794
+ screenId: string;
5795
+ /** Position relative to screen body (CSS px). */
5796
+ localLeft: number;
5797
+ localTop: number;
5798
+ localWidth: number;
5799
+ localHeight: number;
5800
+ isContainer: boolean;
5801
+ }
5802
+
5803
+ /** Fast djb2-variant hash of a string. Runs in O(n) but is inlined for the
5804
+ * JIT — no allocations, no imports. Produces a 32-bit unsigned integer as a
5805
+ * hex string. Used only for cache-key disambiguation, not cryptography. */
5806
+ function hashString(s: string): string {
5807
+ let h = 5381;
5808
+ for (let i = 0; i < s.length; i++) {
5809
+ // h = h * 33 ^ charCode (djb2 xor variant)
5810
+ h = ((h << 5) + h) ^ s.charCodeAt(i);
5811
+ h = h >>> 0; // keep as unsigned 32-bit
5812
+ }
5813
+ return h.toString(16);
5814
+ }
5815
+
5816
+ /** Simple LRU-style cache to avoid re-parsing the same screen HTML on every
5817
+ * mousemove frame. Keyed by `screenId:contentLength:hash` so that ANY edit
5818
+ * (including changes deep in the middle of a large HTML document) invalidates
5819
+ * the entry. A length:hash pair eliminates the collision zone that existed
5820
+ * when only prefix48+suffix48 were used — edits in `content[48..len-49]` would
5821
+ * produce the same key even though the content differed. */
5822
+ export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
5823
+ const PRIMITIVE_PARSE_CACHE_MAX = 64;
5824
+
5825
+ export function parsePrimitivesFromScreen(
5826
+ screen: ScreenFile,
5827
+ ): ParsedScreenPrimitive[] {
5828
+ const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
5829
+ const cached = primitiveParseCache.get(cacheKey);
5830
+ if (cached) return cached;
5831
+
5832
+ const result: ParsedScreenPrimitive[] = [];
5833
+ if (typeof DOMParser === "undefined" || !screen.content) {
5834
+ return result;
5835
+ }
5836
+
5837
+ try {
5838
+ const doc = new DOMParser().parseFromString(screen.content, "text/html");
5839
+ const nodes = doc.querySelectorAll("[data-agent-native-node-id]");
5840
+ nodes.forEach((el) => {
5841
+ const nodeId = el.getAttribute("data-agent-native-node-id");
5842
+ if (!nodeId) return;
5843
+
5844
+ const htmlEl = el as HTMLElement;
5845
+ const style = htmlEl.style;
5846
+ const tag = el.tagName.toLowerCase();
5847
+
5848
+ // Only block elements with explicit absolute positioning are considered
5849
+ // positioned primitives drawn by appendCanvasPrimitiveToHtml.
5850
+ if (style.position !== "absolute") return;
5851
+
5852
+ const left = parseFloat(style.left) || 0;
5853
+ const top = parseFloat(style.top) || 0;
5854
+ const width = parseFloat(style.width) || 0;
5855
+ const height = parseFloat(style.height) || 0;
5856
+
5857
+ // Validity: must have non-zero size
5858
+ if (width <= 0 || height <= 0) return;
5859
+
5860
+ // Container check:
5861
+ // - Must be a div (not svg/path/circle/polygon/etc.)
5862
+ // - Must NOT be an ellipse (border-radius:50%)
5863
+ // - NOT a text primitive (but text divs could be containers in principle;
5864
+ // we exclude them by checking for display:inline-block autoSize pattern)
5865
+ const isDiv = tag === "div";
5866
+ const isEllipse =
5867
+ style.borderRadius === "50%" ||
5868
+ style.borderRadius === "50% 50% 50% 50%";
5869
+ const isTextAutoSize = style.display === "inline-block";
5870
+ const isContainer = isDiv && !isEllipse && !isTextAutoSize;
5871
+
5872
+ result.push({
5873
+ nodeId,
5874
+ screenId: screen.id,
5875
+ localLeft: left,
5876
+ localTop: top,
5877
+ localWidth: width,
5878
+ localHeight: height,
5879
+ isContainer,
5880
+ });
5881
+ });
5882
+ } catch {
5883
+ // Silently ignore parse errors
5884
+ }
5885
+
5886
+ if (primitiveParseCache.size >= PRIMITIVE_PARSE_CACHE_MAX) {
5887
+ // Evict the oldest entry
5888
+ const firstKey = primitiveParseCache.keys().next().value;
5889
+ if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
5890
+ }
5891
+ primitiveParseCache.set(cacheKey, result);
5892
+ return result;
5893
+ }
5894
+
5895
+ /**
5896
+ * Convert a screen-local primitive rect to board/canvas coordinates.
5897
+ *
5898
+ * appendCanvasPrimitiveToHtml stores positions in screen-local CSS pixels
5899
+ * scaled from the board draft geometry:
5900
+ * localX = (boardX - frame.x) * (metadata.width / frame.width)
5901
+ * Inverting:
5902
+ * boardX = frame.x + localX * (frame.width / metadata.width)
5903
+ */
5904
+ export function primitiveLocalToBoardRect(
5905
+ localLeft: number,
5906
+ localTop: number,
5907
+ localWidth: number,
5908
+ localHeight: number,
5909
+ frameGeometry: FrameGeometry,
5910
+ metadata: { width: number; height: number },
5911
+ ): FrameGeometry {
5912
+ const scaleX = frameGeometry.width / Math.max(1, metadata.width);
5913
+ const scaleY = frameGeometry.height / Math.max(1, metadata.height);
5914
+ return {
5915
+ x: frameGeometry.x + localLeft * scaleX,
5916
+ y: frameGeometry.y + localTop * scaleY,
5917
+ width: Math.max(1, localWidth * scaleX),
5918
+ height: Math.max(1, localHeight * scaleY),
5919
+ };
5920
+ }
5921
+
5922
+ /**
5923
+ * Find the topmost committed container primitive at `point` (canvas coords),
5924
+ * excluding `draggedNodeId` and any of its descendants.
5925
+ *
5926
+ * Descendants are detected geometrically: a primitive whose board rect is
5927
+ * fully enclosed by the dragged node's board rect is treated as a descendant
5928
+ * and excluded. This avoids a circular parent-child relationship on drop.
5929
+ *
5930
+ * Returns null if no valid target found.
5931
+ */
5932
+ export function getPrimitiveDropTargetForPoint(
5933
+ point: Point,
5934
+ draggedNodeId: string | null,
5935
+ screens: ScreenFile[],
5936
+ frameGeometryById: FrameGeometryById,
5937
+ getMetadata: (screen: ScreenFile) => { width: number; height: number },
5938
+ ): PrimitiveDropTarget | null {
5939
+ // Pre-compute the dragged node's board rect so we can exclude its descendants.
5940
+ let draggedBoardRect: FrameGeometry | null = null;
5941
+ if (draggedNodeId) {
5942
+ outer: for (const screen of screens) {
5943
+ const frameGeometry = frameGeometryById[screen.id];
5944
+ if (!frameGeometry) continue;
5945
+ const metadata = getMetadata(screen);
5946
+ const primitives = parsePrimitivesFromScreen(screen);
5947
+ for (const prim of primitives) {
5948
+ if (prim.nodeId === draggedNodeId) {
5949
+ draggedBoardRect = primitiveLocalToBoardRect(
5950
+ prim.localLeft,
5951
+ prim.localTop,
5952
+ prim.localWidth,
5953
+ prim.localHeight,
5954
+ frameGeometry,
5955
+ metadata,
5956
+ );
5957
+ break outer;
5958
+ }
5959
+ }
5960
+ }
5961
+ }
5962
+
5963
+ let best: PrimitiveDropTarget | null = null;
5964
+
5965
+ for (const screen of screens) {
5966
+ const frameGeometry = frameGeometryById[screen.id];
5967
+ if (!frameGeometry) continue;
5968
+
5969
+ const frameBounds = {
5970
+ left: frameGeometry.x,
5971
+ top: frameGeometry.y,
5972
+ right: frameGeometry.x + frameGeometry.width,
5973
+ bottom: frameGeometry.y + frameGeometry.height,
5974
+ };
5975
+ if (!rectContainsPoint(frameBounds, point)) {
5976
+ continue;
5977
+ }
5978
+
5979
+ const metadata = getMetadata(screen);
5980
+ const primitives = parsePrimitivesFromScreen(screen);
5981
+
5982
+ for (const prim of primitives) {
5983
+ if (!prim.isContainer) continue;
5984
+ if (draggedNodeId && prim.nodeId === draggedNodeId) continue;
5985
+
5986
+ const boardRect = primitiveLocalToBoardRect(
5987
+ prim.localLeft,
5988
+ prim.localTop,
5989
+ prim.localWidth,
5990
+ prim.localHeight,
5991
+ frameGeometry,
5992
+ metadata,
5993
+ );
5994
+
5995
+ // Exclude geometric descendants: a primitive whose board rect is fully
5996
+ // contained within the dragged node's board rect is a child/descendant
5997
+ // and cannot be a valid reparent target (would create a cycle).
5998
+ if (
5999
+ draggedBoardRect &&
6000
+ boardRect.x >= draggedBoardRect.x &&
6001
+ boardRect.y >= draggedBoardRect.y &&
6002
+ boardRect.x + boardRect.width <=
6003
+ draggedBoardRect.x + draggedBoardRect.width &&
6004
+ boardRect.y + boardRect.height <=
6005
+ draggedBoardRect.y + draggedBoardRect.height
6006
+ ) {
6007
+ continue;
6008
+ }
6009
+
6010
+ if (
6011
+ point.x >= boardRect.x &&
6012
+ point.x <= boardRect.x + boardRect.width &&
6013
+ point.y >= boardRect.y &&
6014
+ point.y <= boardRect.y + boardRect.height
6015
+ ) {
6016
+ // Later in the DOM = higher paint order = topmost visually.
6017
+ // We take the last match within each screen (DOM order).
6018
+ best = { nodeId: prim.nodeId, screenId: screen.id, boardRect };
6019
+ }
6020
+ }
6021
+ }
6022
+
6023
+ return best;
6024
+ }
6025
+
6026
+ /**
6027
+ * Resolve which screen (ScreenFile.id) owns a committed primitive nodeId by
6028
+ * scanning all screen HTML for the given data-agent-native-node-id value.
6029
+ */
6030
+ export function resolveNodeScreenId(
6031
+ nodeId: string,
6032
+ screens: ScreenFile[],
6033
+ ): string | null {
6034
+ for (const screen of screens) {
6035
+ const primitives = parsePrimitivesFromScreen(screen);
6036
+ if (primitives.some((p) => p.nodeId === nodeId)) {
6037
+ return screen.id;
6038
+ }
6039
+ }
6040
+ return null;
6041
+ }