@agent-native/core 0.80.9 → 0.80.10

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 (111) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/design-connect.ts +28 -2
  5. package/corpus/core/src/cli/skills.ts +45 -24
  6. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  7. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  8. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  9. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  10. package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
  11. package/corpus/templates/design/actions/apply-design-state.ts +200 -0
  12. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
  14. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  15. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  16. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  17. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  18. package/corpus/templates/design/actions/create-component.ts +447 -0
  19. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  20. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  21. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  22. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  23. package/corpus/templates/design/actions/get-component-details.ts +242 -0
  24. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  25. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  26. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  27. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  28. package/corpus/templates/design/actions/index-components.ts +258 -0
  29. package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
  30. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  31. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  32. package/corpus/templates/design/actions/list-design-states.ts +72 -0
  33. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  34. package/corpus/templates/design/actions/open-component-source.ts +237 -0
  35. package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
  36. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  37. package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
  38. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  39. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  40. package/corpus/templates/design/actions/run-design-audit.ts +421 -0
  41. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  42. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  43. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
  44. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
  45. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  46. package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
  47. package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
  48. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  49. package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
  51. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
  52. package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
  53. package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
  54. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  55. package/corpus/templates/design/app/components/design/index.ts +16 -0
  56. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  57. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  58. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  59. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  60. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  61. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  62. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  63. package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
  64. package/corpus/templates/design/app/i18n-data.ts +269 -0
  65. package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
  66. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  67. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  68. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  69. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  70. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  71. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  72. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  73. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  74. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  75. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  76. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  77. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  78. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  79. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  80. package/corpus/templates/design/e2e/helpers.ts +10 -4
  81. package/corpus/templates/design/server/db/schema.ts +123 -0
  82. package/corpus/templates/design/server/plugins/db.ts +90 -0
  83. package/corpus/templates/design/shared/builder-app.ts +297 -0
  84. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  85. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  86. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  87. package/corpus/templates/design/shared/component-model.ts +239 -0
  88. package/corpus/templates/design/shared/design-review.ts +275 -0
  89. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  90. package/corpus/templates/design/shared/design-state.ts +112 -0
  91. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  92. package/corpus/templates/design/shared/motion-compiler.ts +278 -0
  93. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  94. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  95. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  96. package/corpus/templates/design/shared/source-mode.ts +245 -0
  97. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  98. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  99. package/dist/cli/design-connect.d.ts.map +1 -1
  100. package/dist/cli/design-connect.js +28 -2
  101. package/dist/cli/design-connect.js.map +1 -1
  102. package/dist/cli/skills.d.ts.map +1 -1
  103. package/dist/cli/skills.js +37 -21
  104. package/dist/cli/skills.js.map +1 -1
  105. package/dist/collab/routes.d.ts +1 -1
  106. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  107. package/dist/notifications/routes.d.ts +2 -2
  108. package/dist/observability/routes.d.ts +7 -7
  109. package/dist/resources/handlers.d.ts +2 -2
  110. package/dist/server/transcribe-voice.d.ts +1 -1
  111. package/package.json +1 -1
