@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
@@ -15,6 +15,259 @@ import { isTrustedCanvasBridgeMessage } from "./bridge-security";
15
15
  import { DeviceFrame } from "./DeviceFrame";
16
16
  import type { ElementInfo, DeviceFrameType } from "./types";
17
17
 
18
+ /**
19
+ * Allowlist check for Fusion (Builder-hosted) frame origins.
20
+ *
21
+ * Fusion frames are served cross-origin from the Builder-hosted app, so the
22
+ * strict `origin === parentOrigin` bridge check can never match. Before relaxing
23
+ * trust to window-identity only, we must confirm the message origin is actually
24
+ * a Builder host — the exact origin of the `fusionUrl` we were asked to render,
25
+ * or any `*.builder.io` host (plus the bare `builder.io`), over https. This
26
+ * prevents the relaxed-trust path from accepting messages from an arbitrary
27
+ * cross-origin frame that merely shares our iframe's window reference.
28
+ */
29
+ function isAllowedFusionOrigin(
30
+ origin: string,
31
+ fusionUrl: string | undefined,
32
+ ): boolean {
33
+ if (!origin || origin === "null") return false;
34
+ let host: string;
35
+ let protocol: string;
36
+ try {
37
+ const parsed = new URL(origin);
38
+ host = parsed.hostname.toLowerCase();
39
+ protocol = parsed.protocol;
40
+ } catch {
41
+ return false;
42
+ }
43
+ // Only allow secure (https) Builder origins.
44
+ if (protocol !== "https:") return false;
45
+ // Exact match against the configured fusion URL's origin.
46
+ if (fusionUrl) {
47
+ try {
48
+ if (new URL(fusionUrl).origin === origin) return true;
49
+ } catch {
50
+ // Malformed fusionUrl — fall through to the host-family allowlist.
51
+ }
52
+ }
53
+ // Builder host family: builder.io and any subdomain of it.
54
+ return host === "builder.io" || host.endsWith(".builder.io");
55
+ }
56
+
57
+ /**
58
+ * Wire shape for a single motion track sent via the `motion-load-tracks`
59
+ * postMessage. Matches the serialisable subset of `MotionTrack` from
60
+ * `shared/motion-timeline.ts` without requiring an import at the UI layer.
61
+ */
62
+ export interface MotionTrackWire {
63
+ targetNodeId: string;
64
+ property: string;
65
+ keyframes: Array<{ t: number; value: string; ease?: string }>;
66
+ }
67
+
68
+ /**
69
+ * Motion-preview bridge. Injected alongside the other bridge scripts so the
70
+ * MotionDock's scrubbing preview works in ALL editor modes without writing
71
+ * anything to the DB, Yjs state, or source files.
72
+ *
73
+ * Protocol (parent → iframe):
74
+ *
75
+ * { type: 'motion-load-tracks', tracks: MotionTrackWire[] }
76
+ * Load (or replace) the track list for this document. Each entry:
77
+ * { targetNodeId, property, keyframes: [{ t, value, ease? }] }
78
+ * where t ∈ [0, 1]. Sent whenever the active timeline changes.
79
+ *
80
+ * { type: 'motion-preview', t, durationMs }
81
+ * Seek all loaded tracks to normalised position t ∈ [0, 1] and apply
82
+ * the interpolated CSS property values as inline styles on the matching
83
+ * [data-agent-native-node-id="…"] elements. Never writes to storage.
84
+ *
85
+ * { type: 'motion-preview-clear' }
86
+ * Remove all motion-preview inline-style overrides and the in-memory
87
+ * track list. Called when the dock is closed or the timeline is
88
+ * discarded.
89
+ *
90
+ * Easing is deliberately simple: linear interpolation between keyframe
91
+ * values (CSS handles easing when the compiled CSS is actually applied;
92
+ * preview is a live visualisation, not a perfect recreation of the final
93
+ * animation).
94
+ */
95
+ const MOTION_PREVIEW_BRIDGE_SCRIPT = `
96
+ <script data-agent-native-motion-preview-bridge>
97
+ (function() {
98
+ // Track list loaded by 'motion-load-tracks'.
99
+ var loadedTracks = [];
100
+ // Map of nodeId -> [property, ...] we have touched, for cleanup.
101
+ var touchedProps = {};
102
+ // Map of nodeId -> property -> original inline style value.
103
+ var originalInlineValues = {};
104
+
105
+ function lerp(a, b, ratio) {
106
+ var numA = parseFloat(a);
107
+ var numB = parseFloat(b);
108
+ if (Number.isFinite(numA) && Number.isFinite(numB)) {
109
+ var suffix = a.replace(/^-?[\\d.]+/, '') || b.replace(/^-?[\\d.]+/, '');
110
+ return (numA + (numB - numA) * ratio).toFixed(4).replace(/\\.?0+$/, '') + suffix;
111
+ }
112
+ // Non-numeric: snap to b after the midpoint.
113
+ return ratio < 0.5 ? a : b;
114
+ }
115
+
116
+ function interpolate(keyframes, t) {
117
+ if (!keyframes || keyframes.length === 0) return '';
118
+ if (keyframes.length === 1) return keyframes[0].value;
119
+ // Find surrounding keyframes.
120
+ var prev = keyframes[0];
121
+ var next = keyframes[keyframes.length - 1];
122
+ for (var i = 0; i < keyframes.length - 1; i++) {
123
+ if (t >= keyframes[i].t && t <= keyframes[i + 1].t) {
124
+ prev = keyframes[i];
125
+ next = keyframes[i + 1];
126
+ break;
127
+ }
128
+ }
129
+ var span = next.t - prev.t;
130
+ if (span <= 0) return prev.value;
131
+ var ratio = Math.max(0, Math.min(1, (t - prev.t) / span));
132
+ return lerp(prev.value, next.value, ratio);
133
+ }
134
+
135
+ function applyPreview(t) {
136
+ for (var i = 0; i < loadedTracks.length; i++) {
137
+ var track = loadedTracks[i];
138
+ var el = document.querySelector('[data-agent-native-node-id="' + track.targetNodeId + '"]');
139
+ if (!el) continue;
140
+ var value = interpolate(track.keyframes, t);
141
+ if (value === '') continue;
142
+ if (!originalInlineValues[track.targetNodeId]) originalInlineValues[track.targetNodeId] = {};
143
+ if (!(track.property in originalInlineValues[track.targetNodeId])) {
144
+ originalInlineValues[track.targetNodeId][track.property] = el.style[track.property] || '';
145
+ }
146
+ el.style[track.property] = value;
147
+ if (!touchedProps[track.targetNodeId]) touchedProps[track.targetNodeId] = [];
148
+ if (touchedProps[track.targetNodeId].indexOf(track.property) === -1) {
149
+ touchedProps[track.targetNodeId].push(track.property);
150
+ }
151
+ }
152
+ }
153
+
154
+ function clearPreview() {
155
+ var nodeIds = Object.keys(touchedProps);
156
+ for (var i = 0; i < nodeIds.length; i++) {
157
+ var el = document.querySelector('[data-agent-native-node-id="' + nodeIds[i] + '"]');
158
+ if (!el) continue;
159
+ var props = touchedProps[nodeIds[i]];
160
+ for (var j = 0; j < props.length; j++) {
161
+ var originals = originalInlineValues[nodeIds[i]] || {};
162
+ el.style[props[j]] = Object.prototype.hasOwnProperty.call(originals, props[j])
163
+ ? originals[props[j]]
164
+ : '';
165
+ }
166
+ }
167
+ touchedProps = {};
168
+ originalInlineValues = {};
169
+ loadedTracks = [];
170
+ }
171
+
172
+ window.addEventListener('message', function(e) {
173
+ if (e.source !== window.parent) return;
174
+ if (!e.data || typeof e.data.type !== 'string') return;
175
+ if (e.data.type === 'motion-load-tracks') {
176
+ clearPreview();
177
+ loadedTracks = Array.isArray(e.data.tracks) ? e.data.tracks : [];
178
+ return;
179
+ }
180
+ if (e.data.type === 'motion-preview') {
181
+ var t = Number(e.data.t);
182
+ if (!Number.isFinite(t)) return;
183
+ t = Math.max(0, Math.min(1, t));
184
+ applyPreview(t);
185
+ return;
186
+ }
187
+ if (e.data.type === 'motion-preview-clear') {
188
+ clearPreview();
189
+ return;
190
+ }
191
+ });
192
+ })();
193
+ </script>
194
+ `;
195
+
196
+ /**
197
+ * Shader-fill preview bridge. ALWAYS injected alongside the other bridge
198
+ * scripts so the parent can apply a CSS gradient approximation of a shader
199
+ * fill to the currently-selected element **without** persisting anything.
200
+ *
201
+ * Protocol (parent → iframe):
202
+ *
203
+ * { type: 'shader-fill-preview', selector, nodeId, css }
204
+ * Apply `css` as the `background` inline style on the first element that
205
+ * matches `selector` (preferred) or `[data-agent-native-node-id="nodeId"]`.
206
+ * When both are absent, targets `document.body`. Stores the previous
207
+ * background value so it can be restored on clear.
208
+ * Preview-only — never writes to DB, Yjs, or source files.
209
+ *
210
+ * { type: 'shader-fill-preview-clear' }
211
+ * Remove the applied background override and restore the previous value.
212
+ * Called when the user discards the preview or switches selections.
213
+ */
214
+ const SHADER_FILL_PREVIEW_BRIDGE_SCRIPT = `
215
+ <script data-agent-native-shader-fill-preview-bridge>
216
+ (function() {
217
+ // Track the element we patched and its original background so we can undo.
218
+ var patchedEl = null;
219
+ var originalBackground = '';
220
+
221
+ function resolveTarget(selector, nodeId) {
222
+ if (selector) {
223
+ try {
224
+ var hit = document.querySelector(selector);
225
+ if (hit) return hit;
226
+ } catch (_err) {}
227
+ }
228
+ if (nodeId) {
229
+ var byId = document.querySelector('[data-agent-native-node-id="' + nodeId.replace(/"/g, '\\\\"') + '"]');
230
+ if (byId) return byId;
231
+ }
232
+ return document.body;
233
+ }
234
+
235
+ function applyPreview(selector, nodeId, css) {
236
+ // Clear any prior patch first so we don't stack patches.
237
+ clearPreview();
238
+ var el = resolveTarget(selector, nodeId);
239
+ if (!el) return;
240
+ originalBackground = el.style.background || '';
241
+ el.style.background = css || '';
242
+ patchedEl = el;
243
+ }
244
+
245
+ function clearPreview() {
246
+ if (!patchedEl) return;
247
+ patchedEl.style.background = originalBackground;
248
+ patchedEl = null;
249
+ originalBackground = '';
250
+ }
251
+
252
+ window.addEventListener('message', function(e) {
253
+ if (e.source !== window.parent) return;
254
+ if (!e.data || typeof e.data.type !== 'string') return;
255
+ if (e.data.type === 'shader-fill-preview') {
256
+ var selector = typeof e.data.selector === 'string' ? e.data.selector : '';
257
+ var nodeId = typeof e.data.nodeId === 'string' ? e.data.nodeId : '';
258
+ var css = typeof e.data.css === 'string' ? e.data.css : '';
259
+ applyPreview(selector, nodeId, css);
260
+ return;
261
+ }
262
+ if (e.data.type === 'shader-fill-preview-clear') {
263
+ clearPreview();
264
+ return;
265
+ }
266
+ });
267
+ })();
268
+ </script>
269
+ `;
270
+
18
271
  /**
19
272
  * Tweak-bridge script. ALWAYS injected so the parent's postMessage
20
273
  * (`tweak-values`) can update CSS custom properties on the iframe's :root
@@ -260,7 +513,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
260
513
  var chromeTransitionStyle = document.createElement('style');
261
514
  chromeTransitionStyle.textContent =
262
515
  '[data-agent-native-edit-overlay="selection"]{transition:border-width 150ms ease-out}' +
263
- '[data-agent-native-edge-handle],[data-agent-native-edit-handle],[data-agent-native-rotate-handle]{transition:width 150ms ease-out,height 150ms ease-out,border-width 150ms ease-out,top 150ms ease-out,bottom 150ms ease-out,left 150ms ease-out,right 150ms ease-out}';
516
+ '[data-agent-native-edge-handle],[data-agent-native-edit-handle],[data-agent-native-rotate-handle]{transition:width 150ms ease-out,height 150ms ease-out,border-width 150ms ease-out,top 150ms ease-out,bottom 150ms ease-out,left 150ms ease-out,right 150ms ease-out}' +
517
+ '[data-agent-native-spacing-line]{position:absolute;display:none;pointer-events:none;border-radius:999px}' +
518
+ '[data-agent-native-spacing-region]{position:absolute;display:none;box-sizing:border-box;pointer-events:auto;background-size:6px 6px}' +
519
+ '[data-agent-native-spacing-region][data-orientation="vertical"]{cursor:ew-resize}' +
520
+ '[data-agent-native-spacing-region][data-orientation="horizontal"]{cursor:ns-resize}';
264
521
  (document.head || document.documentElement).appendChild(chromeTransitionStyle);
265
522
  })();
266
523
 
@@ -484,6 +741,38 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
484
741
  reason: 'Parent layout context decides whether movement means gap, order, alignment, or wrapper structure.',
485
742
  });
486
743
  }
744
+ // --- provenance: read source-location attributes when present ---
745
+ // These are emitted by connected apps via @vitejs/plugin-react jsxDEV or a
746
+ // Babel source plugin. Cross-origin localhost iframes cannot be read (CSP /
747
+ // same-origin policy), so this will be undefined in that case — expected.
748
+ var provenance = undefined;
749
+ var dataSourceFile = el.getAttribute('data-source-file');
750
+ var dataSourceLine = el.getAttribute('data-source-line');
751
+ var dataSourceColumn = el.getAttribute('data-source-column');
752
+ var dataComponentName = el.getAttribute('data-component-name');
753
+ var dataLoc = el.getAttribute('data-loc');
754
+ // data-loc may encode "file:line:col" (Babel source plugin convention).
755
+ // Only parse it when data-source-file is absent, to avoid double-reads.
756
+ if (!dataSourceFile && dataLoc) {
757
+ var lastColonIndex = dataLoc.lastIndexOf(':');
758
+ var lastPart = lastColonIndex >= 0 ? dataLoc.slice(lastColonIndex + 1) : '';
759
+ if (lastColonIndex >= 0 && /^\\d+$/.test(lastPart)) {
760
+ var beforeLastPart = dataLoc.slice(0, lastColonIndex);
761
+ var previousColonIndex = beforeLastPart.lastIndexOf(':');
762
+ var previousPart = previousColonIndex >= 0 ? beforeLastPart.slice(previousColonIndex + 1) : '';
763
+ var hasColumn = /^\\d+$/.test(previousPart);
764
+ dataSourceFile = hasColumn ? beforeLastPart.slice(0, previousColonIndex) : beforeLastPart;
765
+ dataSourceLine = hasColumn ? previousPart : lastPart;
766
+ if (hasColumn) dataSourceColumn = lastPart;
767
+ }
768
+ }
769
+ if (dataSourceFile || dataSourceLine || dataSourceColumn || dataComponentName) {
770
+ provenance = {};
771
+ if (dataSourceFile) provenance.sourceFile = dataSourceFile;
772
+ if (dataSourceLine) { var ln = parseInt(dataSourceLine, 10); if (!isNaN(ln)) provenance.line = ln; }
773
+ if (dataSourceColumn) { var col = parseInt(dataSourceColumn, 10); if (!isNaN(col)) provenance.column = col; }
774
+ if (dataComponentName) provenance.component = dataComponentName;
775
+ }
487
776
  return {
488
777
  tagName: el.tagName.toLowerCase(),
489
778
  id: el.id || undefined,
@@ -559,6 +848,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
559
848
  confidence: capabilities.reduce(function(best, item) {
560
849
  return Math.max(best, item.confidence || 0);
561
850
  }, 0),
851
+ provenance: provenance,
562
852
  };
563
853
  }
564
854
 
@@ -635,10 +925,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
635
925
  if (pos.indexOf('e') !== -1) rotate.style.right = '-26px';
636
926
  selectionOverlay.appendChild(rotate);
637
927
  });
638
- var paddingOverlay = document.createElement('div');
639
- paddingOverlay.setAttribute('data-agent-native-padding-overlay', '');
640
- paddingOverlay.style.cssText = 'position:absolute;border:1px dashed var(--design-editor-accent-strong-color);border-radius:2px;pointer-events:none;';
641
- selectionOverlay.appendChild(paddingOverlay);
928
+ var spacingOverlay = document.createElement('div');
929
+ spacingOverlay.setAttribute('data-agent-native-spacing-overlay', '');
930
+ spacingOverlay.style.cssText = 'position:absolute;inset:0;display:none;pointer-events:none;';
931
+ selectionOverlay.appendChild(spacingOverlay);
642
932
  document.body.appendChild(selectionOverlay);
643
933
 
644
934
  var transformBadge = document.createElement('div');
@@ -646,6 +936,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
646
936
  transformBadge.style.cssText = 'position:fixed;z-index:100000;display:none;pointer-events:none;border:1px solid hsl(var(--border));border-radius:4px;background:hsl(var(--background) / 0.96);color:hsl(var(--foreground));font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;padding:3px 5px;box-shadow:0 8px 20px color-mix(in srgb, hsl(var(--foreground)) 16%, transparent);';
647
937
  document.body.appendChild(transformBadge);
648
938
 
939
+ var spacingBadge = document.createElement('div');
940
+ spacingBadge.setAttribute('data-agent-native-spacing-badge', '');
941
+ spacingBadge.style.cssText = 'position:fixed;z-index:100000;display:none;pointer-events:none;border-radius:3px;color:white;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700;padding:2px 4px;box-shadow:0 4px 14px rgba(0,0,0,0.18);';
942
+ document.body.appendChild(spacingBadge);
943
+
649
944
  var insertionGuide = document.createElement('div');
650
945
  insertionGuide.setAttribute('data-agent-native-insertion-guide', '');
651
946
  insertionGuide.style.cssText = 'position:fixed;z-index:100000;display:none;pointer-events:none;background:var(--design-editor-accent-color);border-radius:999px;box-shadow:0 0 0 1px var(--design-editor-accent-color);';
@@ -656,20 +951,116 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
656
951
  measurementOverlay.style.cssText = 'position:fixed;inset:0;z-index:100001;display:none;pointer-events:none;color:var(--design-editor-measure-color);font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;';
657
952
  document.body.appendChild(measurementOverlay);
658
953
 
954
+ // Component-instance tag: a small pill that floats above the selection
955
+ // outline whenever the selected element carries a data-agent-native-component
956
+ // attribute. Clicking it sends a 'component-source-jump' message to the
957
+ // parent so the editor can invoke open-component-source.
958
+ var componentTagOverlay = document.createElement('div');
959
+ componentTagOverlay.setAttribute('data-agent-native-edit-overlay', 'component-tag');
960
+ componentTagOverlay.style.cssText = [
961
+ 'position:fixed',
962
+ 'z-index:100002',
963
+ 'display:none',
964
+ 'pointer-events:auto',
965
+ 'cursor:pointer',
966
+ 'padding:2px 6px',
967
+ 'border-radius:4px',
968
+ 'font:11px/1.6 ui-sans-serif,system-ui,sans-serif',
969
+ 'white-space:nowrap',
970
+ 'user-select:none',
971
+ '-webkit-user-select:none',
972
+ 'background:var(--design-editor-accent-color)',
973
+ 'color:var(--design-editor-accent-contrast-color)',
974
+ 'box-shadow:0 1px 4px color-mix(in srgb,var(--design-editor-accent-color) 40%,transparent)',
975
+ 'border:1px solid color-mix(in srgb,var(--design-editor-accent-strong-color) 60%,transparent)',
976
+ 'outline:2px solid transparent',
977
+ 'transition:opacity 0.1s',
978
+ ].join(';') + ';';
979
+ document.body.appendChild(componentTagOverlay);
980
+
981
+ componentTagOverlay.addEventListener('click', function(e) {
982
+ e.stopPropagation();
983
+ e.preventDefault();
984
+ var nodeId = componentTagOverlay.getAttribute('data-component-node-id') || '';
985
+ var componentName = componentTagOverlay.getAttribute('data-component-name') || '';
986
+ if (!nodeId || !componentName) return;
987
+ try {
988
+ window.parent.postMessage({
989
+ type: 'component-source-jump',
990
+ nodeId: nodeId,
991
+ componentName: componentName,
992
+ }, '*');
993
+ } catch (_err) {}
994
+ });
995
+
996
+ function updateComponentTag(el) {
997
+ if (!el) {
998
+ clearComponentTag();
999
+ return;
1000
+ }
1001
+ var compName = el.getAttribute && el.getAttribute('data-agent-native-component');
1002
+ if (!compName) {
1003
+ clearComponentTag();
1004
+ return;
1005
+ }
1006
+ var nodeId = (
1007
+ el.getAttribute('data-agent-native-node-id') ||
1008
+ el.getAttribute('data-code-layer-id') ||
1009
+ el.getAttribute('data-layer-id') ||
1010
+ el.id ||
1011
+ ''
1012
+ );
1013
+ componentTagOverlay.textContent = compName + ' →';
1014
+ componentTagOverlay.setAttribute('data-component-node-id', nodeId);
1015
+ componentTagOverlay.setAttribute('data-component-name', compName);
1016
+
1017
+ var rect = el.getBoundingClientRect();
1018
+ var tagHeight = 22;
1019
+ var tagTop = rect.top - tagHeight - 4;
1020
+ if (tagTop < 4) tagTop = rect.top + 4;
1021
+ componentTagOverlay.style.display = 'block';
1022
+ componentTagOverlay.style.left = rect.left + 'px';
1023
+ componentTagOverlay.style.top = tagTop + 'px';
1024
+ // Accent outline on the selection overlay to distinguish component roots.
1025
+ selectionOverlay.style.outline = '2px solid var(--design-editor-accent-strong-color)';
1026
+ selectionOverlay.style.outlineOffset = '2px';
1027
+ }
1028
+
1029
+ function clearComponentTag() {
1030
+ componentTagOverlay.style.display = 'none';
1031
+ componentTagOverlay.removeAttribute('data-component-node-id');
1032
+ componentTagOverlay.removeAttribute('data-component-name');
1033
+ selectionOverlay.style.outline = '';
1034
+ selectionOverlay.style.outlineOffset = '';
1035
+ }
1036
+
659
1037
  var selectedEl = null;
660
1038
  var hoveredEl = null;
661
1039
  var activeTextEditEl = null;
662
1040
  var textEditPointerState = null;
663
1041
  var pendingStructureMove = null;
1042
+ var pendingShieldDrag = null;
1043
+ var suppressNextShieldClick = false;
1044
+ var suppressNextShieldClickTimer = null;
1045
+ var selectedSpacingHovered = false;
1046
+ var hoveredSpacingHandleKey = '';
1047
+ var spacingHandleStateByKey = {};
1048
+ var spacingHandleNodesByKey = {};
1049
+ var spacingDrag = null;
664
1050
  var lockedSelectors = [];
665
1051
  var hiddenSelectors = [];
666
1052
 
667
1053
  function clearRuntimeSelection() {
668
1054
  selectedEl = null;
669
1055
  hoveredEl = null;
1056
+ selectedSpacingHovered = false;
1057
+ hoveredSpacingHandleKey = '';
1058
+ spacingDrag = null;
670
1059
  selectionOverlay.style.display = 'none';
671
1060
  highlightOverlay.style.display = 'none';
1061
+ hideSpacingOverlay();
672
1062
  hideMeasurements();
1063
+ clearComponentTag();
673
1064
  }
674
1065
 
675
1066
  function matchesSelectorList(el, selectors) {
@@ -693,6 +1084,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
693
1084
  }
694
1085
 
695
1086
  function isLayerInteractionBlocked(el) {
1087
+ if (!el) return false;
1088
+ if (el.closest && el.closest('[data-agent-native-locked="true"], [data-agent-native-hidden="true"]')) {
1089
+ return true;
1090
+ }
696
1091
  return matchesSelectorList(el, lockedSelectors) || matchesSelectorList(el, hiddenSelectors);
697
1092
  }
698
1093
 
@@ -849,22 +1244,365 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
849
1244
  refreshOverlays();
850
1245
  }
851
1246
 
852
- function updatePaddingOverlay(el) {
853
- if (!el) {
854
- paddingOverlay.style.display = 'none';
855
- return;
1247
+ function hideSpacingOverlay() {
1248
+ spacingOverlay.style.display = 'none';
1249
+ spacingOverlay.innerHTML = '';
1250
+ spacingHandleStateByKey = {};
1251
+ spacingHandleNodesByKey = {};
1252
+ if (!spacingDrag) spacingBadge.style.display = 'none';
1253
+ }
1254
+
1255
+ function visibleLayoutChildren(el) {
1256
+ if (!el || !el.children) return [];
1257
+ return Array.prototype.slice.call(el.children).filter(function(child) {
1258
+ if (!child || child.nodeType !== 1 || isOverlayElement(child) || isLayerInteractionBlocked(child)) return false;
1259
+ var cs = window.getComputedStyle(child);
1260
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.position === 'fixed') return false;
1261
+ var rect = child.getBoundingClientRect();
1262
+ return rect.width > 0 && rect.height > 0;
1263
+ });
1264
+ }
1265
+
1266
+ function spacingColor(kind) {
1267
+ return kind === 'gap' ? '#ff4fd8' : 'var(--design-editor-accent-color)';
1268
+ }
1269
+
1270
+ function spacingFill(kind, orientation) {
1271
+ var tint = kind === 'gap' ? 'rgba(255, 79, 216, 0.28)' : 'rgba(46, 168, 255, 0.24)';
1272
+ var stripe = kind === 'gap' ? 'rgba(255, 79, 216, 0.58)' : 'rgba(46, 168, 255, 0.52)';
1273
+ var angle = orientation === 'vertical' ? '135deg' : '45deg';
1274
+ return 'repeating-linear-gradient(' + angle + ', ' + stripe + ' 0 1px, ' + tint + ' 1px 4px, transparent 4px 7px)';
1275
+ }
1276
+
1277
+ function clampSpacingValue(value) {
1278
+ var rounded = Math.round(value);
1279
+ if (!Number.isFinite(rounded)) return 0;
1280
+ return Math.max(0, Math.min(999, rounded));
1281
+ }
1282
+
1283
+ function makeSpacingHandle(config) {
1284
+ var region = config.region;
1285
+ if (!region || region.width <= 0 || region.height <= 0) return null;
1286
+ return {
1287
+ key: config.key,
1288
+ groupKey: config.groupKey || config.key,
1289
+ kind: config.kind,
1290
+ property: config.property,
1291
+ oppositeProperty: config.oppositeProperty || '',
1292
+ side: config.side || '',
1293
+ orientation: config.orientation,
1294
+ value: clampSpacingValue(config.value),
1295
+ region: {
1296
+ x: Math.round(region.x),
1297
+ y: Math.round(region.y),
1298
+ width: Math.max(1, Math.round(region.width)),
1299
+ height: Math.max(1, Math.round(region.height)),
1300
+ },
1301
+ line: config.line,
1302
+ };
1303
+ }
1304
+
1305
+ function childLocalRect(child, containerRect) {
1306
+ var rect = child.getBoundingClientRect();
1307
+ return {
1308
+ left: rect.left - containerRect.left,
1309
+ top: rect.top - containerRect.top,
1310
+ right: rect.right - containerRect.left,
1311
+ bottom: rect.bottom - containerRect.top,
1312
+ width: rect.width,
1313
+ height: rect.height,
1314
+ };
1315
+ }
1316
+
1317
+ function childRectsOverlap(a, b, axis) {
1318
+ if (axis === 'x') {
1319
+ return Math.max(a.top, b.top) < Math.min(a.bottom, b.bottom);
856
1320
  }
1321
+ return Math.max(a.left, b.left) < Math.min(a.right, b.right);
1322
+ }
1323
+
1324
+ function buildPaddingSpacingHandles(el, rect, cs) {
1325
+ var handles = [];
1326
+ var borderTop = readPx(cs.borderTopWidth);
1327
+ var borderRight = readPx(cs.borderRightWidth);
1328
+ var borderBottom = readPx(cs.borderBottomWidth);
1329
+ var borderLeft = readPx(cs.borderLeftWidth);
1330
+ var paddingTop = readPx(cs.paddingTop);
1331
+ var paddingRight = readPx(cs.paddingRight);
1332
+ var paddingBottom = readPx(cs.paddingBottom);
1333
+ var paddingLeft = readPx(cs.paddingLeft);
1334
+ var sx = chromeScaleX();
1335
+ var sy = chromeScaleY();
1336
+ var line = Math.max(1, chromeLineScale());
1337
+ var hLineWidth = Math.max(8, Math.min(24, rect.width * 0.18)) * sx;
1338
+ var vLineHeight = Math.max(8, Math.min(24, rect.height * 0.18)) * sy;
1339
+ var innerLeft = borderLeft;
1340
+ var innerTop = borderTop;
1341
+ var innerWidth = Math.max(1, rect.width - borderLeft - borderRight);
1342
+ var innerHeight = Math.max(1, rect.height - borderTop - borderBottom);
1343
+ if (paddingTop > 0) {
1344
+ handles.push(makeSpacingHandle({
1345
+ key: 'padding:top',
1346
+ kind: 'padding',
1347
+ property: 'paddingTop',
1348
+ oppositeProperty: 'paddingBottom',
1349
+ side: 'top',
1350
+ orientation: 'horizontal',
1351
+ value: paddingTop,
1352
+ region: { x: innerLeft, y: innerTop, width: innerWidth, height: paddingTop },
1353
+ line: {
1354
+ x: rect.width / 2 - hLineWidth / 2,
1355
+ y: innerTop + paddingTop / 2 - line / 2,
1356
+ width: hLineWidth,
1357
+ height: line,
1358
+ },
1359
+ }));
1360
+ }
1361
+ if (paddingBottom > 0) {
1362
+ handles.push(makeSpacingHandle({
1363
+ key: 'padding:bottom',
1364
+ kind: 'padding',
1365
+ property: 'paddingBottom',
1366
+ oppositeProperty: 'paddingTop',
1367
+ side: 'bottom',
1368
+ orientation: 'horizontal',
1369
+ value: paddingBottom,
1370
+ region: { x: innerLeft, y: rect.height - borderBottom - paddingBottom, width: innerWidth, height: paddingBottom },
1371
+ line: {
1372
+ x: rect.width / 2 - hLineWidth / 2,
1373
+ y: rect.height - borderBottom - paddingBottom / 2 - line / 2,
1374
+ width: hLineWidth,
1375
+ height: line,
1376
+ },
1377
+ }));
1378
+ }
1379
+ if (paddingLeft > 0) {
1380
+ handles.push(makeSpacingHandle({
1381
+ key: 'padding:left',
1382
+ kind: 'padding',
1383
+ property: 'paddingLeft',
1384
+ oppositeProperty: 'paddingRight',
1385
+ side: 'left',
1386
+ orientation: 'vertical',
1387
+ value: paddingLeft,
1388
+ region: { x: innerLeft, y: innerTop, width: paddingLeft, height: innerHeight },
1389
+ line: {
1390
+ x: innerLeft + paddingLeft / 2 - line / 2,
1391
+ y: rect.height / 2 - vLineHeight / 2,
1392
+ width: line,
1393
+ height: vLineHeight,
1394
+ },
1395
+ }));
1396
+ }
1397
+ if (paddingRight > 0) {
1398
+ handles.push(makeSpacingHandle({
1399
+ key: 'padding:right',
1400
+ kind: 'padding',
1401
+ property: 'paddingRight',
1402
+ oppositeProperty: 'paddingLeft',
1403
+ side: 'right',
1404
+ orientation: 'vertical',
1405
+ value: paddingRight,
1406
+ region: { x: rect.width - borderRight - paddingRight, y: innerTop, width: paddingRight, height: innerHeight },
1407
+ line: {
1408
+ x: rect.width - borderRight - paddingRight / 2 - line / 2,
1409
+ y: rect.height / 2 - vLineHeight / 2,
1410
+ width: line,
1411
+ height: vLineHeight,
1412
+ },
1413
+ }));
1414
+ }
1415
+ return handles.filter(Boolean);
1416
+ }
1417
+
1418
+ function buildGapSpacingHandles(el, rect, cs) {
1419
+ var children = visibleLayoutChildren(el);
1420
+ if (children.length < 2) return [];
1421
+ var handles = [];
1422
+ var sx = chromeScaleX();
1423
+ var sy = chromeScaleY();
1424
+ var line = Math.max(1, chromeLineScale());
1425
+ var hLineWidth = 10 * sx;
1426
+ var vLineHeight = 10 * sy;
1427
+ var isFlex = cs.display === 'flex' || cs.display === 'inline-flex';
1428
+ var isGrid = cs.display === 'grid' || cs.display === 'inline-grid';
1429
+ if (!isFlex && !isGrid) return handles;
1430
+ var primaryAxis = isFlex && cs.flexDirection && cs.flexDirection.indexOf('column') === 0 ? 'y' : 'x';
1431
+ var childRects = children.map(function(child) {
1432
+ return childLocalRect(child, rect);
1433
+ });
1434
+
1435
+ function addAxisGaps(axis, property, groupKey) {
1436
+ var cssGap = readPx(cs[property]);
1437
+ if (cssGap <= 0) return;
1438
+ var sorted = childRects.slice().sort(function(a, b) {
1439
+ return axis === 'x' ? a.left - b.left : a.top - b.top;
1440
+ });
1441
+ var count = 0;
1442
+ for (var i = 0; i < sorted.length - 1; i += 1) {
1443
+ var a = sorted[i];
1444
+ var b = sorted[i + 1];
1445
+ if (!childRectsOverlap(a, b, axis)) continue;
1446
+ var gap = axis === 'x' ? b.left - a.right : b.top - a.bottom;
1447
+ if (gap <= 1) continue;
1448
+ if (axis === 'x') {
1449
+ var top = Math.max(a.top, b.top);
1450
+ var bottom = Math.min(a.bottom, b.bottom);
1451
+ var height = Math.max(1, bottom - top);
1452
+ handles.push(makeSpacingHandle({
1453
+ key: groupKey + ':' + count,
1454
+ groupKey: groupKey,
1455
+ kind: 'gap',
1456
+ property: property,
1457
+ orientation: 'vertical',
1458
+ value: cssGap,
1459
+ region: { x: a.right, y: top, width: gap, height: height },
1460
+ line: {
1461
+ x: a.right + gap / 2 - line / 2,
1462
+ y: top + height / 2 - vLineHeight / 2,
1463
+ width: line,
1464
+ height: vLineHeight,
1465
+ },
1466
+ }));
1467
+ } else {
1468
+ var left = Math.max(a.left, b.left);
1469
+ var right = Math.min(a.right, b.right);
1470
+ var width = Math.max(1, right - left);
1471
+ handles.push(makeSpacingHandle({
1472
+ key: groupKey + ':' + count,
1473
+ groupKey: groupKey,
1474
+ kind: 'gap',
1475
+ property: property,
1476
+ orientation: 'horizontal',
1477
+ value: cssGap,
1478
+ region: { x: left, y: a.bottom, width: width, height: gap },
1479
+ line: {
1480
+ x: left + width / 2 - hLineWidth / 2,
1481
+ y: a.bottom + gap / 2 - line / 2,
1482
+ width: hLineWidth,
1483
+ height: line,
1484
+ },
1485
+ }));
1486
+ }
1487
+ count += 1;
1488
+ }
1489
+ }
1490
+
1491
+ if (primaryAxis === 'x') {
1492
+ addAxisGaps('x', 'columnGap', 'gap:column');
1493
+ if (isGrid) addAxisGaps('y', 'rowGap', 'gap:row');
1494
+ } else {
1495
+ addAxisGaps('y', 'rowGap', 'gap:row');
1496
+ if (isGrid) addAxisGaps('x', 'columnGap', 'gap:column');
1497
+ }
1498
+ return handles.filter(Boolean);
1499
+ }
1500
+
1501
+ function buildSpacingHandles(el) {
1502
+ if (!el || !document.documentElement.contains(el)) return [];
1503
+ if (Math.abs(currentRotation(el)) > 0.01) return [];
1504
+ var children = visibleLayoutChildren(el);
1505
+ if (children.length === 0) return [];
1506
+ var rect = el.getBoundingClientRect();
1507
+ if (rect.width <= 0 || rect.height <= 0) return [];
857
1508
  var cs = window.getComputedStyle(el);
858
- var top = readPx(cs.paddingTop) + readPx(cs.borderTopWidth);
859
- var right = readPx(cs.paddingRight) + readPx(cs.borderRightWidth);
860
- var bottom = readPx(cs.paddingBottom) + readPx(cs.borderBottomWidth);
861
- var left = readPx(cs.paddingLeft) + readPx(cs.borderLeftWidth);
862
- var hasPadding = top > 0 || right > 0 || bottom > 0 || left > 0;
863
- paddingOverlay.style.display = hasPadding ? 'block' : 'none';
864
- paddingOverlay.style.top = top + 'px';
865
- paddingOverlay.style.right = right + 'px';
866
- paddingOverlay.style.bottom = bottom + 'px';
867
- paddingOverlay.style.left = left + 'px';
1509
+ return buildPaddingSpacingHandles(el, rect, cs).concat(buildGapSpacingHandles(el, rect, cs));
1510
+ }
1511
+
1512
+ function showSpacingBadgeForHandle(handle, value) {
1513
+ if (!selectedEl || !handle) {
1514
+ spacingBadge.style.display = 'none';
1515
+ return;
1516
+ }
1517
+ var rect = selectedEl.getBoundingClientRect();
1518
+ var x = rect.left + handle.region.x + handle.region.width / 2;
1519
+ var y = rect.top + handle.region.y + handle.region.height / 2;
1520
+ spacingBadge.textContent = String(clampSpacingValue(value));
1521
+ spacingBadge.style.display = 'block';
1522
+ spacingBadge.style.background = spacingColor(handle.kind);
1523
+ spacingBadge.style.left = x + 'px';
1524
+ spacingBadge.style.top = y + 'px';
1525
+ spacingBadge.style.transform = 'translate(-50%, -50%)';
1526
+ }
1527
+
1528
+ function renderSpacingHandle(handle, activeGroupKey) {
1529
+ if (!handle) return;
1530
+ spacingHandleStateByKey[handle.key] = handle;
1531
+ var highlighted = Boolean(activeGroupKey && handle.groupKey === activeGroupKey);
1532
+ var lineNode = document.createElement('span');
1533
+ lineNode.setAttribute('data-agent-native-spacing-line', handle.kind);
1534
+ lineNode.style.display = 'block';
1535
+ lineNode.style.left = handle.line.x + 'px';
1536
+ lineNode.style.top = handle.line.y + 'px';
1537
+ lineNode.style.width = Math.max(1, handle.line.width) + 'px';
1538
+ lineNode.style.height = Math.max(1, handle.line.height) + 'px';
1539
+ lineNode.style.background = spacingColor(handle.kind);
1540
+ spacingOverlay.appendChild(lineNode);
1541
+
1542
+ var regionNode = document.createElement('span');
1543
+ regionNode.setAttribute('data-agent-native-spacing-region', handle.kind);
1544
+ regionNode.setAttribute('data-orientation', handle.orientation);
1545
+ regionNode.setAttribute('data-spacing-key', handle.key);
1546
+ regionNode.style.display = 'block';
1547
+ regionNode.style.left = handle.region.x + 'px';
1548
+ regionNode.style.top = handle.region.y + 'px';
1549
+ regionNode.style.width = handle.region.width + 'px';
1550
+ regionNode.style.height = handle.region.height + 'px';
1551
+ regionNode.style.background = highlighted ? spacingFill(handle.kind, handle.orientation) : 'transparent';
1552
+ regionNode.style.outline = highlighted ? '1px solid ' + spacingColor(handle.kind) : '0';
1553
+ regionNode.style.outlineOffset = '-1px';
1554
+ regionNode.addEventListener('mouseenter', function() {
1555
+ hoveredSpacingHandleKey = handle.key;
1556
+ selectedSpacingHovered = true;
1557
+ updateSpacingOverlay(selectedEl);
1558
+ });
1559
+ regionNode.addEventListener('mouseleave', function() {
1560
+ if (spacingDrag) return;
1561
+ hoveredSpacingHandleKey = '';
1562
+ updateSpacingOverlay(selectedEl);
1563
+ });
1564
+ regionNode.addEventListener('mousedown', function(event) {
1565
+ startSpacingDrag(handle.key, event);
1566
+ }, true);
1567
+ spacingHandleNodesByKey[handle.key] = regionNode;
1568
+ spacingOverlay.appendChild(regionNode);
1569
+ }
1570
+
1571
+ function updateSpacingOverlay(el) {
1572
+ if (el && el !== selectedEl) {
1573
+ hideSpacingOverlay();
1574
+ return;
1575
+ }
1576
+ if (!selectedEl || !document.documentElement.contains(selectedEl)) {
1577
+ hideSpacingOverlay();
1578
+ return;
1579
+ }
1580
+ if (!selectedSpacingHovered && !hoveredSpacingHandleKey && !spacingDrag) {
1581
+ hideSpacingOverlay();
1582
+ return;
1583
+ }
1584
+ var handles = buildSpacingHandles(selectedEl);
1585
+ if (handles.length === 0) {
1586
+ hideSpacingOverlay();
1587
+ return;
1588
+ }
1589
+ spacingOverlay.style.display = 'block';
1590
+ spacingOverlay.innerHTML = '';
1591
+ spacingHandleStateByKey = {};
1592
+ spacingHandleNodesByKey = {};
1593
+ var activeHandle =
1594
+ (spacingDrag && spacingDrag.handle) ||
1595
+ handles.find(function(handle) { return handle.key === hoveredSpacingHandleKey; }) ||
1596
+ null;
1597
+ var activeGroupKey = activeHandle ? activeHandle.groupKey : '';
1598
+ handles.forEach(function(handle) {
1599
+ renderSpacingHandle(handle, activeGroupKey);
1600
+ });
1601
+ if (activeHandle) {
1602
+ showSpacingBadgeForHandle(activeHandle, spacingDrag ? spacingDrag.currentValue : activeHandle.value);
1603
+ } else {
1604
+ spacingBadge.style.display = 'none';
1605
+ }
868
1606
  }
869
1607
 
870
1608
  function applyEditorChromeScale() {
@@ -874,7 +1612,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
874
1612
  var line = chromeLineScale();
875
1613
  highlightOverlay.style.borderWidth = (1.5 * line) + 'px';
876
1614
  selectionOverlay.style.borderWidth = (1.5 * line) + 'px';
877
- paddingOverlay.style.borderWidth = Math.max(1, 1 * line) + 'px';
1615
+ if (selectedEl) updateSpacingOverlay(selectedEl);
878
1616
 
879
1617
  selectionOverlay.querySelectorAll('[data-agent-native-edge-handle]').forEach(function(edge) {
880
1618
  var pos = edge.getAttribute('data-agent-native-edge-handle');
@@ -913,6 +1651,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
913
1651
  function positionOverlay(overlay, el) {
914
1652
  if (!el || !document.documentElement.contains(el)) {
915
1653
  overlay.style.display = 'none';
1654
+ if (overlay === selectionOverlay) clearComponentTag();
916
1655
  return;
917
1656
  }
918
1657
  // For the selection overlay, prefer the CSS box + rotation transform so
@@ -936,7 +1675,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
936
1675
  overlay.style.height = elH + 'px';
937
1676
  overlay.style.transform = 'rotate(' + elRot + 'deg)';
938
1677
  overlay.style.transformOrigin = '0 0';
939
- updatePaddingOverlay(el);
1678
+ updateSpacingOverlay(el);
1679
+ updateComponentTag(el);
940
1680
  return;
941
1681
  }
942
1682
  }
@@ -947,7 +1687,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
947
1687
  overlay.style.width = rect.width + 'px';
948
1688
  overlay.style.height = rect.height + 'px';
949
1689
  overlay.style.transform = '';
950
- if (overlay === selectionOverlay) updatePaddingOverlay(el);
1690
+ if (overlay === selectionOverlay) {
1691
+ updateSpacingOverlay(el);
1692
+ updateComponentTag(el);
1693
+ }
951
1694
  }
952
1695
 
953
1696
  function refreshOverlays() {
@@ -1026,6 +1769,13 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1026
1769
  );
1027
1770
  }
1028
1771
 
1772
+ function dragEventNames(e) {
1773
+ var pointerGesture = e && e.type && e.type.indexOf('pointer') === 0;
1774
+ return pointerGesture
1775
+ ? { move: 'pointermove', up: 'pointerup' }
1776
+ : { move: 'mousemove', up: 'mouseup' };
1777
+ }
1778
+
1029
1779
  function elementFromEditorPoint(clientX, clientY) {
1030
1780
  var shieldPointerEvents = shieldOverlay.style.pointerEvents;
1031
1781
  var selectionPointerEvents = selectionOverlay.style.pointerEvents;
@@ -1190,6 +1940,14 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1190
1940
  function selectElementAtEvent(e) {
1191
1941
  stopNativeInteraction(e);
1192
1942
  blurActiveTextEditor();
1943
+ if (suppressNextShieldClick) {
1944
+ suppressNextShieldClick = false;
1945
+ if (suppressNextShieldClickTimer !== null) {
1946
+ clearTimeout(suppressNextShieldClickTimer);
1947
+ suppressNextShieldClickTimer = null;
1948
+ }
1949
+ return;
1950
+ }
1193
1951
  // Suppress the first click in a double-click sequence — the dblclick
1194
1952
  // handler (beginTextEditingFromEvent) will fire immediately after and a
1195
1953
  // spurious element-select would cause inspector flicker.
@@ -1197,27 +1955,53 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1197
1955
  var target = elementFromEditorPoint(e.clientX, e.clientY);
1198
1956
  if (!target || target === document.body || target === document.documentElement) {
1199
1957
  // Click on empty canvas: clear the current selection (matches Figma).
1200
- selectedEl = null;
1201
- selectionOverlay.style.display = 'none';
1958
+ clearRuntimeSelection();
1202
1959
  window.parent.postMessage({ type: 'clear-selection' }, '*');
1203
1960
  return;
1204
1961
  }
1962
+ selectedSpacingHovered = false;
1963
+ hoveredSpacingHandleKey = '';
1205
1964
  selectedEl = selectionTargetForHit(target);
1965
+ if (!selectedEl || isLayerInteractionBlocked(selectedEl)) {
1966
+ selectedEl = null;
1967
+ selectionOverlay.style.display = 'none';
1968
+ clearComponentTag();
1969
+ return;
1970
+ }
1206
1971
  var info = getElementInfo(selectedEl);
1207
1972
  positionOverlay(selectionOverlay, selectedEl);
1208
1973
  window.parent.postMessage({ type: 'element-select', payload: info }, '*');
1209
1974
  }
1210
1975
 
1976
+ function suppressNextShieldClickBriefly() {
1977
+ suppressNextShieldClick = true;
1978
+ if (suppressNextShieldClickTimer !== null) {
1979
+ clearTimeout(suppressNextShieldClickTimer);
1980
+ }
1981
+ suppressNextShieldClickTimer = setTimeout(function() {
1982
+ suppressNextShieldClick = false;
1983
+ suppressNextShieldClickTimer = null;
1984
+ }, 250);
1985
+ }
1986
+
1211
1987
  function openContextMenuAtEvent(e) {
1212
1988
  stopNativeInteraction(e);
1213
1989
  blurActiveTextEditor();
1214
1990
  var target = elementFromEditorPoint(e.clientX, e.clientY);
1215
1991
  var info = null;
1216
1992
  if (target) {
1993
+ selectedSpacingHovered = false;
1994
+ hoveredSpacingHandleKey = '';
1217
1995
  selectedEl = selectionTargetForHit(target);
1218
- info = getElementInfo(selectedEl);
1219
- positionOverlay(selectionOverlay, selectedEl);
1220
- window.parent.postMessage({ type: 'element-select', payload: info }, '*');
1996
+ if (selectedEl && !isLayerInteractionBlocked(selectedEl)) {
1997
+ info = getElementInfo(selectedEl);
1998
+ positionOverlay(selectionOverlay, selectedEl);
1999
+ window.parent.postMessage({ type: 'element-select', payload: info }, '*');
2000
+ } else {
2001
+ selectedEl = null;
2002
+ selectionOverlay.style.display = 'none';
2003
+ clearComponentTag();
2004
+ }
1221
2005
  }
1222
2006
  window.parent.postMessage({
1223
2007
  type: 'element-contextmenu',
@@ -1319,6 +2103,81 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1319
2103
  }, '*');
1320
2104
  }
1321
2105
 
2106
+ function spacingValueFromPointer(handle, originValue, startX, startY, clientX, clientY) {
2107
+ var delta =
2108
+ handle.orientation === 'vertical' ? clientX - startX : clientY - startY;
2109
+ if (handle.kind === 'padding' && (handle.side === 'right' || handle.side === 'bottom')) {
2110
+ delta = -delta;
2111
+ }
2112
+ return clampSpacingValue(originValue + delta);
2113
+ }
2114
+
2115
+ function applySpacingDragValue(target, handle, value, mirrorOpposite) {
2116
+ if (!target || !handle) return;
2117
+ target.style[handle.property] = value + 'px';
2118
+ if (handle.kind === 'padding' && mirrorOpposite && handle.oppositeProperty) {
2119
+ target.style[handle.oppositeProperty] = value + 'px';
2120
+ }
2121
+ }
2122
+
2123
+ function startSpacingDrag(key, e) {
2124
+ var handle = spacingHandleStateByKey[key];
2125
+ if (!selectedEl || !handle || isLayerInteractionBlocked(selectedEl)) return;
2126
+ e.preventDefault();
2127
+ e.stopPropagation();
2128
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2129
+ var events = dragEventNames(e);
2130
+ var dragEl = selectedEl;
2131
+ var originValue = handle.value;
2132
+ var startX = e.clientX;
2133
+ var startY = e.clientY;
2134
+ hoveredSpacingHandleKey = key;
2135
+ selectedSpacingHovered = true;
2136
+ spacingDrag = {
2137
+ handle: handle,
2138
+ currentValue: originValue,
2139
+ mirrorOpposite: !!e.altKey,
2140
+ };
2141
+ showSpacingBadgeForHandle(handle, originValue);
2142
+
2143
+ function onMove(ev) {
2144
+ if (!dragEl || !document.documentElement.contains(dragEl)) return;
2145
+ var nextValue = spacingValueFromPointer(handle, originValue, startX, startY, ev.clientX, ev.clientY);
2146
+ spacingDrag = {
2147
+ handle: handle,
2148
+ currentValue: nextValue,
2149
+ mirrorOpposite: !!ev.altKey,
2150
+ };
2151
+ applySpacingDragValue(dragEl, handle, nextValue, !!ev.altKey);
2152
+ positionOverlay(selectionOverlay, dragEl);
2153
+ showSpacingBadgeForHandle(handle, nextValue);
2154
+ }
2155
+
2156
+ function onUp(ev) {
2157
+ document.removeEventListener(events.move, onMove, true);
2158
+ document.removeEventListener(events.up, onUp, true);
2159
+ if (!dragEl || !document.documentElement.contains(dragEl)) {
2160
+ spacingDrag = null;
2161
+ return;
2162
+ }
2163
+ var finalValue = spacingDrag ? spacingDrag.currentValue : originValue;
2164
+ var mirrorOpposite = spacingDrag ? spacingDrag.mirrorOpposite : !!ev.altKey;
2165
+ applySpacingDragValue(dragEl, handle, finalValue, mirrorOpposite);
2166
+ selectedEl = dragEl;
2167
+ spacingDrag = null;
2168
+ var styles = {};
2169
+ styles[handle.property] = finalValue + 'px';
2170
+ if (handle.kind === 'padding' && mirrorOpposite && handle.oppositeProperty) {
2171
+ styles[handle.oppositeProperty] = finalValue + 'px';
2172
+ }
2173
+ postVisualStyleChange(styles);
2174
+ positionOverlay(selectionOverlay, dragEl);
2175
+ }
2176
+
2177
+ document.addEventListener(events.move, onMove, true);
2178
+ document.addEventListener(events.up, onUp, true);
2179
+ }
2180
+
1322
2181
  function postTextContentChange(el, value, html) {
1323
2182
  window.parent.postMessage({
1324
2183
  type: 'text-content-change',
@@ -1365,6 +2224,21 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1365
2224
  return !!(ancestorEl && (ancestorEl === el || el.contains(ancestorEl)));
1366
2225
  }
1367
2226
 
2227
+ function normalizeCssPropertyName(property) {
2228
+ var prop = String(property || '').trim();
2229
+ if (!prop) return '';
2230
+ if (prop.indexOf('--') === 0) return prop;
2231
+ return prop.replace(/([A-Z])/g, '-$1').toLowerCase();
2232
+ }
2233
+
2234
+ function applyInlineStyleProperty(el, property, value) {
2235
+ if (!el || !property) return false;
2236
+ var cssProperty = normalizeCssPropertyName(property);
2237
+ if (!cssProperty) return false;
2238
+ el.style.setProperty(cssProperty, String(value));
2239
+ return true;
2240
+ }
2241
+
1368
2242
  function applyTextRangeStyle(property, value) {
1369
2243
  if (!activeTextEditEl || !property) return false;
1370
2244
  var selection = window.getSelection && window.getSelection();
@@ -1372,7 +2246,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1372
2246
  if (!selectionBelongsToElement(selection, activeTextEditEl)) return false;
1373
2247
  var range = selection.getRangeAt(0);
1374
2248
  var span = document.createElement('span');
1375
- span.style[property] = value;
2249
+ applyInlineStyleProperty(span, property, value);
1376
2250
  if (!span.getAttribute('style')) return false;
1377
2251
  try {
1378
2252
  range.surroundContents(span);
@@ -1404,7 +2278,14 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1404
2278
  }
1405
2279
 
1406
2280
  function isOverlayElement(el) {
1407
- return Boolean(el && el.getAttribute && el.getAttribute('data-agent-native-edit-overlay'));
2281
+ // Use closest() so that children of overlay elements (e.g. spacing-region
2282
+ // spans inside selectionOverlay that have pointer-events:auto via a CSS rule
2283
+ // and can therefore still be returned by elementFromPoint even when the
2284
+ // parent overlay has pointer-events:none set inline) are also treated as
2285
+ // overlay elements and never used as drag-drop anchor targets.
2286
+ return Boolean(
2287
+ el && el.closest && el.closest('[data-agent-native-edit-overlay]')
2288
+ );
1408
2289
  }
1409
2290
 
1410
2291
  function draggableElementChildren(parent) {
@@ -1418,9 +2299,40 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1418
2299
  if (el === document.body || el === document.documentElement) return false;
1419
2300
  var cs = window.getComputedStyle(el);
1420
2301
  if (cs.position === 'absolute' || cs.position === 'fixed') return false;
1421
- var parent = el.parentElement;
1422
- if (parent === document.body && draggableElementChildren(parent).length <= 2) return false;
1423
- return draggableElementChildren(parent).filter(function(child) { return child !== el; }).length > 0;
2302
+ return true;
2303
+ }
2304
+
2305
+ // keep in sync with EditPanel.tsx CONTAINER_TAGS/LEAF_TAGS (~line 1679)
2306
+ var BRIDGE_CONTAINER_TAGS = ['div', 'section', 'main', 'header', 'footer', 'nav', 'article', 'aside', 'form', 'ul', 'ol', 'figure', 'fieldset', 'details', 'dialog', 'blockquote', 'table', 'tbody', 'thead', 'tr'];
2307
+ var BRIDGE_LEAF_TAGS = ['img', 'video', 'picture', 'audio', 'canvas', 'svg', 'path', 'input', 'textarea', 'select', 'br', 'hr', 'iframe'];
2308
+ var BRIDGE_TEXT_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'a', 'strong', 'em', 'label', 'li'];
2309
+
2310
+ function isContainerDropTarget(el) {
2311
+ if (!el || el === document.documentElement) return false;
2312
+ if (isOverlayElement(el) || isLayerInteractionBlocked(el)) return false;
2313
+ if (el === document.body) return true;
2314
+ var tag = (el.tagName || '').toLowerCase();
2315
+ // Reject leaf/text tags — they cannot accept children
2316
+ if (BRIDGE_LEAF_TAGS.indexOf(tag) !== -1 || BRIDGE_TEXT_TAGS.indexOf(tag) !== -1) return false;
2317
+ var cs = window.getComputedStyle(el);
2318
+ if (
2319
+ cs.display === 'flex' ||
2320
+ cs.display === 'inline-flex' ||
2321
+ cs.display === 'grid' ||
2322
+ cs.display === 'inline-grid'
2323
+ ) {
2324
+ return true;
2325
+ }
2326
+ return BRIDGE_CONTAINER_TAGS.indexOf(tag) !== -1;
2327
+ }
2328
+
2329
+ function edgePlacementForRect(rect, axis, clientX, clientY) {
2330
+ var size = axis === 'x' ? rect.width : rect.height;
2331
+ if (!size) return null;
2332
+ var offset = axis === 'x' ? clientX - rect.left : clientY - rect.top;
2333
+ if (offset < size * 0.22) return 'before';
2334
+ if (offset > size * 0.78) return 'after';
2335
+ return null;
1424
2336
  }
1425
2337
 
1426
2338
  function parentFlowAxis(parent) {
@@ -1445,18 +2357,19 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1445
2357
  var hit = elementFromEditorPoint(clientX, clientY);
1446
2358
  if (
1447
2359
  hit &&
1448
- hit !== document.body &&
1449
2360
  hit !== document.documentElement &&
1450
2361
  hit !== el &&
1451
2362
  !el.contains(hit) &&
1452
- !hit.contains(el) &&
1453
2363
  !isOverlayElement(hit)
1454
2364
  ) {
1455
- var hitChildren = draggableElementChildren(hit).filter(function(child) {
1456
- return child !== el;
1457
- });
1458
- if (hitChildren.length === 0) {
1459
- return { anchor: hit, placement: 'inside', axis: parentFlowAxis(hit) };
2365
+ if (isContainerDropTarget(hit)) {
2366
+ var containerRect = hit.getBoundingClientRect();
2367
+ var edgeAxis = hit.parentElement ? parentFlowAxis(hit.parentElement) : parentFlowAxis(hit);
2368
+ var edgePlacement = edgePlacementForRect(containerRect, edgeAxis, clientX, clientY);
2369
+ if (!edgePlacement) {
2370
+ return { anchor: hit, placement: 'inside', axis: parentFlowAxis(hit) };
2371
+ }
2372
+ return { anchor: hit, placement: edgePlacement, axis: edgeAxis };
1460
2373
  }
1461
2374
  var hitParent = hit.parentElement;
1462
2375
  if (hitParent) {
@@ -1503,6 +2416,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1503
2416
  insertionGuide.style.display = 'block';
1504
2417
  insertionGuide.style.background = 'var(--design-editor-accent-color)';
1505
2418
  insertionGuide.style.border = '0';
2419
+ insertionGuide.style.borderRadius = '999px';
1506
2420
  insertionGuide.style.boxShadow = '0 0 0 1px var(--design-editor-accent-color)';
1507
2421
  if (target.placement === 'inside') {
1508
2422
  insertionGuide.style.left = rect.left + 'px';
@@ -1511,6 +2425,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1511
2425
  insertionGuide.style.height = rect.height + 'px';
1512
2426
  insertionGuide.style.background = 'color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)';
1513
2427
  insertionGuide.style.border = '2px solid var(--design-editor-accent-color)';
2428
+ insertionGuide.style.borderRadius = '2px';
1514
2429
  insertionGuide.style.boxShadow = 'none';
1515
2430
  return;
1516
2431
  }
@@ -1578,6 +2493,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1578
2493
  if (isLayerInteractionBlocked(selectedEl)) return;
1579
2494
  e.preventDefault();
1580
2495
  e.stopPropagation();
2496
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2497
+ var events = dragEventNames(e);
1581
2498
  var originalSelectedEl = selectedEl;
1582
2499
  var duplicatedForDrag = false;
1583
2500
  if (e.altKey && selectedEl !== document.body && selectedEl !== document.documentElement) {
@@ -1595,16 +2512,96 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1595
2512
  var reorderEl = selectedEl;
1596
2513
  var currentTarget = reorderTargetForPoint(reorderEl, e.clientX, e.clientY);
1597
2514
  showInsertionGuideFor(currentTarget);
2515
+ // Cross-screen drag state: true when the pointer is outside this iframe's
2516
+ // viewport bounds. The host frame renders the ghost + highlight and owns
2517
+ // the drop when this is true; the bridge suppresses its in-iframe reorder.
2518
+ var pointerOutsideIframe = false;
2519
+ var reorderSelector = getSelector(reorderEl);
2520
+ var reorderSourceId = getSourceId(reorderEl);
1598
2521
  function onReorderMove(ev) {
1599
- currentTarget = reorderTargetForPoint(reorderEl, ev.clientX, ev.clientY);
1600
- showInsertionGuideFor(currentTarget);
1601
- showTransformBadge(currentTarget ? 'Move layer' : 'Move', ev.clientX, ev.clientY);
2522
+ var vw = window.innerWidth;
2523
+ var vh = window.innerHeight;
2524
+ var cx = ev.clientX;
2525
+ var cy = ev.clientY;
2526
+ var outside = cx < 0 || cy < 0 || cx > vw || cy > vh;
2527
+ pointerOutsideIframe = outside;
2528
+ // Always notify the host frame so it can track the cursor position,
2529
+ // render the ghost, and highlight the target screen.
2530
+ window.parent.postMessage({
2531
+ type: 'agent-native:cross-screen-drag',
2532
+ phase: 'move',
2533
+ selector: reorderSelector,
2534
+ sourceId: reorderSourceId,
2535
+ iframeX: cx,
2536
+ iframeY: cy,
2537
+ viewportW: vw,
2538
+ viewportH: vh,
2539
+ }, '*');
2540
+ if (outside) {
2541
+ // Cursor left this iframe — hide the in-iframe insertion guide so
2542
+ // it does not render while the host shows a cross-screen drop target.
2543
+ hideInsertionGuide();
2544
+ showTransformBadge('Move layer', cx, cy);
2545
+ } else {
2546
+ // Cursor is inside this iframe — use existing in-iframe behavior.
2547
+ currentTarget = reorderTargetForPoint(reorderEl, cx, cy);
2548
+ showInsertionGuideFor(currentTarget);
2549
+ showTransformBadge(currentTarget ? 'Move layer' : 'Move', cx, cy);
2550
+ }
1602
2551
  }
1603
- function onReorderUp() {
1604
- document.removeEventListener('mousemove', onReorderMove, true);
1605
- document.removeEventListener('mouseup', onReorderUp, true);
2552
+ function onReorderEscape() {
2553
+ document.removeEventListener(events.move, onReorderMove, true);
2554
+ document.removeEventListener(events.up, onReorderUp, true);
2555
+ document.removeEventListener('keydown', onReorderKeyDown, true);
2556
+ hideTransformBadge();
2557
+ hideInsertionGuide();
2558
+ window.parent.postMessage({ type: 'agent-native:cross-screen-drag', phase: 'cancel' }, '*');
2559
+ // Revert any clone that was inserted for alt-drag.
2560
+ if (duplicatedForDrag && reorderEl && reorderEl !== originalSelectedEl) {
2561
+ if (reorderEl.parentElement) reorderEl.parentElement.removeChild(reorderEl);
2562
+ selectedEl = originalSelectedEl;
2563
+ positionOverlay(selectionOverlay, selectedEl);
2564
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2565
+ }
2566
+ }
2567
+ function onReorderKeyDown(ev) {
2568
+ if (ev.key === 'Escape') {
2569
+ ev.preventDefault();
2570
+ ev.stopPropagation();
2571
+ if (ev.stopImmediatePropagation) ev.stopImmediatePropagation();
2572
+ onReorderEscape();
2573
+ }
2574
+ }
2575
+ function onReorderUp(ev) {
2576
+ document.removeEventListener(events.move, onReorderMove, true);
2577
+ document.removeEventListener(events.up, onReorderUp, true);
2578
+ document.removeEventListener('keydown', onReorderKeyDown, true);
1606
2579
  hideTransformBadge();
1607
2580
  hideInsertionGuide();
2581
+ var vw = window.innerWidth;
2582
+ var vh = window.innerHeight;
2583
+ var cx = ev ? ev.clientX : 0;
2584
+ var cy = ev ? ev.clientY : 0;
2585
+ var outsideOnDrop = cx < 0 || cy < 0 || cx > vw || cy > vh;
2586
+ // Post the end message so the host can finalize a cross-screen drop.
2587
+ window.parent.postMessage({
2588
+ type: 'agent-native:cross-screen-drag',
2589
+ phase: 'end',
2590
+ selector: reorderSelector,
2591
+ sourceId: reorderSourceId,
2592
+ iframeX: cx,
2593
+ iframeY: cy,
2594
+ viewportW: vw,
2595
+ viewportH: vh,
2596
+ }, '*');
2597
+ // When the pointer is outside this iframe at release, the host owns the
2598
+ // move (cross-screen drop). Do NOT apply the in-iframe reorder so we
2599
+ // avoid a ghost element left in screen A's DOM.
2600
+ // Use outsideOnDrop only — pointerOutsideIframe is stale when the user
2601
+ // briefly exits the iframe and re-enters before releasing. The host
2602
+ // already clears cross-screen state on re-entry so checking the
2603
+ // momentary excursion flag here would wrongly drop the element nowhere.
2604
+ if (outsideOnDrop) return;
1608
2605
  if (!currentTarget) {
1609
2606
  // No valid drop target — clean up the clone if one was inserted so
1610
2607
  // no ghost element is left in the DOM.
@@ -1630,9 +2627,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1630
2627
  applyRuntimeReorder(reorderEl, currentTarget);
1631
2628
  postVisualStructureChange(reorderEl, currentTarget, { prevParent: prevParent, prevNextSibling: prevNextSibling });
1632
2629
  }
1633
- }
1634
- document.addEventListener('mousemove', onReorderMove, true);
1635
- document.addEventListener('mouseup', onReorderUp, true);
2630
+ }
2631
+ document.addEventListener(events.move, onReorderMove, true);
2632
+ document.addEventListener(events.up, onReorderUp, true);
2633
+ document.addEventListener('keydown', onReorderKeyDown, true);
1636
2634
  return;
1637
2635
  }
1638
2636
  ensurePositionable(selectedEl);
@@ -1659,8 +2657,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1659
2657
  refreshOverlays();
1660
2658
  }
1661
2659
  function onUp() {
1662
- document.removeEventListener('mousemove', onMove, true);
1663
- document.removeEventListener('mouseup', onUp, true);
2660
+ document.removeEventListener(events.move, onMove, true);
2661
+ document.removeEventListener(events.up, onUp, true);
1664
2662
  hideTransformBadge();
1665
2663
  if (!dragEl) return;
1666
2664
  if (duplicatedForDrag && !moved) {
@@ -1686,8 +2684,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1686
2684
  }, '*');
1687
2685
  }
1688
2686
  }
1689
- document.addEventListener('mousemove', onMove, true);
1690
- document.addEventListener('mouseup', onUp, true);
2687
+ document.addEventListener(events.move, onMove, true);
2688
+ document.addEventListener(events.up, onUp, true);
1691
2689
  }
1692
2690
 
1693
2691
  function startResize(handle, e) {
@@ -1864,7 +2862,68 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1864
2862
  document.addEventListener('mouseup', onUp, true);
1865
2863
  }
1866
2864
 
2865
+ function clearPendingShieldDrag() {
2866
+ if (!pendingShieldDrag) return;
2867
+ document.removeEventListener(pendingShieldDrag.move, pendingShieldDrag.onMove, true);
2868
+ document.removeEventListener(pendingShieldDrag.up, pendingShieldDrag.onUp, true);
2869
+ pendingShieldDrag = null;
2870
+ }
2871
+
2872
+ function beginPotentialShieldDrag(e) {
2873
+ stopNativeInteraction(e);
2874
+ if (e.button !== 0 || activeTextEditEl) return;
2875
+ var events = dragEventNames(e);
2876
+ var hit = elementFromEditorPoint(e.clientX, e.clientY);
2877
+ if (!hit || hit === document.body || hit === document.documentElement) return;
2878
+ var dragTarget =
2879
+ selectedEl &&
2880
+ document.documentElement.contains(selectedEl) &&
2881
+ selectedEl.contains(hit)
2882
+ ? selectedEl
2883
+ : selectionTargetForHit(hit);
2884
+ if (
2885
+ !dragTarget ||
2886
+ dragTarget === document.body ||
2887
+ dragTarget === document.documentElement ||
2888
+ isLayerInteractionBlocked(dragTarget)
2889
+ ) {
2890
+ return;
2891
+ }
2892
+ var startX = e.clientX;
2893
+ var startY = e.clientY;
2894
+ var didStartDrag = false;
2895
+ function selectDragTarget() {
2896
+ selectedEl = dragTarget;
2897
+ positionOverlay(selectionOverlay, selectedEl);
2898
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2899
+ }
2900
+ function onMove(ev) {
2901
+ if (Math.hypot(ev.clientX - startX, ev.clientY - startY) <= 3) return;
2902
+ clearPendingShieldDrag();
2903
+ didStartDrag = true;
2904
+ selectDragTarget();
2905
+ suppressNextShieldClickBriefly();
2906
+ startMove(ev);
2907
+ }
2908
+ function onUp(ev) {
2909
+ clearPendingShieldDrag();
2910
+ if (didStartDrag) return;
2911
+ if (ev) stopNativeInteraction(ev);
2912
+ selectDragTarget();
2913
+ suppressNextShieldClickBriefly();
2914
+ }
2915
+ clearPendingShieldDrag();
2916
+ pendingShieldDrag = { move: events.move, up: events.up, onMove: onMove, onUp: onUp };
2917
+ document.addEventListener(events.move, onMove, true);
2918
+ document.addEventListener(events.up, onUp, true);
2919
+ }
2920
+
1867
2921
  selectionOverlay.addEventListener('mousedown', function(e) {
2922
+ var spacingKey = e.target && e.target.getAttribute && e.target.getAttribute('data-spacing-key');
2923
+ if (spacingKey) {
2924
+ startSpacingDrag(spacingKey, e);
2925
+ return;
2926
+ }
1868
2927
  var resizeHandle = e.target && e.target.getAttribute && e.target.getAttribute('data-agent-native-edit-handle');
1869
2928
  if (!resizeHandle && e.target && e.target.getAttribute) {
1870
2929
  resizeHandle = e.target.getAttribute('data-agent-native-edge-handle');
@@ -1881,10 +2940,23 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1881
2940
  startMove(e);
1882
2941
  }, true);
1883
2942
 
2943
+ shieldOverlay.addEventListener('pointerdown', beginPotentialShieldDrag, true);
2944
+
1884
2945
  ['pointerdown','pointerup','mousedown','mouseup','auxclick'].forEach(function(type) {
1885
2946
  shieldOverlay.addEventListener(type, stopNativeInteraction, true);
1886
2947
  });
1887
2948
 
2949
+ function stopBlockedLayerInteraction(e) {
2950
+ if (isOverlayElement(e.target)) return;
2951
+ var target = e.target && e.target.nodeType === 1 ? e.target : null;
2952
+ if (!target || !isLayerInteractionBlocked(target)) return;
2953
+ stopNativeInteraction(e);
2954
+ }
2955
+
2956
+ ['pointerdown','pointerup','mousedown','mouseup','click','auxclick'].forEach(function(type) {
2957
+ document.addEventListener(type, stopBlockedLayerInteraction, true);
2958
+ });
2959
+
1888
2960
  shieldOverlay.addEventListener('click', selectElementAtEvent, true);
1889
2961
  shieldOverlay.addEventListener('contextmenu', openContextMenuAtEvent, true);
1890
2962
  selectionOverlay.addEventListener('contextmenu', openContextMenuAtEvent, true);
@@ -2050,10 +3122,25 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2050
3122
  hoveredEl = elementFromEditorPoint(e.clientX, e.clientY);
2051
3123
  if (!hoveredEl) {
2052
3124
  highlightOverlay.style.display = 'none';
3125
+ if (!spacingDrag) {
3126
+ selectedSpacingHovered = false;
3127
+ hoveredSpacingHandleKey = '';
3128
+ updateSpacingOverlay(selectedEl);
3129
+ }
2053
3130
  hideMeasurements();
2054
3131
  return;
2055
3132
  }
2056
3133
  if (hoveredEl && hoveredEl.closest('[data-agent-native-text-editing]')) return;
3134
+ if (!spacingDrag) {
3135
+ selectedSpacingHovered = Boolean(
3136
+ selectedEl &&
3137
+ hoveredEl &&
3138
+ (hoveredEl === selectedEl ||
3139
+ (selectedEl.contains && selectedEl.contains(hoveredEl))),
3140
+ );
3141
+ if (!selectedSpacingHovered) hoveredSpacingHandleKey = '';
3142
+ updateSpacingOverlay(selectedEl);
3143
+ }
2057
3144
  if (hoveredEl === selectedEl) {
2058
3145
  highlightOverlay.style.display = 'none';
2059
3146
  } else {
@@ -2071,6 +3158,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2071
3158
  shieldOverlay.addEventListener('pointerleave', function(e) {
2072
3159
  stopNativeInteraction(e);
2073
3160
  hoveredEl = null;
3161
+ if (!spacingDrag) {
3162
+ selectedSpacingHovered = false;
3163
+ hoveredSpacingHandleKey = '';
3164
+ updateSpacingOverlay(selectedEl);
3165
+ }
2074
3166
  highlightOverlay.style.display = 'none';
2075
3167
  hideMeasurements();
2076
3168
  window.parent.postMessage({ type: 'element-hover', payload: null }, '*');
@@ -2083,6 +3175,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2083
3175
  window.addEventListener('message', function(e) {
2084
3176
  if (e.source !== window.parent) return;
2085
3177
  if (!e.data) return;
3178
+ if (e.data.payload && typeof e.data.payload === 'object') {
3179
+ Object.keys(e.data.payload).forEach(function(key) {
3180
+ if (e.data[key] === undefined) e.data[key] = e.data.payload[key];
3181
+ });
3182
+ }
2086
3183
  if (e.data.type === 'set-editor-chrome-scale') {
2087
3184
  // Live-update the constant-size chrome scale WITHOUT rebuilding srcdoc.
2088
3185
  // Rebuilding srcdoc reloads the iframe and flashes the content white.
@@ -2130,6 +3227,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2130
3227
  } catch (_err) {}
2131
3228
  }
2132
3229
  if (!target) return;
3230
+ selectedSpacingHovered = false;
3231
+ hoveredSpacingHandleKey = '';
2133
3232
  selectedEl = target;
2134
3233
  positionOverlay(selectionOverlay, target);
2135
3234
  if (hoveredEl === selectedEl) highlightOverlay.style.display = 'none';
@@ -2169,6 +3268,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2169
3268
  if (selectedEl && isLayerInteractionBlocked(selectedEl)) {
2170
3269
  selectedEl = null;
2171
3270
  selectionOverlay.style.display = 'none';
3271
+ clearComponentTag();
2172
3272
  }
2173
3273
  if (hoveredEl && isLayerInteractionBlocked(hoveredEl)) {
2174
3274
  hoveredEl = null;
@@ -2200,7 +3300,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2200
3300
  return;
2201
3301
  }
2202
3302
  if (e.data.type === 'replace-document-content') {
2203
- replaceRuntimeDocument(e.data.content, e.data.selectedSelector, e.data.selectorCandidates);
3303
+ replaceRuntimeDocument(
3304
+ e.data.content,
3305
+ e.data.forceFullDocument ? '' : e.data.selectedSelector,
3306
+ e.data.forceFullDocument ? [] : e.data.selectorCandidates
3307
+ );
2204
3308
  return;
2205
3309
  }
2206
3310
  if (e.data.type === 'delete-element') {
@@ -2211,13 +3315,63 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2211
3315
  var sel = e.data.selector;
2212
3316
  var prop = e.data.property;
2213
3317
  var val = e.data.value;
2214
- var el = sel ? document.querySelector(sel) : null;
2215
- if (activeTextEditEl && el === activeTextEditEl && applyTextRangeStyle(prop, val)) {
3318
+ var candidatesForStyle = Array.isArray(e.data.selectorCandidates) ? e.data.selectorCandidates : [];
3319
+ if (sel && candidatesForStyle.indexOf(String(sel)) === -1) candidatesForStyle.push(String(sel));
3320
+ if (e.data.nodeId) {
3321
+ candidatesForStyle.push('[data-agent-native-node-id="' + String(e.data.nodeId).replace(/"/g, '\\\\"') + '"]');
3322
+ }
3323
+ var el = findRuntimeTarget(String(sel || ''), candidatesForStyle);
3324
+ if (prop && activeTextEditEl && el === activeTextEditEl && applyTextRangeStyle(prop, val)) {
2216
3325
  postTextContentChange(activeTextEditEl, activeTextEditEl.textContent || '', activeTextEditEl.innerHTML || '');
2217
3326
  refreshOverlays();
2218
3327
  return;
2219
3328
  }
2220
- if (el) el.style[prop] = val;
3329
+ if (!el) return;
3330
+ var didPatchDom = false;
3331
+ var attributeOverrides = e.data.attributeOverrides;
3332
+ if (attributeOverrides && typeof attributeOverrides === 'object' && !Array.isArray(attributeOverrides)) {
3333
+ Object.keys(attributeOverrides).forEach(function(name) {
3334
+ if (!/^(?!on)[a-zA-Z][a-zA-Z0-9:_.-]*$/i.test(name)) return;
3335
+ var nextValue = attributeOverrides[name];
3336
+ if (nextValue === null || nextValue === undefined || nextValue === false) {
3337
+ el.removeAttribute(name);
3338
+ } else {
3339
+ el.setAttribute(name, String(nextValue));
3340
+ }
3341
+ didPatchDom = true;
3342
+ });
3343
+ }
3344
+ var classEdit = e.data.classEdit;
3345
+ if (classEdit && typeof classEdit === 'object') {
3346
+ var currentClass = (el.getAttribute('class') || '').trim().split(/\\s+/).filter(Boolean);
3347
+ var nextClass = currentClass.slice();
3348
+ if (classEdit.operation === 'replace' && classEdit.from && classEdit.to) {
3349
+ var replaced = false;
3350
+ nextClass = currentClass.map(function(token) {
3351
+ if (token === classEdit.from) {
3352
+ replaced = true;
3353
+ return String(classEdit.to);
3354
+ }
3355
+ return token;
3356
+ });
3357
+ if (!replaced && nextClass.indexOf(String(classEdit.to)) === -1) nextClass.push(String(classEdit.to));
3358
+ didPatchDom = true;
3359
+ } else if (classEdit.operation === 'add' && classEdit.className) {
3360
+ if (nextClass.indexOf(String(classEdit.className)) === -1) nextClass.push(String(classEdit.className));
3361
+ didPatchDom = true;
3362
+ } else if (classEdit.operation === 'remove' && classEdit.className) {
3363
+ nextClass = currentClass.filter(function(token) { return token !== String(classEdit.className); });
3364
+ didPatchDom = true;
3365
+ }
3366
+ if (didPatchDom) el.setAttribute('class', nextClass.join(' '));
3367
+ }
3368
+ if (prop && typeof prop === 'string') {
3369
+ applyInlineStyleProperty(el, prop, val);
3370
+ didPatchDom = true;
3371
+ }
3372
+ if (didPatchDom) {
3373
+ refreshOverlays();
3374
+ }
2221
3375
  });
2222
3376
 
2223
3377
  window.addEventListener('scroll', refreshOverlays, true);
@@ -2230,6 +3384,41 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2230
3384
  interface DesignCanvasProps {
2231
3385
  content: string;
2232
3386
  contentKey?: string;
3387
+ /**
3388
+ * The runtime source tier for this canvas.
3389
+ *
3390
+ * - `"inline"` (default) — HTML/Alpine `srcdoc` iframe; same-origin null
3391
+ * origin; all bridge scripts injected by DesignCanvas.
3392
+ * - `"localhost"` — `src=devServerUrl`; dev server is same-origin in most
3393
+ * setups; bridge trust: origin must match parent or be "null".
3394
+ * - `"fusion"` — `src=builderHostedUrl`; cross-origin Builder-hosted app;
3395
+ * bridge trust is relaxed to window-identity only (no origin check) so
3396
+ * the Builder-hosted iframe can communicate with the editor. The sandbox
3397
+ * grants `allow-same-origin` so the Builder app can reach its own resources.
3398
+ *
3399
+ * When omitted, DesignCanvas infers the tier from the content value:
3400
+ * a value that passes `getExternalPreviewUrl` is treated as `"localhost"`;
3401
+ * otherwise `"inline"`. Pass `sourceType="fusion"` explicitly when the
3402
+ * content URL is a Builder-hosted (cross-origin) app so the bridge security
3403
+ * model uses window-identity trust instead of same-origin trust.
3404
+ */
3405
+ sourceType?: "inline" | "localhost" | "fusion";
3406
+ /**
3407
+ * Explicit Builder-hosted app URL for fusion source rendering.
3408
+ *
3409
+ * When `sourceType === "fusion"` and this prop is provided, the iframe uses
3410
+ * this URL as `src` regardless of what `content` contains. This lets the
3411
+ * caller hold the original inline HTML in `content` (for collab/history
3412
+ * purposes) while pointing the canvas at the migrated Builder-hosted app.
3413
+ *
3414
+ * When absent and `sourceType === "fusion"`, the component falls back to
3415
+ * the existing external-URL detection on `content` (i.e. if `content` is
3416
+ * itself a URL it is used as-is, which is the pattern when the branch URL
3417
+ * has been written into the design file content).
3418
+ *
3419
+ * For `"inline"` and `"localhost"` sources this prop is ignored.
3420
+ */
3421
+ fusionUrl?: string;
2233
3422
  zoom: number;
