@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
@@ -38,20 +38,30 @@ import {
38
38
  type CanvasFrameGeometry,
39
39
  type CanvasFrameGeometryById,
40
40
  } from "@shared/canvas-frames";
41
+ import { resolveSourceCapabilities } from "@shared/capability-resolver";
41
42
  import {
42
43
  applyVisualEdit,
43
44
  buildCodeLayerProjection,
44
45
  buildCodeLayerTree,
45
46
  ensureCodeLayerNodeIdsInHtml,
47
+ moveNodeBetweenDocuments,
46
48
  removeCodeLayerNodeFromHtml,
47
49
  type CodeLayerNode,
50
+ type CodeLayerProjection,
48
51
  type CodeLayerTreeNode,
49
52
  } from "@shared/code-layer";
53
+ import { componentNameFor, isComponentInstance } from "@shared/component-model";
54
+ import {
55
+ DESIGN_CAPABILITY_NAMES,
56
+ hasCapability,
57
+ } from "@shared/design-source-capabilities";
50
58
  import { shouldUseLiveFileContent } from "@shared/html-content";
51
59
  import {
52
60
  resolveTweaksToCssVars,
53
61
  type TweakSelections,
54
62
  } from "@shared/resolve-tweaks";
63
+ import { widthToPrefix } from "@shared/responsive-classes";
64
+ import { normalizeDesignSourceType } from "@shared/source-mode";
55
65
  import {
56
66
  IconArrowLeft,
57
67
  IconArrowUpRight,
@@ -94,6 +104,9 @@ import {
94
104
  IconFileExport,
95
105
  IconPlayerPlay,
96
106
  IconDeviceFloppy,
107
+ IconRocket,
108
+ IconExternalLink,
109
+ IconCircleCheck,
97
110
  IconTerminal2,
98
111
  } from "@tabler/icons-react";
99
112
  import { useQueryClient } from "@tanstack/react-query";
@@ -113,6 +126,7 @@ import { useParams, useNavigate, Link, useLocation } from "react-router";
113
126
  import { toast } from "sonner";
114
127
  import * as Y from "yjs";
115
128
 
129
+ import { canvasPrimitiveVisual } from "@/components/design/canvas-primitive-style";
116
130
  import {
117
131
  CanvasContextMenu,
118
132
  type CanvasContextMenuHandle,
@@ -121,10 +135,15 @@ import {
121
135
  DesignCanvas,
122
136
  type IframeContextMenuPayload,
123
137
  type IframeHotkeyPayload,
138
+ type MotionTrackWire,
124
139
  } from "@/components/design/DesignCanvas";
125
140
  import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton";
126
141
  import type { DesignExtensionSlotContext } from "@/components/design/DesignExtensionsPanel";
127
- import { EditPanel, type InspectorTab } from "@/components/design/EditPanel";
142
+ import {
143
+ EditPanel,
144
+ type InspectCodeData,
145
+ type InspectorTab,
146
+ } from "@/components/design/EditPanel";
128
147
  import type { ExportSettingsValue } from "@/components/design/inspector";
129
148
  import {
130
149
  LayersPanel,
@@ -132,12 +151,18 @@ import {
132
151
  type LayersPanelMoveIntent,
133
152
  type LayersPanelNode,
134
153
  } from "@/components/design/LayersPanel";
154
+ import { LocalSourceEditBanner } from "@/components/design/LocalSourceEditBanner";
155
+ import {
156
+ MotionDock,
157
+ type MotionDockTrack,
158
+ } from "@/components/design/MotionDock";
135
159
  import {
136
160
  MultiScreenCanvas,
137
161
  OVERVIEW_FRAME_WIDTH,
138
162
  type CanvasPrimitiveInsert,
139
163
  } from "@/components/design/MultiScreenCanvas";
140
164
  import { QuestionFlow } from "@/components/design/QuestionFlow";
165
+ import type { ReviewPanelProps } from "@/components/design/ReviewPanel";
141
166
  import type { ElementInfo, DeviceFrameType } from "@/components/design/types";
142
167
  import {
143
168
  DEVICE_FRAME_VIEWPORTS,
@@ -148,6 +173,14 @@ import type { UploadedFile } from "@/components/editor/PromptDialog";
148
173
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
149
174
  import { Button } from "@/components/ui/button";
150
175
  import { Checkbox } from "@/components/ui/checkbox";
176
+ import {
177
+ Dialog,
178
+ DialogContent,
179
+ DialogDescription,
180
+ DialogFooter,
181
+ DialogHeader,
182
+ DialogTitle,
183
+ } from "@/components/ui/dialog";
151
184
  import {
152
185
  DropdownMenu,
153
186
  DropdownMenuContent,
@@ -190,6 +223,8 @@ import {
190
223
  import { prettyScreenName } from "@/lib/screen-names";
191
224
  import { cn } from "@/lib/utils";
192
225
 
226
+ import type { A11yFinding } from "../../shared/design-review.js";
227
+
193
228
  const TAB_ID = generateTabId();
194
229
 
195
230
  // Selection is tab-scoped (like navigation) so a second editor tab cannot
@@ -263,6 +298,32 @@ export function getSelectedScreenIdsForEditorState(args: {
263
298
  return activeFileId ? [activeFileId] : [];
264
299
  }
265
300
 
301
+ function fileIdFromLayerSelectionId(
302
+ layerId: string,
303
+ fileIds: Set<string>,
304
+ ): string | null {
305
+ const normalized = layerId.startsWith("code:")
306
+ ? layerId.slice("code:".length)
307
+ : layerId;
308
+ return fileIds.has(normalized) ? normalized : null;
309
+ }
310
+
311
+ export function getOverviewScreenIdsFromLayerSelection(args: {
312
+ fileIds: string[];
313
+ layerIds: string[];
314
+ }) {
315
+ const fileIds = new Set(args.fileIds);
316
+ const seen = new Set<string>();
317
+ const selectedScreenIds: string[] = [];
318
+ args.layerIds.forEach((layerId) => {
319
+ const fileId = fileIdFromLayerSelectionId(layerId, fileIds);
320
+ if (!fileId || seen.has(fileId)) return;
321
+ seen.add(fileId);
322
+ selectedScreenIds.push(fileId);
323
+ });
324
+ return selectedScreenIds;
325
+ }
326
+
266
327
  export function getOverviewEnterTarget(args: {
267
328
  activeFileId: string | null | undefined;
268
329
  overviewSelectedScreenIds: string[];
@@ -334,6 +395,45 @@ export function getDesignEditorShareUrl(
334
395
  return new URL(pathname, origin).toString();
335
396
  }
336
397
 
398
+ export function getLocalhostRouteSourceFile(args: {
399
+ sourceFile?: string;
400
+ source?: string;
401
+ }): string | undefined {
402
+ if (args.sourceFile?.trim()) return args.sourceFile;
403
+ const raw = args.source;
404
+ if (!raw) return undefined;
405
+ try {
406
+ const parsed = JSON.parse(raw) as unknown;
407
+ if (
408
+ parsed &&
409
+ typeof parsed === "object" &&
410
+ "file" in parsed &&
411
+ typeof (parsed as Record<string, unknown>).file === "string"
412
+ ) {
413
+ return (parsed as Record<string, string>).file;
414
+ }
415
+ } catch {
416
+ if (raw.length > 0) return raw;
417
+ }
418
+ return undefined;
419
+ }
420
+
421
+ export function getLayerMoveSourceContent(args: {
422
+ sourceFileId: string;
423
+ activeFileId?: string | null;
424
+ activeContent: string;
425
+ sourceFileContent?: string;
426
+ sourceContentMap: ReadonlyMap<string, string>;
427
+ }) {
428
+ return (
429
+ args.sourceContentMap.get(args.sourceFileId) ??
430
+ (args.sourceFileId === args.activeFileId
431
+ ? args.activeContent
432
+ : args.sourceFileContent) ??
433
+ ""
434
+ );
435
+ }
436
+
337
437
  function resolveZoomUpdate(update: SetStateAction<number>, current: number) {
338
438
  return typeof update === "function" ? update(current) : update;
339
439
  }
@@ -367,6 +467,27 @@ export function shouldEscapeToOverview(args: {
367
467
  );
368
468
  }
369
469
 
470
+ /**
471
+ * Build the set of all node ids (both projection ids and data-agent-native-node-id
472
+ * attribute values) that exist in the given projection. Used by handleGroupSelection
473
+ * and handleUngroupSelection to filter selectedLayerIdsState to the active file's
474
+ * nodes before passing them to wrapNodes / unwrap, preventing cross-file stale ids
475
+ * from causing spurious "conflict" errors.
476
+ *
477
+ * Exported for unit testing.
478
+ */
479
+ export function buildActiveFileNodeIdSet(
480
+ projection: CodeLayerProjection,
481
+ ): Set<string> {
482
+ const ids = new Set<string>();
483
+ for (const n of projection.nodes) {
484
+ ids.add(n.id);
485
+ const attrId = n.dataAttributes["data-agent-native-node-id"];
486
+ if (attrId) ids.add(attrId);
487
+ }
488
+ return ids;
489
+ }
490
+
370
491
  let html2CanvasColorContext: CanvasRenderingContext2D | null | undefined;
371
492
 
372
493
  interface FileContentSaveRequest {
@@ -603,6 +724,12 @@ interface GeometryHistoryEntry {
603
724
  after: CanvasFrameGeometryById;
604
725
  }
605
726
 
727
+ interface ContentHistoryEntry {
728
+ fileId: string;
729
+ before: string;
730
+ after: string;
731
+ }
732
+
606
733
  type PatchProofStatus =
607
734
  | "runtime"
608
735
  | "queued"
@@ -820,6 +947,44 @@ function escapeHtmlAttributeValue(value: string): string {
820
947
  .replace(/>/g, "&gt;");
821
948
  }
822
949
 
950
+ const ABS_POSITION_PROPS = [
951
+ "position",
952
+ "left",
953
+ "top",
954
+ "right",
955
+ "bottom",
956
+ ] as const;
957
+
958
+ /**
959
+ * Remove absolute-positioning style properties from the element identified by
960
+ * `data-agent-native-node-id` so that it becomes a flow child after being
961
+ * reparented into a container. Returns the updated HTML, or the original HTML
962
+ * if the node cannot be found or parsing is unavailable.
963
+ *
964
+ * Uses DOMParser + CSSStyleDeclaration.removeProperty() rather than
965
+ * applyVisualEdit({kind:"style",value:""}) because the substrate rejects
966
+ * empty-string values in isSafeStyleValue, making that approach a silent no-op.
967
+ */
968
+ function removeAbsolutePositioningFromNodeInHtml(
969
+ content: string,
970
+ nodeAttrId: string,
971
+ ): string {
972
+ if (typeof window === "undefined") return content;
973
+ try {
974
+ const doc = new DOMParser().parseFromString(content, "text/html");
975
+ const element = doc.querySelector(
976
+ `[data-agent-native-node-id="${CSS.escape(nodeAttrId)}"]`,
977
+ ) as HTMLElement | null;
978
+ if (!element) return content;
979
+ for (const prop of ABS_POSITION_PROPS) {
980
+ element.style.removeProperty(prop);
981
+ }
982
+ return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
983
+ } catch {
984
+ return content;
985
+ }
986
+ }
987
+
823
988
  function escapeHtmlText(value: string): string {
824
989
  return value
825
990
  .replace(/&/g, "&amp;")
@@ -1147,12 +1312,20 @@ function appendCanvasPrimitiveToHtml(
1147
1312
  element.style.transform = `rotate(${geometry.rotation}deg)`;
1148
1313
  }
1149
1314
 
1315
+ // Use the shared canvas-primitive-style module so committed output is
1316
+ // pixel-identical to the draft preview (fixes B5 color jump, B6 ellipse
1317
+ // radius jump). User-supplied fill/stroke/strokeWidth override the
1318
+ // canonical defaults so hand-chosen colours are preserved.
1319
+ const canonical = canvasPrimitiveVisual(
1320
+ primitive.kind === "rectangle" ? "rect" : primitive.kind,
1321
+ );
1150
1322
  if (primitive.kind === "frame") {
1151
- element.style.background = primitive.fill ?? "rgba(255, 255, 255, 0.04)";
1152
- element.style.border = `${primitive.strokeWidth ?? 1}px solid ${
1153
- primitive.stroke ?? "rgba(148, 163, 184, 0.35)"
1154
- }`;
1155
- element.style.borderRadius = "2px";
1323
+ element.style.background = primitive.fill ?? canonical.background;
1324
+ element.style.border =
1325
+ primitive.stroke !== undefined || primitive.strokeWidth !== undefined
1326
+ ? `${primitive.strokeWidth ?? 1}px dashed ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
1327
+ : canonical.border;
1328
+ element.style.borderRadius = canonical.borderRadius;
1156
1329
  element.style.overflow = "hidden";
1157
1330
  } else if (primitive.kind === "text") {
1158
1331
  element.textContent = primitive.text ?? "Text";
@@ -1164,18 +1337,23 @@ function appendCanvasPrimitiveToHtml(
1164
1337
  element.style.fontSize = "16px";
1165
1338
  element.style.lineHeight = "1.2";
1166
1339
  element.style.whiteSpace = "pre-wrap";
1340
+ element.style.border = canonical.border;
1341
+ element.style.borderRadius = canonical.borderRadius;
1167
1342
  } else if (primitive.kind === "ellipse") {
1168
- element.style.background = primitive.fill ?? "rgba(37, 99, 235, 0.16)";
1169
- element.style.border = `${primitive.strokeWidth ?? 1}px solid ${
1170
- primitive.stroke ?? "rgb(37, 99, 235)"
1171
- }`;
1172
- element.style.borderRadius = "50%";
1343
+ element.style.background = primitive.fill ?? canonical.background;
1344
+ element.style.border =
1345
+ primitive.stroke !== undefined || primitive.strokeWidth !== undefined
1346
+ ? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
1347
+ : canonical.border;
1348
+ element.style.borderRadius = canonical.borderRadius; // "50%"
1173
1349
  } else {
1174
- element.style.background = primitive.fill ?? "rgba(37, 99, 235, 0.16)";
1175
- element.style.border = `${primitive.strokeWidth ?? 1}px solid ${
1176
- primitive.stroke ?? "rgb(37, 99, 235)"
1177
- }`;
1178
- element.style.borderRadius = "2px";
1350
+ // rect / rectangle / frame fallthrough
1351
+ element.style.background = primitive.fill ?? canonical.background;
1352
+ element.style.border =
1353
+ primitive.stroke !== undefined || primitive.strokeWidth !== undefined
1354
+ ? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
1355
+ : canonical.border;
1356
+ element.style.borderRadius = canonical.borderRadius;
1179
1357
  }
1180
1358
 
1181
1359
  doc.body.appendChild(element);
@@ -1918,6 +2096,48 @@ function collectCodeLayerAncestors(
1918
2096
  return [];
1919
2097
  }
1920
2098
 
2099
+ function findCodeLayerNodeInProjection(
2100
+ projection: CodeLayerProjection,
2101
+ previousNode: CodeLayerNode,
2102
+ ): CodeLayerNode | null {
2103
+ const stableSourceIds = [
2104
+ previousNode.dataAttributes["data-agent-native-node-id"],
2105
+ previousNode.dataAttributes["data-code-layer-id"],
2106
+ previousNode.dataAttributes["data-layer-id"],
2107
+ previousNode.dataAttributes["data-builder-id"],
2108
+ previousNode.dataAttributes["data-loc"],
2109
+ typeof previousNode.attributes.id === "string"
2110
+ ? previousNode.attributes.id
2111
+ : undefined,
2112
+ ].filter((id): id is string => Boolean(id));
2113
+
2114
+ for (const sourceId of stableSourceIds) {
2115
+ const stableMatch = projection.nodes.find(
2116
+ (node) =>
2117
+ node.dataAttributes["data-agent-native-node-id"] === sourceId ||
2118
+ node.dataAttributes["data-code-layer-id"] === sourceId ||
2119
+ node.dataAttributes["data-layer-id"] === sourceId ||
2120
+ node.dataAttributes["data-builder-id"] === sourceId ||
2121
+ node.dataAttributes["data-loc"] === sourceId ||
2122
+ node.attributes.id === sourceId,
2123
+ );
2124
+ if (stableMatch) return stableMatch;
2125
+ }
2126
+
2127
+ const exactMatch = projection.nodes.find(
2128
+ (node) => node.id === previousNode.id,
2129
+ );
2130
+ if (exactMatch) return exactMatch;
2131
+
2132
+ const fallbackMatches = projection.nodes.filter(
2133
+ (node) =>
2134
+ node.tag === previousNode.tag &&
2135
+ node.layerName === previousNode.layerName &&
2136
+ (node.textSnippet ?? "") === (previousNode.textSnippet ?? ""),
2137
+ );
2138
+ return fallbackMatches.length === 1 ? (fallbackMatches[0] ?? null) : null;
2139
+ }
2140
+
1921
2141
  function AgentNativeMenuMark({ className }: { className?: string }) {
1922
2142
  return (
1923
2143
  <svg
@@ -2828,6 +3048,24 @@ export default function DesignEditor() {
2828
3048
  const spaceHandPreviousToolRef = useRef<DesignTool | null>(null);
2829
3049
  const hasSelectedElement = Boolean(selectedElement);
2830
3050
 
3051
+ // ── Motion dock state (§6.3) ────────────────────────────────────────────────
3052
+ // The MotionDock is mounted below the canvas and shown when motionDockOpen.
3053
+ // Tracks and durationMs are local state; "Write to CSS" calls applyMotionEdit.
3054
+ const [motionDockOpen, setMotionDockOpen] = useState(false);
3055
+ const [motionTracks, setMotionTracks] = useState<MotionDockTrack[]>([]);
3056
+ const [motionDurationMs, setMotionDurationMs] = useState(1000);
3057
+
3058
+ // ── Breakpoint preview state (§6.4) ─────────────────────────────────────────
3059
+ // Active breakpoint width for the current design (pixels). Controls which
3060
+ // side-by-side frame is focused. undefined = no frame selected (overview mode).
3061
+ const [activeBreakpointWidthState, setActiveBreakpointWidthState] = useState<
3062
+ number | undefined
3063
+ >(undefined);
3064
+
3065
+ // ── Design state selection (§6.4 / §8) ───────────────────────────────────────
3066
+ // null = Default (live) view; a string id = one of the design_state rows.
3067
+ const [selectedStateId, setSelectedStateId] = useState<string | null>(null);
3068
+
2831
3069
  useEffect(() => {
2832
3070
  if (!isBuilderDesignEmbed) return;
2833
3071
  // Announce ready to Builder. The trusted origin is not yet known at this
@@ -2955,6 +3193,9 @@ export default function DesignEditor() {
2955
3193
  const [canUndo, setCanUndo] = useState(false);
2956
3194
  const [canRedo, setCanRedo] = useState(false);
2957
3195
  const undoManagerRef = useRef<Y.UndoManager | null>(null);
3196
+ const contentUndoStackRef = useRef<ContentHistoryEntry[]>([]);
3197
+ const contentRedoStackRef = useRef<ContentHistoryEntry[]>([]);
3198
+ const suppressContentHistoryRef = useRef(false);
2958
3199
  const geometryUndoStackRef = useRef<GeometryHistoryEntry[]>([]);
2959
3200
  const geometryRedoStackRef = useRef<GeometryHistoryEntry[]>([]);
2960
3201
  const historyOrderRef = useRef<Array<"content" | "geometry">>([]);
@@ -2963,10 +3204,12 @@ export default function DesignEditor() {
2963
3204
  const undoManager = undoManagerRef.current;
2964
3205
  setCanUndo(
2965
3206
  Boolean(undoManager?.canUndo()) ||
3207
+ contentUndoStackRef.current.length > 0 ||
2966
3208
  geometryUndoStackRef.current.length > 0,
2967
3209
  );
2968
3210
  setCanRedo(
2969
3211
  Boolean(undoManager?.canRedo()) ||
3212
+ contentRedoStackRef.current.length > 0 ||
2970
3213
  geometryRedoStackRef.current.length > 0,
2971
3214
  );
2972
3215
  }, []);
@@ -2974,6 +3217,9 @@ export default function DesignEditor() {
2974
3217
  const designSelectionOwnerIdRef = useRef(`${TAB_ID}:${generateTabId()}`);
2975
3218
  const frameGeometrySaveTimerRef = useRef<number | null>(null);
2976
3219
  const [tweakSaveActive, setTweakSaveActive] = useState(false);
3220
+ // Dismissible localhost-source banner (reset per session).
3221
+ const [localSourceBannerDismissed, setLocalSourceBannerDismissed] =
3222
+ useState(false);
2977
3223
  // Shared visual-editor annotate overlays. drawMode owns the send toolbar,
2978
3224
  // while pinMode temporarily routes canvas clicks to comment pins that queue
2979
3225
  // into the same agent submission.
@@ -3292,6 +3538,58 @@ export default function DesignEditor() {
3292
3538
  designAccessRole === "owner" || designAccessRole === "admin";
3293
3539
  const canEditDesign = canShareDesign || designAccessRole === "editor";
3294
3540
  const canEditDesignRef = useRef(canEditDesign);
3541
+ const pendingLocalFileContentsRef = useRef<
3542
+ Map<
3543
+ string,
3544
+ { content: string; startedAt: number; baseUpdatedAt?: string | null }
3545
+ >
3546
+ >(new Map());
3547
+ const [
3548
+ pendingLocalFileContentsRevision,
3549
+ setPendingLocalFileContentsRevision,
3550
+ ] = useState(0);
3551
+
3552
+ const markPendingLocalFileContent = useCallback(
3553
+ (fileId: string, content: string, baseUpdatedAt?: string | null) => {
3554
+ const current = pendingLocalFileContentsRef.current.get(fileId);
3555
+ if (current?.content === content) {
3556
+ if (
3557
+ baseUpdatedAt !== undefined &&
3558
+ current.baseUpdatedAt === undefined
3559
+ ) {
3560
+ pendingLocalFileContentsRef.current.set(fileId, {
3561
+ ...current,
3562
+ baseUpdatedAt,
3563
+ });
3564
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
3565
+ }
3566
+ return;
3567
+ }
3568
+ pendingLocalFileContentsRef.current.set(fileId, {
3569
+ content,
3570
+ startedAt: Date.now(),
3571
+ baseUpdatedAt,
3572
+ });
3573
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
3574
+ },
3575
+ [],
3576
+ );
3577
+
3578
+ const clearPendingLocalFileContent = useCallback(
3579
+ (fileId: string, expectedContent?: string) => {
3580
+ const current = pendingLocalFileContentsRef.current.get(fileId);
3581
+ if (!current) return;
3582
+ if (
3583
+ expectedContent !== undefined &&
3584
+ current.content !== expectedContent
3585
+ ) {
3586
+ return;
3587
+ }
3588
+ pendingLocalFileContentsRef.current.delete(fileId);
3589
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
3590
+ },
3591
+ [],
3592
+ );
3295
3593
 
3296
3594
  useEffect(() => {
3297
3595
  canEditDesignRef.current = canEditDesign;
@@ -3332,6 +3630,45 @@ export default function DesignEditor() {
3332
3630
  const duplicateDesignMutation = useActionMutation("duplicate-design");
3333
3631
  const exportHtmlMutation = useActionMutation("export-html");
3334
3632
  const exportZipMutation = useActionMutation("export-zip");
3633
+ const applyMotionEditMutation = useActionMutation("apply-motion-edit");
3634
+ // §6.4 breakpoint mutations — wired to MultiScreenCanvas + affordance
3635
+ const addBreakpointMutation = useActionMutation("add-breakpoint");
3636
+ const setActiveBreakpointMutation = useActionMutation(
3637
+ "set-active-breakpoint",
3638
+ );
3639
+
3640
+ // §6.1 — promote a selection into a reusable component instance.
3641
+ const createComponentMutation = useActionMutation("create-component");
3642
+ // §6.1 — jump to a component instance's source (selects the root + navigates).
3643
+ const openComponentSourceMutation = useActionMutation(
3644
+ "open-component-source",
3645
+ );
3646
+
3647
+ // §6.6 — "Make it real" migration flow (migrate-inline-design-to-app).
3648
+ // The mutation stays unconditional; the dialog gates on isSignedIn.
3649
+ const migrateMutation = useActionMutation("migrate-inline-design-to-app");
3650
+
3651
+ // Dialog open/close state for the "Make this a real app" flow.
3652
+ const [makeRealDialogOpen, setMakeRealDialogOpen] = useState(false);
3653
+
3654
+ // Result payload returned by migrate-inline-design-to-app on success.
3655
+ // `null` = not yet migrated; populated once the Builder agent accepts the job.
3656
+ const [migrationResult, setMigrationResult] = useState<{
3657
+ branchName?: string;
3658
+ url?: string;
3659
+ versionId?: string;
3660
+ seedFileCount?: number;
3661
+ status?: string;
3662
+ projectId?: string;
3663
+ cta?: {
3664
+ kind: string;
3665
+ label: string;
3666
+ description: string;
3667
+ connectUrl?: string;
3668
+ primaryAction: string;
3669
+ };
3670
+ } | null>(null);
3671
+
3335
3672
  const [shareExportFormat, setShareExportFormat] =
3336
3673
  useState<ShareExportFormat>("html");
3337
3674
  const [codingHandoffResult, setCodingHandoffResult] =
@@ -3356,6 +3693,7 @@ export default function DesignEditor() {
3356
3693
  const saveFileContent = useCallback(
3357
3694
  (pending: FileContentSaveRequest) => {
3358
3695
  if (!canEditDesignRef.current) return;
3696
+ markPendingLocalFileContent(pending.id, pending.content);
3359
3697
  latestFileSaveForUnloadRef.current[pending.id] = pending;
3360
3698
  const previous =
3361
3699
  fileSaveChainsRef.current[pending.id] ?? Promise.resolve();
@@ -3374,6 +3712,7 @@ export default function DesignEditor() {
3374
3712
  : prev,
3375
3713
  );
3376
3714
  } catch (error) {
3715
+ clearPendingLocalFileContent(pending.id, pending.content);
3377
3716
  setPatchProof((prev) =>
3378
3717
  prev && prev.fileId === pending.id && prev.status === "queued"
3379
3718
  ? {
@@ -3395,7 +3734,12 @@ export default function DesignEditor() {
3395
3734
  }
3396
3735
  });
3397
3736
  },
3398
- [t, updateFileMutation],
3737
+ [
3738
+ clearPendingLocalFileContent,
3739
+ markPendingLocalFileContent,
3740
+ t,
3741
+ updateFileMutation,
3742
+ ],
3399
3743
  );
3400
3744
 
3401
3745
  const queueFileContentSave = useCallback(
@@ -3410,6 +3754,7 @@ export default function DesignEditor() {
3410
3754
  content,
3411
3755
  syncCollab: options.syncCollab ?? true,
3412
3756
  };
3757
+ markPendingLocalFileContent(fileId, content);
3413
3758
  latestFileSaveForUnloadRef.current[fileId] = pending;
3414
3759
  if (options.immediate) {
3415
3760
  const timer = fileSaveTimersRef.current[fileId];
@@ -3434,7 +3779,7 @@ export default function DesignEditor() {
3434
3779
  saveFileContent(pending);
3435
3780
  }, 400);
3436
3781
  },
3437
- [saveFileContent],
3782
+ [markPendingLocalFileContent, saveFileContent],
3438
3783
  );
3439
3784
 
3440
3785
  useEffect(() => {
@@ -3679,7 +4024,38 @@ export default function DesignEditor() {
3679
4024
  updateDesignMutation,
3680
4025
  ]);
3681
4026
 
3682
- const files = design?.files ?? [];
4027
+ const serverFiles = design?.files ?? [];
4028
+ useEffect(() => {
4029
+ if (pendingLocalFileContentsRef.current.size === 0) return;
4030
+ let changed = false;
4031
+ for (const file of serverFiles) {
4032
+ const pending = pendingLocalFileContentsRef.current.get(file.id);
4033
+ if (pending && (file.content ?? "") === pending.content) {
4034
+ if (
4035
+ pending.baseUpdatedAt !== undefined &&
4036
+ file.updatedAt === pending.baseUpdatedAt
4037
+ ) {
4038
+ continue;
4039
+ }
4040
+ pendingLocalFileContentsRef.current.delete(file.id);
4041
+ changed = true;
4042
+ }
4043
+ }
4044
+ if (changed) {
4045
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
4046
+ }
4047
+ }, [serverFiles]);
4048
+ const pendingLocalFileContentsSnapshot = useMemo(
4049
+ () => new Map(pendingLocalFileContentsRef.current),
4050
+ [pendingLocalFileContentsRevision],
4051
+ );
4052
+ const files = useMemo(() => {
4053
+ if (pendingLocalFileContentsSnapshot.size === 0) return serverFiles;
4054
+ return serverFiles.map((file) => {
4055
+ const pending = pendingLocalFileContentsSnapshot.get(file.id);
4056
+ return pending ? { ...file, content: pending.content } : file;
4057
+ });
4058
+ }, [pendingLocalFileContentsSnapshot, serverFiles]);
3683
4059
  const designDataJson = useMemo(
3684
4060
  () => parseDesignDataJson(design?.data),
3685
4061
  [design?.data],
@@ -3699,6 +4075,38 @@ export default function DesignEditor() {
3699
4075
  designDataJson,
3700
4076
  "screenMetadata",
3701
4077
  );
4078
+ // §6.4 — breakpoint set stored in designs.data.breakpointSet as a
4079
+ // BreakpointSet { id, breakpoints: BreakpointDefinition[] }.
4080
+ // Each BreakpointDefinition has { id, label, widthPx, prefix }.
4081
+ const breakpointSet = (() => {
4082
+ try {
4083
+ const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
4084
+ if (
4085
+ raw &&
4086
+ typeof raw === "object" &&
4087
+ !Array.isArray(raw) &&
4088
+ Array.isArray((raw as Record<string, unknown>).breakpoints)
4089
+ ) {
4090
+ return raw as {
4091
+ id: string;
4092
+ breakpoints: Array<{
4093
+ id: string;
4094
+ widthPx: number;
4095
+ label?: string;
4096
+ prefix?: string;
4097
+ }>;
4098
+ };
4099
+ }
4100
+ } catch {
4101
+ // ignore
4102
+ }
4103
+ return undefined;
4104
+ })();
4105
+ const bpWidths =
4106
+ breakpointSet && breakpointSet.breakpoints.length > 0
4107
+ ? breakpointSet.breakpoints.map((bp) => bp.widthPx)
4108
+ : undefined;
4109
+
3702
4110
  return files.map((file) => {
3703
4111
  const metadata = getDesignDataRecord(metadataByFileId, file.id);
3704
4112
  const stringValue = (key: string) =>
@@ -3716,6 +4124,7 @@ export default function DesignEditor() {
3716
4124
  updatedAt: file.updatedAt,
3717
4125
  sourceType: stringValue("sourceType"),
3718
4126
  source: stringValue("source"),
4127
+ sourceFile: stringValue("sourceFile"),
3719
4128
  lod: stringValue("lod"),
3720
4129
  previewState: stringValue("previewState"),
3721
4130
  status: stringValue("status"),
@@ -3724,9 +4133,19 @@ export default function DesignEditor() {
3724
4133
  height: numberValue("height"),
3725
4134
  url: stringValue("url"),
3726
4135
  previewUrl: stringValue("previewUrl"),
4136
+ // Breakpoint preview widths (§6.4). When non-empty, MultiScreenCanvas
4137
+ // renders one iframe per width to the right of the primary frame.
4138
+ breakpointWidths: bpWidths,
4139
+ // Active breakpoint width tracked in component state; shared across all
4140
+ // screens (a design has one active breakpoint set at a time in v1).
4141
+ activeBreakpointWidth: bpWidths?.includes(
4142
+ activeBreakpointWidthState ?? -1,
4143
+ )
4144
+ ? activeBreakpointWidthState
4145
+ : undefined,
3727
4146
  };
3728
4147
  });
3729
- }, [designDataJson, files]);
4148
+ }, [designDataJson, files, activeBreakpointWidthState]);
3730
4149
  const queueFrameGeometrySave = useCallback(
3731
4150
  (geometryById: CanvasFrameGeometryById) => {
3732
4151
  if (!id || !canEditDesignRef.current) return;
@@ -3838,6 +4257,61 @@ export default function DesignEditor() {
3838
4257
  [syncUndoRedoState, writeFrameGeometrySnapshot],
3839
4258
  );
3840
4259
 
4260
+ // §6.6 — "Make this a real app" handler.
4261
+ // Opens the dialog; actual migration fires when the user confirms.
4262
+ const handleOpenMakeReal = useCallback(() => {
4263
+ setMigrationResult(null);
4264
+ setMakeRealDialogOpen(true);
4265
+ }, []);
4266
+
4267
+ // Fires when the user clicks "Start migration" in the dialog.
4268
+ // Calls migrate-inline-design-to-app, then on success flips sourceType to
4269
+ // "fusion" in the design data blob so gated panels light up.
4270
+ const handleConfirmMakeReal = useCallback(async () => {
4271
+ if (!id) return;
4272
+ try {
4273
+ const result = await migrateMutation.mutateAsync({ designId: id } as any);
4274
+ const r = result as any;
4275
+ setMigrationResult({
4276
+ branchName: r?.branchName,
4277
+ url: r?.url,
4278
+ versionId: r?.versionId,
4279
+ seedFileCount: r?.seedFileCount,
4280
+ status: r?.status,
4281
+ projectId: r?.projectId,
4282
+ cta: r?.cta,
4283
+ });
4284
+
4285
+ // When the Builder agent accepted the job (status = "processing"),
4286
+ // flip the design data to sourceType "fusion" so capability-gated
4287
+ // panels (branches, deploy) light up on refresh.
4288
+ if (r?.status === "processing" && r?.url) {
4289
+ const nextData = {
4290
+ ...designDataJsonRef.current,
4291
+ sourceType: "fusion",
4292
+ fusionBranchName: r.branchName,
4293
+ fusionUrl: r.url,
4294
+ fusionProjectId: r.projectId,
4295
+ };
4296
+ updateDesignMutation.mutate(
4297
+ { id, data: JSON.stringify(nextData) } as any,
4298
+ {
4299
+ onSuccess: () => {
4300
+ queryClient.invalidateQueries({
4301
+ queryKey: ["action", "get-design"],
4302
+ });
4303
+ },
4304
+ },
4305
+ );
4306
+ } else if (r?.status === "not-configured") {
4307
+ // Builder not connected — leave dialog open to show the CTA.
4308
+ }
4309
+ } catch (err) {
4310
+ const message = err instanceof Error ? err.message : "Migration failed";
4311
+ toast.error(message);
4312
+ }
4313
+ }, [id, migrateMutation, updateDesignMutation, queryClient]);
4314
+
3841
4315
  generationOutputReadyRef.current = files.length > 0;
3842
4316
 
3843
4317
  useEffect(() => {
@@ -3952,6 +4426,65 @@ export default function DesignEditor() {
3952
4426
  generating,
3953
4427
  pendingGenerationActive,
3954
4428
  });
4429
+
4430
+ // §6.5 Review panel — lazy, on-demand accessibility audit for the active
4431
+ // file. The audit walks the file's rendered HTML, so it must never run on
4432
+ // editor load: it is gated behind `auditRequested` (flipped by the panel's
4433
+ // "Run" button) and reset whenever the active file changes so each file is
4434
+ // audited explicitly. The ReviewPanel itself owns the apply-a11y-fix mutation
4435
+ // behind its inline "Fix" button; we just supply the active design source and
4436
+ // refetch on apply so a resolved finding drops out of the list.
4437
+ const [auditRequested, setAuditRequested] = useState(false);
4438
+ useEffect(() => {
4439
+ setAuditRequested(false);
4440
+ }, [activeFile?.id]);
4441
+
4442
+ const auditQuery = useActionQuery<{
4443
+ auditedAt: string;
4444
+ findings: A11yFinding[];
4445
+ }>(
4446
+ "run-design-audit",
4447
+ { designId: id ?? "", fileId: activeFile?.id },
4448
+ {
4449
+ enabled: auditRequested && !!id && !!activeFile?.id,
4450
+ refetchOnWindowFocus: false,
4451
+ },
4452
+ );
4453
+
4454
+ const reviewPanelProps = useMemo<
4455
+ Omit<ReviewPanelProps, "className"> | undefined
4456
+ >(() => {
4457
+ if (!id || !activeFile) return undefined;
4458
+ const audit = auditRequested ? auditQuery.data : undefined;
4459
+ return {
4460
+ findings: audit?.findings ?? [],
4461
+ auditLoading: auditRequested && auditQuery.isFetching,
4462
+ auditedAt: audit?.auditedAt ?? null,
4463
+ auditError:
4464
+ auditRequested && auditQuery.isError
4465
+ ? auditQuery.error?.message ||
4466
+ "Audit failed. Try again." /* i18n-ignore design review fallback */
4467
+ : null,
4468
+ onRunAudit: () => {
4469
+ // First run flips the gate (the query auto-fetches); later runs refetch.
4470
+ if (auditRequested) {
4471
+ void auditQuery.refetch();
4472
+ } else {
4473
+ setAuditRequested(true);
4474
+ }
4475
+ },
4476
+ fixSource: {
4477
+ designId: id,
4478
+ fileId: activeFile.id,
4479
+ filename: activeFile.filename,
4480
+ },
4481
+ onFixApplied: () => {
4482
+ // Re-run the audit so the resolved finding drops out of the list.
4483
+ void auditQuery.refetch();
4484
+ },
4485
+ };
4486
+ }, [id, activeFile, auditRequested, auditQuery]);
4487
+
3955
4488
  const selectedScreenIds = useMemo(
3956
4489
  () =>
3957
4490
  getSelectedScreenIdsForEditorState({
@@ -4383,7 +4916,10 @@ export default function DesignEditor() {
4383
4916
  setCollabContentFileId(null);
4384
4917
  lastAppliedFileUpdatedAtRef.current = null;
4385
4918
  lastLocalContentRef.current = null;
4919
+ contentUndoStackRef.current = [];
4920
+ contentRedoStackRef.current = [];
4386
4921
  clearStaleAgentCollabRecovery();
4922
+ syncUndoRedoState();
4387
4923
  return;
4388
4924
  }
4389
4925
  if (activeFileId !== prevActiveFileIdRef.current) {
@@ -4392,9 +4928,17 @@ export default function DesignEditor() {
4392
4928
  setCollabContentFileId(null);
4393
4929
  lastAppliedFileUpdatedAtRef.current = null;
4394
4930
  lastLocalContentRef.current = null;
4931
+ contentUndoStackRef.current = [];
4932
+ contentRedoStackRef.current = [];
4395
4933
  clearStaleAgentCollabRecovery();
4934
+ syncUndoRedoState();
4396
4935
  }
4397
- }, [activeFileId, clearStaleAgentCollabRecovery, viewMode]);
4936
+ }, [
4937
+ activeFileId,
4938
+ clearStaleAgentCollabRecovery,
4939
+ syncUndoRedoState,
4940
+ viewMode,
4941
+ ]);
4398
4942
 
4399
4943
  useEffect(() => {
4400
4944
  return clearStaleAgentCollabRecovery;
@@ -4406,6 +4950,19 @@ export default function DesignEditor() {
4406
4950
  const fileId = activeFileId;
4407
4951
  const ytext = ydoc.getText("content");
4408
4952
  const text = ytext.toString();
4953
+ const pendingLocalContent =
4954
+ pendingLocalFileContentsRef.current.get(fileId)?.content;
4955
+ if (pendingLocalContent && text !== pendingLocalContent) {
4956
+ setCollabContent(pendingLocalContent);
4957
+ setCollabContentFileId(fileId);
4958
+ lastLocalContentRef.current = pendingLocalContent;
4959
+ setContentRenderRevision((revision) => revision + 1);
4960
+ ydoc.transact(() => {
4961
+ ytext.delete(0, ytext.length);
4962
+ ytext.insert(0, pendingLocalContent);
4963
+ }, TAB_ID);
4964
+ return;
4965
+ }
4409
4966
  if (text.length > 0) {
4410
4967
  const storedContent = activeFile?.content ?? "";
4411
4968
  if (
@@ -4432,7 +4989,14 @@ export default function DesignEditor() {
4432
4989
  setCollabContentFileId(fileId);
4433
4990
  setContentRenderRevision((revision) => revision + 1);
4434
4991
  }
4435
- }, [ydoc, isSynced, activeFileId, activeFile?.content, activeFile?.fileType]);
4992
+ }, [
4993
+ ydoc,
4994
+ isSynced,
4995
+ activeFileId,
4996
+ activeFile?.content,
4997
+ activeFile?.fileType,
4998
+ pendingLocalFileContentsRevision,
4999
+ ]);
4436
5000
 
4437
5001
  // Keep the freshest DB `updatedAt` in a ref the observe handler can read.
4438
5002
  useEffect(() => {
@@ -4453,14 +5017,27 @@ export default function DesignEditor() {
4453
5017
  const ytext = ydoc.getText("content");
4454
5018
  const handler = (_event: unknown, transaction?: { origin?: unknown }) => {
4455
5019
  const next = ytext.toString();
4456
- setCollabContent(next);
4457
- setCollabContentFileId(fileId);
4458
5020
  // UndoManager fires with itself as the origin; treat those as local too
4459
5021
  // so the reconcile watermark and stale-selection fix are consistent.
4460
5022
  const isLocalEdit =
4461
5023
  transaction?.origin === TAB_ID ||
4462
5024
  transaction?.origin === LOCAL_EDIT_ORIGIN ||
4463
5025
  transaction?.origin === undoManagerRef.current;
5026
+ const pendingLocalContent =
5027
+ pendingLocalFileContentsRef.current.get(fileId)?.content;
5028
+ if (pendingLocalContent && next !== pendingLocalContent && !isLocalEdit) {
5029
+ setCollabContent(pendingLocalContent);
5030
+ setCollabContentFileId(fileId);
5031
+ lastLocalContentRef.current = pendingLocalContent;
5032
+ setContentRenderRevision((revision) => revision + 1);
5033
+ ydoc.transact(() => {
5034
+ ytext.delete(0, ytext.length);
5035
+ ytext.insert(0, pendingLocalContent);
5036
+ }, TAB_ID);
5037
+ return;
5038
+ }
5039
+ setCollabContent(next);
5040
+ setCollabContentFileId(fileId);
4464
5041
  if (isLocalEdit) {
4465
5042
  lastLocalContentRef.current = next;
4466
5043
  } else {
@@ -4688,6 +5265,22 @@ export default function DesignEditor() {
4688
5265
  const canvasContextMenuRef = useRef<CanvasContextMenuHandle | null>(null);
4689
5266
  const canvasContainerRef = useRef<HTMLDivElement>(null);
4690
5267
 
5268
+ // Live handle to the active DesignCanvas preview iframe. DesignCanvas owns the
5269
+ // <iframe> internally (tagged data-design-preview-iframe) and does not forward
5270
+ // its ref, so we resolve the element lazily from the DOM at read time. The
5271
+ // MotionDock reads `.current` only when scrubbing, so this always returns the
5272
+ // currently-mounted iframe even after content swaps recreate the element.
5273
+ const canvasIframeRef = useMemo<React.RefObject<HTMLIFrameElement | null>>(
5274
+ () => ({
5275
+ get current() {
5276
+ return document.querySelector<HTMLIFrameElement>(
5277
+ "iframe[data-design-preview-iframe]",
5278
+ );
5279
+ },
5280
+ }),
5281
+ [],
5282
+ );
5283
+
4691
5284
  // Broadcast pointer position (normalized to canvas container) and
4692
5285
  // selected element selector so peers can see where the user is working.
4693
5286
  const handleCanvasPointerMove = useCallback(
@@ -4801,18 +5394,27 @@ export default function DesignEditor() {
4801
5394
  // Resolve the content to render: prefer collab content only after the
4802
5395
  // per-file reconcile state has reset for the current active file. Otherwise a
4803
5396
  // file switch can render one frame with the previous file's Yjs text.
5397
+ // Always resolve to a string — a non-string source (e.g. a collab value that
5398
+ // is not yet a plain string, or a not-yet-loaded file) must never reach the
5399
+ // many `content.trim()` / projection callers below, which would crash render.
4804
5400
  const activeCollabFileReady =
4805
5401
  viewMode === "single" && activeFileId === prevActiveFileIdRef.current;
4806
- const activeContent =
4807
- activeCollabFileReady &&
5402
+ const pendingActiveFileContent = activeFile?.id
5403
+ ? pendingLocalFileContentsSnapshot.get(activeFile.id)?.content
5404
+ : undefined;
5405
+ const activeContentSource =
5406
+ pendingActiveFileContent ??
5407
+ (activeCollabFileReady &&
4808
5408
  collabContentFileId === activeFile?.id &&
4809
5409
  collabContent !== null
4810
5410
  ? collabContent
4811
- : (activeFile?.content ?? "");
5411
+ : (activeFile?.content ?? ""));
5412
+ const activeContent =
5413
+ typeof activeContentSource === "string" ? activeContentSource : "";
4812
5414
  const fileContentById = useMemo(() => {
4813
5415
  const map = new Map<string, string>();
4814
5416
  for (const file of files) {
4815
- map.set(file.id, file.content ?? "");
5417
+ map.set(file.id, typeof file.content === "string" ? file.content : "");
4816
5418
  }
4817
5419
  return map;
4818
5420
  }, [files]);
@@ -4846,6 +5448,173 @@ export default function DesignEditor() {
4846
5448
  return selectedElement?.selector ? [selectedElement.selector] : [];
4847
5449
  }, [selectedCodeLayerNode, selectedElement?.selector]);
4848
5450
  const selectedCanvasSelector = selectedCanvasSelectorCandidates[0] ?? null;
5451
+
5452
+ // ── Inspector header quick actions (Create component / Inspect code) ───────
5453
+ // Resolve the design-level source type + capability map so the inspector can
5454
+ // gate the real-app affordances (jump-to-source, prop write-back).
5455
+ const designSourceType = useMemo(
5456
+ () =>
5457
+ normalizeDesignSourceType(designDataJson.sourceType as unknown) ??
5458
+ "inline",
5459
+ [designDataJson.sourceType],
5460
+ );
5461
+ const sourceCapabilities = useMemo(() => {
5462
+ const caps = resolveSourceCapabilities(designSourceType);
5463
+ return DESIGN_CAPABILITY_NAMES.filter((name) => hasCapability(caps, name));
5464
+ }, [designSourceType]);
5465
+
5466
+ // Builder-hosted preview URL for fusion-source designs, written into the
5467
+ // design data blob by the "Make it real" migration. Threaded into DesignCanvas
5468
+ // so the fusion preview renders (and so the bridge trust check can validate
5469
+ // the frame's origin against it).
5470
+ const designFusionUrl = useMemo(() => {
5471
+ const raw = (designDataJson as { fusionUrl?: unknown }).fusionUrl;
5472
+ return typeof raw === "string" && raw ? raw : undefined;
5473
+ }, [designDataJson]);
5474
+
5475
+ // §6.1 — open a component instance's source. open-component-source selects the
5476
+ // component root in the editor and emits a navigate app-state; for real-app
5477
+ // (localhost / fusion) sources it also resolves the external file location.
5478
+ const handleComponentSourceJump = useCallback(
5479
+ ({ nodeId }: { nodeId: string; componentName: string }) => {
5480
+ if (!id || !nodeId) return;
5481
+ openComponentSourceMutation.mutate(
5482
+ { designId: id, nodeId, fileId: activeFileId ?? undefined } as any,
5483
+ {
5484
+ onError: () => {
5485
+ toast.error(
5486
+ "Could not open component source" /* i18n-ignore edge-case jump failure */,
5487
+ );
5488
+ },
5489
+ },
5490
+ );
5491
+ },
5492
+ [id, activeFileId, openComponentSourceMutation],
5493
+ );
5494
+
5495
+ // The selected node id, when it already is a recognised component instance —
5496
+ // unlocks the contextual Component section at the top of the Design tab.
5497
+ const selectedComponentNodeId = useMemo(() => {
5498
+ if (!selectedCodeLayerNode) return undefined;
5499
+ return isComponentInstance(selectedCodeLayerNode)
5500
+ ? selectedCodeLayerNode.id
5501
+ : undefined;
5502
+ }, [selectedCodeLayerNode]);
5503
+
5504
+ // A friendly default name for the create-component dialog, derived from the
5505
+ // selected element's layer name / tag.
5506
+ const defaultComponentName = useMemo(() => {
5507
+ if (selectedCodeLayerNode?.layerName)
5508
+ return selectedCodeLayerNode.layerName;
5509
+ if (selectedElement?.tagName) {
5510
+ const tag = selectedElement.tagName;
5511
+ return tag.charAt(0).toUpperCase() + tag.slice(1);
5512
+ }
5513
+ return "Component";
5514
+ }, [selectedCodeLayerNode?.layerName, selectedElement?.tagName]);
5515
+
5516
+ // Outer HTML of the selection — backs the inline/Alpine "Inspect code" view.
5517
+ const selectedElementOuterHtml = useMemo(() => {
5518
+ if (!selectedElement?.selector) return null;
5519
+ return (
5520
+ selectedElement.htmlContent ??
5521
+ getElementOuterHtml(activeContent, selectedElement.selector)
5522
+ );
5523
+ }, [activeContent, selectedElement?.selector, selectedElement?.htmlContent]);
5524
+
5525
+ // §6.3 — the motion-dock target: the selected element's literal
5526
+ // `data-agent-native-node-id` (the value the motion compiler + preview bridge
5527
+ // match on, NOT the hashed projection id) plus a friendly label. Single-screen
5528
+ // mode auto-stamps every selectable node with this attribute (see the
5529
+ // ensureCodeLayerNodeIdsInHtml effect), so a selection reliably resolves to a
5530
+ // stable node id here. `null` when nothing animatable is selected — the dock
5531
+ // then disables its "Add track" affordance.
5532
+ const motionSelectedTarget = useMemo<{
5533
+ nodeId: string;
5534
+ label: string;
5535
+ } | null>(() => {
5536
+ if (!selectedCodeLayerNode) return null;
5537
+ const nodeId =
5538
+ selectedCodeLayerNode.dataAttributes["data-agent-native-node-id"]?.trim();
5539
+ if (!nodeId) return null;
5540
+ const label =
5541
+ selectedCodeLayerNode.layerName ||
5542
+ selectedElement?.tagName ||
5543
+ "Selected element";
5544
+ return { nodeId, label };
5545
+ }, [selectedCodeLayerNode, selectedElement?.tagName]);
5546
+
5547
+ // Serialisable subset of the dock's tracks for the DesignCanvas motion-preview
5548
+ // bridge. Strips the UI-only `label` field. Only populated while the dock is
5549
+ // open so a closed dock never leaves preview overrides on the canvas; an empty
5550
+ // array makes DesignCanvas send `motion-preview-clear`. Scrubbing previews
5551
+ // these tracks live in the iframe — it never writes (that is "Write to CSS").
5552
+ const motionTracksWire = useMemo<MotionTrackWire[]>(() => {
5553
+ if (!motionDockOpen || motionTracks.length === 0) return [];
5554
+ return motionTracks.map(({ label: _label, ...track }) => track);
5555
+ }, [motionDockOpen, motionTracks]);
5556
+
5557
+ const inspectCodeData = useMemo<InspectCodeData | undefined>(() => {
5558
+ if (!selectedElement) return undefined;
5559
+ // Inline/Alpine: the design HTML is the source — show the element's HTML.
5560
+ // Real-app source resolution (vscode:// deep link) requires the
5561
+ // resolveNodeToFile bridge op, which is wired through open-component-source
5562
+ // when an external file path is available; until that round-trip is hooked
5563
+ // up here the popover shows the projected HTML for all source types.
5564
+ return { html: selectedElementOuterHtml, sourceLocation: null };
5565
+ }, [selectedElement, selectedElementOuterHtml]);
5566
+
5567
+ const handleCreateComponent = useCallback(
5568
+ (name: string) => {
5569
+ if (!id || !selectedElement) return;
5570
+ const nodeId = selectedElementLayerId ?? undefined;
5571
+ const selector = selectedCanvasSelector ?? selectedElement.selector;
5572
+ createComponentMutation.mutate(
5573
+ { designId: id, nodeId, selector, name } as any,
5574
+ {
5575
+ onSuccess: () => {
5576
+ queryClient.invalidateQueries({
5577
+ queryKey: ["action", "get-design"],
5578
+ });
5579
+ toast.success(t("designEditor.toasts.componentCreated"));
5580
+ },
5581
+ onError: () => {
5582
+ toast.error(t("designEditor.toasts.componentCreateFailed"));
5583
+ },
5584
+ },
5585
+ );
5586
+
5587
+ // Follow-up: ask the Design agent to extract props and replace repeated
5588
+ // instances with this component. The deterministic annotate above is the
5589
+ // core; this is an enhancement that runs in the agent chat.
5590
+ sendToAgentChat({
5591
+ message: `Extract props for the "${name}" component and replace repeated instances on this design with it.`,
5592
+ context: [
5593
+ `Design id: "${id}".`,
5594
+ selectedElement.selector
5595
+ ? `Component root selector: ${selectedElement.selector}.`
5596
+ : "",
5597
+ nodeId ? `Component root node id: ${nodeId}.` : "",
5598
+ `The element was just annotated with data-agent-native-component="${name}".`,
5599
+ "Call view-screen first, then use get-code-layer-projection to find repeated instances, and apply-visual-edit / apply-component-prop-edit to converge them on this component with data-agent-native-prop-* props.",
5600
+ ]
5601
+ .filter(Boolean)
5602
+ .join("\n"),
5603
+ submit: true,
5604
+ openSidebar: true,
5605
+ });
5606
+ },
5607
+ [
5608
+ id,
5609
+ selectedElement,
5610
+ selectedElementLayerId,
5611
+ selectedCanvasSelector,
5612
+ createComponentMutation,
5613
+ queryClient,
5614
+ t,
5615
+ ],
5616
+ );
5617
+
4849
5618
  const hoveredCodeLayerNode = useMemo(() => {
4850
5619
  if (!hoveredElement) return null;
4851
5620
  if (isScreenRootElementInfo(hoveredElement)) return null;
@@ -4923,6 +5692,40 @@ export default function DesignEditor() {
4923
5692
  } = {},
4924
5693
  ) => {
4925
5694
  if (!activeFile || !canEditDesignRef.current) return;
5695
+ const previousContent =
5696
+ collabContentFileIdRef.current === activeFile.id &&
5697
+ typeof collabContentRef.current === "string"
5698
+ ? collabContentRef.current
5699
+ : (activeFile.content ?? "");
5700
+ const yjsHistoryAvailable = Boolean(
5701
+ ydoc && isSynced && undoManagerRef.current,
5702
+ );
5703
+ if (
5704
+ !suppressContentHistoryRef.current &&
5705
+ !yjsHistoryAvailable &&
5706
+ previousContent !== nextContent
5707
+ ) {
5708
+ contentUndoStackRef.current = [
5709
+ ...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
5710
+ {
5711
+ fileId: activeFile.id,
5712
+ before: previousContent,
5713
+ after: nextContent,
5714
+ },
5715
+ ];
5716
+ contentRedoStackRef.current = [];
5717
+ historyOrderRef.current = [
5718
+ ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
5719
+ "content",
5720
+ ];
5721
+ redoOrderRef.current = [];
5722
+ syncUndoRedoState();
5723
+ }
5724
+ markPendingLocalFileContent(
5725
+ activeFile.id,
5726
+ nextContent,
5727
+ activeFile.updatedAt,
5728
+ );
4926
5729
  setCollabContent(nextContent);
4927
5730
  setCollabContentFileId(activeFile.id);
4928
5731
  lastLocalContentRef.current = nextContent;
@@ -4976,9 +5779,11 @@ export default function DesignEditor() {
4976
5779
  activeFile,
4977
5780
  id,
4978
5781
  isSynced,
5782
+ markPendingLocalFileContent,
4979
5783
  queryClient,
4980
5784
  queueFileContentSave,
4981
5785
  replacePreviewContent,
5786
+ syncUndoRedoState,
4982
5787
  ydoc,
4983
5788
  ],
4984
5789
  );
@@ -4994,6 +5799,8 @@ export default function DesignEditor() {
4994
5799
  applyLocalContentUpdate(nextContent, options);
4995
5800
  return;
4996
5801
  }
5802
+ const previousFile = files.find((file) => file.id === fileId);
5803
+ markPendingLocalFileContent(fileId, nextContent, previousFile?.updatedAt);
4997
5804
  queryClient.setQueryData(["action", "get-design", { id }], (old: any) => {
4998
5805
  if (!old || typeof old !== "object" || !Array.isArray(old.files)) {
4999
5806
  return old;
@@ -5011,7 +5818,15 @@ export default function DesignEditor() {
5011
5818
  syncCollab: true,
5012
5819
  });
5013
5820
  },
5014
- [activeFile?.id, applyLocalContentUpdate, id, queryClient, saveFileContent],
5821
+ [
5822
+ activeFile?.id,
5823
+ applyLocalContentUpdate,
5824
+ files,
5825
+ id,
5826
+ markPendingLocalFileContent,
5827
+ queryClient,
5828
+ saveFileContent,
5829
+ ],
5015
5830
  );
5016
5831
 
5017
5832
  const handleCreatePrimitive = useCallback(
@@ -5085,8 +5900,12 @@ export default function DesignEditor() {
5085
5900
 
5086
5901
  const handlePrimitiveCreated = useCallback(
5087
5902
  (screenId: string, nodeId: string) => {
5903
+ // B2/B4 fix: stay in overview mode after drawing a primitive. The user
5904
+ // drew a shape on the board — they should remain on the board with the
5905
+ // new primitive selected, matching Figma behaviour. We activate the
5906
+ // target screen (so the layers panel shows its content) and select the
5907
+ // new node, but do NOT switch to single/full view.
5088
5908
  pendingOverviewScreenSelectionRef.current = null;
5089
- viewModeRef.current = "single";
5090
5909
  setActiveFileId(screenId);
5091
5910
  setSelectedElement(null);
5092
5911
  setHoveredElement(null);
@@ -5094,7 +5913,7 @@ export default function DesignEditor() {
5094
5913
  setOverviewSelectedScreenIds([]);
5095
5914
  setActiveTool("move");
5096
5915
  setMode("edit");
5097
- setViewMode("single");
5916
+ // viewMode stays at "overview" — no setViewMode("single") call here.
5098
5917
  },
5099
5918
  [],
5100
5919
  );
@@ -5329,6 +6148,58 @@ export default function DesignEditor() {
5329
6148
  mode,
5330
6149
  activeTool,
5331
6150
  inspectorTab: activeInspectorTab,
6151
+ // §8 DesignNavigationState additions — dock + breakpoint context
6152
+ dock: { kind: "motion" as const, open: motionDockOpen },
6153
+ motion: {
6154
+ previewing: false,
6155
+ playheadMs: 0,
6156
+ timelineId: undefined as string | undefined,
6157
+ selectedTrackId: undefined as string | undefined,
6158
+ selectedKeyframeId: undefined as string | undefined,
6159
+ },
6160
+ // §8 breakpoint fields — "auto" = no specific breakpoint focused.
6161
+ breakpoint: (activeBreakpointWidthState != null
6162
+ ? activeBreakpointWidthState < 500
6163
+ ? "mobile"
6164
+ : activeBreakpointWidthState < 1024
6165
+ ? "tablet"
6166
+ : "desktop"
6167
+ : "auto") as "auto" | "mobile" | "tablet" | "desktop",
6168
+ activeBreakpointId: (() => {
6169
+ if (activeBreakpointWidthState == null) return undefined;
6170
+ try {
6171
+ const raw = (designDataJson as Record<string, unknown>)
6172
+ ?.breakpointSet;
6173
+ if (
6174
+ raw &&
6175
+ typeof raw === "object" &&
6176
+ Array.isArray((raw as Record<string, unknown>).breakpoints)
6177
+ ) {
6178
+ const bps = (
6179
+ raw as { breakpoints: Array<{ id: string; widthPx: number }> }
6180
+ ).breakpoints;
6181
+ return bps.find((b) => b.widthPx === activeBreakpointWidthState)
6182
+ ?.id;
6183
+ }
6184
+ } catch {
6185
+ // ignore
6186
+ }
6187
+ return undefined;
6188
+ })(),
6189
+ breakpointSetId: (() => {
6190
+ try {
6191
+ const raw = (designDataJson as Record<string, unknown>)
6192
+ ?.breakpointSet;
6193
+ if (raw && typeof raw === "object") {
6194
+ return (raw as Record<string, unknown>).id as string | undefined;
6195
+ }
6196
+ } catch {
6197
+ // ignore
6198
+ }
6199
+ return undefined;
6200
+ })(),
6201
+ // §8 — active design state (null = Default / live view)
6202
+ selectedStateId,
5332
6203
  };
5333
6204
  (window as any).__designSelection = selection;
5334
6205
  const persistedSelection = {
@@ -5344,6 +6215,12 @@ export default function DesignEditor() {
5344
6215
  mode: selection.mode,
5345
6216
  activeTool: selection.activeTool,
5346
6217
  inspectorTab: selection.inspectorTab,
6218
+ dock: selection.dock,
6219
+ motion: selection.motion,
6220
+ breakpoint: selection.breakpoint,
6221
+ activeBreakpointId: selection.activeBreakpointId,
6222
+ breakpointSetId: selection.breakpointSetId,
6223
+ selectedStateId: selection.selectedStateId,
5347
6224
  ownerId: designSelectionOwnerIdRef.current,
5348
6225
  };
5349
6226
  const persistedKey = JSON.stringify(persistedSelection);
@@ -5381,6 +6258,10 @@ export default function DesignEditor() {
5381
6258
  overviewSelectedScreenIds,
5382
6259
  viewMode,
5383
6260
  zoom,
6261
+ motionDockOpen,
6262
+ activeBreakpointWidthState,
6263
+ designDataJson,
6264
+ selectedStateId,
5384
6265
  ]);
5385
6266
 
5386
6267
  useEffect(() => {
@@ -5751,12 +6632,49 @@ export default function DesignEditor() {
5751
6632
  const nextContent = applyInlineStylesToHtml(baseContent, selector, {
5752
6633
  ...Object.fromEntries(entries),
5753
6634
  });
6635
+ // §6.4 — Breakpoint-scoped class editing. Reuses the `projection` and
6636
+ // `targetNode` resolved above for the patch-proof block (same baseContent).
6637
+ // When an active non-base breakpoint frame is set, attempt to route class
6638
+ // edits through `kind: "responsive-class"` so the write targets only that
6639
+ // breakpoint prefix (e.g. "md:text-lg" instead of "text-lg"). This fires
6640
+ // when the element has a `responsive-class` EditCapability, which signals
6641
+ // that its values come from Tailwind class tokens and can carry a prefix.
6642
+ // Falls back to `kind: "style"` (inline attribute) for any entry that
6643
+ // fails the responsive path (e.g. raw CSS values with no Tailwind utility).
6644
+ const activeBreakpointPrefix =
6645
+ activeBreakpointWidthState != null
6646
+ ? widthToPrefix(activeBreakpointWidthState)
6647
+ : null;
6648
+ // `responsive-class` is a code-layer EditCapability kind not yet reflected
6649
+ // in the ElementInfo type union (types.ts); cast to string for the check.
6650
+ const hasResponsiveCapability =
6651
+ activeBreakpointPrefix != null &&
6652
+ activeBreakpointPrefix !== "base" &&
6653
+ selectedElement?.editCapabilities?.some(
6654
+ (cap) => (cap.kind as string) === "responsive-class",
6655
+ );
5754
6656
  const stylePatch = entries.reduce<{
5755
6657
  content: string;
5756
6658
  failed: string | null;
5757
6659
  }>(
5758
6660
  (current, [property, value]) => {
5759
6661
  if (current.failed) return current;
6662
+ // Try responsive-class path first when appropriate.
6663
+ if (hasResponsiveCapability && activeBreakpointPrefix) {
6664
+ const rcPatch = applyVisualEdit(current.content, {
6665
+ kind: "responsive-class",
6666
+ target: targetNode ? { nodeId: targetNode.id } : { selector },
6667
+ prefix: activeBreakpointPrefix,
6668
+ operation: "replace",
6669
+ utility: value,
6670
+ stem: property,
6671
+ });
6672
+ if (rcPatch.result.status === "applied") {
6673
+ return { content: rcPatch.content, failed: null };
6674
+ }
6675
+ // Responsive-class path didn't apply (e.g. value is a raw CSS value,
6676
+ // not a Tailwind utility); fall through to the inline-style path.
6677
+ }
5760
6678
  const patch = applyVisualEdit(current.content, {
5761
6679
  kind: "style",
5762
6680
  target: targetNode ? { nodeId: targetNode.id } : { selector },
@@ -5865,6 +6783,7 @@ export default function DesignEditor() {
5865
6783
  [
5866
6784
  activeContent,
5867
6785
  activeFile,
6786
+ activeBreakpointWidthState,
5868
6787
  canEditDesign,
5869
6788
  queueFileContentSave,
5870
6789
  selectedElement,
@@ -6490,7 +7409,6 @@ export default function DesignEditor() {
6490
7409
  setHasCanvasClipboard(true);
6491
7410
  try {
6492
7411
  await navigator.clipboard.writeText(html);
6493
- toast.success(t("designEditor.toasts.copied"));
6494
7412
  } catch {
6495
7413
  toast.error(t("designEditor.toasts.clipboardBlocked"));
6496
7414
  }
@@ -6499,7 +7417,53 @@ export default function DesignEditor() {
6499
7417
  const handlePasteSelection = useCallback(
6500
7418
  (position?: { x: number; y: number }) => {
6501
7419
  if (!activeFile || !canEditDesign || !copiedLayerHtmlRef.current) return;
6502
- // Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
7420
+
7421
+ // B7 fix: when an element is selected and no explicit canvas position was
7422
+ // given, insert the clone as an in-flow sibling right AFTER the selected
7423
+ // element. Strip any position/left/top from the clone so it participates
7424
+ // in normal document flow instead of being an absolutely-positioned body
7425
+ // child. Fall back to the old position-based clone when nothing is
7426
+ // selected or a "Paste here" position is provided.
7427
+ if (!position && selectedElement?.selector) {
7428
+ const selector = selectedCanvasSelector ?? selectedElement.selector;
7429
+ // Strip position properties from the pasted clone so it becomes an
7430
+ // in-flow sibling (not absolute).
7431
+ const strippedHtml = (() => {
7432
+ try {
7433
+ const parser = new DOMParser();
7434
+ const tmp = parser.parseFromString(
7435
+ `<template>${copiedLayerHtmlRef.current!}</template>`,
7436
+ "text/html",
7437
+ );
7438
+ const root =
7439
+ tmp.querySelector("template")?.content.firstElementChild ??
7440
+ tmp.body.firstElementChild;
7441
+ if (root && root instanceof HTMLElement) {
7442
+ root.style.position = "";
7443
+ root.style.left = "";
7444
+ root.style.top = "";
7445
+ root.style.right = "";
7446
+ root.style.bottom = "";
7447
+ }
7448
+ return root?.outerHTML ?? copiedLayerHtmlRef.current!;
7449
+ } catch {
7450
+ return copiedLayerHtmlRef.current!;
7451
+ }
7452
+ })();
7453
+
7454
+ const nextContent = insertClonedHtmlLayer(activeContent, strippedHtml, {
7455
+ targetSelectors: [selector],
7456
+ placement: "after",
7457
+ });
7458
+ if (nextContent) {
7459
+ pasteCascadeRef.current += 1;
7460
+ applyLocalContentUpdate(nextContent);
7461
+ return;
7462
+ }
7463
+ // Fall through to position-based clone if insert failed.
7464
+ }
7465
+
7466
+ // Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
6503
7467
  // Keyboard pastes land near the source layer and cascade so repeats don't
6504
7468
  // stack exactly.
6505
7469
  const targetPosition =
@@ -6519,9 +7483,15 @@ export default function DesignEditor() {
6519
7483
  if (!nextContent) return;
6520
7484
  if (!position) pasteCascadeRef.current += 1;
6521
7485
  applyLocalContentUpdate(nextContent);
6522
- toast.success(t("designEditor.toasts.pasted"), { duration: 3000 });
6523
7486
  },
6524
- [activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
7487
+ [
7488
+ activeContent,
7489
+ activeFile,
7490
+ applyLocalContentUpdate,
7491
+ canEditDesign,
7492
+ selectedCanvasSelector,
7493
+ selectedElement,
7494
+ ],
6525
7495
  );
6526
7496
 
6527
7497
  const handlePasteOverSelection = useCallback(() => {
@@ -6535,7 +7505,6 @@ export default function DesignEditor() {
6535
7505
  );
6536
7506
  if (!nextContent) return;
6537
7507
  applyLocalContentUpdate(nextContent);
6538
- toast.success(t("designEditor.toasts.pasted"));
6539
7508
  } else {
6540
7509
  handlePasteSelection();
6541
7510
  }
@@ -6545,20 +7514,46 @@ export default function DesignEditor() {
6545
7514
  applyLocalContentUpdate,
6546
7515
  handlePasteSelection,
6547
7516
  selectedElement,
6548
- t,
6549
7517
  ]);
6550
7518
 
6551
7519
  const handleDuplicateSelection = useCallback(() => {
6552
7520
  if (!canEditDesign) return;
6553
7521
  if (selectedElement?.selector) {
6554
7522
  const html = getElementOuterHtml(activeContent, selectedElement.selector);
6555
- const rect = selectedElement.boundingRect;
6556
- const nextContent = html
6557
- ? cloneHtmlLayerAtPosition(activeContent, html, {
6558
- x: rect.x + 16,
6559
- y: rect.y + 16,
6560
- })
6561
- : null;
7523
+ if (!html) {
7524
+ toast.error(t("designEditor.toasts.duplicateElementFailed"));
7525
+ return;
7526
+ }
7527
+ // B7 fix: duplicate inserts the clone as an in-flow sibling right AFTER
7528
+ // the original — not as an absolutely-positioned body child. Strip
7529
+ // position/left/top so it joins normal document flow.
7530
+ const selector = selectedCanvasSelector ?? selectedElement.selector;
7531
+ const strippedHtml = (() => {
7532
+ try {
7533
+ const parser = new DOMParser();
7534
+ const tmp = parser.parseFromString(
7535
+ `<template>${html}</template>`,
7536
+ "text/html",
7537
+ );
7538
+ const root =
7539
+ tmp.querySelector("template")?.content.firstElementChild ??
7540
+ tmp.body.firstElementChild;
7541
+ if (root && root instanceof HTMLElement) {
7542
+ root.style.position = "";
7543
+ root.style.left = "";
7544
+ root.style.top = "";
7545
+ root.style.right = "";
7546
+ root.style.bottom = "";
7547
+ }
7548
+ return root?.outerHTML ?? html;
7549
+ } catch {
7550
+ return html;
7551
+ }
7552
+ })();
7553
+ const nextContent = insertClonedHtmlLayer(activeContent, strippedHtml, {
7554
+ targetSelectors: [selector],
7555
+ placement: "after",
7556
+ });
6562
7557
  if (nextContent) {
6563
7558
  applyLocalContentUpdate(nextContent);
6564
7559
  } else {
@@ -6573,7 +7568,9 @@ export default function DesignEditor() {
6573
7568
  applyLocalContentUpdate,
6574
7569
  canEditDesign,
6575
7570
  handleDuplicateScreen,
7571
+ selectedCanvasSelector,
6576
7572
  selectedElement,
7573
+ t,
6577
7574
  ]);
6578
7575
 
6579
7576
  const handleDeleteSelection = useCallback(() => {
@@ -6639,6 +7636,402 @@ export default function DesignEditor() {
6639
7636
  selectedLayerIdsState,
6640
7637
  ]);
6641
7638
 
7639
+ // Wrap the current multi-layer selection into a new group container.
7640
+ const handleGroupSelection = useCallback(() => {
7641
+ if (!canEditDesign || !activeFile) return;
7642
+ // Collect the DOM-node layer ids that belong to the active screen.
7643
+ // Build a set of ids present in the active content so stale ids from
7644
+ // other files (which can persist in selectedLayerIdsState after a
7645
+ // cross-screen layers-panel selection) are excluded before wrapNodes
7646
+ // runs against activeContent. Without this filter, cross-file ids
7647
+ // cause wrapNodes to return "conflict" even for a valid same-file
7648
+ // selection.
7649
+ const fileIds = new Set(files.map((f) => f.id));
7650
+ const activeNodeIdSet = buildActiveFileNodeIdSet(
7651
+ buildCodeLayerProjection(activeContent),
7652
+ );
7653
+ const nodeIds = selectedLayerIdsState.filter(
7654
+ (id) =>
7655
+ !id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
7656
+ );
7657
+ if (nodeIds.length < 2) return;
7658
+ const patch = applyVisualEdit(activeContent, {
7659
+ kind: "wrapNodes",
7660
+ targetIds: nodeIds,
7661
+ autoLayout: false,
7662
+ });
7663
+ if (patch.result.status !== "applied") {
7664
+ toast.error(
7665
+ codeLayerPatchMessage(
7666
+ patch.result.message,
7667
+ t("designEditor.toasts.layerMoveFailed"),
7668
+ ),
7669
+ { duration: 4000 },
7670
+ );
7671
+ return;
7672
+ }
7673
+ applyLocalContentUpdate(patch.content, { skipPreview: true });
7674
+ // Select the new wrapper node if the substrate reported its id.
7675
+ const wrapperId = patch.result.wrapperNodeId;
7676
+ if (wrapperId) {
7677
+ // Find the projection node whose data-agent-native-node-id matches.
7678
+ const wrapperNode = patch.projection.nodes.find(
7679
+ (n) => n.dataAttributes["data-agent-native-node-id"] === wrapperId,
7680
+ );
7681
+ if (wrapperNode) {
7682
+ setSelectedLayerIdsState([wrapperNode.id]);
7683
+ setSelectedElement(elementInfoFromCodeLayerNode(wrapperNode));
7684
+ }
7685
+ }
7686
+ }, [
7687
+ activeContent,
7688
+ activeFile,
7689
+ applyLocalContentUpdate,
7690
+ canEditDesign,
7691
+ files,
7692
+ selectedLayerIdsState,
7693
+ t,
7694
+ ]);
7695
+
7696
+ // Unwrap the currently selected single-container layer.
7697
+ const handleUngroupSelection = useCallback(() => {
7698
+ if (!canEditDesign || !activeFile) return;
7699
+ // Filter to active-file nodes only (mirrors handleGroupSelection fix).
7700
+ // A stale id from another file must not be passed to unwrap or it will
7701
+ // fail with "conflict" even though the actual selection is valid.
7702
+ const fileIds = new Set(files.map((f) => f.id));
7703
+ const activeNodeIdSet = buildActiveFileNodeIdSet(
7704
+ buildCodeLayerProjection(activeContent),
7705
+ );
7706
+ const nodeIds = selectedLayerIdsState.filter(
7707
+ (id) =>
7708
+ !id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
7709
+ );
7710
+ const targetId = nodeIds[0];
7711
+ if (!targetId) return;
7712
+ const patch = applyVisualEdit(activeContent, {
7713
+ kind: "unwrap",
7714
+ targetId,
7715
+ });
7716
+ if (patch.result.status !== "applied") {
7717
+ toast.error(
7718
+ codeLayerPatchMessage(
7719
+ patch.result.message,
7720
+ t("designEditor.toasts.layerMoveFailed"),
7721
+ ),
7722
+ { duration: 4000 },
7723
+ );
7724
+ return;
7725
+ }
7726
+ applyLocalContentUpdate(patch.content, { skipPreview: true });
7727
+ setSelectedElement(null);
7728
+ setSelectedLayerIdsState([]);
7729
+ }, [
7730
+ activeContent,
7731
+ activeFile,
7732
+ applyLocalContentUpdate,
7733
+ canEditDesign,
7734
+ files,
7735
+ selectedLayerIdsState,
7736
+ t,
7737
+ ]);
7738
+
7739
+ /**
7740
+ * Convert the selected container to full auto-layout. Applies the
7741
+ * { kind: "autoLayout", enabled: true } substrate intent which sets
7742
+ * display:flex on the target AND strips position:absolute/left/top/right/bottom
7743
+ * from its direct children so they become flow children.
7744
+ */
7745
+ const handleAutoLayoutConvert = useCallback(
7746
+ (
7747
+ targetNodeId: string,
7748
+ opts?: { direction?: "row" | "column"; gap?: string },
7749
+ ) => {
7750
+ if (!canEditDesign || !activeFile) return;
7751
+ const patch = applyVisualEdit(activeContent, {
7752
+ kind: "autoLayout",
7753
+ targetId: targetNodeId,
7754
+ enabled: true,
7755
+ direction: opts?.direction ?? "row",
7756
+ gap: opts?.gap ?? "8px",
7757
+ });
7758
+ if (patch.result.status !== "applied") {
7759
+ toast.error(
7760
+ codeLayerPatchMessage(
7761
+ patch.result.message,
7762
+ t("designEditor.toasts.layerMoveFailed"),
7763
+ ),
7764
+ { duration: 4000 },
7765
+ );
7766
+ return;
7767
+ }
7768
+ applyLocalContentUpdate(patch.content, { skipPreview: true });
7769
+ // Re-select the container so the inspector refreshes its layout state.
7770
+ const containerNode = patch.projection.nodes.find(
7771
+ (n) =>
7772
+ n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
7773
+ n.id === targetNodeId,
7774
+ );
7775
+ if (containerNode) {
7776
+ setSelectedLayerIdsState([containerNode.id]);
7777
+ setSelectedElement(elementInfoFromCodeLayerNode(containerNode));
7778
+ }
7779
+ },
7780
+ [activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
7781
+ );
7782
+
7783
+ /**
7784
+ * Handle a primitive being drag-dropped onto another primitive in the
7785
+ * MultiScreenCanvas overview (CONTRACT: onPrimitiveReparent prop).
7786
+ *
7787
+ * Same-screen: applies a moveNode intent then strips the moved node's
7788
+ * absolute positioning so it becomes a flow child of the container.
7789
+ * Cross-screen: uses moveNodeBetweenDocuments and persists both files.
7790
+ */
7791
+ const handleOverviewPrimitiveReparent = useCallback(
7792
+ ({
7793
+ sourceNodeId,
7794
+ sourceScreenId,
7795
+ targetNodeId,
7796
+ targetScreenId,
7797
+ }: {
7798
+ sourceNodeId: string;
7799
+ sourceScreenId: string;
7800
+ targetNodeId: string;
7801
+ targetScreenId: string;
7802
+ placement: "inside";
7803
+ }) => {
7804
+ if (!canEditDesign) return;
7805
+
7806
+ if (sourceScreenId === targetScreenId) {
7807
+ // --- Same-screen reparent ---
7808
+ const baseContent = getScreenContent(sourceScreenId);
7809
+ if (!baseContent) return;
7810
+
7811
+ // 1. Move the node inside the target container.
7812
+ const movePatch = applyVisualEdit(baseContent, {
7813
+ kind: "moveNode",
7814
+ target: { nodeId: sourceNodeId },
7815
+ anchor: { nodeId: targetNodeId },
7816
+ placement: "inside",
7817
+ });
7818
+ if (movePatch.result.status !== "applied") {
7819
+ toast.error(
7820
+ codeLayerPatchMessage(
7821
+ movePatch.result.message,
7822
+ t("designEditor.toasts.layerMoveFailed"),
7823
+ ),
7824
+ { duration: 4000 },
7825
+ );
7826
+ return;
7827
+ }
7828
+
7829
+ // 2. Strip absolute positioning from the moved node so it flows naturally.
7830
+ // Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because the
7831
+ // applyVisualEdit substrate rejects empty-string values in isSafeStyleValue,
7832
+ // making applyVisualEdit({kind:"style",value:""}) a silent no-op.
7833
+ const movedNodeAttrId =
7834
+ movePatch.projection.nodes.find(
7835
+ (n) =>
7836
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
7837
+ n.id === sourceNodeId,
7838
+ )?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
7839
+ const strippedContent = removeAbsolutePositioningFromNodeInHtml(
7840
+ movePatch.content,
7841
+ movedNodeAttrId,
7842
+ );
7843
+
7844
+ applyFileContentUpdate(sourceScreenId, strippedContent, {
7845
+ skipPreview: true,
7846
+ });
7847
+
7848
+ // Re-select the moved node.
7849
+ const nextProjection = buildCodeLayerProjection(strippedContent);
7850
+ const movedNodeAfter = nextProjection.nodes.find(
7851
+ (n) =>
7852
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
7853
+ n.id === sourceNodeId,
7854
+ );
7855
+ if (movedNodeAfter) {
7856
+ setSelectedLayerIdsState([movedNodeAfter.id]);
7857
+ setSelectedElement(elementInfoFromCodeLayerNode(movedNodeAfter));
7858
+ }
7859
+ return;
7860
+ }
7861
+
7862
+ // --- Cross-screen reparent ---
7863
+ const sourceContent = getScreenContent(sourceScreenId);
7864
+ const destContent = getScreenContent(targetScreenId);
7865
+ if (!sourceContent || !destContent) return;
7866
+
7867
+ // Resolve data-agent-native-node-id attributes for moveNodeBetweenDocuments.
7868
+ const sourceProjection = buildCodeLayerProjection(sourceContent);
7869
+ const destProjection = buildCodeLayerProjection(destContent);
7870
+ const sourceNode = sourceProjection.nodes.find(
7871
+ (n) =>
7872
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
7873
+ n.id === sourceNodeId,
7874
+ );
7875
+ const anchorNode = destProjection.nodes.find(
7876
+ (n) =>
7877
+ n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
7878
+ n.id === targetNodeId,
7879
+ );
7880
+ const nodeAttrId =
7881
+ sourceNode?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
7882
+ const anchorAttrId =
7883
+ anchorNode?.dataAttributes["data-agent-native-node-id"] ?? targetNodeId;
7884
+
7885
+ const result = moveNodeBetweenDocuments(sourceContent, destContent, {
7886
+ nodeId: nodeAttrId,
7887
+ anchorNodeId: anchorAttrId,
7888
+ placement: "inside",
7889
+ });
7890
+ if (result.status !== "applied") {
7891
+ toast.error(
7892
+ codeLayerPatchMessage(
7893
+ result.message,
7894
+ t("designEditor.toasts.layerMoveFailed"),
7895
+ ),
7896
+ { duration: 4000 },
7897
+ );
7898
+ return;
7899
+ }
7900
+
7901
+ // Strip absolute positioning from the moved node in the destination.
7902
+ // Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because
7903
+ // applyVisualEdit({kind:"style",value:""}) is a silent no-op (the
7904
+ // substrate rejects empty-string values in isSafeStyleValue). Use
7905
+ // result.movedNodeId (the final id in destHtml, which may differ from
7906
+ // nodeAttrId when a collision triggered an id re-stamp) so the strip and
7907
+ // re-selection always find the correct element.
7908
+ const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
7909
+ const strippedDest = removeAbsolutePositioningFromNodeInHtml(
7910
+ result.destHtml,
7911
+ destNodeAttrId,
7912
+ );
7913
+
7914
+ applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
7915
+ refreshPreview: true,
7916
+ });
7917
+ applyFileContentUpdate(targetScreenId, strippedDest, {
7918
+ refreshPreview: true,
7919
+ });
7920
+
7921
+ // Re-select the moved node in the destination.
7922
+ const finalProjection = buildCodeLayerProjection(strippedDest);
7923
+ const movedNodeFinal = finalProjection.nodes.find(
7924
+ (n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
7925
+ );
7926
+ if (movedNodeFinal) {
7927
+ setSelectedLayerIdsState([movedNodeFinal.id]);
7928
+ setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
7929
+ }
7930
+ },
7931
+ [applyFileContentUpdate, canEditDesign, getScreenContent, t],
7932
+ );
7933
+
7934
+ /**
7935
+ * Cross-screen element drag-drop handler (CONTRACT: onCrossScreenElementDrop
7936
+ * prop on MultiScreenCanvas).
7937
+ *
7938
+ * The bridge in the source screen's iframe posts phase:"end" with the
7939
+ * selector / sourceNodeId of the dragged element. MultiScreenCanvas maps
7940
+ * the board point to a target screen and calls this handler. We resolve
7941
+ * both screens' content, identify the node by its data-agent-native-node-id
7942
+ * (falling back to a projection lookup by selector when only the selector is
7943
+ * available), call moveNodeBetweenDocuments to move it into the target
7944
+ * screen's <body>, persist both files, switch the active screen to the
7945
+ * target, and select the moved node — keeping viewMode "overview" throughout.
7946
+ */
7947
+ const handleCrossScreenElementDrop = useCallback(
7948
+ ({
7949
+ sourceSelector,
7950
+ sourceNodeId,
7951
+ sourceScreenId,
7952
+ targetScreenId,
7953
+ }: {
7954
+ sourceSelector: string;
7955
+ sourceNodeId?: string;
7956
+ sourceScreenId: string;
7957
+ targetScreenId: string;
7958
+ }) => {
7959
+ if (!canEditDesign) return;
7960
+ if (sourceScreenId === targetScreenId) return;
7961
+
7962
+ const sourceContent = getScreenContent(sourceScreenId);
7963
+ const destContent = getScreenContent(targetScreenId);
7964
+ if (!sourceContent || !destContent) return;
7965
+
7966
+ // Resolve the data-agent-native-node-id that moveNodeBetweenDocuments
7967
+ // uses as a stable key. Prefer the bridge-supplied sourceNodeId when it
7968
+ // looks like a node-attr id; otherwise look up via selector projection.
7969
+ const sourceProjection = buildCodeLayerProjection(sourceContent);
7970
+ const resolvedSourceNode = sourceNodeId
7971
+ ? (sourceProjection.nodes.find(
7972
+ (n) =>
7973
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
7974
+ n.id === sourceNodeId,
7975
+ ) ??
7976
+ resolveCodeLayerNodeFromBridge(
7977
+ sourceProjection,
7978
+ sourceSelector,
7979
+ sourceNodeId,
7980
+ ))
7981
+ : resolveCodeLayerNodeFromBridge(sourceProjection, sourceSelector);
7982
+ const nodeAttrId =
7983
+ resolvedSourceNode?.dataAttributes["data-agent-native-node-id"] ??
7984
+ sourceNodeId ??
7985
+ sourceSelector;
7986
+
7987
+ const result = moveNodeBetweenDocuments(sourceContent, destContent, {
7988
+ nodeId: nodeAttrId,
7989
+ placement: "inside",
7990
+ });
7991
+ if (result.status !== "applied") {
7992
+ toast.error(
7993
+ codeLayerPatchMessage(
7994
+ result.message,
7995
+ t("designEditor.toasts.layerMoveFailed"),
7996
+ ),
7997
+ { duration: 4000 },
7998
+ );
7999
+ return;
8000
+ }
8001
+
8002
+ // Strip absolute positioning from the moved node in the destination so
8003
+ // it flows naturally as a body child (mirrors handleOverviewPrimitiveReparent).
8004
+ // Use result.movedNodeId (the final id in destHtml, which may differ from
8005
+ // nodeAttrId when a collision triggered an id re-stamp) so the strip and
8006
+ // re-selection always find the correct element.
8007
+ const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
8008
+ const strippedDest = removeAbsolutePositioningFromNodeInHtml(
8009
+ result.destHtml,
8010
+ destNodeAttrId,
8011
+ );
8012
+
8013
+ applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
8014
+ refreshPreview: true,
8015
+ });
8016
+ applyFileContentUpdate(targetScreenId, strippedDest, {
8017
+ refreshPreview: true,
8018
+ });
8019
+
8020
+ // Switch active screen to the target and select the moved node; viewMode
8021
+ // stays "overview" (no setViewMode call).
8022
+ setActiveFileId(targetScreenId);
8023
+ const finalProjection = buildCodeLayerProjection(strippedDest);
8024
+ const movedNodeFinal = finalProjection.nodes.find(
8025
+ (n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
8026
+ );
8027
+ if (movedNodeFinal) {
8028
+ setSelectedLayerIdsState([movedNodeFinal.id]);
8029
+ setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
8030
+ }
8031
+ },
8032
+ [applyFileContentUpdate, canEditDesign, getScreenContent, t],
8033
+ );
8034
+
6642
8035
  const handleCutSelection = useCallback(async () => {
6643
8036
  if (!selectedElement?.selector) return;
6644
8037
  // Copy first (populates the internal clipboard ref even if the async
@@ -6737,8 +8130,7 @@ export default function DesignEditor() {
6737
8130
  textAlign: selectedElement.computedStyles.textAlign,
6738
8131
  };
6739
8132
  setHasPropsClipboard(true);
6740
- toast.success(t("designEditor.toasts.propsCopied"));
6741
- }, [selectedElement, t]);
8133
+ }, [selectedElement]);
6742
8134
 
6743
8135
  const handlePasteProps = useCallback(() => {
6744
8136
  if (!canEditDesign) return;
@@ -6749,8 +8141,7 @@ export default function DesignEditor() {
6749
8141
  ),
6750
8142
  );
6751
8143
  commitVisualStyles(selectedElement.selector, styles);
6752
- toast.success(t("designEditor.toasts.propsPasted"));
6753
- }, [canEditDesign, commitVisualStyles, selectedElement, t]);
8144
+ }, [canEditDesign, commitVisualStyles, selectedElement]);
6754
8145
 
6755
8146
  const changeSelectedZIndex = useCallback(
6756
8147
  (mode: "forward" | "front" | "backward" | "back") => {
@@ -6810,31 +8201,67 @@ export default function DesignEditor() {
6810
8201
  if (!canEditDesign) return;
6811
8202
  const um = undoManagerRef.current;
6812
8203
  const undoContent = () => {
6813
- if (!um || !um.canUndo()) return false;
6814
- um.undo();
6815
- if (ydoc && activeFile) {
6816
- const next = ydoc.getText("content").toString();
6817
- lastLocalContentRef.current = next;
6818
- queueFileContentSave(activeFile.id, next, {
6819
- syncCollab: !(ydoc && isSynced),
6820
- });
6821
- if (!replacePreviewContent(next)) {
6822
- setContentRenderRevision((revision) => revision + 1);
8204
+ if (um?.canUndo()) {
8205
+ um.undo();
8206
+ if (ydoc && activeFile) {
8207
+ const next = ydoc.getText("content").toString();
8208
+ markPendingLocalFileContent(
8209
+ activeFile.id,
8210
+ next,
8211
+ activeFile.updatedAt,
8212
+ );
8213
+ lastLocalContentRef.current = next;
8214
+ queueFileContentSave(activeFile.id, next, {
8215
+ syncCollab: !(ydoc && isSynced),
8216
+ });
8217
+ if (!replacePreviewContent(next)) {
8218
+ setContentRenderRevision((revision) => revision + 1);
8219
+ }
8220
+ // Clear stale selection if the undo removed the selected element.
8221
+ setSelectedElement((prev) => {
8222
+ if (!prev) return prev;
8223
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8224
+ });
8225
+ setHoveredElement((prev) => {
8226
+ if (!prev) return prev;
8227
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8228
+ });
6823
8229
  }
6824
- // Clear stale selection if the undo removed the selected element.
6825
- setSelectedElement((prev) => {
6826
- if (!prev) return prev;
6827
- return elementInfoExistsInContent(next, prev) ? prev : null;
6828
- });
6829
- setHoveredElement((prev) => {
6830
- if (!prev) return prev;
6831
- return elementInfoExistsInContent(next, prev) ? prev : null;
6832
- });
8230
+ redoOrderRef.current = [
8231
+ ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8232
+ "content",
8233
+ ];
8234
+ return true;
6833
8235
  }
8236
+
8237
+ if (!activeFile) return false;
8238
+ const entry = contentUndoStackRef.current.pop();
8239
+ if (!entry || entry.fileId !== activeFile.id) return false;
8240
+ contentRedoStackRef.current = [
8241
+ ...contentRedoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8242
+ entry,
8243
+ ];
6834
8244
  redoOrderRef.current = [
6835
8245
  ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
6836
8246
  "content",
6837
8247
  ];
8248
+ suppressContentHistoryRef.current = true;
8249
+ try {
8250
+ applyLocalContentUpdate(entry.before, {
8251
+ refreshPreview: false,
8252
+ immediateSave: true,
8253
+ });
8254
+ } finally {
8255
+ suppressContentHistoryRef.current = false;
8256
+ }
8257
+ setSelectedElement((prev) => {
8258
+ if (!prev) return prev;
8259
+ return elementInfoExistsInContent(entry.before, prev) ? prev : null;
8260
+ });
8261
+ setHoveredElement((prev) => {
8262
+ if (!prev) return prev;
8263
+ return elementInfoExistsInContent(entry.before, prev) ? prev : null;
8264
+ });
6838
8265
  return true;
6839
8266
  };
6840
8267
  const undoGeometry = () => {
@@ -6870,8 +8297,10 @@ export default function DesignEditor() {
6870
8297
  }, [
6871
8298
  ydoc,
6872
8299
  activeFile,
8300
+ applyLocalContentUpdate,
6873
8301
  canEditDesign,
6874
8302
  isSynced,
8303
+ markPendingLocalFileContent,
6875
8304
  queueFileContentSave,
6876
8305
  replacePreviewContent,
6877
8306
  syncUndoRedoState,
@@ -6882,31 +8311,67 @@ export default function DesignEditor() {
6882
8311
  if (!canEditDesign) return;
6883
8312
  const um = undoManagerRef.current;
6884
8313
  const redoContent = () => {
6885
- if (!um || !um.canRedo()) return false;
6886
- um.redo();
6887
- if (ydoc && activeFile) {
6888
- const next = ydoc.getText("content").toString();
6889
- lastLocalContentRef.current = next;
6890
- queueFileContentSave(activeFile.id, next, {
6891
- syncCollab: !(ydoc && isSynced),
6892
- });
6893
- if (!replacePreviewContent(next)) {
6894
- setContentRenderRevision((revision) => revision + 1);
8314
+ if (um?.canRedo()) {
8315
+ um.redo();
8316
+ if (ydoc && activeFile) {
8317
+ const next = ydoc.getText("content").toString();
8318
+ markPendingLocalFileContent(
8319
+ activeFile.id,
8320
+ next,
8321
+ activeFile.updatedAt,
8322
+ );
8323
+ lastLocalContentRef.current = next;
8324
+ queueFileContentSave(activeFile.id, next, {
8325
+ syncCollab: !(ydoc && isSynced),
8326
+ });
8327
+ if (!replacePreviewContent(next)) {
8328
+ setContentRenderRevision((revision) => revision + 1);
8329
+ }
8330
+ // Clear stale selection if the redo removed the selected element.
8331
+ setSelectedElement((prev) => {
8332
+ if (!prev) return prev;
8333
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8334
+ });
8335
+ setHoveredElement((prev) => {
8336
+ if (!prev) return prev;
8337
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8338
+ });
6895
8339
  }
6896
- // Clear stale selection if the redo removed the selected element.
6897
- setSelectedElement((prev) => {
6898
- if (!prev) return prev;
6899
- return elementInfoExistsInContent(next, prev) ? prev : null;
6900
- });
6901
- setHoveredElement((prev) => {
6902
- if (!prev) return prev;
6903
- return elementInfoExistsInContent(next, prev) ? prev : null;
6904
- });
8340
+ historyOrderRef.current = [
8341
+ ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8342
+ "content",
8343
+ ];
8344
+ return true;
6905
8345
  }
8346
+
8347
+ if (!activeFile) return false;
8348
+ const entry = contentRedoStackRef.current.pop();
8349
+ if (!entry || entry.fileId !== activeFile.id) return false;
8350
+ contentUndoStackRef.current = [
8351
+ ...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8352
+ entry,
8353
+ ];
6906
8354
  historyOrderRef.current = [
6907
8355
  ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
6908
8356
  "content",
6909
8357
  ];
8358
+ suppressContentHistoryRef.current = true;
8359
+ try {
8360
+ applyLocalContentUpdate(entry.after, {
8361
+ refreshPreview: false,
8362
+ immediateSave: true,
8363
+ });
8364
+ } finally {
8365
+ suppressContentHistoryRef.current = false;
8366
+ }
8367
+ setSelectedElement((prev) => {
8368
+ if (!prev) return prev;
8369
+ return elementInfoExistsInContent(entry.after, prev) ? prev : null;
8370
+ });
8371
+ setHoveredElement((prev) => {
8372
+ if (!prev) return prev;
8373
+ return elementInfoExistsInContent(entry.after, prev) ? prev : null;
8374
+ });
6910
8375
  return true;
6911
8376
  };
6912
8377
  const redoGeometry = () => {
@@ -6942,8 +8407,10 @@ export default function DesignEditor() {
6942
8407
  }, [
6943
8408
  ydoc,
6944
8409
  activeFile,
8410
+ applyLocalContentUpdate,
6945
8411
  canEditDesign,
6946
8412
  isSynced,
8413
+ markPendingLocalFileContent,
6947
8414
  queueFileContentSave,
6948
8415
  replacePreviewContent,
6949
8416
  syncUndoRedoState,
@@ -7382,6 +8849,8 @@ export default function DesignEditor() {
7382
8849
  setTitleDraft(design?.title ?? "");
7383
8850
  setTitleEditing(true);
7384
8851
  },
8852
+ onGroup: canEditDesign ? handleGroupSelection : undefined,
8853
+ onUngroup: canEditDesign ? handleUngroupSelection : undefined,
7385
8854
  onSelectAll: handleSelectAllFrames,
7386
8855
  onUndo: canEditDesign ? handleUndo : undefined,
7387
8856
  onRedo: canEditDesign ? handleRedo : undefined,
@@ -8458,6 +9927,55 @@ ${serializedHtml}
8458
9927
  );
8459
9928
  }, [activeContent, activeFile?.id, builderPreviewUrl, overviewScreens]);
8460
9929
 
9930
+ // §6.4 / §8 — Breakpoints list for the StatesPanel, derived from
9931
+ // designs.data.breakpointSet. Returns a stable empty array when none are set.
9932
+ const statesPanelBreakpoints = useMemo<
9933
+ Array<{ id: string; label: string; widthPx: number }>
9934
+ >(() => {
9935
+ try {
9936
+ const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
9937
+ if (
9938
+ raw &&
9939
+ typeof raw === "object" &&
9940
+ !Array.isArray(raw) &&
9941
+ Array.isArray((raw as Record<string, unknown>).breakpoints)
9942
+ ) {
9943
+ const bps = (
9944
+ raw as {
9945
+ breakpoints: Array<{
9946
+ id: string;
9947
+ widthPx: number;
9948
+ label?: string;
9949
+ }>;
9950
+ }
9951
+ ).breakpoints;
9952
+ return bps.map((bp) => ({
9953
+ id: bp.id,
9954
+ widthPx: bp.widthPx,
9955
+ label:
9956
+ bp.label ??
9957
+ (bp.widthPx >= 1024
9958
+ ? "Desktop"
9959
+ : bp.widthPx >= 600
9960
+ ? "Tablet"
9961
+ : "Mobile"),
9962
+ }));
9963
+ }
9964
+ } catch {
9965
+ // ignore
9966
+ }
9967
+ return [];
9968
+ }, [designDataJson]);
9969
+
9970
+ // Active breakpoint id for the StatesPanel — "auto" when no frame is focused.
9971
+ const statesPanelActiveBreakpointId = useMemo<string>(() => {
9972
+ if (activeBreakpointWidthState == null) return "auto";
9973
+ const match = statesPanelBreakpoints.find(
9974
+ (bp) => bp.widthPx === activeBreakpointWidthState,
9975
+ );
9976
+ return match?.id ?? "auto";
9977
+ }, [activeBreakpointWidthState, statesPanelBreakpoints]);
9978
+
8461
9979
  const handleOpenDesignPreview = useCallback(() => {
8462
9980
  if (activeScreenPreviewUrl) {
8463
9981
  window.open(activeScreenPreviewUrl, "_blank", "noopener,noreferrer");
@@ -8479,6 +9997,16 @@ ${serializedHtml}
8479
9997
  selectedElementLayerId ??
8480
9998
  activeFile?.id ??
8481
9999
  "";
10000
+ const selectedElementFullViewScreenId =
10001
+ viewMode === "overview" && selectedElement
10002
+ ? selectedElementLayerId
10003
+ ? (codeLayerOwnerByNodeId.get(selectedElementLayerId)?.fileId ??
10004
+ activeFileId)
10005
+ : activeFileId
10006
+ : null;
10007
+ const fullViewScreenIds = selectedElementFullViewScreenId
10008
+ ? [selectedElementFullViewScreenId]
10009
+ : [];
8482
10010
  const activeLayerLocked = Boolean(
8483
10011
  activeLayerId && effectiveCodeLayerState.lockedIds.has(activeLayerId),
8484
10012
  );
@@ -8486,6 +10014,35 @@ ${serializedHtml}
8486
10014
  activeLayerId && effectiveCodeLayerState.hiddenIds.has(activeLayerId),
8487
10015
  );
8488
10016
 
10017
+ // Detect if the active screen is a localhost/local source so we can show a banner.
10018
+ const activeScreenIsLocalSource =
10019
+ viewMode === "single" &&
10020
+ Boolean(activeFile) &&
10021
+ activeOverviewScreen?.sourceType === "localhost";
10022
+ const activeScreenRouteSourceFile = activeScreenIsLocalSource
10023
+ ? getLocalhostRouteSourceFile({
10024
+ sourceFile: activeOverviewScreen?.sourceFile,
10025
+ source: activeOverviewScreen?.source,
10026
+ })
10027
+ : undefined;
10028
+
10029
+ // canGroup: 2+ DOM-node layers selected in the active screen (not file rows).
10030
+ const fileIdSet = new Set(files.map((f) => f.id));
10031
+ const selectedDomLayerIds = selectedLayerIds.filter(
10032
+ (id) => !id.startsWith("__") && !fileIdSet.has(id),
10033
+ );
10034
+ const canGroup =
10035
+ canEditDesign &&
10036
+ viewMode === "single" &&
10037
+ Boolean(activeFile) &&
10038
+ selectedDomLayerIds.length >= 2;
10039
+ // canUngroup: exactly one DOM-node layer selected.
10040
+ const canUngroup =
10041
+ canEditDesign &&
10042
+ viewMode === "single" &&
10043
+ Boolean(activeFile) &&
10044
+ selectedDomLayerIds.length === 1;
10045
+
8489
10046
  const canMoveLayer = useCallback(
8490
10047
  (intent: LayersPanelMoveIntent) => {
8491
10048
  const targetOwner = codeLayerOwnerByNodeId.get(intent.targetId);
@@ -8498,17 +10055,24 @@ ${serializedHtml}
8498
10055
  }
8499
10056
  return intent.draggedIds.some((draggedId) => {
8500
10057
  const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
8501
- return (
8502
- draggedId !== intent.targetId &&
8503
- !!draggedOwner &&
8504
- draggedOwner.fileId === targetOwner.fileId &&
8505
- !collectCodeLayerAncestors(
10058
+ if (
10059
+ draggedId === intent.targetId ||
10060
+ !draggedOwner ||
10061
+ effectiveCodeLayerState.lockedIds.has(draggedId) ||
10062
+ effectiveCodeLayerState.hiddenIds.has(draggedId)
10063
+ ) {
10064
+ return false;
10065
+ }
10066
+ // Same-file move: also exclude ancestor drags (would orphan the node).
10067
+ if (draggedOwner.fileId === targetOwner.fileId) {
10068
+ return !collectCodeLayerAncestors(
8506
10069
  targetOwner.tree,
8507
10070
  intent.targetId,
8508
- ).includes(draggedId) &&
8509
- !effectiveCodeLayerState.lockedIds.has(draggedId) &&
8510
- !effectiveCodeLayerState.hiddenIds.has(draggedId)
8511
- );
10071
+ ).includes(draggedId);
10072
+ }
10073
+ // Cross-file move: allowed as long as neither side is locked/hidden
10074
+ // (already checked above). File-row ids are excluded by the owner check.
10075
+ return true;
8512
10076
  });
8513
10077
  },
8514
10078
  [codeLayerOwnerByNodeId, effectiveCodeLayerState],
@@ -8526,26 +10090,45 @@ ${serializedHtml}
8526
10090
  ) {
8527
10091
  return;
8528
10092
  }
8529
- const sourceFile = files.find((file) => file.id === targetOwner.fileId);
8530
- const sourceContent =
10093
+ const destFile = files.find((file) => file.id === targetOwner.fileId);
10094
+ const destContent =
8531
10095
  targetOwner.fileId === activeFile?.id
8532
10096
  ? activeContent
8533
- : (sourceFile?.content ?? "");
8534
- if (!sourceContent) return;
8535
- let nextContent = sourceContent;
8536
- let moved = false;
10097
+ : (destFile?.content ?? "");
10098
+ if (!destContent) return;
10099
+
10100
+ // Group dragged ids by source file so we can handle same-file and
10101
+ // cross-file moves independently.
10102
+ const sameFileDragIds: string[] = [];
10103
+ const crossFileDrags: Array<{ draggedId: string; sourceFileId: string }> =
10104
+ [];
10105
+ const movedNodeSnapshots = new Map<string, CodeLayerNode>();
8537
10106
  for (const draggedId of intent.draggedIds) {
8538
10107
  const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
8539
10108
  if (
8540
10109
  draggedId === intent.targetId ||
8541
10110
  !draggedOwner ||
8542
- draggedOwner.fileId !== targetOwner.fileId ||
8543
10111
  effectiveCodeLayerState.lockedIds.has(draggedId) ||
8544
10112
  effectiveCodeLayerState.hiddenIds.has(draggedId)
8545
10113
  ) {
8546
10114
  continue;
8547
10115
  }
8548
- const patch = applyVisualEdit(nextContent, {
10116
+ movedNodeSnapshots.set(draggedId, draggedOwner.node);
10117
+ if (draggedOwner.fileId === targetOwner.fileId) {
10118
+ sameFileDragIds.push(draggedId);
10119
+ } else {
10120
+ crossFileDrags.push({
10121
+ draggedId,
10122
+ sourceFileId: draggedOwner.fileId,
10123
+ });
10124
+ }
10125
+ }
10126
+
10127
+ // --- Same-file moves (existing path) ---
10128
+ let nextDestContent = destContent;
10129
+ let moved = false;
10130
+ for (const draggedId of sameFileDragIds) {
10131
+ const patch = applyVisualEdit(nextDestContent, {
8549
10132
  kind: "moveNode",
8550
10133
  target: { nodeId: draggedId },
8551
10134
  anchor: { nodeId: intent.targetId },
@@ -8561,13 +10144,109 @@ ${serializedHtml}
8561
10144
  );
8562
10145
  continue;
8563
10146
  }
8564
- nextContent = patch.content;
10147
+ nextDestContent = patch.content;
8565
10148
  moved = true;
8566
10149
  }
8567
- if (!moved || nextContent === sourceContent) return;
8568
- applyFileContentUpdate(targetOwner.fileId, nextContent, {
8569
- refreshPreview: true,
8570
- });
10150
+
10151
+ // --- Cross-file moves: use moveNodeBetweenDocuments ---
10152
+ // Group by source file so multiple nodes from the same source are
10153
+ // applied sequentially against the running source content.
10154
+ const sourceContentMap = new Map<string, string>();
10155
+ for (const { draggedId, sourceFileId } of crossFileDrags) {
10156
+ const srcFile = files.find((f) => f.id === sourceFileId);
10157
+ if (!srcFile) continue;
10158
+ const currentSourceContent = getLayerMoveSourceContent({
10159
+ sourceFileId,
10160
+ activeFileId: activeFile?.id,
10161
+ activeContent,
10162
+ sourceFileContent: srcFile.content,
10163
+ sourceContentMap,
10164
+ });
10165
+
10166
+ // The dragged node's data-agent-native-node-id is the node id tracked
10167
+ // by code-layer. Look up the actual attribute value from the owner.
10168
+ const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
10169
+ const nodeAttrId =
10170
+ draggedOwner?.node.dataAttributes["data-agent-native-node-id"] ??
10171
+ draggedId;
10172
+ const anchorAttrId =
10173
+ codeLayerOwnerByNodeId.get(intent.targetId)?.node.dataAttributes[
10174
+ "data-agent-native-node-id"
10175
+ ] ?? intent.targetId;
10176
+
10177
+ const result = moveNodeBetweenDocuments(
10178
+ currentSourceContent,
10179
+ nextDestContent,
10180
+ {
10181
+ nodeId: nodeAttrId,
10182
+ anchorNodeId: anchorAttrId,
10183
+ placement: intent.placement,
10184
+ },
10185
+ );
10186
+ if (result.status !== "applied") {
10187
+ toast.error(
10188
+ codeLayerPatchMessage(
10189
+ result.message,
10190
+ t("designEditor.toasts.layerMoveFailed"),
10191
+ ),
10192
+ { duration: 4000 },
10193
+ );
10194
+ continue;
10195
+ }
10196
+ sourceContentMap.set(sourceFileId, result.sourceHtml);
10197
+ nextDestContent = result.destHtml;
10198
+ moved = true;
10199
+ }
10200
+
10201
+ if (!moved) return;
10202
+
10203
+ const finalDestProjection =
10204
+ nextDestContent !== destContent
10205
+ ? buildCodeLayerProjection(nextDestContent)
10206
+ : null;
10207
+ const finalDestTree = finalDestProjection
10208
+ ? buildCodeLayerTree(finalDestProjection)
10209
+ : [];
10210
+ const movedNodesAfterMove = intent.draggedIds
10211
+ .map((draggedId) => movedNodeSnapshots.get(draggedId))
10212
+ .map((node) =>
10213
+ node && finalDestProjection
10214
+ ? findCodeLayerNodeInProjection(finalDestProjection, node)
10215
+ : null,
10216
+ )
10217
+ .filter((node): node is CodeLayerNode => Boolean(node));
10218
+
10219
+ if (movedNodesAfterMove.length > 0) {
10220
+ setSelectedLayerIdsState(movedNodesAfterMove.map((node) => node.id));
10221
+ const lastMovedNode =
10222
+ movedNodesAfterMove[movedNodesAfterMove.length - 1];
10223
+ if (lastMovedNode && targetOwner.fileId === activeFile?.id) {
10224
+ setSelectedElement(elementInfoFromCodeLayerNode(lastMovedNode));
10225
+ }
10226
+ const movedAncestorIds = movedNodesAfterMove.flatMap((node) =>
10227
+ collectCodeLayerAncestors(finalDestTree, node.id),
10228
+ );
10229
+ setExpandedLayerIds((current) => {
10230
+ const next = new Set(current);
10231
+ next.add(targetOwner.fileId);
10232
+ movedAncestorIds.forEach((ancestorId) => next.add(ancestorId));
10233
+ return next.size === current.length ? current : Array.from(next);
10234
+ });
10235
+ }
10236
+
10237
+ // Persist source files that changed.
10238
+ for (const [sourceFileId, newSourceContent] of sourceContentMap) {
10239
+ applyFileContentUpdate(sourceFileId, newSourceContent, {
10240
+ refreshPreview: false,
10241
+ });
10242
+ }
10243
+
10244
+ // Persist dest file (which may also be the active file).
10245
+ if (nextDestContent !== destContent) {
10246
+ applyFileContentUpdate(targetOwner.fileId, nextDestContent, {
10247
+ refreshPreview: false,
10248
+ });
10249
+ }
8571
10250
  },
8572
10251
  [
8573
10252
  activeContent,
@@ -8616,6 +10295,21 @@ ${serializedHtml}
8616
10295
  ? currentLayerIds.filter((layerId) => layerId !== intent.id)
8617
10296
  : [...currentLayerIds, intent.id];
8618
10297
  setSelectedLayerIdsState(additiveLayerIds);
10298
+ if (viewModeRef.current === "overview") {
10299
+ const fileIds = files.map((file) => file.id);
10300
+ const selectedScreenIds = getOverviewScreenIdsFromLayerSelection({
10301
+ fileIds,
10302
+ layerIds: additiveLayerIds,
10303
+ });
10304
+ const toggledScreen =
10305
+ getOverviewScreenIdsFromLayerSelection({
10306
+ fileIds,
10307
+ layerIds: [intent.id],
10308
+ }).length > 0;
10309
+ if (toggledScreen || selectedScreenIds.length > 0) {
10310
+ setOverviewSelectedScreenIds(selectedScreenIds);
10311
+ }
10312
+ }
8619
10313
  setSelectedElement(null);
8620
10314
  focusDesignInspectorForSelection();
8621
10315
  setActiveTool("move");
@@ -9096,6 +10790,19 @@ ${serializedHtml}
9096
10790
  ? t("designEditor.stopPinningComments")
9097
10791
  : t("designEditor.pinComment")}
9098
10792
  </DropdownMenuItem>
10793
+ {isSignedIn && (
10794
+ <>
10795
+ <DropdownMenuSeparator />
10796
+ <DropdownMenuItem
10797
+ onClick={() => {
10798
+ handleOpenMakeReal();
10799
+ }}
10800
+ >
10801
+ <IconRocket className="mr-2 h-4 w-4" />
10802
+ {"Make this a real app" /* i18n-ignore */}
10803
+ </DropdownMenuItem>
10804
+ </>
10805
+ )}
9099
10806
  </DropdownMenuContent>
9100
10807
  </DropdownMenu>
9101
10808
  );
@@ -9254,6 +10961,28 @@ ${serializedHtml}
9254
10961
  <TooltipContent>{t("designEditor.designPreview")}</TooltipContent>
9255
10962
  </Tooltip>
9256
10963
 
10964
+ {/* §6.6 — "Make this a real app" shortcut button (signed-in only).
10965
+ Surfaces the migration CTA without requiring the project menu. */}
10966
+ {isSignedIn && (
10967
+ <Tooltip>
10968
+ <TooltipTrigger asChild>
10969
+ <Button
10970
+ variant="ghost"
10971
+ size="icon"
10972
+ className="size-8 cursor-pointer rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
10973
+ onClick={handleOpenMakeReal}
10974
+ disabled={migrateMutation.isPending}
10975
+ aria-label={"Make this a real app" /* i18n-ignore */}
10976
+ >
10977
+ <IconRocket className="size-4" />
10978
+ </Button>
10979
+ </TooltipTrigger>
10980
+ <TooltipContent>
10981
+ {"Make this a real app" /* i18n-ignore */}
10982
+ </TooltipContent>
10983
+ </Tooltip>
10984
+ )}
10985
+
9257
10986
  {isSignedIn ? (
9258
10987
  <ShareButton
9259
10988
  resourceType="design"
@@ -9447,6 +11176,19 @@ ${serializedHtml}
9447
11176
  ? t("designEditor.stopPinningComments")
9448
11177
  : t("designEditor.pinComment")}
9449
11178
  </DropdownMenuItem>
11179
+ {isSignedIn && (
11180
+ <>
11181
+ <DropdownMenuSeparator />
11182
+ <DropdownMenuItem
11183
+ onClick={() => {
11184
+ handleOpenMakeReal();
11185
+ }}
11186
+ >
11187
+ <IconRocket className="mr-2 h-4 w-4" />
11188
+ {"Make this a real app" /* i18n-ignore */}
11189
+ </DropdownMenuItem>
11190
+ </>
11191
+ )}
9450
11192
  </DropdownMenuContent>
9451
11193
  </DropdownMenu>
9452
11194
  {titleEditing && canEditDesign ? (
@@ -9786,7 +11528,9 @@ ${serializedHtml}
9786
11528
  canEditDesign && hasPropsClipboard && Boolean(selectedElement)
9787
11529
  }
9788
11530
  canCopyAsCode={Boolean(selectedElement?.selector)}
9789
- hiddenActions={["group", "ungroup", "rename"]}
11531
+ canGroup={canGroup}
11532
+ canUngroup={canUngroup}
11533
+ hiddenActions={["rename"]}
9790
11534
  getCanvasPoint={getContextCanvasPoint}
9791
11535
  onPasteHere={(details) =>
9792
11536
  handlePasteSelection(
@@ -9818,310 +11562,396 @@ ${serializedHtml}
9818
11562
  handleToggleLayerHidden(activeLayerId, !activeLayerHidden);
9819
11563
  }
9820
11564
  }}
11565
+ onGroup={canGroup ? handleGroupSelection : undefined}
11566
+ onUngroup={canUngroup ? handleUngroupSelection : undefined}
9821
11567
  onCopyProps={handleCopyProps}
9822
11568
  onPasteProps={handlePasteProps}
9823
11569
  onCopyAsCode={handleCopySelection}
9824
11570
  >
9825
11571
  {activeFile ? (
9826
- <div
9827
- ref={canvasContainerRef}
9828
- className="relative mx-1 h-full min-w-0 flex-1 overflow-hidden rounded-xl bg-[var(--design-editor-canvas-bg)]"
9829
- onPointerMove={handleCanvasPointerMove}
9830
- >
9831
- {/* Transparent shield that blocks pointer events reaching the
11572
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
11573
+ {/* Banner for screens connected to a local dev server: edits
11574
+ route through the agent rather than being applied inline. */}
11575
+ {activeScreenIsLocalSource &&
11576
+ !localSourceBannerDismissed &&
11577
+ id && (
11578
+ <LocalSourceEditBanner
11579
+ designId={id}
11580
+ fileId={activeFile.id}
11581
+ routeSourceFile={activeScreenRouteSourceFile}
11582
+ onDismiss={() => setLocalSourceBannerDismissed(true)}
11583
+ />
11584
+ )}
11585
+ <div
11586
+ ref={canvasContainerRef}
11587
+ className="relative mx-1 min-w-0 flex-1 overflow-hidden rounded-xl bg-[var(--design-editor-canvas-bg)]"
11588
+ onPointerMove={handleCanvasPointerMove}
11589
+ >
11590
+ {/* Transparent shield that blocks pointer events reaching the
9832
11591
  iframe when a portaled Radix popover (e.g. color picker) is
9833
11592
  open. The iframe has its own event context so it receives
9834
11593
  pointer events even when visually covered by the popover. */}
9835
- {inspectorPopoverOpen && (
9836
- <div
9837
- aria-hidden="true"
9838
- style={{
9839
- position: "absolute",
9840
- inset: 0,
9841
- zIndex: 10,
9842
- pointerEvents: "auto",
9843
- }}
9844
- />
9845
- )}
9846
- {viewMode === "overview" ? (
9847
- <MultiScreenCanvas
9848
- screens={overviewScreens}
9849
- zoom={overviewCanvasZoom}
9850
- onZoomChange={setOverviewCanvasZoom}
9851
- activeId={activeFileId}
9852
- selectedScreenIds={overviewSelectedScreenIds}
9853
- activeScreenHasHoveredChild={
9854
- Boolean(hoveredElement) &&
9855
- !hoveredElementIsScreenRoot &&
9856
- hoveredElementScreenId === activeFileId
9857
- }
9858
- hoveredChildScreenId={hoveredChildScreenId}
9859
- directlyHoveredScreenId={hoveredScreenRootId}
9860
- previewDeviceFrame={deviceFrame}
9861
- activeTool={activeTool}
9862
- onActiveToolChange={(tool) =>
9863
- setActiveTool(tool === "rectangle" ? "rect" : tool)
9864
- }
9865
- selectAllRequest={overviewSelectAllRequest}
9866
- clearSelectionRequest={overviewClearSelectionRequest}
9867
- onScreenSelectionChange={
9868
- handleOverviewScreenSelectionChange
9869
- }
9870
- geometryById={canvasFrameGeometryById}
9871
- onGeometryChange={queueFrameGeometrySave}
9872
- onGeometryCommit={handleGeometryCommit}
9873
- onCreatePrimitive={handleCreatePrimitive}
9874
- onPrimitiveCreated={handlePrimitiveCreated}
9875
- onCreateScreenFrame={handleCreateScreenFrame}
9876
- onDeleteSelection={handleDeleteOverviewSelection}
9877
- onSelectionChange={setOverviewSelectedScreenIds}
9878
- onPick={(id) => {
9879
- pendingOverviewScreenSelectionRef.current = null;
9880
- setSelectedElement(null);
9881
- setHoveredElement(null);
9882
- setSelectedLayerIdsState([id]);
9883
- setActiveFileId(id);
9884
- setActiveTool("move");
9885
- setMode("edit");
9886
- }}
9887
- onEdit={enterSingleScreen}
9888
- onDuplicate={handleDuplicateScreen}
9889
- renderScreenContent={(screen, metadata, geometry) => {
9890
- const screenIsActive = screen.id === activeFile?.id;
9891
- const screenContent = getScreenContent(screen.id);
9892
- const screenContentKey = [
9893
- screen.id,
9894
- screen.updatedAt ?? "",
9895
- getContentSignature(screenContent),
9896
- screenIsActive ? contentRenderRevision : 0,
9897
- ].join(":");
9898
-
9899
- return (
9900
- <DesignCanvas
9901
- content={screenContent}
9902
- contentKey={screenContentKey}
9903
- zoom={100}
9904
- deviceFrame="none"
9905
- embeddedFrame={{
9906
- viewportWidth: Math.max(
9907
- 1,
9908
- Math.round(geometry.width),
9909
- ),
9910
- viewportHeight: Math.max(
9911
- 1,
9912
- Math.round(geometry.height),
9913
- ),
9914
- displayWidth: Math.max(
9915
- 1,
9916
- Math.round(geometry.width),
9917
- ),
9918
- displayHeight: Math.max(
9919
- 1,
9920
- Math.round(geometry.height),
9921
- ),
9922
- fluid: true,
9923
- }}
9924
- editorChromeScaleX={overviewCanvasZoom / 100}
9925
- editorChromeScaleY={overviewCanvasZoom / 100}
9926
- editMode={mode === "edit"}
9927
- interactMode={false}
9928
- readOnly={!canEditDesign}
9929
- scaleMode={screenIsActive && activeTool === "scale"}
9930
- clearSelectionRequest={overviewClearSelectionRequest}
9931
- registerRuntimeBridge={screenIsActive}
9932
- selectedSelector={
9933
- screenIsActive ? selectedCanvasSelector : null
9934
- }
9935
- selectedSelectorCandidates={
9936
- screenIsActive
9937
- ? selectedCanvasSelectorCandidates
9938
- : []
9939
- }
9940
- hoveredSelector={
9941
- hoveredElementScreenId === screen.id
9942
- ? hoveredCanvasSelector
9943
- : null
9944
- }
9945
- hoveredSelectorCandidates={
9946
- hoveredElementScreenId === screen.id
9947
- ? hoveredCanvasSelectorCandidates
9948
- : []
9949
- }
9950
- lockedSelectors={getLayerSelectorsForFile(
9951
- screen.id,
9952
- lockedLayerIds,
9953
- )}
9954
- hiddenSelectors={getLayerSelectorsForFile(
9955
- screen.id,
9956
- hiddenLayerIds,
9957
- )}
9958
- onElementSelect={(info) =>
9959
- handleScreenElementSelect(screen.id, info)
9960
- }
9961
- onElementHover={(info) =>
9962
- handleScreenElementHover(screen.id, info)
9963
- }
9964
- onClearSelection={() =>
9965
- handleScreenElementClear(screen.id)
11594
+ {inspectorPopoverOpen && (
11595
+ <div
11596
+ aria-hidden="true"
11597
+ style={{
11598
+ position: "absolute",
11599
+ inset: 0,
11600
+ zIndex: 10,
11601
+ pointerEvents: "auto",
11602
+ }}
11603
+ />
11604
+ )}
11605
+ {viewMode === "overview" ? (
11606
+ <MultiScreenCanvas
11607
+ screens={overviewScreens}
11608
+ zoom={overviewCanvasZoom}
11609
+ onZoomChange={setOverviewCanvasZoom}
11610
+ activeId={activeFileId}
11611
+ selectedScreenIds={overviewSelectedScreenIds}
11612
+ fullViewScreenIds={fullViewScreenIds}
11613
+ activeScreenHasHoveredChild={
11614
+ Boolean(hoveredElement) &&
11615
+ !hoveredElementIsScreenRoot &&
11616
+ hoveredElementScreenId === activeFileId
11617
+ }
11618
+ hoveredChildScreenId={hoveredChildScreenId}
11619
+ directlyHoveredScreenId={hoveredScreenRootId}
11620
+ previewDeviceFrame={deviceFrame}
11621
+ activeTool={activeTool}
11622
+ onActiveToolChange={(tool) =>
11623
+ setActiveTool(tool === "rectangle" ? "rect" : tool)
11624
+ }
11625
+ selectAllRequest={overviewSelectAllRequest}
11626
+ clearSelectionRequest={overviewClearSelectionRequest}
11627
+ onScreenSelectionChange={
11628
+ handleOverviewScreenSelectionChange
11629
+ }
11630
+ geometryById={canvasFrameGeometryById}
11631
+ onGeometryChange={queueFrameGeometrySave}
11632
+ onGeometryCommit={handleGeometryCommit}
11633
+ onCreatePrimitive={handleCreatePrimitive}
11634
+ onPrimitiveCreated={handlePrimitiveCreated}
11635
+ onPrimitiveReparent={handleOverviewPrimitiveReparent}
11636
+ onCrossScreenElementDrop={handleCrossScreenElementDrop}
11637
+ onCreateScreenFrame={handleCreateScreenFrame}
11638
+ onDeleteSelection={handleDeleteOverviewSelection}
11639
+ onSelectionChange={setOverviewSelectedScreenIds}
11640
+ onPick={(id) => {
11641
+ pendingOverviewScreenSelectionRef.current = null;
11642
+ setSelectedElement(null);
11643
+ setHoveredElement(null);
11644
+ setSelectedLayerIdsState([id]);
11645
+ setActiveFileId(id);
11646
+ setActiveTool("move");
11647
+ setMode("edit");
11648
+ }}
11649
+ onEdit={enterSingleScreen}
11650
+ onDuplicate={handleDuplicateScreen}
11651
+ onAddBreakpoint={(screenId, widthPx) => {
11652
+ if (!id) return;
11653
+ const breakpointLabel =
11654
+ widthPx <= 480
11655
+ ? "Mobile"
11656
+ : widthPx <= 1024
11657
+ ? "Tablet"
11658
+ : "Desktop";
11659
+ void addBreakpointMutation.mutateAsync({
11660
+ designId: id,
11661
+ label: breakpointLabel,
11662
+ widthPx,
11663
+ });
11664
+ }}
11665
+ onActiveBreakpointChange={(_screenId, widthPx) => {
11666
+ setActiveBreakpointWidthState(widthPx);
11667
+ if (!id) return;
11668
+ const bpSet = (() => {
11669
+ try {
11670
+ const raw = (
11671
+ designDataJson as Record<string, unknown>
11672
+ )?.breakpointSet;
11673
+ if (
11674
+ raw &&
11675
+ typeof raw === "object" &&
11676
+ Array.isArray(
11677
+ (raw as Record<string, unknown>).breakpoints,
11678
+ )
11679
+ ) {
11680
+ return raw as {
11681
+ breakpoints: Array<{
11682
+ id: string;
11683
+ widthPx: number;
11684
+ }>;
11685
+ };
11686
+ }
11687
+ } catch {
11688
+ // Ignore malformed design data; the mutation below
11689
+ // can still clear back to auto.
9966
11690
  }
9967
- onIframeHotkey={handleIframeHotkey}
9968
- onIframeContextMenu={handleIframeContextMenu}
9969
- onVisualStyleChange={(selector, styles, info) =>
9970
- handleScreenVisualStyleChange(
11691
+ return null;
11692
+ })();
11693
+ const bp = bpSet?.breakpoints.find(
11694
+ (b) => b.widthPx === widthPx,
11695
+ );
11696
+ void setActiveBreakpointMutation.mutateAsync({
11697
+ designId: id,
11698
+ breakpointId:
11699
+ widthPx !== undefined && bp ? bp.id : "auto",
11700
+ });
11701
+ }}
11702
+ renderScreenContent={(screen, metadata, geometry) => {
11703
+ const screenIsActive = screen.id === activeFile?.id;
11704
+ const screenContent = getScreenContent(screen.id);
11705
+ const screenContentKey = screenIsActive
11706
+ ? [screen.id, contentRenderRevision].join(":")
11707
+ : [
9971
11708
  screen.id,
9972
- selector,
9973
- styles,
9974
- info,
9975
- )
9976
- }
9977
- onVisualStructureChange={(
9978
- selector,
9979
- anchorSelector,
9980
- placement,
9981
- info,
9982
- details,
9983
- ) =>
9984
- handleScreenVisualStructureChange(
11709
+ screen.updatedAt ?? "",
11710
+ getContentSignature(screenContent),
11711
+ 0,
11712
+ ].join(":");
11713
+
11714
+ return (
11715
+ <DesignCanvas
11716
+ content={screenContent}
11717
+ contentKey={screenContentKey}
11718
+ zoom={100}
11719
+ deviceFrame="none"
11720
+ sourceType={designSourceType}
11721
+ fusionUrl={designFusionUrl}
11722
+ onComponentSourceJump={handleComponentSourceJump}
11723
+ embeddedFrame={{
11724
+ viewportWidth: Math.max(
11725
+ 1,
11726
+ Math.round(geometry.width),
11727
+ ),
11728
+ viewportHeight: Math.max(
11729
+ 1,
11730
+ Math.round(geometry.height),
11731
+ ),
11732
+ displayWidth: Math.max(
11733
+ 1,
11734
+ Math.round(geometry.width),
11735
+ ),
11736
+ displayHeight: Math.max(
11737
+ 1,
11738
+ Math.round(geometry.height),
11739
+ ),
11740
+ fluid: true,
11741
+ }}
11742
+ editorChromeScaleX={overviewCanvasZoom / 100}
11743
+ editorChromeScaleY={overviewCanvasZoom / 100}
11744
+ editMode={mode === "edit"}
11745
+ interactMode={false}
11746
+ readOnly={!canEditDesign}
11747
+ scaleMode={screenIsActive && activeTool === "scale"}
11748
+ clearSelectionRequest={
11749
+ overviewClearSelectionRequest
11750
+ }
11751
+ registerRuntimeBridge={screenIsActive}
11752
+ selectedSelector={
11753
+ screenIsActive ? selectedCanvasSelector : null
11754
+ }
11755
+ selectedSelectorCandidates={
11756
+ screenIsActive
11757
+ ? selectedCanvasSelectorCandidates
11758
+ : []
11759
+ }
11760
+ hoveredSelector={
11761
+ hoveredElementScreenId === screen.id
11762
+ ? hoveredCanvasSelector
11763
+ : null
11764
+ }
11765
+ hoveredSelectorCandidates={
11766
+ hoveredElementScreenId === screen.id
11767
+ ? hoveredCanvasSelectorCandidates
11768
+ : []
11769
+ }
11770
+ lockedSelectors={getLayerSelectorsForFile(
9985
11771
  screen.id,
11772
+ lockedLayerIds,
11773
+ )}
11774
+ hiddenSelectors={getLayerSelectorsForFile(
11775
+ screen.id,
11776
+ hiddenLayerIds,
11777
+ )}
11778
+ onElementSelect={(info) =>
11779
+ handleScreenElementSelect(screen.id, info)
11780
+ }
11781
+ onElementHover={(info) =>
11782
+ handleScreenElementHover(screen.id, info)
11783
+ }
11784
+ onClearSelection={() =>
11785
+ handleScreenElementClear(screen.id)
11786
+ }
11787
+ onIframeHotkey={handleIframeHotkey}
11788
+ onIframeContextMenu={handleIframeContextMenu}
11789
+ onVisualStyleChange={(selector, styles, info) =>
11790
+ handleScreenVisualStyleChange(
11791
+ screen.id,
11792
+ selector,
11793
+ styles,
11794
+ info,
11795
+ )
11796
+ }
11797
+ onVisualStructureChange={(
9986
11798
  selector,
9987
11799
  anchorSelector,
9988
11800
  placement,
9989
11801
  info,
9990
11802
  details,
9991
- )
9992
- }
9993
- onVisualDuplicateChange={(
9994
- selector,
9995
- cloneHtml,
9996
- info,
9997
- details,
9998
- ) =>
9999
- handleScreenVisualDuplicateChange(
10000
- screen.id,
11803
+ ) =>
11804
+ handleScreenVisualStructureChange(
11805
+ screen.id,
11806
+ selector,
11807
+ anchorSelector,
11808
+ placement,
11809
+ info,
11810
+ details,
11811
+ )
11812
+ }
11813
+ onVisualDuplicateChange={(
10001
11814
  selector,
10002
11815
  cloneHtml,
10003
11816
  info,
10004
11817
  details,
10005
- )
10006
- }
10007
- onTextContentChange={(
10008
- selector,
10009
- value,
10010
- info,
10011
- details,
10012
- ) =>
10013
- handleScreenTextContentChange(
10014
- screen.id,
11818
+ ) =>
11819
+ handleScreenVisualDuplicateChange(
11820
+ screen.id,
11821
+ selector,
11822
+ cloneHtml,
11823
+ info,
11824
+ details,
11825
+ )
11826
+ }
11827
+ onTextContentChange={(
10015
11828
  selector,
10016
11829
  value,
10017
11830
  info,
10018
11831
  details,
10019
- )
10020
- }
10021
- onTextEditingStateChange={setTextEditingState}
10022
- onElementDblClickText={(info) =>
10023
- handleScreenElementDblClickText(screen.id, info)
10024
- }
10025
- tweakValues={cssVarValues}
10026
- drawMode={false}
10027
- pinMode={false}
10028
- designId={id}
10029
- designTitle={design?.title}
10030
- commentContextId={`${id}:${screen.id}`}
10031
- commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(screen.filename)}`}
10032
- />
10033
- );
10034
- }}
10035
- />
10036
- ) : (
10037
- <>
10038
- <DesignCanvas
10039
- content={activeContent}
10040
- contentKey={`${activeFile.id}:${contentRenderRevision}`}
10041
- zoom={zoom}
10042
- onZoomChange={setZoom}
10043
- deviceFrame={deviceFrame}
10044
- editMode={mode === "edit"}
10045
- interactMode={mode === "interact"}
10046
- readOnly={!canEditDesign}
10047
- scaleMode={activeTool === "scale"}
10048
- clearSelectionRequest={overviewClearSelectionRequest}
10049
- selectedSelector={selectedCanvasSelector}
10050
- selectedSelectorCandidates={
10051
- selectedCanvasSelectorCandidates
10052
- }
10053
- hoveredSelector={hoveredCanvasSelector}
10054
- hoveredSelectorCandidates={
10055
- hoveredCanvasSelectorCandidates
10056
- }
10057
- lockedSelectors={lockedLayerSelectors}
10058
- hiddenSelectors={hiddenLayerSelectors}
10059
- onElementSelect={handleElementSelect}
10060
- onElementHover={handleElementHover}
10061
- onClearSelection={() => {
10062
- setSelectedElement(null);
10063
- setHoveredElement(null);
10064
- setHoveredElementScreenId(null);
10065
- setSelectedLayerIdsState([]);
10066
- }}
10067
- onIframeHotkey={handleIframeHotkey}
10068
- onIframeContextMenu={handleIframeContextMenu}
10069
- onVisualStyleChange={handleVisualStyleChange}
10070
- onVisualStructureChange={handleVisualStructureChange}
10071
- onVisualDuplicateChange={handleVisualDuplicateChange}
10072
- onTextContentChange={handleTextContentChange}
10073
- onTextEditingStateChange={setTextEditingState}
10074
- onElementDblClickText={handleElementDblClickText}
10075
- tweakValues={cssVarValues}
10076
- drawMode={drawMode}
10077
- onExitDrawMode={() => {
10078
- setDrawMode(false);
10079
- setPinMode(false);
10080
- setActiveTool("move");
10081
- setMode("edit");
10082
- }}
10083
- pinMode={pinMode}
10084
- onExitPinMode={() => {
10085
- setPinMode(false);
10086
- if (mode === "annotate") {
10087
- setActiveTool("draw");
10088
- }
10089
- }}
10090
- designId={id}
10091
- designTitle={design?.title}
10092
- commentContextId={`${id}:${activeFile.id}`}
10093
- commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
10094
- onPrototypeNavigate={(screen) => {
10095
- if (!screen) return;
10096
- const norm = (s: string) =>
10097
- s
10098
- .replace(/^\.?\//, "")
10099
- .replace(/\.html?$/i, "")
10100
- .toLowerCase();
10101
- const target = norm(screen);
10102
- if (!target) return;
10103
- // Exact (normalized) filename match only — a substring match
10104
- // could send "board" to "dashboard.html".
10105
- const match = files.find(
10106
- (f) => norm(f.filename) === target,
11832
+ ) =>
11833
+ handleScreenTextContentChange(
11834
+ screen.id,
11835
+ selector,
11836
+ value,
11837
+ info,
11838
+ details,
11839
+ )
11840
+ }
11841
+ onTextEditingStateChange={setTextEditingState}
11842
+ onElementDblClickText={(info) =>
11843
+ handleScreenElementDblClickText(screen.id, info)
11844
+ }
11845
+ tweakValues={cssVarValues}
11846
+ drawMode={false}
11847
+ pinMode={false}
11848
+ designId={id}
11849
+ designTitle={design?.title}
11850
+ commentContextId={`${id}:${screen.id}`}
11851
+ commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(screen.filename)}`}
11852
+ />
10107
11853
  );
10108
- if (match) {
10109
- viewModeRef.current = "single";
10110
- setScreenZoom(FOCUSED_SCREEN_ZOOM);
10111
- setViewMode("single");
10112
- setActiveFileId(match.id);
10113
- }
10114
11854
  }}
10115
11855
  />
10116
- {/* Presence: live cursor overlay for remote participants */}
10117
- {others.length > 0 && (
10118
- <LiveCursorOverlay
10119
- others={others}
10120
- containerRef={canvasContainerRef}
11856
+ ) : (
11857
+ <>
11858
+ <DesignCanvas
11859
+ content={activeContent}
11860
+ contentKey={`${activeFile.id}:${contentRenderRevision}`}
11861
+ zoom={zoom}
11862
+ onZoomChange={setZoom}
11863
+ deviceFrame={deviceFrame}
11864
+ sourceType={designSourceType}
11865
+ fusionUrl={designFusionUrl}
11866
+ previewWidthPx={activeBreakpointWidthState}
11867
+ onComponentSourceJump={handleComponentSourceJump}
11868
+ // TODO: thread motionTracks / shaderFillPreview live-preview
11869
+ // props once the canvas-side live preview state is plumbed
11870
+ // here (non-trivial new state; MotionDock already drives
11871
+ // motion-preview directly via canvasIframeRef for now).
11872
+ motionTracks={motionTracksWire}
11873
+ editMode={mode === "edit"}
11874
+ interactMode={mode === "interact"}
11875
+ readOnly={!canEditDesign}
11876
+ scaleMode={activeTool === "scale"}
11877
+ clearSelectionRequest={overviewClearSelectionRequest}
11878
+ selectedSelector={selectedCanvasSelector}
11879
+ selectedSelectorCandidates={
11880
+ selectedCanvasSelectorCandidates
11881
+ }
11882
+ hoveredSelector={hoveredCanvasSelector}
11883
+ hoveredSelectorCandidates={
11884
+ hoveredCanvasSelectorCandidates
11885
+ }
11886
+ lockedSelectors={lockedLayerSelectors}
11887
+ hiddenSelectors={hiddenLayerSelectors}
11888
+ onElementSelect={handleElementSelect}
11889
+ onElementHover={handleElementHover}
11890
+ onClearSelection={() => {
11891
+ setSelectedElement(null);
11892
+ setHoveredElement(null);
11893
+ setHoveredElementScreenId(null);
11894
+ setSelectedLayerIdsState([]);
11895
+ }}
11896
+ onIframeHotkey={handleIframeHotkey}
11897
+ onIframeContextMenu={handleIframeContextMenu}
11898
+ onVisualStyleChange={handleVisualStyleChange}
11899
+ onVisualStructureChange={handleVisualStructureChange}
11900
+ onVisualDuplicateChange={handleVisualDuplicateChange}
11901
+ onTextContentChange={handleTextContentChange}
11902
+ onTextEditingStateChange={setTextEditingState}
11903
+ onElementDblClickText={handleElementDblClickText}
11904
+ tweakValues={cssVarValues}
11905
+ drawMode={drawMode}
11906
+ onExitDrawMode={() => {
11907
+ setDrawMode(false);
11908
+ setPinMode(false);
11909
+ setActiveTool("move");
11910
+ setMode("edit");
11911
+ }}
11912
+ pinMode={pinMode}
11913
+ onExitPinMode={() => {
11914
+ setPinMode(false);
11915
+ if (mode === "annotate") {
11916
+ setActiveTool("draw");
11917
+ }
11918
+ }}
11919
+ designId={id}
11920
+ designTitle={design?.title}
11921
+ commentContextId={`${id}:${activeFile.id}`}
11922
+ commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
11923
+ onPrototypeNavigate={(screen) => {
11924
+ if (!screen) return;
11925
+ const norm = (s: string) =>
11926
+ s
11927
+ .replace(/^\.?\//, "")
11928
+ .replace(/\.html?$/i, "")
11929
+ .toLowerCase();
11930
+ const target = norm(screen);
11931
+ if (!target) return;
11932
+ // Exact (normalized) filename match only — a substring match
11933
+ // could send "board" to "dashboard.html".
11934
+ const match = files.find(
11935
+ (f) => norm(f.filename) === target,
11936
+ );
11937
+ if (match) {
11938
+ viewModeRef.current = "single";
11939
+ setScreenZoom(FOCUSED_SCREEN_ZOOM);
11940
+ setViewMode("single");
11941
+ setActiveFileId(match.id);
11942
+ }
11943
+ }}
10121
11944
  />
10122
- )}
10123
- </>
10124
- )}
11945
+ {/* Presence: live cursor overlay for remote participants */}
11946
+ {others.length > 0 && (
11947
+ <LiveCursorOverlay
11948
+ others={others}
11949
+ containerRef={canvasContainerRef}
11950
+ />
11951
+ )}
11952
+ </>
11953
+ )}
11954
+ </div>
10125
11955
  </div>
10126
11956
  ) : (
10127
11957
  <div className="flex flex-1 items-center justify-center">
@@ -10140,7 +11970,7 @@ ${serializedHtml}
10140
11970
  </p>
10141
11971
  {retryablePrompt ? (
10142
11972
  <p className="mx-auto mb-4 max-w-sm text-xs italic text-muted-foreground/70">
10143
- "{retryablePrompt.prompt}"
11973
+ {`"${retryablePrompt.prompt}"`}
10144
11974
  </p>
10145
11975
  ) : null}
10146
11976
  <div className="flex items-center justify-center gap-2">
@@ -10217,8 +12047,65 @@ ${serializedHtml}
10217
12047
  onRequestTweaks={handleRequestTweaks}
10218
12048
  onStyleChange={handleStyleChange}
10219
12049
  onStylesChange={handleStylesChange}
12050
+ onAutoLayoutConvert={handleAutoLayoutConvert}
10220
12051
  onExport={handleInspectorExport}
10221
12052
  exporting={pngExporting || svgExporting}
12053
+ designId={id}
12054
+ fileId={activeFile?.id}
12055
+ filename={activeFile?.filename}
12056
+ componentNodeId={selectedComponentNodeId}
12057
+ sourceCapabilities={sourceCapabilities}
12058
+ onCreateComponent={
12059
+ id && selectedElement ? handleCreateComponent : undefined
12060
+ }
12061
+ defaultComponentName={defaultComponentName}
12062
+ inspectCode={inspectCodeData}
12063
+ statesPanelProps={
12064
+ id
12065
+ ? {
12066
+ // §6.4 / §8 — active state and breakpoint wired into
12067
+ // the StatesPanel so selection is agent-visible.
12068
+ activeStateId: selectedStateId,
12069
+ activeBreakpointId: statesPanelActiveBreakpointId,
12070
+ breakpoints: statesPanelBreakpoints,
12071
+ onStateSelect: (stateId) => {
12072
+ setSelectedStateId(stateId);
12073
+ },
12074
+ onBreakpointSelect: (breakpointId) => {
12075
+ // "auto" = clear the active breakpoint (overview).
12076
+ if (breakpointId === "auto") {
12077
+ setActiveBreakpointWidthState(undefined);
12078
+ if (id) {
12079
+ void setActiveBreakpointMutation.mutateAsync({
12080
+ designId: id,
12081
+ breakpointId: "auto",
12082
+ });
12083
+ }
12084
+ return;
12085
+ }
12086
+ const bp = statesPanelBreakpoints.find(
12087
+ (b) => b.id === breakpointId,
12088
+ );
12089
+ if (!bp) return;
12090
+ setActiveBreakpointWidthState(bp.widthPx);
12091
+ if (id) {
12092
+ void setActiveBreakpointMutation.mutateAsync({
12093
+ designId: id,
12094
+ breakpointId,
12095
+ });
12096
+ }
12097
+ },
12098
+ onAddBreakpoint: () => {
12099
+ // Delegate to the MultiScreenCanvas affordance by
12100
+ // navigating to overview where the "+" button lives.
12101
+ if (viewMode !== "overview") {
12102
+ setViewMode("overview");
12103
+ }
12104
+ },
12105
+ }
12106
+ : undefined
12107
+ }
12108
+ reviewPanelProps={reviewPanelProps}
10222
12109
  />
10223
12110
  </div>
10224
12111
  ) : (
@@ -10228,6 +12115,34 @@ ${serializedHtml}
10228
12115
  ) : null}
10229
12116
  </div>
10230
12117
 
12118
+ {/* Motion dock (§6.3) — collapsible bottom dock; visible when activeFile
12119
+ is open and the user has opened it. Canvas remains visible above.
12120
+ Preview-only scrubbing fires a motion-preview postMessage to the
12121
+ canvas iframe (via canvasIframeRef); "Write to CSS" fires
12122
+ apply-motion-edit. */}
12123
+ {!embedded && activeFile ? (
12124
+ <MotionDock
12125
+ tracks={motionTracks}
12126
+ durationMs={motionDurationMs}
12127
+ open={motionDockOpen}
12128
+ onOpenChange={setMotionDockOpen}
12129
+ onTracksChange={setMotionTracks}
12130
+ onDurationChange={setMotionDurationMs}
12131
+ canvasIframeRef={canvasIframeRef}
12132
+ selectedTarget={motionSelectedTarget}
12133
+ onApply={(tracks, durationMs) => {
12134
+ if (!id) return;
12135
+ applyMotionEditMutation.mutate({
12136
+ designId: id,
12137
+ fileId: activeFile.id,
12138
+ tracks: tracks.map(({ label: _label, ...t }) => t),
12139
+ durationMs,
12140
+ });
12141
+ }}
12142
+ applying={applyMotionEditMutation.isPending}
12143
+ />
12144
+ ) : null}
12145
+
10231
12146
  <PromptPopover
10232
12147
  open={showPrompt}
10233
12148
  onOpenChange={handlePromptOpenChange}
@@ -10313,6 +12228,183 @@ ${serializedHtml}
10313
12228
  loading={generating || pendingGenerationActive}
10314
12229
  anchorRef={tweakPromptAnchorRef}
10315
12230
  />
12231
+
12232
+ {/* §6.6 — "Make this a real app" dialog.
12233
+ Three states:
12234
+ 1. Idle — confirm prompt with description of what will happen.
12235
+ 2. Migrating — spinner while the Builder cloud agent accepts the job.
12236
+ 3. Success — branchName + url; sourceType already flipped to fusion.
12237
+ 4. Not-configured — CTA to connect Builder.io.
12238
+ */}
12239
+ <Dialog
12240
+ open={makeRealDialogOpen}
12241
+ onOpenChange={(open) => {
12242
+ if (!migrateMutation.isPending) setMakeRealDialogOpen(open);
12243
+ }}
12244
+ >
12245
+ <DialogContent className="sm:max-w-md">
12246
+ {/* Not-configured: Builder not connected or no project ID */}
12247
+ {migrationResult?.status === "not-configured" &&
12248
+ migrationResult.cta ? (
12249
+ <>
12250
+ <DialogHeader>
12251
+ <DialogTitle className="flex items-center gap-2">
12252
+ <IconRocket className="size-5 text-muted-foreground" />
12253
+ {migrationResult.cta.label}
12254
+ </DialogTitle>
12255
+ <DialogDescription>
12256
+ {migrationResult.cta.description}
12257
+ </DialogDescription>
12258
+ </DialogHeader>
12259
+ <DialogFooter className="flex-col gap-2 sm:flex-row">
12260
+ <Button
12261
+ variant="outline"
12262
+ onClick={() => setMakeRealDialogOpen(false)}
12263
+ className="cursor-pointer"
12264
+ >
12265
+ Cancel
12266
+ </Button>
12267
+ {migrationResult.cta.connectUrl ? (
12268
+ <Button asChild className="cursor-pointer">
12269
+ <a
12270
+ href={migrationResult.cta.connectUrl}
12271
+ target="_blank"
12272
+ rel="noopener noreferrer"
12273
+ >
12274
+ {migrationResult.cta.primaryAction}
12275
+ <IconExternalLink className="ml-1.5 size-3.5" />
12276
+ </a>
12277
+ </Button>
12278
+ ) : null}
12279
+ </DialogFooter>
12280
+ </>
12281
+ ) : migrationResult?.status === "processing" ? (
12282
+ /* Success: Builder accepted the migration job */
12283
+ <>
12284
+ <DialogHeader>
12285
+ <DialogTitle className="flex items-center gap-2">
12286
+ <IconCircleCheck className="size-5 text-green-500" />
12287
+ {"Migration started" /* i18n-ignore */}
12288
+ </DialogTitle>
12289
+ <DialogDescription>
12290
+ {
12291
+ "Builder is generating a React app branch from your design. The original inline design is preserved and recoverable." /* i18n-ignore */
12292
+ }
12293
+ </DialogDescription>
12294
+ </DialogHeader>
12295
+ <div className="space-y-3 py-2">
12296
+ {migrationResult.branchName && (
12297
+ <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
12298
+ <span className="text-muted-foreground">
12299
+ {"Branch: " /* i18n-ignore */}
12300
+ </span>
12301
+ <span className="font-mono font-medium">
12302
+ {migrationResult.branchName}
12303
+ </span>
12304
+ </div>
12305
+ )}
12306
+ {migrationResult.url && (
12307
+ <a
12308
+ href={migrationResult.url}
12309
+ target="_blank"
12310
+ rel="noopener noreferrer"
12311
+ className="flex items-center gap-1.5 text-sm text-[var(--design-editor-accent-color)] hover:underline"
12312
+ >
12313
+ {"Open in Builder" /* i18n-ignore */}
12314
+ <IconExternalLink className="size-3.5" />
12315
+ </a>
12316
+ )}
12317
+ {migrationResult.seedFileCount !== undefined && (
12318
+ <p className="text-xs text-muted-foreground">
12319
+ {
12320
+ `${migrationResult.seedFileCount} design file${migrationResult.seedFileCount === 1 ? "" : "s"} included in migration seed.` /* i18n-ignore */
12321
+ }
12322
+ </p>
12323
+ )}
12324
+ </div>
12325
+ <DialogFooter>
12326
+ <Button
12327
+ onClick={() => setMakeRealDialogOpen(false)}
12328
+ className="cursor-pointer"
12329
+ >
12330
+ {"Done" /* i18n-ignore */}
12331
+ </Button>
12332
+ </DialogFooter>
12333
+ </>
12334
+ ) : (
12335
+ /* Idle or migrating */
12336
+ <>
12337
+ <DialogHeader>
12338
+ <DialogTitle className="flex items-center gap-2">
12339
+ <IconRocket className="size-5" />
12340
+ {"Make this a real app" /* i18n-ignore */}
12341
+ </DialogTitle>
12342
+ <DialogDescription>
12343
+ {
12344
+ "Connect Builder.io to convert this design into a React + Tailwind app with real components, props, branches, and deploys. Your current inline design is preserved as a snapshot you can restore at any time." /* i18n-ignore */
12345
+ }
12346
+ </DialogDescription>
12347
+ </DialogHeader>
12348
+ <div className="space-y-2 py-1 text-sm text-muted-foreground">
12349
+ <p>{"What happens:" /* i18n-ignore */}</p>
12350
+ <ul className="list-disc pl-4 space-y-1">
12351
+ <li>
12352
+ {
12353
+ "Your design HTML and tokens are sent to the Builder cloud agent" /* i18n-ignore */
12354
+ }
12355
+ </li>
12356
+ <li>
12357
+ {
12358
+ "A React + Tailwind branch is generated in Builder" /* i18n-ignore */
12359
+ }
12360
+ </li>
12361
+ <li>
12362
+ {
12363
+ "The editor switches to fusion source mode — gated panels light up" /* i18n-ignore */
12364
+ }
12365
+ </li>
12366
+ <li>
12367
+ {
12368
+ "The original inline design is saved as a restorable snapshot" /* i18n-ignore */
12369
+ }
12370
+ </li>
12371
+ </ul>
12372
+ <p className="pt-1 text-xs">
12373
+ {
12374
+ "Requires Builder.io to be connected with a branch project configured." /* i18n-ignore */
12375
+ }
12376
+ </p>
12377
+ </div>
12378
+ <DialogFooter className="flex-col gap-2 sm:flex-row">
12379
+ <Button
12380
+ variant="outline"
12381
+ onClick={() => setMakeRealDialogOpen(false)}
12382
+ disabled={migrateMutation.isPending}
12383
+ className="cursor-pointer"
12384
+ >
12385
+ Cancel
12386
+ </Button>
12387
+ <Button
12388
+ onClick={() => void handleConfirmMakeReal()}
12389
+ disabled={migrateMutation.isPending}
12390
+ className="cursor-pointer"
12391
+ >
12392
+ {
12393
+ migrateMutation.isPending ? (
12394
+ <>
12395
+ <Spinner className="mr-2 size-3.5" />
12396
+ {"Starting migration…" /* i18n-ignore */}
12397
+ </>
12398
+ ) : (
12399
+ "Start migration"
12400
+ ) /* i18n-ignore */
12401
+ }
12402
+ </Button>
12403
+ </DialogFooter>
12404
+ </>
12405
+ )}
12406
+ </DialogContent>
12407
+ </Dialog>
10316
12408
  </div>
10317
12409
  );
10318
12410
  }