@@ -15,6 +15,249 @@ 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
+
103
+ function lerp(a, b, ratio) {
104
+ var numA = parseFloat(a);
105
+ var numB = parseFloat(b);
106
+ if (Number.isFinite(numA) && Number.isFinite(numB)) {
107
+ var suffix = a.replace(/^-?[\\d.]+/, '') || b.replace(/^-?[\\d.]+/, '');
108
+ return (numA + (numB - numA) * ratio).toFixed(4).replace(/\\.?0+$/, '') + suffix;
109
+ }
110
+ // Non-numeric: snap to b after the midpoint.
111
+ return ratio < 0.5 ? a : b;
112
+ }
113
+
114
+ function interpolate(keyframes, t) {
115
+ if (!keyframes || keyframes.length === 0) return '';
116
+ if (keyframes.length === 1) return keyframes[0].value;
117
+ // Find surrounding keyframes.
118
+ var prev = keyframes[0];
119
+ var next = keyframes[keyframes.length - 1];
120
+ for (var i = 0; i < keyframes.length - 1; i++) {
121
+ if (t >= keyframes[i].t && t <= keyframes[i + 1].t) {
122
+ prev = keyframes[i];
123
+ next = keyframes[i + 1];
124
+ break;
125
+ }
126
+ }
127
+ var span = next.t - prev.t;
128
+ if (span <= 0) return prev.value;
129
+ var ratio = Math.max(0, Math.min(1, (t - prev.t) / span));
130
+ return lerp(prev.value, next.value, ratio);
131
+ }
132
+
133
+ function applyPreview(t) {
134
+ for (var i = 0; i < loadedTracks.length; i++) {
135
+ var track = loadedTracks[i];
136
+ var el = document.querySelector('[data-agent-native-node-id="' + track.targetNodeId + '"]');
137
+ if (!el) continue;
138
+ var value = interpolate(track.keyframes, t);
139
+ if (value === '') continue;
140
+ el.style[track.property] = value;
141
+ if (!touchedProps[track.targetNodeId]) touchedProps[track.targetNodeId] = [];
142
+ if (touchedProps[track.targetNodeId].indexOf(track.property) === -1) {
143
+ touchedProps[track.targetNodeId].push(track.property);
144
+ }
145
+ }
146
+ }
147
+
148
+ function clearPreview() {
149
+ var nodeIds = Object.keys(touchedProps);
150
+ for (var i = 0; i < nodeIds.length; i++) {
151
+ var el = document.querySelector('[data-agent-native-node-id="' + nodeIds[i] + '"]');
152
+ if (!el) continue;
153
+ var props = touchedProps[nodeIds[i]];
154
+ for (var j = 0; j < props.length; j++) {
155
+ el.style[props[j]] = '';
156
+ }
157
+ }
158
+ touchedProps = {};
159
+ loadedTracks = [];
160
+ }
161
+
162
+ window.addEventListener('message', function(e) {
163
+ if (e.source !== window.parent) return;
164
+ if (!e.data || typeof e.data.type !== 'string') return;
165
+ if (e.data.type === 'motion-load-tracks') {
166
+ loadedTracks = Array.isArray(e.data.tracks) ? e.data.tracks : [];
167
+ touchedProps = {};
168
+ return;
169
+ }
170
+ if (e.data.type === 'motion-preview') {
171
+ var t = Number(e.data.t);
172
+ if (!Number.isFinite(t)) return;
173
+ t = Math.max(0, Math.min(1, t));
174
+ applyPreview(t);
175
+ return;
176
+ }
177
+ if (e.data.type === 'motion-preview-clear') {
178
+ clearPreview();
179
+ return;
180
+ }
181
+ });
182
+ })();
183
+ </script>
184
+ `;
185
+
186
+ /**
187
+ * Shader-fill preview bridge. ALWAYS injected alongside the other bridge
188
+ * scripts so the parent can apply a CSS gradient approximation of a shader
189
+ * fill to the currently-selected element **without** persisting anything.
190
+ *
191
+ * Protocol (parent → iframe):
192
+ *
193
+ * { type: 'shader-fill-preview', selector, nodeId, css }
194
+ * Apply `css` as the `background` inline style on the first element that
195
+ * matches `selector` (preferred) or `[data-agent-native-node-id="nodeId"]`.
196
+ * When both are absent, targets `document.body`. Stores the previous
197
+ * background value so it can be restored on clear.
198
+ * Preview-only — never writes to DB, Yjs, or source files.
199
+ *
200
+ * { type: 'shader-fill-preview-clear' }
201
+ * Remove the applied background override and restore the previous value.
202
+ * Called when the user discards the preview or switches selections.
203
+ */
204
+ const SHADER_FILL_PREVIEW_BRIDGE_SCRIPT = `
205
+ <script data-agent-native-shader-fill-preview-bridge>
206
+ (function() {
207
+ // Track the element we patched and its original background so we can undo.
208
+ var patchedEl = null;
209
+ var originalBackground = '';
210
+
211
+ function resolveTarget(selector, nodeId) {
212
+ if (selector) {
213
+ try {
214
+ var hit = document.querySelector(selector);
215
+ if (hit) return hit;
216
+ } catch (_err) {}
217
+ }
218
+ if (nodeId) {
219
+ var byId = document.querySelector('[data-agent-native-node-id="' + nodeId.replace(/"/g, '\\\\"') + '"]');
220
+ if (byId) return byId;
221
+ }
222
+ return document.body;
223
+ }
224
+
225
+ function applyPreview(selector, nodeId, css) {
226
+ // Clear any prior patch first so we don't stack patches.
227
+ clearPreview();
228
+ var el = resolveTarget(selector, nodeId);
229
+ if (!el) return;
230
+ originalBackground = el.style.background || '';
231
+ el.style.background = css || '';
232
+ patchedEl = el;
233
+ }
234
+
235
+ function clearPreview() {
236
+ if (!patchedEl) return;
237
+ patchedEl.style.background = originalBackground;
238
+ patchedEl = null;
239
+ originalBackground = '';
240
+ }
241
+
242
+ window.addEventListener('message', function(e) {
243
+ if (e.source !== window.parent) return;
244
+ if (!e.data || typeof e.data.type !== 'string') return;
245
+ if (e.data.type === 'shader-fill-preview') {
246
+ var selector = typeof e.data.selector === 'string' ? e.data.selector : '';
247
+ var nodeId = typeof e.data.nodeId === 'string' ? e.data.nodeId : '';
248
+ var css = typeof e.data.css === 'string' ? e.data.css : '';
249
+ applyPreview(selector, nodeId, css);
250
+ return;
251
+ }
252
+ if (e.data.type === 'shader-fill-preview-clear') {
253
+ clearPreview();
254
+ return;
255
+ }
256
+ });
257
+ })();
258
+ </script>
259
+ `;
260
+
18
261
  /**
19
262
  * Tweak-bridge script. ALWAYS injected so the parent's postMessage
20
263
  * (`tweak-values`) can update CSS custom properties on the iframe's :root
@@ -260,7 +503,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
260
503
  var chromeTransitionStyle = document.createElement('style');
261
504
  chromeTransitionStyle.textContent =
262
505
  '[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}';
506
+ '[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}' +
507
+ '[data-agent-native-spacing-line]{position:absolute;display:none;pointer-events:none;border-radius:999px}' +
508
+ '[data-agent-native-spacing-region]{position:absolute;display:none;box-sizing:border-box;pointer-events:auto;background-size:6px 6px}' +
509
+ '[data-agent-native-spacing-region][data-orientation="vertical"]{cursor:ew-resize}' +
510
+ '[data-agent-native-spacing-region][data-orientation="horizontal"]{cursor:ns-resize}';
264
511
  (document.head || document.documentElement).appendChild(chromeTransitionStyle);
265
512
  })();
266
513
 
@@ -484,6 +731,38 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
484
731
  reason: 'Parent layout context decides whether movement means gap, order, alignment, or wrapper structure.',
485
732
  });
486
733
  }
734
+ // --- provenance: read source-location attributes when present ---
735
+ // These are emitted by connected apps via @vitejs/plugin-react jsxDEV or a
736
+ // Babel source plugin. Cross-origin localhost iframes cannot be read (CSP /
737
+ // same-origin policy), so this will be undefined in that case — expected.
738
+ var provenance = undefined;
739
+ var dataSourceFile = el.getAttribute('data-source-file');
740
+ var dataSourceLine = el.getAttribute('data-source-line');
741
+ var dataSourceColumn = el.getAttribute('data-source-column');
742
+ var dataComponentName = el.getAttribute('data-component-name');
743
+ var dataLoc = el.getAttribute('data-loc');
744
+ // data-loc may encode "file:line:col" (Babel source plugin convention).
745
+ // Only parse it when data-source-file is absent, to avoid double-reads.
746
+ if (!dataSourceFile && dataLoc) {
747
+ var lastColonIndex = dataLoc.lastIndexOf(':');
748
+ var lastPart = lastColonIndex >= 0 ? dataLoc.slice(lastColonIndex + 1) : '';
749
+ if (lastColonIndex >= 0 && /^\\d+$/.test(lastPart)) {
750
+ var beforeLastPart = dataLoc.slice(0, lastColonIndex);
751
+ var previousColonIndex = beforeLastPart.lastIndexOf(':');
752
+ var previousPart = previousColonIndex >= 0 ? beforeLastPart.slice(previousColonIndex + 1) : '';
753
+ var hasColumn = /^\\d+$/.test(previousPart);
754
+ dataSourceFile = hasColumn ? beforeLastPart.slice(0, previousColonIndex) : beforeLastPart;
755
+ dataSourceLine = hasColumn ? previousPart : lastPart;
756
+ if (hasColumn) dataSourceColumn = lastPart;
757
+ }
758
+ }
759
+ if (dataSourceFile || dataSourceLine || dataSourceColumn || dataComponentName) {
760
+ provenance = {};
761
+ if (dataSourceFile) provenance.sourceFile = dataSourceFile;
762
+ if (dataSourceLine) { var ln = parseInt(dataSourceLine, 10); if (!isNaN(ln)) provenance.line = ln; }
763
+ if (dataSourceColumn) { var col = parseInt(dataSourceColumn, 10); if (!isNaN(col)) provenance.column = col; }
764
+ if (dataComponentName) provenance.component = dataComponentName;
765
+ }
487
766
  return {
488
767
  tagName: el.tagName.toLowerCase(),
489
768
  id: el.id || undefined,
@@ -559,6 +838,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
559
838
  confidence: capabilities.reduce(function(best, item) {
560
839
  return Math.max(best, item.confidence || 0);
561
840
  }, 0),
841
+ provenance: provenance,
562
842
  };
563
843
  }
564
844
 
@@ -635,10 +915,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
635
915
  if (pos.indexOf('e') !== -1) rotate.style.right = '-26px';
636
916
  selectionOverlay.appendChild(rotate);
637
917
  });
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);
918
+ var spacingOverlay = document.createElement('div');
919
+ spacingOverlay.setAttribute('data-agent-native-spacing-overlay', '');
920
+ spacingOverlay.style.cssText = 'position:absolute;inset:0;display:none;pointer-events:none;';
921
+ selectionOverlay.appendChild(spacingOverlay);
642
922
  document.body.appendChild(selectionOverlay);
643
923
 
644
924
  var transformBadge = document.createElement('div');
@@ -646,6 +926,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
646
926
  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
927
  document.body.appendChild(transformBadge);
648
928
 
929
+ var spacingBadge = document.createElement('div');
930
+ spacingBadge.setAttribute('data-agent-native-spacing-badge', '');
931
+ 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);';
932
+ document.body.appendChild(spacingBadge);
933
+
649
934
  var insertionGuide = document.createElement('div');
650
935
  insertionGuide.setAttribute('data-agent-native-insertion-guide', '');
651
936
  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 +941,116 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
656
941
  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
942
  document.body.appendChild(measurementOverlay);
658
943
 
944
+ // Component-instance tag: a small pill that floats above the selection
945
+ // outline whenever the selected element carries a data-agent-native-component
946
+ // attribute. Clicking it sends a 'component-source-jump' message to the
947
+ // parent so the editor can invoke open-component-source.
948
+ var componentTagOverlay = document.createElement('div');
949
+ componentTagOverlay.setAttribute('data-agent-native-edit-overlay', 'component-tag');
950
+ componentTagOverlay.style.cssText = [
951
+ 'position:fixed',
952
+ 'z-index:100002',
953
+ 'display:none',
954
+ 'pointer-events:auto',
955
+ 'cursor:pointer',
956
+ 'padding:2px 6px',
957
+ 'border-radius:4px',
958
+ 'font:11px/1.6 ui-sans-serif,system-ui,sans-serif',
959
+ 'white-space:nowrap',
960
+ 'user-select:none',
961
+ '-webkit-user-select:none',
962
+ 'background:var(--design-editor-accent-color)',
963
+ 'color:var(--design-editor-accent-contrast-color)',
964
+ 'box-shadow:0 1px 4px color-mix(in srgb,var(--design-editor-accent-color) 40%,transparent)',
965
+ 'border:1px solid color-mix(in srgb,var(--design-editor-accent-strong-color) 60%,transparent)',
966
+ 'outline:2px solid transparent',
967
+ 'transition:opacity 0.1s',
968
+ ].join(';') + ';';
969
+ document.body.appendChild(componentTagOverlay);
970
+
971
+ componentTagOverlay.addEventListener('click', function(e) {
972
+ e.stopPropagation();
973
+ e.preventDefault();
974
+ var nodeId = componentTagOverlay.getAttribute('data-component-node-id') || '';
975
+ var componentName = componentTagOverlay.getAttribute('data-component-name') || '';
976
+ if (!nodeId || !componentName) return;
977
+ try {
978
+ window.parent.postMessage({
979
+ type: 'component-source-jump',
980
+ nodeId: nodeId,
981
+ componentName: componentName,
982
+ }, '*');
983
+ } catch (_err) {}
984
+ });
985
+
986
+ function updateComponentTag(el) {
987
+ if (!el) {
988
+ clearComponentTag();
989
+ return;
990
+ }
991
+ var compName = el.getAttribute && el.getAttribute('data-agent-native-component');
992
+ if (!compName) {
993
+ clearComponentTag();
994
+ return;
995
+ }
996
+ var nodeId = (
997
+ el.getAttribute('data-agent-native-node-id') ||
998
+ el.getAttribute('data-code-layer-id') ||
999
+ el.getAttribute('data-layer-id') ||
1000
+ el.id ||
1001
+ ''
1002
+ );
1003
+ componentTagOverlay.textContent = compName + ' →';
1004
+ componentTagOverlay.setAttribute('data-component-node-id', nodeId);
1005
+ componentTagOverlay.setAttribute('data-component-name', compName);
1006
+
1007
+ var rect = el.getBoundingClientRect();
1008
+ var tagHeight = 22;
1009
+ var tagTop = rect.top - tagHeight - 4;
1010
+ if (tagTop < 4) tagTop = rect.top + 4;
1011
+ componentTagOverlay.style.display = 'block';
1012
+ componentTagOverlay.style.left = rect.left + 'px';
1013
+ componentTagOverlay.style.top = tagTop + 'px';
1014
+ // Accent outline on the selection overlay to distinguish component roots.
1015
+ selectionOverlay.style.outline = '2px solid var(--design-editor-accent-strong-color)';
1016
+ selectionOverlay.style.outlineOffset = '2px';
1017
+ }
1018
+
1019
+ function clearComponentTag() {
1020
+ componentTagOverlay.style.display = 'none';
1021
+ componentTagOverlay.removeAttribute('data-component-node-id');
1022
+ componentTagOverlay.removeAttribute('data-component-name');
1023
+ selectionOverlay.style.outline = '';
1024
+ selectionOverlay.style.outlineOffset = '';
1025
+ }
1026
+
659
1027
  var selectedEl = null;
660
1028
  var hoveredEl = null;
661
1029
  var activeTextEditEl = null;
662
1030
  var textEditPointerState = null;
663
1031
  var pendingStructureMove = null;
1032
+ var pendingShieldDrag = null;
1033
+ var suppressNextShieldClick = false;
1034
+ var suppressNextShieldClickTimer = null;
1035
+ var selectedSpacingHovered = false;
1036
+ var hoveredSpacingHandleKey = '';
1037
+ var spacingHandleStateByKey = {};
1038
+ var spacingHandleNodesByKey = {};
1039
+ var spacingDrag = null;
664
1040
  var lockedSelectors = [];
665
1041
  var hiddenSelectors = [];
666
1042
 
667
1043
  function clearRuntimeSelection() {
668
1044
  selectedEl = null;
669
1045
  hoveredEl = null;
1046
+ selectedSpacingHovered = false;
1047
+ hoveredSpacingHandleKey = '';
1048
+ spacingDrag = null;
670
1049
  selectionOverlay.style.display = 'none';
671
1050
  highlightOverlay.style.display = 'none';
1051
+ hideSpacingOverlay();
672
1052
  hideMeasurements();
1053
+ clearComponentTag();
673
1054
  }
674
1055
 
675
1056
  function matchesSelectorList(el, selectors) {
@@ -849,22 +1230,365 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
849
1230
  refreshOverlays();
850
1231
  }
851
1232
 
852
- function updatePaddingOverlay(el) {
853
- if (!el) {
854
- paddingOverlay.style.display = 'none';
855
- return;
1233
+ function hideSpacingOverlay() {
1234
+ spacingOverlay.style.display = 'none';
1235
+ spacingOverlay.innerHTML = '';
1236
+ spacingHandleStateByKey = {};
1237
+ spacingHandleNodesByKey = {};
1238
+ if (!spacingDrag) spacingBadge.style.display = 'none';
1239
+ }
1240
+
1241
+ function visibleLayoutChildren(el) {
1242
+ if (!el || !el.children) return [];
1243
+ return Array.prototype.slice.call(el.children).filter(function(child) {
1244
+ if (!child || child.nodeType !== 1 || isOverlayElement(child) || isLayerInteractionBlocked(child)) return false;
1245
+ var cs = window.getComputedStyle(child);
1246
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.position === 'fixed') return false;
1247
+ var rect = child.getBoundingClientRect();
1248
+ return rect.width > 0 && rect.height > 0;
1249
+ });
1250
+ }
1251
+
1252
+ function spacingColor(kind) {
1253
+ return kind === 'gap' ? '#ff4fd8' : 'var(--design-editor-accent-color)';
1254
+ }
1255
+
1256
+ function spacingFill(kind, orientation) {
1257
+ var tint = kind === 'gap' ? 'rgba(255, 79, 216, 0.28)' : 'rgba(46, 168, 255, 0.24)';
1258
+ var stripe = kind === 'gap' ? 'rgba(255, 79, 216, 0.58)' : 'rgba(46, 168, 255, 0.52)';
1259
+ var angle = orientation === 'vertical' ? '135deg' : '45deg';
1260
+ return 'repeating-linear-gradient(' + angle + ', ' + stripe + ' 0 1px, ' + tint + ' 1px 4px, transparent 4px 7px)';
1261
+ }
1262
+
1263
+ function clampSpacingValue(value) {
1264
+ var rounded = Math.round(value);
1265
+ if (!Number.isFinite(rounded)) return 0;
1266
+ return Math.max(0, Math.min(999, rounded));
1267
+ }
1268
+
1269
+ function makeSpacingHandle(config) {
1270
+ var region = config.region;
1271
+ if (!region || region.width <= 0 || region.height <= 0) return null;
1272
+ return {
1273
+ key: config.key,
1274
+ groupKey: config.groupKey || config.key,
1275
+ kind: config.kind,
1276
+ property: config.property,
1277
+ oppositeProperty: config.oppositeProperty || '',
1278
+ side: config.side || '',
1279
+ orientation: config.orientation,
1280
+ value: clampSpacingValue(config.value),
1281
+ region: {
1282
+ x: Math.round(region.x),
1283
+ y: Math.round(region.y),
1284
+ width: Math.max(1, Math.round(region.width)),
1285
+ height: Math.max(1, Math.round(region.height)),
1286
+ },
1287
+ line: config.line,
1288
+ };
1289
+ }
1290
+
1291
+ function childLocalRect(child, containerRect) {
1292
+ var rect = child.getBoundingClientRect();
1293
+ return {
1294
+ left: rect.left - containerRect.left,
1295
+ top: rect.top - containerRect.top,
1296
+ right: rect.right - containerRect.left,
1297
+ bottom: rect.bottom - containerRect.top,
1298
+ width: rect.width,
1299
+ height: rect.height,
1300
+ };
1301
+ }
1302
+
1303
+ function childRectsOverlap(a, b, axis) {
1304
+ if (axis === 'x') {
1305
+ return Math.max(a.top, b.top) < Math.min(a.bottom, b.bottom);
856
1306
  }
1307
+ return Math.max(a.left, b.left) < Math.min(a.right, b.right);
1308
+ }
1309
+
1310
+ function buildPaddingSpacingHandles(el, rect, cs) {
1311
+ var handles = [];
1312
+ var borderTop = readPx(cs.borderTopWidth);
1313
+ var borderRight = readPx(cs.borderRightWidth);
1314
+ var borderBottom = readPx(cs.borderBottomWidth);
1315
+ var borderLeft = readPx(cs.borderLeftWidth);
1316
+ var paddingTop = readPx(cs.paddingTop);
1317
+ var paddingRight = readPx(cs.paddingRight);
1318
+ var paddingBottom = readPx(cs.paddingBottom);
1319
+ var paddingLeft = readPx(cs.paddingLeft);
1320
+ var sx = chromeScaleX();
1321
+ var sy = chromeScaleY();
1322
+ var line = Math.max(1, chromeLineScale());
1323
+ var hLineWidth = Math.max(8, Math.min(24, rect.width * 0.18)) * sx;
1324
+ var vLineHeight = Math.max(8, Math.min(24, rect.height * 0.18)) * sy;
1325
+ var innerLeft = borderLeft;
1326
+ var innerTop = borderTop;
1327
+ var innerWidth = Math.max(1, rect.width - borderLeft - borderRight);
1328
+ var innerHeight = Math.max(1, rect.height - borderTop - borderBottom);
1329
+ if (paddingTop > 0) {
1330
+ handles.push(makeSpacingHandle({
1331
+ key: 'padding:top',
1332
+ kind: 'padding',
1333
+ property: 'paddingTop',
1334
+ oppositeProperty: 'paddingBottom',
1335
+ side: 'top',
1336
+ orientation: 'horizontal',
1337
+ value: paddingTop,
1338
+ region: { x: innerLeft, y: innerTop, width: innerWidth, height: paddingTop },
1339
+ line: {
1340
+ x: rect.width / 2 - hLineWidth / 2,
1341
+ y: innerTop + paddingTop / 2 - line / 2,
1342
+ width: hLineWidth,
1343
+ height: line,
1344
+ },
1345
+ }));
1346
+ }
1347
+ if (paddingBottom > 0) {
1348
+ handles.push(makeSpacingHandle({
1349
+ key: 'padding:bottom',
1350
+ kind: 'padding',
1351
+ property: 'paddingBottom',
1352
+ oppositeProperty: 'paddingTop',
1353
+ side: 'bottom',
1354
+ orientation: 'horizontal',
1355
+ value: paddingBottom,
1356
+ region: { x: innerLeft, y: rect.height - borderBottom - paddingBottom, width: innerWidth, height: paddingBottom },
1357
+ line: {
1358
+ x: rect.width / 2 - hLineWidth / 2,
1359
+ y: rect.height - borderBottom - paddingBottom / 2 - line / 2,
1360
+ width: hLineWidth,
1361
+ height: line,
1362
+ },
1363
+ }));
1364
+ }
1365
+ if (paddingLeft > 0) {
1366
+ handles.push(makeSpacingHandle({
1367
+ key: 'padding:left',
1368
+ kind: 'padding',
1369
+ property: 'paddingLeft',
1370
+ oppositeProperty: 'paddingRight',
1371
+ side: 'left',
1372
+ orientation: 'vertical',
1373
+ value: paddingLeft,
1374
+ region: { x: innerLeft, y: innerTop, width: paddingLeft, height: innerHeight },
1375
+ line: {
1376
+ x: innerLeft + paddingLeft / 2 - line / 2,
1377
+ y: rect.height / 2 - vLineHeight / 2,
1378
+ width: line,
1379
+ height: vLineHeight,
1380
+ },
1381
+ }));
1382
+ }
1383
+ if (paddingRight > 0) {
1384
+ handles.push(makeSpacingHandle({
1385
+ key: 'padding:right',
1386
+ kind: 'padding',
1387
+ property: 'paddingRight',
1388
+ oppositeProperty: 'paddingLeft',
1389
+ side: 'right',
1390
+ orientation: 'vertical',
1391
+ value: paddingRight,
1392
+ region: { x: rect.width - borderRight - paddingRight, y: innerTop, width: paddingRight, height: innerHeight },
1393
+ line: {
1394
+ x: rect.width - borderRight - paddingRight / 2 - line / 2,
1395
+ y: rect.height / 2 - vLineHeight / 2,
1396
+ width: line,
1397
+ height: vLineHeight,
1398
+ },
1399
+ }));
1400
+ }
1401
+ return handles.filter(Boolean);
1402
+ }
1403
+
1404
+ function buildGapSpacingHandles(el, rect, cs) {
1405
+ var children = visibleLayoutChildren(el);
1406
+ if (children.length < 2) return [];
1407
+ var handles = [];
1408
+ var sx = chromeScaleX();
1409
+ var sy = chromeScaleY();
1410
+ var line = Math.max(1, chromeLineScale());
1411
+ var hLineWidth = 10 * sx;
1412
+ var vLineHeight = 10 * sy;
1413
+ var isFlex = cs.display === 'flex' || cs.display === 'inline-flex';
1414
+ var isGrid = cs.display === 'grid' || cs.display === 'inline-grid';
1415
+ if (!isFlex && !isGrid) return handles;
1416
+ var primaryAxis = isFlex && cs.flexDirection && cs.flexDirection.indexOf('column') === 0 ? 'y' : 'x';
1417
+ var childRects = children.map(function(child) {
1418
+ return childLocalRect(child, rect);
1419
+ });
1420
+
1421
+ function addAxisGaps(axis, property, groupKey) {
1422
+ var cssGap = readPx(cs[property]);
1423
+ if (cssGap <= 0) return;
1424
+ var sorted = childRects.slice().sort(function(a, b) {
1425
+ return axis === 'x' ? a.left - b.left : a.top - b.top;
1426
+ });
1427
+ var count = 0;
1428
+ for (var i = 0; i < sorted.length - 1; i += 1) {
1429
+ var a = sorted[i];
1430
+ var b = sorted[i + 1];
1431
+ if (!childRectsOverlap(a, b, axis)) continue;
1432
+ var gap = axis === 'x' ? b.left - a.right : b.top - a.bottom;
1433
+ if (gap <= 1) continue;
1434
+ if (axis === 'x') {
1435
+ var top = Math.max(a.top, b.top);
1436
+ var bottom = Math.min(a.bottom, b.bottom);
1437
+ var height = Math.max(1, bottom - top);
1438
+ handles.push(makeSpacingHandle({
1439
+ key: groupKey + ':' + count,
1440
+ groupKey: groupKey,
1441
+ kind: 'gap',
1442
+ property: property,
1443
+ orientation: 'vertical',
1444
+ value: cssGap,
1445
+ region: { x: a.right, y: top, width: gap, height: height },
1446
+ line: {
1447
+ x: a.right + gap / 2 - line / 2,
1448
+ y: top + height / 2 - vLineHeight / 2,
1449
+ width: line,
1450
+ height: vLineHeight,
1451
+ },
1452
+ }));
1453
+ } else {
1454
+ var left = Math.max(a.left, b.left);
1455
+ var right = Math.min(a.right, b.right);
1456
+ var width = Math.max(1, right - left);
1457
+ handles.push(makeSpacingHandle({
1458
+ key: groupKey + ':' + count,
1459
+ groupKey: groupKey,
1460
+ kind: 'gap',
1461
+ property: property,
1462
+ orientation: 'horizontal',
1463
+ value: cssGap,
1464
+ region: { x: left, y: a.bottom, width: width, height: gap },
1465
+ line: {
1466
+ x: left + width / 2 - hLineWidth / 2,
1467
+ y: a.bottom + gap / 2 - line / 2,
1468
+ width: hLineWidth,
1469
+ height: line,
1470
+ },
1471
+ }));
1472
+ }
1473
+ count += 1;
1474
+ }
1475
+ }
1476
+
1477
+ if (primaryAxis === 'x') {
1478
+ addAxisGaps('x', 'columnGap', 'gap:column');
1479
+ if (isGrid) addAxisGaps('y', 'rowGap', 'gap:row');
1480
+ } else {
1481
+ addAxisGaps('y', 'rowGap', 'gap:row');
1482
+ if (isGrid) addAxisGaps('x', 'columnGap', 'gap:column');
1483
+ }
1484
+ return handles.filter(Boolean);
1485
+ }
1486
+
1487
+ function buildSpacingHandles(el) {
1488
+ if (!el || !document.documentElement.contains(el)) return [];
1489
+ if (Math.abs(currentRotation(el)) > 0.01) return [];
1490
+ var children = visibleLayoutChildren(el);
1491
+ if (children.length === 0) return [];
1492
+ var rect = el.getBoundingClientRect();
1493
+ if (rect.width <= 0 || rect.height <= 0) return [];
857
1494
  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';
1495
+ return buildPaddingSpacingHandles(el, rect, cs).concat(buildGapSpacingHandles(el, rect, cs));
1496
+ }
1497
+
1498
+ function showSpacingBadgeForHandle(handle, value) {
1499
+ if (!selectedEl || !handle) {
1500
+ spacingBadge.style.display = 'none';
1501
+ return;
1502
+ }
1503
+ var rect = selectedEl.getBoundingClientRect();
1504
+ var x = rect.left + handle.region.x + handle.region.width / 2;
1505
+ var y = rect.top + handle.region.y + handle.region.height / 2;
1506
+ spacingBadge.textContent = String(clampSpacingValue(value));
1507
+ spacingBadge.style.display = 'block';
1508
+ spacingBadge.style.background = spacingColor(handle.kind);
1509
+ spacingBadge.style.left = x + 'px';
1510
+ spacingBadge.style.top = y + 'px';
1511
+ spacingBadge.style.transform = 'translate(-50%, -50%)';
1512
+ }
1513
+
1514
+ function renderSpacingHandle(handle, activeGroupKey) {
1515
+ if (!handle) return;
1516
+ spacingHandleStateByKey[handle.key] = handle;
1517
+ var highlighted = Boolean(activeGroupKey && handle.groupKey === activeGroupKey);
1518
+ var lineNode = document.createElement('span');
1519
+ lineNode.setAttribute('data-agent-native-spacing-line', handle.kind);
1520
+ lineNode.style.display = 'block';
1521
+ lineNode.style.left = handle.line.x + 'px';
1522
+ lineNode.style.top = handle.line.y + 'px';
1523
+ lineNode.style.width = Math.max(1, handle.line.width) + 'px';
1524
+ lineNode.style.height = Math.max(1, handle.line.height) + 'px';
1525
+ lineNode.style.background = spacingColor(handle.kind);
1526
+ spacingOverlay.appendChild(lineNode);
1527
+
1528
+ var regionNode = document.createElement('span');
1529
+ regionNode.setAttribute('data-agent-native-spacing-region', handle.kind);
1530
+ regionNode.setAttribute('data-orientation', handle.orientation);
1531
+ regionNode.setAttribute('data-spacing-key', handle.key);
1532
+ regionNode.style.display = 'block';
1533
+ regionNode.style.left = handle.region.x + 'px';
1534
+ regionNode.style.top = handle.region.y + 'px';
1535
+ regionNode.style.width = handle.region.width + 'px';
1536
+ regionNode.style.height = handle.region.height + 'px';
1537
+ regionNode.style.background = highlighted ? spacingFill(handle.kind, handle.orientation) : 'transparent';
1538
+ regionNode.style.outline = highlighted ? '1px solid ' + spacingColor(handle.kind) : '0';
1539
+ regionNode.style.outlineOffset = '-1px';
1540
+ regionNode.addEventListener('mouseenter', function() {
1541
+ hoveredSpacingHandleKey = handle.key;
1542
+ selectedSpacingHovered = true;
1543
+ updateSpacingOverlay(selectedEl);
1544
+ });
1545
+ regionNode.addEventListener('mouseleave', function() {
1546
+ if (spacingDrag) return;
1547
+ hoveredSpacingHandleKey = '';
1548
+ updateSpacingOverlay(selectedEl);
1549
+ });
1550
+ regionNode.addEventListener('mousedown', function(event) {
1551
+ startSpacingDrag(handle.key, event);
1552
+ }, true);
1553
+ spacingHandleNodesByKey[handle.key] = regionNode;
1554
+ spacingOverlay.appendChild(regionNode);
1555
+ }
1556
+
1557
+ function updateSpacingOverlay(el) {
1558
+ if (el && el !== selectedEl) {
1559
+ hideSpacingOverlay();
1560
+ return;
1561
+ }
1562
+ if (!selectedEl || !document.documentElement.contains(selectedEl)) {
1563
+ hideSpacingOverlay();
1564
+ return;
1565
+ }
1566
+ if (!selectedSpacingHovered && !hoveredSpacingHandleKey && !spacingDrag) {
1567
+ hideSpacingOverlay();
1568
+ return;
1569
+ }
1570
+ var handles = buildSpacingHandles(selectedEl);
1571
+ if (handles.length === 0) {
1572
+ hideSpacingOverlay();
1573
+ return;
1574
+ }
1575
+ spacingOverlay.style.display = 'block';
1576
+ spacingOverlay.innerHTML = '';
1577
+ spacingHandleStateByKey = {};
1578
+ spacingHandleNodesByKey = {};
1579
+ var activeHandle =
1580
+ (spacingDrag && spacingDrag.handle) ||
1581
+ handles.find(function(handle) { return handle.key === hoveredSpacingHandleKey; }) ||
1582
+ null;
1583
+ var activeGroupKey = activeHandle ? activeHandle.groupKey : '';
1584
+ handles.forEach(function(handle) {
1585
+ renderSpacingHandle(handle, activeGroupKey);
1586
+ });
1587
+ if (activeHandle) {
1588
+ showSpacingBadgeForHandle(activeHandle, spacingDrag ? spacingDrag.currentValue : activeHandle.value);
1589
+ } else {
1590
+ spacingBadge.style.display = 'none';
1591
+ }
868
1592
  }
869
1593
 
870
1594
  function applyEditorChromeScale() {
@@ -874,7 +1598,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
874
1598
  var line = chromeLineScale();
875
1599
  highlightOverlay.style.borderWidth = (1.5 * line) + 'px';
876
1600
  selectionOverlay.style.borderWidth = (1.5 * line) + 'px';
877
- paddingOverlay.style.borderWidth = Math.max(1, 1 * line) + 'px';
1601
+ if (selectedEl) updateSpacingOverlay(selectedEl);
878
1602
 
879
1603
  selectionOverlay.querySelectorAll('[data-agent-native-edge-handle]').forEach(function(edge) {
880
1604
  var pos = edge.getAttribute('data-agent-native-edge-handle');
@@ -913,6 +1637,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
913
1637
  function positionOverlay(overlay, el) {
914
1638
  if (!el || !document.documentElement.contains(el)) {
915
1639
  overlay.style.display = 'none';
1640
+ if (overlay === selectionOverlay) clearComponentTag();
916
1641
  return;
917
1642
  }
918
1643
  // For the selection overlay, prefer the CSS box + rotation transform so
@@ -936,7 +1661,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
936
1661
  overlay.style.height = elH + 'px';
937
1662
  overlay.style.transform = 'rotate(' + elRot + 'deg)';
938
1663
  overlay.style.transformOrigin = '0 0';
939
- updatePaddingOverlay(el);
1664
+ updateSpacingOverlay(el);
1665
+ updateComponentTag(el);
940
1666
  return;
941
1667
  }
942
1668
  }
@@ -947,7 +1673,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
947
1673
  overlay.style.width = rect.width + 'px';
948
1674
  overlay.style.height = rect.height + 'px';
949
1675
  overlay.style.transform = '';
950
- if (overlay === selectionOverlay) updatePaddingOverlay(el);
1676
+ if (overlay === selectionOverlay) {
1677
+ updateSpacingOverlay(el);
1678
+ updateComponentTag(el);
1679
+ }
951
1680
  }
952
1681
 
953
1682
  function refreshOverlays() {
@@ -1026,6 +1755,13 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1026
1755
  );
1027
1756
  }
1028
1757
 
1758
+ function dragEventNames(e) {
1759
+ var pointerGesture = e && e.type && e.type.indexOf('pointer') === 0;
1760
+ return pointerGesture
1761
+ ? { move: 'pointermove', up: 'pointerup' }
1762
+ : { move: 'mousemove', up: 'mouseup' };
1763
+ }
1764
+
1029
1765
  function elementFromEditorPoint(clientX, clientY) {
1030
1766
  var shieldPointerEvents = shieldOverlay.style.pointerEvents;
1031
1767
  var selectionPointerEvents = selectionOverlay.style.pointerEvents;
@@ -1190,6 +1926,14 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1190
1926
  function selectElementAtEvent(e) {
1191
1927
  stopNativeInteraction(e);
1192
1928
  blurActiveTextEditor();
1929
+ if (suppressNextShieldClick) {
1930
+ suppressNextShieldClick = false;
1931
+ if (suppressNextShieldClickTimer !== null) {
1932
+ clearTimeout(suppressNextShieldClickTimer);
1933
+ suppressNextShieldClickTimer = null;
1934
+ }
1935
+ return;
1936
+ }
1193
1937
  // Suppress the first click in a double-click sequence — the dblclick
1194
1938
  // handler (beginTextEditingFromEvent) will fire immediately after and a
1195
1939
  // spurious element-select would cause inspector flicker.
@@ -1197,23 +1941,37 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1197
1941
  var target = elementFromEditorPoint(e.clientX, e.clientY);
1198
1942
  if (!target || target === document.body || target === document.documentElement) {
1199
1943
  // Click on empty canvas: clear the current selection (matches Figma).
1200
- selectedEl = null;
1201
- selectionOverlay.style.display = 'none';
1944
+ clearRuntimeSelection();
1202
1945
  window.parent.postMessage({ type: 'clear-selection' }, '*');
1203
1946
  return;
1204
1947
  }
1948
+ selectedSpacingHovered = false;
1949
+ hoveredSpacingHandleKey = '';
1205
1950
  selectedEl = selectionTargetForHit(target);
1206
1951
  var info = getElementInfo(selectedEl);
1207
1952
  positionOverlay(selectionOverlay, selectedEl);
1208
1953
  window.parent.postMessage({ type: 'element-select', payload: info }, '*');
1209
1954
  }
1210
1955
 
1956
+ function suppressNextShieldClickBriefly() {
1957
+ suppressNextShieldClick = true;
1958
+ if (suppressNextShieldClickTimer !== null) {
1959
+ clearTimeout(suppressNextShieldClickTimer);
1960
+ }
1961
+ suppressNextShieldClickTimer = setTimeout(function() {
1962
+ suppressNextShieldClick = false;
1963
+ suppressNextShieldClickTimer = null;
1964
+ }, 250);
1965
+ }
1966
+
1211
1967
  function openContextMenuAtEvent(e) {
1212
1968
  stopNativeInteraction(e);
1213
1969
  blurActiveTextEditor();
1214
1970
  var target = elementFromEditorPoint(e.clientX, e.clientY);
1215
1971
  var info = null;
1216
1972
  if (target) {
1973
+ selectedSpacingHovered = false;
1974
+ hoveredSpacingHandleKey = '';
1217
1975
  selectedEl = selectionTargetForHit(target);
1218
1976
  info = getElementInfo(selectedEl);
1219
1977
  positionOverlay(selectionOverlay, selectedEl);
@@ -1319,6 +2077,81 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1319
2077
  }, '*');
1320
2078
  }
1321
2079
 
2080
+ function spacingValueFromPointer(handle, originValue, startX, startY, clientX, clientY) {
2081
+ var delta =
2082
+ handle.orientation === 'vertical' ? clientX - startX : clientY - startY;
2083
+ if (handle.kind === 'padding' && (handle.side === 'right' || handle.side === 'bottom')) {
2084
+ delta = -delta;
2085
+ }
2086
+ return clampSpacingValue(originValue + delta);
2087
+ }
2088
+
2089
+ function applySpacingDragValue(target, handle, value, mirrorOpposite) {
2090
+ if (!target || !handle) return;
2091
+ target.style[handle.property] = value + 'px';
2092
+ if (handle.kind === 'padding' && mirrorOpposite && handle.oppositeProperty) {
2093
+ target.style[handle.oppositeProperty] = value + 'px';
2094
+ }
2095
+ }
2096
+
2097
+ function startSpacingDrag(key, e) {
2098
+ var handle = spacingHandleStateByKey[key];
2099
+ if (!selectedEl || !handle || isLayerInteractionBlocked(selectedEl)) return;
2100
+ e.preventDefault();
2101
+ e.stopPropagation();
2102
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2103
+ var events = dragEventNames(e);
2104
+ var dragEl = selectedEl;
2105
+ var originValue = handle.value;
2106
+ var startX = e.clientX;
2107
+ var startY = e.clientY;
2108
+ hoveredSpacingHandleKey = key;
2109
+ selectedSpacingHovered = true;
2110
+ spacingDrag = {
2111
+ handle: handle,
2112
+ currentValue: originValue,
2113
+ mirrorOpposite: !!e.altKey,
2114
+ };
2115
+ showSpacingBadgeForHandle(handle, originValue);
2116
+
2117
+ function onMove(ev) {
2118
+ if (!dragEl || !document.documentElement.contains(dragEl)) return;
2119
+ var nextValue = spacingValueFromPointer(handle, originValue, startX, startY, ev.clientX, ev.clientY);
2120
+ spacingDrag = {
2121
+ handle: handle,
2122
+ currentValue: nextValue,
2123
+ mirrorOpposite: !!ev.altKey,
2124
+ };
2125
+ applySpacingDragValue(dragEl, handle, nextValue, !!ev.altKey);
2126
+ positionOverlay(selectionOverlay, dragEl);
2127
+ showSpacingBadgeForHandle(handle, nextValue);
2128
+ }
2129
+
2130
+ function onUp(ev) {
2131
+ document.removeEventListener(events.move, onMove, true);
2132
+ document.removeEventListener(events.up, onUp, true);
2133
+ if (!dragEl || !document.documentElement.contains(dragEl)) {
2134
+ spacingDrag = null;
2135
+ return;
2136
+ }
2137
+ var finalValue = spacingDrag ? spacingDrag.currentValue : originValue;
2138
+ var mirrorOpposite = spacingDrag ? spacingDrag.mirrorOpposite : !!ev.altKey;
2139
+ applySpacingDragValue(dragEl, handle, finalValue, mirrorOpposite);
2140
+ selectedEl = dragEl;
2141
+ spacingDrag = null;
2142
+ var styles = {};
2143
+ styles[handle.property] = finalValue + 'px';
2144
+ if (handle.kind === 'padding' && mirrorOpposite && handle.oppositeProperty) {
2145
+ styles[handle.oppositeProperty] = finalValue + 'px';
2146
+ }
2147
+ postVisualStyleChange(styles);
2148
+ positionOverlay(selectionOverlay, dragEl);
2149
+ }
2150
+
2151
+ document.addEventListener(events.move, onMove, true);
2152
+ document.addEventListener(events.up, onUp, true);
2153
+ }
2154
+
1322
2155
  function postTextContentChange(el, value, html) {
1323
2156
  window.parent.postMessage({
1324
2157
  type: 'text-content-change',
@@ -1404,7 +2237,14 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1404
2237
  }
1405
2238
 
1406
2239
  function isOverlayElement(el) {
1407
- return Boolean(el && el.getAttribute && el.getAttribute('data-agent-native-edit-overlay'));
2240
+ // Use closest() so that children of overlay elements (e.g. spacing-region
2241
+ // spans inside selectionOverlay that have pointer-events:auto via a CSS rule
2242
+ // and can therefore still be returned by elementFromPoint even when the
2243
+ // parent overlay has pointer-events:none set inline) are also treated as
2244
+ // overlay elements and never used as drag-drop anchor targets.
2245
+ return Boolean(
2246
+ el && el.closest && el.closest('[data-agent-native-edit-overlay]')
2247
+ );
1408
2248
  }
1409
2249
 
1410
2250
  function draggableElementChildren(parent) {
@@ -1418,9 +2258,40 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1418
2258
  if (el === document.body || el === document.documentElement) return false;
1419
2259
  var cs = window.getComputedStyle(el);
1420
2260
  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;
2261
+ return true;
2262
+ }
2263
+
2264
+ // keep in sync with EditPanel.tsx CONTAINER_TAGS/LEAF_TAGS (~line 1679)
2265
+ var BRIDGE_CONTAINER_TAGS = ['div', 'section', 'main', 'header', 'footer', 'nav', 'article', 'aside', 'form', 'ul', 'ol', 'figure', 'fieldset', 'details', 'dialog', 'blockquote', 'table', 'tbody', 'thead', 'tr'];
2266
+ var BRIDGE_LEAF_TAGS = ['img', 'video', 'picture', 'audio', 'canvas', 'svg', 'path', 'input', 'textarea', 'select', 'br', 'hr', 'iframe'];
2267
+ var BRIDGE_TEXT_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'a', 'strong', 'em', 'label', 'li'];
2268
+
2269
+ function isContainerDropTarget(el) {
2270
+ if (!el || el === document.documentElement) return false;
2271
+ if (isOverlayElement(el) || isLayerInteractionBlocked(el)) return false;
2272
+ if (el === document.body) return true;
2273
+ var tag = (el.tagName || '').toLowerCase();
2274
+ // Reject leaf/text tags — they cannot accept children
2275
+ if (BRIDGE_LEAF_TAGS.indexOf(tag) !== -1 || BRIDGE_TEXT_TAGS.indexOf(tag) !== -1) return false;
2276
+ var cs = window.getComputedStyle(el);
2277
+ if (
2278
+ cs.display === 'flex' ||
2279
+ cs.display === 'inline-flex' ||
2280
+ cs.display === 'grid' ||
2281
+ cs.display === 'inline-grid'
2282
+ ) {
2283
+ return true;
2284
+ }
2285
+ return BRIDGE_CONTAINER_TAGS.indexOf(tag) !== -1;
2286
+ }
2287
+
2288
+ function edgePlacementForRect(rect, axis, clientX, clientY) {
2289
+ var size = axis === 'x' ? rect.width : rect.height;
2290
+ if (!size) return null;
2291
+ var offset = axis === 'x' ? clientX - rect.left : clientY - rect.top;
2292
+ if (offset < size * 0.22) return 'before';
2293
+ if (offset > size * 0.78) return 'after';
2294
+ return null;
1424
2295
  }
1425
2296
 
1426
2297
  function parentFlowAxis(parent) {
@@ -1445,18 +2316,19 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1445
2316
  var hit = elementFromEditorPoint(clientX, clientY);
1446
2317
  if (
1447
2318
  hit &&
1448
- hit !== document.body &&
1449
2319
  hit !== document.documentElement &&
1450
2320
  hit !== el &&
1451
2321
  !el.contains(hit) &&
1452
- !hit.contains(el) &&
1453
2322
  !isOverlayElement(hit)
1454
2323
  ) {
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) };
2324
+ if (isContainerDropTarget(hit)) {
2325
+ var containerRect = hit.getBoundingClientRect();
2326
+ var edgeAxis = hit.parentElement ? parentFlowAxis(hit.parentElement) : parentFlowAxis(hit);
2327
+ var edgePlacement = edgePlacementForRect(containerRect, edgeAxis, clientX, clientY);
2328
+ if (!edgePlacement) {
2329
+ return { anchor: hit, placement: 'inside', axis: parentFlowAxis(hit) };
2330
+ }
2331
+ return { anchor: hit, placement: edgePlacement, axis: edgeAxis };
1460
2332
  }
1461
2333
  var hitParent = hit.parentElement;
1462
2334
  if (hitParent) {
@@ -1503,6 +2375,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1503
2375
  insertionGuide.style.display = 'block';
1504
2376
  insertionGuide.style.background = 'var(--design-editor-accent-color)';
1505
2377
  insertionGuide.style.border = '0';
2378
+ insertionGuide.style.borderRadius = '999px';
1506
2379
  insertionGuide.style.boxShadow = '0 0 0 1px var(--design-editor-accent-color)';
1507
2380
  if (target.placement === 'inside') {
1508
2381
  insertionGuide.style.left = rect.left + 'px';
@@ -1511,6 +2384,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1511
2384
  insertionGuide.style.height = rect.height + 'px';
1512
2385
  insertionGuide.style.background = 'color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)';
1513
2386
  insertionGuide.style.border = '2px solid var(--design-editor-accent-color)';
2387
+ insertionGuide.style.borderRadius = '4px';
1514
2388
  insertionGuide.style.boxShadow = 'none';
1515
2389
  return;
1516
2390
  }
@@ -1578,6 +2452,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1578
2452
  if (isLayerInteractionBlocked(selectedEl)) return;
1579
2453
  e.preventDefault();
1580
2454
  e.stopPropagation();
2455
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2456
+ var events = dragEventNames(e);
1581
2457
  var originalSelectedEl = selectedEl;
1582
2458
  var duplicatedForDrag = false;
1583
2459
  if (e.altKey && selectedEl !== document.body && selectedEl !== document.documentElement) {
@@ -1595,16 +2471,96 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1595
2471
  var reorderEl = selectedEl;
1596
2472
  var currentTarget = reorderTargetForPoint(reorderEl, e.clientX, e.clientY);
1597
2473
  showInsertionGuideFor(currentTarget);
2474
+ // Cross-screen drag state: true when the pointer is outside this iframe's
2475
+ // viewport bounds. The host frame renders the ghost + highlight and owns
2476
+ // the drop when this is true; the bridge suppresses its in-iframe reorder.
2477
+ var pointerOutsideIframe = false;
2478
+ var reorderSelector = getSelector(reorderEl);
2479
+ var reorderSourceId = getSourceId(reorderEl);
1598
2480
  function onReorderMove(ev) {
1599
- currentTarget = reorderTargetForPoint(reorderEl, ev.clientX, ev.clientY);
1600
- showInsertionGuideFor(currentTarget);
1601
- showTransformBadge(currentTarget ? 'Move layer' : 'Move', ev.clientX, ev.clientY);
2481
+ var vw = window.innerWidth;
2482
+ var vh = window.innerHeight;
2483
+ var cx = ev.clientX;
2484
+ var cy = ev.clientY;
2485
+ var outside = cx < 0 || cy < 0 || cx > vw || cy > vh;
2486
+ pointerOutsideIframe = outside;
2487
+ // Always notify the host frame so it can track the cursor position,
2488
+ // render the ghost, and highlight the target screen.
2489
+ window.parent.postMessage({
2490
+ type: 'agent-native:cross-screen-drag',
2491
+ phase: 'move',
2492
+ selector: reorderSelector,
2493
+ sourceId: reorderSourceId,
2494
+ iframeX: cx,
2495
+ iframeY: cy,
2496
+ viewportW: vw,
2497
+ viewportH: vh,
2498
+ }, '*');
2499
+ if (outside) {
2500
+ // Cursor left this iframe — hide the in-iframe insertion guide so
2501
+ // it does not render while the host shows a cross-screen drop target.
2502
+ hideInsertionGuide();
2503
+ showTransformBadge('Move layer', cx, cy);
2504
+ } else {
2505
+ // Cursor is inside this iframe — use existing in-iframe behavior.
2506
+ currentTarget = reorderTargetForPoint(reorderEl, cx, cy);
2507
+ showInsertionGuideFor(currentTarget);
2508
+ showTransformBadge(currentTarget ? 'Move layer' : 'Move', cx, cy);
2509
+ }
2510
+ }
2511
+ function onReorderEscape() {
2512
+ document.removeEventListener(events.move, onReorderMove, true);
2513
+ document.removeEventListener(events.up, onReorderUp, true);
2514
+ document.removeEventListener('keydown', onReorderKeyDown, true);
2515
+ hideTransformBadge();
2516
+ hideInsertionGuide();
2517
+ window.parent.postMessage({ type: 'agent-native:cross-screen-drag', phase: 'cancel' }, '*');
2518
+ // Revert any clone that was inserted for alt-drag.
2519
+ if (duplicatedForDrag && reorderEl && reorderEl !== originalSelectedEl) {
2520
+ if (reorderEl.parentElement) reorderEl.parentElement.removeChild(reorderEl);
2521
+ selectedEl = originalSelectedEl;
2522
+ positionOverlay(selectionOverlay, selectedEl);
2523
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2524
+ }
2525
+ }
2526
+ function onReorderKeyDown(ev) {
2527
+ if (ev.key === 'Escape') {
2528
+ ev.preventDefault();
2529
+ ev.stopPropagation();
2530
+ if (ev.stopImmediatePropagation) ev.stopImmediatePropagation();
2531
+ onReorderEscape();
2532
+ }
1602
2533
  }
1603
- function onReorderUp() {
1604
- document.removeEventListener('mousemove', onReorderMove, true);
1605
- document.removeEventListener('mouseup', onReorderUp, true);
2534
+ function onReorderUp(ev) {
2535
+ document.removeEventListener(events.move, onReorderMove, true);
2536
+ document.removeEventListener(events.up, onReorderUp, true);
2537
+ document.removeEventListener('keydown', onReorderKeyDown, true);
1606
2538
  hideTransformBadge();
1607
2539
  hideInsertionGuide();
2540
+ var vw = window.innerWidth;
2541
+ var vh = window.innerHeight;
2542
+ var cx = ev ? ev.clientX : 0;
2543
+ var cy = ev ? ev.clientY : 0;
2544
+ var outsideOnDrop = cx < 0 || cy < 0 || cx > vw || cy > vh;
2545
+ // Post the end message so the host can finalize a cross-screen drop.
2546
+ window.parent.postMessage({
2547
+ type: 'agent-native:cross-screen-drag',
2548
+ phase: 'end',
2549
+ selector: reorderSelector,
2550
+ sourceId: reorderSourceId,
2551
+ iframeX: cx,
2552
+ iframeY: cy,
2553
+ viewportW: vw,
2554
+ viewportH: vh,
2555
+ }, '*');
2556
+ // When the pointer is outside this iframe at release, the host owns the
2557
+ // move (cross-screen drop). Do NOT apply the in-iframe reorder so we
2558
+ // avoid a ghost element left in screen A's DOM.
2559
+ // Use outsideOnDrop only — pointerOutsideIframe is stale when the user
2560
+ // briefly exits the iframe and re-enters before releasing. The host
2561
+ // already clears cross-screen state on re-entry so checking the
2562
+ // momentary excursion flag here would wrongly drop the element nowhere.
2563
+ if (outsideOnDrop) return;
1608
2564
  if (!currentTarget) {
1609
2565
  // No valid drop target — clean up the clone if one was inserted so
1610
2566
  // no ghost element is left in the DOM.
@@ -1630,9 +2586,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1630
2586
  applyRuntimeReorder(reorderEl, currentTarget);
1631
2587
  postVisualStructureChange(reorderEl, currentTarget, { prevParent: prevParent, prevNextSibling: prevNextSibling });
1632
2588
  }
1633
- }
1634
- document.addEventListener('mousemove', onReorderMove, true);
1635
- document.addEventListener('mouseup', onReorderUp, true);
2589
+ }
2590
+ document.addEventListener(events.move, onReorderMove, true);
2591
+ document.addEventListener(events.up, onReorderUp, true);
2592
+ document.addEventListener('keydown', onReorderKeyDown, true);
1636
2593
  return;
1637
2594
  }
1638
2595
  ensurePositionable(selectedEl);
@@ -1659,8 +2616,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1659
2616
  refreshOverlays();
1660
2617
  }
1661
2618
  function onUp() {
1662
- document.removeEventListener('mousemove', onMove, true);
1663
- document.removeEventListener('mouseup', onUp, true);
2619
+ document.removeEventListener(events.move, onMove, true);
2620
+ document.removeEventListener(events.up, onUp, true);
1664
2621
  hideTransformBadge();
1665
2622
  if (!dragEl) return;
1666
2623
  if (duplicatedForDrag && !moved) {
@@ -1686,8 +2643,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1686
2643
  }, '*');
1687
2644
  }
1688
2645
  }
1689
- document.addEventListener('mousemove', onMove, true);
1690
- document.addEventListener('mouseup', onUp, true);
2646
+ document.addEventListener(events.move, onMove, true);
2647
+ document.addEventListener(events.up, onUp, true);
1691
2648
  }
1692
2649
 
1693
2650
  function startResize(handle, e) {
@@ -1864,7 +2821,68 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1864
2821
  document.addEventListener('mouseup', onUp, true);
1865
2822
  }
1866
2823
 
2824
+ function clearPendingShieldDrag() {
2825
+ if (!pendingShieldDrag) return;
2826
+ document.removeEventListener(pendingShieldDrag.move, pendingShieldDrag.onMove, true);
2827
+ document.removeEventListener(pendingShieldDrag.up, pendingShieldDrag.onUp, true);
2828
+ pendingShieldDrag = null;
2829
+ }
2830
+
2831
+ function beginPotentialShieldDrag(e) {
2832
+ stopNativeInteraction(e);
2833
+ if (e.button !== 0 || activeTextEditEl) return;
2834
+ var events = dragEventNames(e);
2835
+ var hit = elementFromEditorPoint(e.clientX, e.clientY);
2836
+ if (!hit || hit === document.body || hit === document.documentElement) return;
2837
+ var dragTarget =
2838
+ selectedEl &&
2839
+ document.documentElement.contains(selectedEl) &&
2840
+ selectedEl.contains(hit)
2841
+ ? selectedEl
2842
+ : selectionTargetForHit(hit);
2843
+ if (
2844
+ !dragTarget ||
2845
+ dragTarget === document.body ||
2846
+ dragTarget === document.documentElement ||
2847
+ isLayerInteractionBlocked(dragTarget)
2848
+ ) {
2849
+ return;
2850
+ }
2851
+ var startX = e.clientX;
2852
+ var startY = e.clientY;
2853
+ var didStartDrag = false;
2854
+ function selectDragTarget() {
2855
+ selectedEl = dragTarget;
2856
+ positionOverlay(selectionOverlay, selectedEl);
2857
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2858
+ }
2859
+ function onMove(ev) {
2860
+ if (Math.hypot(ev.clientX - startX, ev.clientY - startY) <= 3) return;
2861
+ clearPendingShieldDrag();
2862
+ didStartDrag = true;
2863
+ selectDragTarget();
2864
+ suppressNextShieldClickBriefly();
2865
+ startMove(ev);
2866
+ }
2867
+ function onUp(ev) {
2868
+ clearPendingShieldDrag();
2869
+ if (didStartDrag) return;
2870
+ if (ev) stopNativeInteraction(ev);
2871
+ selectDragTarget();
2872
+ suppressNextShieldClickBriefly();
2873
+ }
2874
+ clearPendingShieldDrag();
2875
+ pendingShieldDrag = { move: events.move, up: events.up, onMove: onMove, onUp: onUp };
2876
+ document.addEventListener(events.move, onMove, true);
2877
+ document.addEventListener(events.up, onUp, true);
2878
+ }
2879
+
1867
2880
  selectionOverlay.addEventListener('mousedown', function(e) {
2881
+ var spacingKey = e.target && e.target.getAttribute && e.target.getAttribute('data-spacing-key');
2882
+ if (spacingKey) {
2883
+ startSpacingDrag(spacingKey, e);
2884
+ return;
2885
+ }
1868
2886
  var resizeHandle = e.target && e.target.getAttribute && e.target.getAttribute('data-agent-native-edit-handle');
1869
2887
  if (!resizeHandle && e.target && e.target.getAttribute) {
1870
2888
  resizeHandle = e.target.getAttribute('data-agent-native-edge-handle');
@@ -1881,6 +2899,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1881
2899
  startMove(e);
1882
2900
  }, true);
1883
2901
 
2902
+ shieldOverlay.addEventListener('pointerdown', beginPotentialShieldDrag, true);
2903
+
1884
2904
  ['pointerdown','pointerup','mousedown','mouseup','auxclick'].forEach(function(type) {
1885
2905
  shieldOverlay.addEventListener(type, stopNativeInteraction, true);
1886
2906
  });
@@ -2050,10 +3070,25 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2050
3070
  hoveredEl = elementFromEditorPoint(e.clientX, e.clientY);
2051
3071
  if (!hoveredEl) {
2052
3072
  highlightOverlay.style.display = 'none';
3073
+ if (!spacingDrag) {
3074
+ selectedSpacingHovered = false;
3075
+ hoveredSpacingHandleKey = '';
3076
+ updateSpacingOverlay(selectedEl);
3077
+ }
2053
3078
  hideMeasurements();
2054
3079
  return;
2055
3080
  }
2056
3081
  if (hoveredEl && hoveredEl.closest('[data-agent-native-text-editing]')) return;
3082
+ if (!spacingDrag) {
3083
+ selectedSpacingHovered = Boolean(
3084
+ selectedEl &&
3085
+ hoveredEl &&
3086
+ (hoveredEl === selectedEl ||
3087
+ (selectedEl.contains && selectedEl.contains(hoveredEl))),
3088
+ );
3089
+ if (!selectedSpacingHovered) hoveredSpacingHandleKey = '';
3090
+ updateSpacingOverlay(selectedEl);
3091
+ }
2057
3092
  if (hoveredEl === selectedEl) {
2058
3093
  highlightOverlay.style.display = 'none';
2059
3094
  } else {
@@ -2071,6 +3106,11 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2071
3106
  shieldOverlay.addEventListener('pointerleave', function(e) {
2072
3107
  stopNativeInteraction(e);
2073
3108
  hoveredEl = null;
3109
+ if (!spacingDrag) {
3110
+ selectedSpacingHovered = false;
3111
+ hoveredSpacingHandleKey = '';
3112
+ updateSpacingOverlay(selectedEl);
3113
+ }
2074
3114
  highlightOverlay.style.display = 'none';
2075
3115
  hideMeasurements();
2076
3116
  window.parent.postMessage({ type: 'element-hover', payload: null }, '*');
@@ -2130,6 +3170,8 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2130
3170
  } catch (_err) {}
2131
3171
  }
2132
3172
  if (!target) return;
3173
+ selectedSpacingHovered = false;
3174
+ hoveredSpacingHandleKey = '';
2133
3175
  selectedEl = target;
2134
3176
  positionOverlay(selectionOverlay, target);
2135
3177
  if (hoveredEl === selectedEl) highlightOverlay.style.display = 'none';
@@ -2230,6 +3272,41 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
2230
3272
  interface DesignCanvasProps {
2231
3273
  content: string;
2232
3274
  contentKey?: string;
3275
+ /**
3276
+ * The runtime source tier for this canvas.
3277
+ *
3278
+ * - `"inline"` (default) — HTML/Alpine `srcdoc` iframe; same-origin null
3279
+ * origin; all bridge scripts injected by DesignCanvas.
3280
+ * - `"localhost"` — `src=devServerUrl`; dev server is same-origin in most
3281
+ * setups; bridge trust: origin must match parent or be "null".
3282
+ * - `"fusion"` — `src=builderHostedUrl`; cross-origin Builder-hosted app;
3283
+ * bridge trust is relaxed to window-identity only (no origin check) so
3284
+ * the Builder-hosted iframe can communicate with the editor. The sandbox
3285
+ * grants `allow-same-origin` so the Builder app can reach its own resources.
3286
+ *
3287
+ * When omitted, DesignCanvas infers the tier from the content value:
3288
+ * a value that passes `getExternalPreviewUrl` is treated as `"localhost"`;
3289
+ * otherwise `"inline"`. Pass `sourceType="fusion"` explicitly when the
3290
+ * content URL is a Builder-hosted (cross-origin) app so the bridge security
3291
+ * model uses window-identity trust instead of same-origin trust.
3292
+ */
3293
+ sourceType?: "inline" | "localhost" | "fusion";
3294
+ /**
3295
+ * Explicit Builder-hosted app URL for fusion source rendering.
3296
+ *
3297
+ * When `sourceType === "fusion"` and this prop is provided, the iframe uses
3298
+ * this URL as `src` regardless of what `content` contains. This lets the
3299
+ * caller hold the original inline HTML in `content` (for collab/history
3300
+ * purposes) while pointing the canvas at the migrated Builder-hosted app.
3301
+ *
3302
+ * When absent and `sourceType === "fusion"`, the component falls back to
3303
+ * the existing external-URL detection on `content` (i.e. if `content` is
3304
+ * itself a URL it is used as-is, which is the pattern when the branch URL
3305
+ * has been written into the design file content).
3306
+ *
3307
+ * For `"inline"` and `"localhost"` sources this prop is ignored.
3308
+ */
3309
+ fusionUrl?: string;
2233
3310
  zoom: number;
2234
3311
  onZoomChange?: (zoom: number) => void;
2235
3312
  deviceFrame: DeviceFrameType;
@@ -2322,6 +3399,55 @@ interface DesignCanvasProps {
2322
3399
  * opened in a new tab by the iframe itself and never reach this callback.
2323
3400
  */
2324
3401
  onPrototypeNavigate?: (screen: string, href: string) => void;
3402
+ /**
3403
+ * Motion tracks to load into the iframe's motion-preview bridge. Sent via
3404
+ * `motion-load-tracks` whenever this prop changes. When cleared
3405
+ * (`undefined` or `[]`) a `motion-preview-clear` message is sent to remove
3406
+ * any applied preview overrides.
3407
+ *
3408
+ * The MotionDock sends scrub ticks as `{ type: 'motion-preview', t,
3409
+ * durationMs }` directly from its `canvasIframeRef`. DesignCanvas only
3410
+ * needs the tracks so the bridge can interpolate values at each tick.
3411
+ */
3412
+ motionTracks?: MotionTrackWire[];
3413
+ /**
3414
+ * Explicit iframe width in pixels. When provided it overrides the width
3415
+ * derived from `deviceFrame`, enabling per-breakpoint preview (e.g. Mobile
3416
+ * 390 / Tablet 768 / Desktop 1280 side-by-side frames in the overview).
3417
+ * The height still comes from `deviceFrame`; `deviceFrame="none"` keeps
3418
+ * 100% height.
3419
+ */
3420
+ previewWidthPx?: number;
3421
+ /**
3422
+ * Shader-fill CSS preview to apply to a selected element inside the iframe.
3423
+ *
3424
+ * When set, the canvas sends a `shader-fill-preview` bridge message that
3425
+ * applies the CSS `background` value on the target element **without
3426
+ * persisting anything**. When cleared (`null` / `undefined`) a
3427
+ * `shader-fill-preview-clear` message is sent to restore the original
3428
+ * background.
3429
+ *
3430
+ * Preview-only — never writes to DB, Yjs, or source. Part of the §6.7
3431
+ * shader-fill PREVIEW path; the apply path remains gated until runtime
3432
+ * rendering + source-write + diff proof are all in place.
3433
+ */
3434
+ shaderFillPreview?: {
3435
+ /** CSS selector for the target element (preferred over nodeId). */
3436
+ selector?: string;
3437
+ /** data-agent-native-node-id value for the target element. */
3438
+ nodeId?: string;
3439
+ /** The CSS `background` value returned by preview-shader-fill. */
3440
+ css: string;
3441
+ } | null;
3442
+ /**
3443
+ * Called when the user clicks the component-instance source tag (the
3444
+ * "ComponentName →" pill that floats above a selected component root).
3445
+ * The parent should invoke `open-component-source` with these params.
3446
+ */
3447
+ onComponentSourceJump?: (params: {
3448
+ nodeId: string;
3449
+ componentName: string;
3450
+ }) => void;
2325
3451
  }
2326
3452
 
2327
3453
  function getExternalPreviewUrl(content: string): string | null {
@@ -2357,6 +3483,8 @@ export interface IframeContextMenuPayload {
2357
3483
  export function DesignCanvas({
2358
3484
  content,
2359
3485
  contentKey,
3486
+ sourceType,
3487
+ fusionUrl,
2360
3488
  zoom,
2361
3489
  onZoomChange,
2362
3490
  deviceFrame,
@@ -2396,6 +3524,10 @@ export function DesignCanvas({
2396
3524
  commentContextId,
2397
3525
  commentContextLabel,
2398
3526
  onPrototypeNavigate,
3527
+ motionTracks,
3528
+ previewWidthPx,
3529
+ onComponentSourceJump,
3530
+ shaderFillPreview,
2399
3531
  }: DesignCanvasProps) {
2400
3532
  const t = useT();
2401
3533
  const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -2406,10 +3538,25 @@ export function DesignCanvas({
2406
3538
  const [annotationPins, setAnnotationPins] = useState<CanvasPin[]>([]);
2407
3539
  const [pinSubmitSignal, setPinSubmitSignal] = useState(0);
2408
3540
  const isEmbeddedFrame = Boolean(embeddedFrame);
2409
- const externalPreviewUrl = useMemo(
2410
- () => getExternalPreviewUrl(renderedContent),
2411
- [renderedContent],
2412
- );
3541
+ // Resolve the URL to render in the iframe:
3542
+ // 1. When sourceType === "fusion" and fusionUrl is set, prefer the explicit
3543
+ // Builder-hosted URL over whatever is in `content` (which may still be the
3544
+ // original inline HTML).
3545
+ // 2. Otherwise fall back to the content-based URL detection (handles the case
3546
+ // where the branch URL has been written into the design file content, or
3547
+ // where the localhost URL is the file content).
3548
+ const externalPreviewUrl = useMemo(() => {
3549
+ if (sourceType === "fusion" && fusionUrl) {
3550
+ try {
3551
+ const url = new URL(fusionUrl);
3552
+ url.hash = "";
3553
+ return url.toString();
3554
+ } catch {
3555
+ // fall through to content detection below
3556
+ }
3557
+ }
3558
+ return getExternalPreviewUrl(renderedContent);
3559
+ }, [fusionUrl, renderedContent, sourceType]);
2413
3560
  zoomRef.current = zoom;
2414
3561
 
2415
3562
  const queuedAnnotationPins = useMemo(
@@ -2462,6 +3609,8 @@ export function DesignCanvas({
2462
3609
  isEmbeddedFrame ? "true" : "false",
2463
3610
  );
2464
3611
  const bridgeToInject =
3612
+ MOTION_PREVIEW_BRIDGE_SCRIPT +
3613
+ SHADER_FILL_PREVIEW_BRIDGE_SCRIPT +
2465
3614
  TWEAK_BRIDGE_SCRIPT +
2466
3615
  ZOOM_BRIDGE_SCRIPT +
2467
3616
  NAV_BRIDGE_SCRIPT +
@@ -2492,14 +3641,27 @@ export function DesignCanvas({
2492
3641
  // Listen for messages from the iframe
2493
3642
  useEffect(() => {
2494
3643
  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
- ) {
3644
+ const iframeWindow = iframeRef.current?.contentWindow;
3645
+ // For fusion sources the Builder-hosted app is cross-origin, so the strict
3646
+ // `origin === parentOrigin` check can never match. We still require window
3647
+ // identity (the message must come from our own iframe window, not any
3648
+ // arbitrary cross-origin frame), AND we validate the message origin
3649
+ // against a Builder-host allowlist (the configured fusionUrl origin or the
3650
+ // *.builder.io family) before relaxing the origin check. If the origin is
3651
+ // not on the allowlist we keep the strict check so a hostile frame that
3652
+ // somehow shares our window reference still can't be trusted.
3653
+ const trusted =
3654
+ sourceType === "fusion"
3655
+ ? iframeWindow != null &&
3656
+ e.source === iframeWindow &&
3657
+ isAllowedFusionOrigin(e.origin, fusionUrl)
3658
+ : isTrustedCanvasBridgeMessage({
3659
+ source: e.source,
3660
+ origin: e.origin,
3661
+ iframeWindow,
3662
+ parentOrigin: window.location.origin,
3663
+ });
3664
+ if (!trusted) {
2503
3665
  return;
2504
3666
  }
2505
3667
  if (!e.data || !e.data.type) return;
@@ -2540,9 +3702,15 @@ export function DesignCanvas({
2540
3702
  const placement = String(e.data.placement || "after");
2541
3703
  const requestId =
2542
3704
  typeof e.data.requestId === "string" ? e.data.requestId : undefined;
3705
+ const sourceId =
3706
+ typeof e.data.sourceId === "string" ? e.data.sourceId : undefined;
3707
+ const anchorSourceId =
3708
+ typeof e.data.anchorSourceId === "string"
3709
+ ? e.data.anchorSourceId
3710
+ : undefined;
2543
3711
  if (
2544
- selector &&
2545
- anchorSelector &&
3712
+ (selector || sourceId) &&
3713
+ (anchorSelector || anchorSourceId) &&
2546
3714
  (placement === "before" ||
2547
3715
  placement === "after" ||
2548
3716
  placement === "inside")
@@ -2554,14 +3722,8 @@ export function DesignCanvas({
2554
3722
  e.data.payload,
2555
3723
  {
2556
3724
  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,
3725
+ sourceId,
3726
+ anchorSourceId,
2565
3727
  },
2566
3728
  );
2567
3729
  if (requestId) {
@@ -2663,6 +3825,16 @@ export function DesignCanvas({
2663
3825
  );
2664
3826
  return;
2665
3827
  }
3828
+ if (e.data.type === "component-source-jump") {
3829
+ // The user clicked the component-instance tag ("ComponentName →").
3830
+ // Relay to the parent so it can invoke open-component-source.
3831
+ const nodeId = String(e.data.nodeId || "");
3832
+ const componentName = String(e.data.componentName || "");
3833
+ if (nodeId && componentName) {
3834
+ onComponentSourceJump?.({ nodeId, componentName });
3835
+ }
3836
+ return;
3837
+ }
2666
3838
  if (e.data.type === "embedded-canvas-wheel") {
2667
3839
  if (!isEmbeddedFrame) return;
2668
3840
  const iframe = iframeRef.current;
@@ -2748,7 +3920,10 @@ export function DesignCanvas({
2748
3920
  onZoomChange,
2749
3921
  deviceFrame,
2750
3922
  onPrototypeNavigate,
3923
+ onComponentSourceJump,
2751
3924
  isEmbeddedFrame,
3925
+ sourceType,
3926
+ fusionUrl,
2752
3927
  ]);
2753
3928
 
2754
3929
  const replayIframeEditorState = useCallback(() => {
@@ -2786,14 +3961,43 @@ export function DesignCanvas({
2786
3961
  : { type: "hover-element", selector: "", selectorCandidates: [] },
2787
3962
  "*",
2788
3963
  );
3964
+ // Re-send motion tracks so the preview bridge is ready after a reload.
3965
+ if (motionTracks && motionTracks.length > 0) {
3966
+ iframe.contentWindow?.postMessage(
3967
+ { type: "motion-load-tracks", tracks: motionTracks },
3968
+ "*",
3969
+ );
3970
+ } else {
3971
+ iframe.contentWindow?.postMessage({ type: "motion-preview-clear" }, "*");
3972
+ }
3973
+ // Re-apply the shader-fill preview after a reload so the preview survives
3974
+ // screen switches. Preview-only — never writes to DB, Yjs, or source.
3975
+ if (shaderFillPreview) {
3976
+ iframe.contentWindow?.postMessage(
3977
+ {
3978
+ type: "shader-fill-preview",
3979
+ selector: shaderFillPreview.selector ?? "",
3980
+ nodeId: shaderFillPreview.nodeId ?? "",
3981
+ css: shaderFillPreview.css,
3982
+ },
3983
+ "*",
3984
+ );
3985
+ } else {
3986
+ iframe.contentWindow?.postMessage(
3987
+ { type: "shader-fill-preview-clear" },
3988
+ "*",
3989
+ );
3990
+ }
2789
3991
  }, [
2790
3992
  hoveredSelector,
2791
3993
  hoveredSelectorCandidates,
2792
3994
  hiddenSelectors,
2793
3995
  lockedSelectors,
3996
+ motionTracks,
2794
3997
  scaleMode,
2795
3998
  selectedSelector,
2796
3999
  selectedSelectorCandidates,
4000
+ shaderFillPreview,
2797
4001
  tweakValues,
2798
4002
  ]);
2799
4003
 
@@ -2816,6 +4020,44 @@ export function DesignCanvas({
2816
4020
  );
2817
4021
  }, [clearSelectionRequest]);
2818
4022
 
4023
+ // Sync motion tracks to the iframe bridge whenever they change.
4024
+ // When motionTracks is empty/undefined, clear any preview overrides so the
4025
+ // design returns to its authored state (no stale inline styles).
4026
+ useEffect(() => {
4027
+ const win = iframeRef.current?.contentWindow;
4028
+ if (!win) return;
4029
+ if (!motionTracks || motionTracks.length === 0) {
4030
+ win.postMessage({ type: "motion-preview-clear" }, "*");
4031
+ } else {
4032
+ win.postMessage(
4033
+ { type: "motion-load-tracks", tracks: motionTracks },
4034
+ "*",
4035
+ );
4036
+ }
4037
+ }, [motionTracks]);
4038
+
4039
+ // Sync shader-fill preview to the iframe whenever the prop changes.
4040
+ // When cleared (null / undefined) send a clear message so the bridge
4041
+ // restores the original background on the previously-patched element.
4042
+ // Preview-only — never writes to DB, Yjs, or source.
4043
+ useEffect(() => {
4044
+ const win = iframeRef.current?.contentWindow;
4045
+ if (!win) return;
4046
+ if (!shaderFillPreview) {
4047
+ win.postMessage({ type: "shader-fill-preview-clear" }, "*");
4048
+ } else {
4049
+ win.postMessage(
4050
+ {
4051
+ type: "shader-fill-preview",
4052
+ selector: shaderFillPreview.selector ?? "",
4053
+ nodeId: shaderFillPreview.nodeId ?? "",
4054
+ css: shaderFillPreview.css,
4055
+ },
4056
+ "*",
4057
+ );
4058
+ }
4059
+ }, [shaderFillPreview]);
4060
+
2819
4061
  // Push the constant-size chrome scale into the iframe LIVE (CSS vars only) when
2820
4062
  // overview zoom settles. This is intentionally separate from the srcdoc build so
2821
4063
  // a scale change never rebuilds srcdoc / reloads the iframe (which flashes the
@@ -2843,6 +4085,61 @@ export function DesignCanvas({
2843
4085
  [],
2844
4086
  );
2845
4087
 
4088
+ /**
4089
+ * Send a motion-preview scrub tick to the iframe. `t` is the normalised
4090
+ * playhead position in [0, 1]. Tracks must have been loaded first via the
4091
+ * `motionTracks` prop (or an explicit `motion-load-tracks` message).
4092
+ * Preview-only — never writes to DB/Yjs/source.
4093
+ */
4094
+ const sendMotionPreview = useCallback((t: number, durationMs?: number) => {
4095
+ const iframe = iframeRef.current;
4096
+ if (!iframe?.contentWindow) return;
4097
+ iframe.contentWindow.postMessage(
4098
+ { type: "motion-preview", t: Math.max(0, Math.min(1, t)), durationMs },
4099
+ "*",
4100
+ );
4101
+ }, []);
4102
+
4103
+ /**
4104
+ * Clear all motion-preview inline-style overrides in the iframe and remove
4105
+ * the in-memory track list. Call when the Motion dock is closed.
4106
+ */
4107
+ const clearMotionPreview = useCallback(() => {
4108
+ iframeRef.current?.contentWindow?.postMessage(
4109
+ { type: "motion-preview-clear" },
4110
+ "*",
4111
+ );
4112
+ }, []);
4113
+
4114
+ /**
4115
+ * Send a shader-fill CSS preview to the iframe. Targets the element
4116
+ * identified by `selector` (preferred) or `nodeId`. Preview-only — the
4117
+ * bridge script restores the original background on clear.
4118
+ */
4119
+ const sendShaderFillPreview = useCallback(
4120
+ (selector: string, nodeId: string, css: string) => {
4121
+ const win = iframeRef.current?.contentWindow;
4122
+ if (!win) return;
4123
+ win.postMessage(
4124
+ { type: "shader-fill-preview", selector, nodeId, css },
4125
+ "*",
4126
+ );
4127
+ },
4128
+ [],
4129
+ );
4130
+
4131
+ /**
4132
+ * Clear the shader-fill preview in the iframe, restoring the original
4133
+ * background on the patched element. Call when the preview is dismissed
4134
+ * or when the selection changes to a different element.
4135
+ */
4136
+ const clearShaderFillPreview = useCallback(() => {
4137
+ iframeRef.current?.contentWindow?.postMessage(
4138
+ { type: "shader-fill-preview-clear" },
4139
+ "*",
4140
+ );
4141
+ }, []);
4142
+
2846
4143
  const replacePreviewContent = useCallback(
2847
4144
  (nextContent: string, selector?: string | null, candidates?: string[]) => {
2848
4145
  const iframe = iframeRef.current;
@@ -2884,7 +4181,15 @@ export function DesignCanvas({
2884
4181
  (window as any).__designCanvasSendStyle = sendStyleChange;
2885
4182
  (window as any).__designCanvasReplaceContent = replacePreviewContent;
2886
4183
  (window as any).__designCanvasDeleteElement = deleteRuntimeElement;
4184
+ (window as any).__designCanvasSendMotionPreview = sendMotionPreview;
4185
+ (window as any).__designCanvasClearMotionPreview = clearMotionPreview;
4186
+ // Shader-fill preview helpers (preview-only, §6.7 gating applies to apply).
4187
+ (window as any).__designCanvasSendShaderFillPreview = sendShaderFillPreview;
4188
+ (window as any).__designCanvasClearShaderFillPreview =
4189
+ clearShaderFillPreview;
2887
4190
  return () => {
4191
+ // Identity-guard each delete so a stale unmounting instance never clobbers
4192
+ // a freshly mounted instance's bridge during a remount race.
2888
4193
  if ((window as any).__designCanvasSendStyle === sendStyleChange) {
2889
4194
  delete (window as any).__designCanvasSendStyle;
2890
4195
  }
@@ -2898,12 +4203,38 @@ export function DesignCanvas({
2898
4203
  ) {
2899
4204
  delete (window as any).__designCanvasDeleteElement;
2900
4205
  }
4206
+ if (
4207
+ (window as any).__designCanvasSendMotionPreview === sendMotionPreview
4208
+ ) {
4209
+ delete (window as any).__designCanvasSendMotionPreview;
4210
+ }
4211
+ if (
4212
+ (window as any).__designCanvasClearMotionPreview === clearMotionPreview
4213
+ ) {
4214
+ delete (window as any).__designCanvasClearMotionPreview;
4215
+ }
4216
+ if (
4217
+ (window as any).__designCanvasSendShaderFillPreview ===
4218
+ sendShaderFillPreview
4219
+ ) {
4220
+ delete (window as any).__designCanvasSendShaderFillPreview;
4221
+ }
4222
+ if (
4223
+ (window as any).__designCanvasClearShaderFillPreview ===
4224
+ clearShaderFillPreview
4225
+ ) {
4226
+ delete (window as any).__designCanvasClearShaderFillPreview;
4227
+ }
2901
4228
  };
2902
4229
  }, [
2903
4230
  deleteRuntimeElement,
2904
4231
  registerRuntimeBridge,
2905
4232
  replacePreviewContent,
2906
4233
  sendStyleChange,
4234
+ sendMotionPreview,
4235
+ clearMotionPreview,
4236
+ sendShaderFillPreview,
4237
+ clearShaderFillPreview,
2907
4238
  ]);
2908
4239
 
2909
4240
  // Device dimensions match real-world devices. iframes are replaced elements
@@ -2923,6 +4254,12 @@ export function DesignCanvas({
2923
4254
  deviceDimensions[deviceFrame];
2924
4255
  const embeddedFrameFluid = embeddedFrame?.fluid === true;
2925
4256
 
4257
+ // Per-breakpoint override: when previewWidthPx is set it takes priority over
4258
+ // the deviceFrame width so the caller can render the same source at an
4259
+ // explicit viewport width (e.g. 390 / 768 / 1280 side-by-side breakpoints).
4260
+ const resolvedWidth =
4261
+ previewWidthPx != null ? `${previewWidthPx}px` : iframeWidth;
4262
+
2926
4263
  // Wrap the iframe in a positioned container so DrawOverlay /
2927
4264
  // CanvasCommentPins can absolutely-position themselves on top of the
2928
4265
  // iframe. The pin component anchors to `.design-canvas-iframe-wrapper`
@@ -2938,7 +4275,7 @@ export function DesignCanvas({
2938
4275
  ? embeddedFrameFluid
2939
4276
  ? "100%"
2940
4277
  : embeddedFrame.viewportWidth
2941
- : iframeWidth,
4278
+ : resolvedWidth,
2942
4279
  height: embeddedFrame
2943
4280
  ? embeddedFrameFluid
2944
4281
  ? "100%"
@@ -2958,6 +4295,12 @@ export function DesignCanvas({
2958
4295
  : "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
2959
4296
  }
2960
4297
  data-design-preview-iframe
4298
+ data-design-source-type={
4299
+ sourceType ??
4300
+ (externalPreviewUrl
4301
+ ? "localhost" // inferred — content is a URL
4302
+ : "inline")
4303
+ }
2961
4304
  className="block h-full w-full border-0 bg-transparent"
2962
4305
  title={t("designEditor.designPreview")}
2963
4306
  />