2234
3423
  onZoomChange?: (zoom: number) => void;
2235
3424
  deviceFrame: DeviceFrameType;
@@ -2322,6 +3511,55 @@ interface DesignCanvasProps {
2322
3511
  * opened in a new tab by the iframe itself and never reach this callback.
2323
3512
  */
2324
3513
  onPrototypeNavigate?: (screen: string, href: string) => void;
3514
+ /**
3515
+ * Motion tracks to load into the iframe's motion-preview bridge. Sent via
3516
+ * `motion-load-tracks` whenever this prop changes. When cleared
3517
+ * (`undefined` or `[]`) a `motion-preview-clear` message is sent to remove
3518
+ * any applied preview overrides.
3519
+ *
3520
+ * The MotionDock sends scrub ticks as `{ type: 'motion-preview', t,
3521
+ * durationMs }` directly from its `canvasIframeRef`. DesignCanvas only
3522
+ * needs the tracks so the bridge can interpolate values at each tick.
3523
+ */
3524
+ motionTracks?: MotionTrackWire[];
3525
+ /**
3526
+ * Explicit iframe width in pixels. When provided it overrides the width
3527
+ * derived from `deviceFrame`, enabling per-breakpoint preview (e.g. Mobile
3528
+ * 390 / Tablet 768 / Desktop 1280 side-by-side frames in the overview).
3529
+ * The height still comes from `deviceFrame`; `deviceFrame="none"` keeps
3530
+ * 100% height.
3531
+ */
3532
+ previewWidthPx?: number;
3533
+ /**
3534
+ * Shader-fill CSS preview to apply to a selected element inside the iframe.
3535
+ *
3536
+ * When set, the canvas sends a `shader-fill-preview` bridge message that
3537
+ * applies the CSS `background` value on the target element **without
3538
+ * persisting anything**. When cleared (`null` / `undefined`) a
3539
+ * `shader-fill-preview-clear` message is sent to restore the original
3540
+ * background.
3541
+ *
3542
+ * Preview-only — never writes to DB, Yjs, or source. Part of the §6.7
3543
+ * shader-fill PREVIEW path; the apply path remains gated until runtime
3544
+ * rendering + source-write + diff proof are all in place.
3545
+ */
3546
+ shaderFillPreview?: {
3547
+ /** CSS selector for the target element (preferred over nodeId). */
3548
+ selector?: string;
3549
+ /** data-agent-native-node-id value for the target element. */
3550
+ nodeId?: string;
3551
+ /** The CSS `background` value returned by preview-shader-fill. */
3552
+ css: string;
3553
+ } | null;
3554
+ /**
3555
+ * Called when the user clicks the component-instance source tag (the
3556
+ * "ComponentName →" pill that floats above a selected component root).
3557
+ * The parent should invoke `open-component-source` with these params.
3558
+ */
3559
+ onComponentSourceJump?: (params: {
3560
+ nodeId: string;
3561
+ componentName: string;
3562
+ }) => void;
2325
3563
  }
2326
3564
 
2327
3565
  function getExternalPreviewUrl(content: string): string | null {
@@ -2357,6 +3595,8 @@ export interface IframeContextMenuPayload {
2357
3595
  export function DesignCanvas({
2358
3596
  content,
2359
3597
  contentKey,
3598
+ sourceType,
3599
+ fusionUrl,
2360
3600
  zoom,
2361
3601
  onZoomChange,
2362
3602
  deviceFrame,
@@ -2396,6 +3636,10 @@ export function DesignCanvas({
2396
3636
  commentContextId,
2397
3637
  commentContextLabel,
2398
3638
  onPrototypeNavigate,
3639
+ motionTracks,
3640
+ previewWidthPx,
3641
+ onComponentSourceJump,
3642
+ shaderFillPreview,
2399
3643
  }: DesignCanvasProps) {
2400
3644
  const t = useT();
2401
3645
  const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -2406,10 +3650,25 @@ export function DesignCanvas({
2406
3650
  const [annotationPins, setAnnotationPins] = useState<CanvasPin[]>([]);
2407
3651
  const [pinSubmitSignal, setPinSubmitSignal] = useState(0);
2408
3652
  const isEmbeddedFrame = Boolean(embeddedFrame);
2409
- const externalPreviewUrl = useMemo(
2410
- () => getExternalPreviewUrl(renderedContent),
2411
- [renderedContent],
2412
- );
3653
+ // Resolve the URL to render in the iframe:
3654
+ // 1. When sourceType === "fusion" and fusionUrl is set, prefer the explicit
3655
+ // Builder-hosted URL over whatever is in `content` (which may still be the
3656
+ // original inline HTML).
3657
+ // 2. Otherwise fall back to the content-based URL detection (handles the case
3658
+ // where the branch URL has been written into the design file content, or
3659
+ // where the localhost URL is the file content).
3660
+ const externalPreviewUrl = useMemo(() => {
3661
+ if (sourceType === "fusion" && fusionUrl) {
3662
+ try {
3663
+ const url = new URL(fusionUrl);
3664
+ url.hash = "";
3665
+ return url.toString();
3666
+ } catch {
3667
+ // fall through to content detection below
3668
+ }
3669
+ }
3670
+ return getExternalPreviewUrl(renderedContent);
3671
+ }, [fusionUrl, renderedContent, sourceType]);
2413
3672
  zoomRef.current = zoom;
2414
3673
 
2415
3674
  const queuedAnnotationPins = useMemo(
@@ -2443,7 +3702,8 @@ export function DesignCanvas({
2443
3702
  });
2444
3703
 
2445
3704
  // Build the srcdoc. The tweak bridge ALWAYS goes in so the panel works
2446
- // outside Edit mode. The editor chrome bridge is omitted only for Interact.
3705
+ // outside Edit mode. The editor chrome bridge is omitted for Interact and
3706
+ // read-only surfaces so preview/app users can interact with the app normally.
2447
3707
  const srcdoc = useMemo(() => {
2448
3708
  if (externalPreviewUrl) return undefined;
2449
3709
  const editorChromeBridge =
@@ -2462,6 +3722,8 @@ export function DesignCanvas({
2462
3722
  isEmbeddedFrame ? "true" : "false",
2463
3723
  );
2464
3724
  const bridgeToInject =
3725
+ MOTION_PREVIEW_BRIDGE_SCRIPT +
3726
+ SHADER_FILL_PREVIEW_BRIDGE_SCRIPT +
2465
3727
  TWEAK_BRIDGE_SCRIPT +
2466
3728
  ZOOM_BRIDGE_SCRIPT +
2467
3729
  NAV_BRIDGE_SCRIPT +
@@ -2492,14 +3754,27 @@ export function DesignCanvas({
2492
3754
  // Listen for messages from the iframe
2493
3755
  useEffect(() => {
2494
3756
  function handleMessage(e: MessageEvent) {
2495
- if (
2496
- !isTrustedCanvasBridgeMessage({
2497
- source: e.source,
2498
- origin: e.origin,
2499
- iframeWindow: iframeRef.current?.contentWindow,
2500
- parentOrigin: window.location.origin,
2501
- })
2502
- ) {
3757
+ const iframeWindow = iframeRef.current?.contentWindow;
3758
+ // For fusion sources the Builder-hosted app is cross-origin, so the strict
3759
+ // `origin === parentOrigin` check can never match. We still require window
3760
+ // identity (the message must come from our own iframe window, not any
3761
+ // arbitrary cross-origin frame), AND we validate the message origin
3762
+ // against a Builder-host allowlist (the configured fusionUrl origin or the
3763
+ // *.builder.io family) before relaxing the origin check. If the origin is
3764
+ // not on the allowlist we keep the strict check so a hostile frame that
3765
+ // somehow shares our window reference still can't be trusted.
3766
+ const trusted =
3767
+ sourceType === "fusion"
3768
+ ? iframeWindow != null &&
3769
+ e.source === iframeWindow &&
3770
+ isAllowedFusionOrigin(e.origin, fusionUrl)
3771
+ : isTrustedCanvasBridgeMessage({
3772
+ source: e.source,
3773
+ origin: e.origin,
3774
+ iframeWindow,
3775
+ parentOrigin: window.location.origin,
3776
+ });
3777
+ if (!trusted) {
2503
3778
  return;
2504
3779
  }
2505
3780
  if (!e.data || !e.data.type) return;
@@ -2540,9 +3815,15 @@ export function DesignCanvas({
2540
3815
  const placement = String(e.data.placement || "after");
2541
3816
  const requestId =
2542
3817
  typeof e.data.requestId === "string" ? e.data.requestId : undefined;
3818
+ const sourceId =
3819
+ typeof e.data.sourceId === "string" ? e.data.sourceId : undefined;
3820
+ const anchorSourceId =
3821
+ typeof e.data.anchorSourceId === "string"
3822
+ ? e.data.anchorSourceId
3823
+ : undefined;
2543
3824
  if (
2544
- selector &&
2545
- anchorSelector &&
3825
+ (selector || sourceId) &&
3826
+ (anchorSelector || anchorSourceId) &&
2546
3827
  (placement === "before" ||
2547
3828
  placement === "after" ||
2548
3829
  placement === "inside")
@@ -2554,14 +3835,8 @@ export function DesignCanvas({
2554
3835
  e.data.payload,
2555
3836
  {
2556
3837
  requestId,
2557
- sourceId:
2558
- typeof e.data.sourceId === "string"
2559
- ? e.data.sourceId
2560
- : undefined,
2561
- anchorSourceId:
2562
- typeof e.data.anchorSourceId === "string"
2563
- ? e.data.anchorSourceId
2564
- : undefined,
3838
+ sourceId,
3839
+ anchorSourceId,
2565
3840
  },
2566
3841
  );
2567
3842
  if (requestId) {
@@ -2663,6 +3938,16 @@ export function DesignCanvas({
2663
3938
  );
2664
3939
  return;
2665
3940
  }
3941
+ if (e.data.type === "component-source-jump") {
3942
+ // The user clicked the component-instance tag ("ComponentName →").
3943
+ // Relay to the parent so it can invoke open-component-source.
3944
+ const nodeId = String(e.data.nodeId || "");
3945
+ const componentName = String(e.data.componentName || "");
3946
+ if (nodeId && componentName) {
3947
+ onComponentSourceJump?.({ nodeId, componentName });
3948
+ }
3949
+ return;
3950
+ }
2666
3951
  if (e.data.type === "embedded-canvas-wheel") {
2667
3952
  if (!isEmbeddedFrame) return;
2668
3953
  const iframe = iframeRef.current;
@@ -2748,7 +4033,10 @@ export function DesignCanvas({
2748
4033
  onZoomChange,
2749
4034
  deviceFrame,
2750
4035
  onPrototypeNavigate,
4036
+ onComponentSourceJump,
2751
4037
  isEmbeddedFrame,
4038
+ sourceType,
4039
+ fusionUrl,
2752
4040
  ]);
2753
4041
 
2754
4042
  const replayIframeEditorState = useCallback(() => {
@@ -2786,14 +4074,43 @@ export function DesignCanvas({
2786
4074
  : { type: "hover-element", selector: "", selectorCandidates: [] },
2787
4075
  "*",
2788
4076
  );
4077
+ // Re-send motion tracks so the preview bridge is ready after a reload.
4078
+ if (motionTracks && motionTracks.length > 0) {
4079
+ iframe.contentWindow?.postMessage(
4080
+ { type: "motion-load-tracks", tracks: motionTracks },
4081
+ "*",
4082
+ );
4083
+ } else {
4084
+ iframe.contentWindow?.postMessage({ type: "motion-preview-clear" }, "*");
4085
+ }
4086
+ // Re-apply the shader-fill preview after a reload so the preview survives
4087
+ // screen switches. Preview-only — never writes to DB, Yjs, or source.
4088
+ if (shaderFillPreview) {
4089
+ iframe.contentWindow?.postMessage(
4090
+ {
4091
+ type: "shader-fill-preview",
4092
+ selector: shaderFillPreview.selector ?? "",
4093
+ nodeId: shaderFillPreview.nodeId ?? "",
4094
+ css: shaderFillPreview.css,
4095
+ },
4096
+ "*",
4097
+ );
4098
+ } else {
4099
+ iframe.contentWindow?.postMessage(
4100
+ { type: "shader-fill-preview-clear" },
4101
+ "*",
4102
+ );
4103
+ }
2789
4104
  }, [
2790
4105
  hoveredSelector,
2791
4106
  hoveredSelectorCandidates,
2792
4107
  hiddenSelectors,
2793
4108
  lockedSelectors,
4109
+ motionTracks,
2794
4110
  scaleMode,
2795
4111
  selectedSelector,
2796
4112
  selectedSelectorCandidates,
4113
+ shaderFillPreview,
2797
4114
  tweakValues,
2798
4115
  ]);
2799
4116
 
@@ -2816,6 +4133,44 @@ export function DesignCanvas({
2816
4133
  );
2817
4134
  }, [clearSelectionRequest]);
2818
4135
 
4136
+ // Sync motion tracks to the iframe bridge whenever they change.
4137
+ // When motionTracks is empty/undefined, clear any preview overrides so the
4138
+ // design returns to its authored state (no stale inline styles).
4139
+ useEffect(() => {
4140
+ const win = iframeRef.current?.contentWindow;
4141
+ if (!win) return;
4142
+ if (!motionTracks || motionTracks.length === 0) {
4143
+ win.postMessage({ type: "motion-preview-clear" }, "*");
4144
+ } else {
4145
+ win.postMessage(
4146
+ { type: "motion-load-tracks", tracks: motionTracks },
4147
+ "*",
4148
+ );
4149
+ }
4150
+ }, [motionTracks]);
4151
+
4152
+ // Sync shader-fill preview to the iframe whenever the prop changes.
4153
+ // When cleared (null / undefined) send a clear message so the bridge
4154
+ // restores the original background on the previously-patched element.
4155
+ // Preview-only — never writes to DB, Yjs, or source.
4156
+ useEffect(() => {
4157
+ const win = iframeRef.current?.contentWindow;
4158
+ if (!win) return;
4159
+ if (!shaderFillPreview) {
4160
+ win.postMessage({ type: "shader-fill-preview-clear" }, "*");
4161
+ } else {
4162
+ win.postMessage(
4163
+ {
4164
+ type: "shader-fill-preview",
4165
+ selector: shaderFillPreview.selector ?? "",
4166
+ nodeId: shaderFillPreview.nodeId ?? "",
4167
+ css: shaderFillPreview.css,
4168
+ },
4169
+ "*",
4170
+ );
4171
+ }
4172
+ }, [shaderFillPreview]);
4173
+
2819
4174
  // Push the constant-size chrome scale into the iframe LIVE (CSS vars only) when
2820
4175
  // overview zoom settles. This is intentionally separate from the srcdoc build so
2821
4176
  // a scale change never rebuilds srcdoc / reloads the iframe (which flashes the
@@ -2832,17 +4187,84 @@ export function DesignCanvas({
2832
4187
  }, [editorChromeScaleX, editorChromeScaleY]);
2833
4188
 
2834
4189
  const sendStyleChange = useCallback(
2835
- (selector: string, property: string, value: string) => {
4190
+ (
4191
+ selector: string,
4192
+ property: string,
4193
+ value: string,
4194
+ options?: { selectorCandidates?: string[]; nodeId?: string | null },
4195
+ ) => {
2836
4196
  const iframe = iframeRef.current;
2837
4197
  if (!iframe?.contentWindow) return;
2838
4198
  iframe.contentWindow.postMessage(
2839
- { type: "style-change", selector, property, value },
4199
+ {
4200
+ type: "style-change",
4201
+ selector,
4202
+ property,
4203
+ value,
4204
+ selectorCandidates: options?.selectorCandidates ?? [],
4205
+ nodeId: options?.nodeId ?? "",
4206
+ },
4207
+ "*",
4208
+ );
4209
+ },
4210
+ [],
4211
+ );
4212
+
4213
+ /**
4214
+ * Send a motion-preview scrub tick to the iframe. `t` is the normalised
4215
+ * playhead position in [0, 1]. Tracks must have been loaded first via the
4216
+ * `motionTracks` prop (or an explicit `motion-load-tracks` message).
4217
+ * Preview-only — never writes to DB/Yjs/source.
4218
+ */
4219
+ const sendMotionPreview = useCallback((t: number, durationMs?: number) => {
4220
+ const iframe = iframeRef.current;
4221
+ if (!iframe?.contentWindow) return;
4222
+ iframe.contentWindow.postMessage(
4223
+ { type: "motion-preview", t: Math.max(0, Math.min(1, t)), durationMs },
4224
+ "*",
4225
+ );
4226
+ }, []);
4227
+
4228
+ /**
4229
+ * Clear all motion-preview inline-style overrides in the iframe and remove
4230
+ * the in-memory track list. Call when the Motion dock is closed.
4231
+ */
4232
+ const clearMotionPreview = useCallback(() => {
4233
+ iframeRef.current?.contentWindow?.postMessage(
4234
+ { type: "motion-preview-clear" },
4235
+ "*",
4236
+ );
4237
+ }, []);
4238
+
4239
+ /**
4240
+ * Send a shader-fill CSS preview to the iframe. Targets the element
4241
+ * identified by `selector` (preferred) or `nodeId`. Preview-only — the
4242
+ * bridge script restores the original background on clear.
4243
+ */
4244
+ const sendShaderFillPreview = useCallback(
4245
+ (selector: string, nodeId: string, css: string) => {
4246
+ const win = iframeRef.current?.contentWindow;
4247
+ if (!win) return;
4248
+ win.postMessage(
4249
+ { type: "shader-fill-preview", selector, nodeId, css },
2840
4250
  "*",
2841
4251
  );
2842
4252
  },
2843
4253
  [],
2844
4254
  );
2845
4255
 
4256
+ /**
4257
+ * Clear the shader-fill preview in the iframe, restoring the original
4258
+ * background on the patched element. Call when the preview is dismissed
4259
+ * or when the selection changes to a different element.
4260
+ */
4261
+ const clearShaderFillPreview = useCallback(() => {
4262
+ iframeRef.current?.contentWindow?.postMessage(
4263
+ { type: "shader-fill-preview-clear" },
4264
+ "*",
4265
+ );
4266
+ }, []);
4267
+
2846
4268
  const replacePreviewContent = useCallback(
2847
4269
  (nextContent: string, selector?: string | null, candidates?: string[]) => {
2848
4270
  const iframe = iframeRef.current;
@@ -2884,7 +4306,15 @@ export function DesignCanvas({
2884
4306
  (window as any).__designCanvasSendStyle = sendStyleChange;
2885
4307
  (window as any).__designCanvasReplaceContent = replacePreviewContent;
2886
4308
  (window as any).__designCanvasDeleteElement = deleteRuntimeElement;
4309
+ (window as any).__designCanvasSendMotionPreview = sendMotionPreview;
4310
+ (window as any).__designCanvasClearMotionPreview = clearMotionPreview;
4311
+ // Shader-fill preview helpers (preview-only, §6.7 gating applies to apply).
4312
+ (window as any).__designCanvasSendShaderFillPreview = sendShaderFillPreview;
4313
+ (window as any).__designCanvasClearShaderFillPreview =
4314
+ clearShaderFillPreview;
2887
4315
  return () => {
4316
+ // Identity-guard each delete so a stale unmounting instance never clobbers
4317
+ // a freshly mounted instance's bridge during a remount race.
2888
4318
  if ((window as any).__designCanvasSendStyle === sendStyleChange) {
2889
4319
  delete (window as any).__designCanvasSendStyle;
2890
4320
  }
@@ -2898,12 +4328,38 @@ export function DesignCanvas({
2898
4328
  ) {
2899
4329
  delete (window as any).__designCanvasDeleteElement;
2900
4330
  }
4331
+ if (
4332
+ (window as any).__designCanvasSendMotionPreview === sendMotionPreview
4333
+ ) {
4334
+ delete (window as any).__designCanvasSendMotionPreview;
4335
+ }
4336
+ if (
4337
+ (window as any).__designCanvasClearMotionPreview === clearMotionPreview
4338
+ ) {
4339
+ delete (window as any).__designCanvasClearMotionPreview;
4340
+ }
4341
+ if (
4342
+ (window as any).__designCanvasSendShaderFillPreview ===
4343
+ sendShaderFillPreview
4344
+ ) {
4345
+ delete (window as any).__designCanvasSendShaderFillPreview;
4346
+ }
4347
+ if (
4348
+ (window as any).__designCanvasClearShaderFillPreview ===
4349
+ clearShaderFillPreview
4350
+ ) {
4351
+ delete (window as any).__designCanvasClearShaderFillPreview;
4352
+ }
2901
4353
  };
2902
4354
  }, [
2903
4355
  deleteRuntimeElement,
2904
4356
  registerRuntimeBridge,
2905
4357
  replacePreviewContent,
2906
4358
  sendStyleChange,
4359
+ sendMotionPreview,
4360
+ clearMotionPreview,
4361
+ sendShaderFillPreview,
4362
+ clearShaderFillPreview,
2907
4363
  ]);
2908
4364
 
2909
4365
  // Device dimensions match real-world devices. iframes are replaced elements
@@ -2923,6 +4379,12 @@ export function DesignCanvas({
2923
4379
  deviceDimensions[deviceFrame];
2924
4380
  const embeddedFrameFluid = embeddedFrame?.fluid === true;
2925
4381
 
4382
+ // Per-breakpoint override: when previewWidthPx is set it takes priority over
4383
+ // the deviceFrame width so the caller can render the same source at an
4384
+ // explicit viewport width (e.g. 390 / 768 / 1280 side-by-side breakpoints).
4385
+ const resolvedWidth =
4386
+ previewWidthPx != null ? `${previewWidthPx}px` : iframeWidth;
4387
+
2926
4388
  // Wrap the iframe in a positioned container so DrawOverlay /
2927
4389
  // CanvasCommentPins can absolutely-position themselves on top of the
2928
4390
  // iframe. The pin component anchors to `.design-canvas-iframe-wrapper`
@@ -2938,7 +4400,7 @@ export function DesignCanvas({
2938
4400
  ? embeddedFrameFluid
2939
4401
  ? "100%"
2940
4402
  : embeddedFrame.viewportWidth
2941
- : iframeWidth,
4403
+ : resolvedWidth,
2942
4404
  height: embeddedFrame
2943
4405
  ? embeddedFrameFluid
2944
4406
  ? "100%"
@@ -2958,6 +4420,12 @@ export function DesignCanvas({
2958
4420
  : "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
2959
4421
  }
2960
4422
  data-design-preview-iframe
4423
+ data-design-source-type={
4424
+ sourceType ??
4425
+ (externalPreviewUrl
4426
+ ? "localhost" // inferred — content is a URL
4427
+ : "inline")
4428
+ }
2961
4429
  className="block h-full w-full border-0 bg-transparent"
2962
4430
  title={t("designEditor.designPreview")}
2963
4431
  />