@agent-native/core 0.80.9 → 0.80.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/cli/design-connect.ts +28 -2
  6. package/corpus/core/src/cli/skills.ts +45 -24
  7. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  8. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  9. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  10. package/corpus/templates/assets/AGENTS.md +4 -0
  11. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  12. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  13. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  14. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  15. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  16. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  17. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  18. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  19. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  20. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  21. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  22. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  23. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  24. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  25. package/corpus/templates/assets/shared/api.ts +4 -0
  26. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  27. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  28. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  29. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  30. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  31. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  32. package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
  33. package/corpus/templates/design/actions/apply-design-state.ts +213 -0
  34. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  35. package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
  36. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  37. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  38. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  39. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  40. package/corpus/templates/design/actions/create-component.ts +447 -0
  41. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  42. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  43. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  44. package/corpus/templates/design/actions/delete-design.ts +20 -0
  45. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  46. package/corpus/templates/design/actions/get-component-details.ts +251 -0
  47. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  48. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  49. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  50. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  51. package/corpus/templates/design/actions/index-components.ts +258 -0
  52. package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
  53. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  54. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  55. package/corpus/templates/design/actions/list-design-states.ts +90 -0
  56. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  57. package/corpus/templates/design/actions/open-component-source.ts +242 -0
  58. package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
  59. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  60. package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
  61. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  62. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  63. package/corpus/templates/design/actions/run-design-audit.ts +436 -0
  64. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  65. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
  67. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
  68. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  69. package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
  70. package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
  71. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  72. package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
  73. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
  74. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
  75. package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
  76. package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
  77. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  78. package/corpus/templates/design/app/components/design/index.ts +16 -0
  79. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  80. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  81. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  82. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  83. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  84. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  85. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  86. package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
  87. package/corpus/templates/design/app/i18n-data.ts +280 -0
  88. package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
  89. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  90. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  91. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  92. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  93. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  94. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  95. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  96. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  97. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  98. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  99. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  100. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  101. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  102. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  103. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  104. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  105. package/corpus/templates/design/e2e/helpers.ts +10 -4
  106. package/corpus/templates/design/server/db/schema.ts +123 -0
  107. package/corpus/templates/design/server/plugins/db.ts +90 -0
  108. package/corpus/templates/design/shared/builder-app.ts +297 -0
  109. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  110. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  111. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  112. package/corpus/templates/design/shared/component-model.ts +274 -0
  113. package/corpus/templates/design/shared/design-review.ts +275 -0
  114. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  115. package/corpus/templates/design/shared/design-state.ts +112 -0
  116. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  117. package/corpus/templates/design/shared/motion-compiler.ts +349 -0
  118. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  119. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  120. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  121. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  122. package/corpus/templates/design/shared/source-mode.ts +245 -0
  123. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  124. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  125. package/dist/agent/production-agent.d.ts.map +1 -1
  126. package/dist/agent/production-agent.js +94 -10
  127. package/dist/agent/production-agent.js.map +1 -1
  128. package/dist/cli/design-connect.d.ts.map +1 -1
  129. package/dist/cli/design-connect.js +28 -2
  130. package/dist/cli/design-connect.js.map +1 -1
  131. package/dist/cli/skills.d.ts.map +1 -1
  132. package/dist/cli/skills.js +37 -21
  133. package/dist/cli/skills.js.map +1 -1
  134. package/dist/client/AssistantChat.d.ts +14 -0
  135. package/dist/client/AssistantChat.d.ts.map +1 -1
  136. package/dist/client/AssistantChat.js +115 -22
  137. package/dist/client/AssistantChat.js.map +1 -1
  138. package/dist/client/sse-event-processor.d.ts.map +1 -1
  139. package/dist/client/sse-event-processor.js +10 -0
  140. package/dist/client/sse-event-processor.js.map +1 -1
  141. package/dist/collab/routes.d.ts +1 -1
  142. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  143. package/dist/notifications/routes.d.ts +2 -2
  144. package/dist/observability/routes.d.ts +8 -8
  145. package/dist/resources/handlers.d.ts +2 -2
  146. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  147. package/dist/server/transcribe-voice.d.ts +1 -1
  148. package/package.json +1 -1
@@ -38,20 +38,31 @@ 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 type { A11yFinding } from "@shared/design-review";
55
+ import {
56
+ DESIGN_CAPABILITY_NAMES,
57
+ hasCapability,
58
+ } from "@shared/design-source-capabilities";
50
59
  import { shouldUseLiveFileContent } from "@shared/html-content";
51
60
  import {
52
61
  resolveTweaksToCssVars,
53
62
  type TweakSelections,
54
63
  } from "@shared/resolve-tweaks";
64
+ import { utilityStem, widthToPrefix } from "@shared/responsive-classes";
65
+ import { normalizeDesignSourceType } from "@shared/source-mode";
55
66
  import {
56
67
  IconArrowLeft,
57
68
  IconArrowUpRight,
@@ -94,6 +105,9 @@ import {
94
105
  IconFileExport,
95
106
  IconPlayerPlay,
96
107
  IconDeviceFloppy,
108
+ IconRocket,
109
+ IconExternalLink,
110
+ IconCircleCheck,
97
111
  IconTerminal2,
98
112
  } from "@tabler/icons-react";
99
113
  import { useQueryClient } from "@tanstack/react-query";
@@ -113,6 +127,7 @@ import { useParams, useNavigate, Link, useLocation } from "react-router";
113
127
  import { toast } from "sonner";
114
128
  import * as Y from "yjs";
115
129
 
130
+ import { canvasPrimitiveVisual } from "@/components/design/canvas-primitive-style";
116
131
  import {
117
132
  CanvasContextMenu,
118
133
  type CanvasContextMenuHandle,
@@ -121,10 +136,15 @@ import {
121
136
  DesignCanvas,
122
137
  type IframeContextMenuPayload,
123
138
  type IframeHotkeyPayload,
139
+ type MotionTrackWire,
124
140
  } from "@/components/design/DesignCanvas";
125
141
  import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton";
126
142
  import type { DesignExtensionSlotContext } from "@/components/design/DesignExtensionsPanel";
127
- import { EditPanel, type InspectorTab } from "@/components/design/EditPanel";
143
+ import {
144
+ EditPanel,
145
+ type InspectCodeData,
146
+ type InspectorTab,
147
+ } from "@/components/design/EditPanel";
128
148
  import type { ExportSettingsValue } from "@/components/design/inspector";
129
149
  import {
130
150
  LayersPanel,
@@ -132,12 +152,18 @@ import {
132
152
  type LayersPanelMoveIntent,
133
153
  type LayersPanelNode,
134
154
  } from "@/components/design/LayersPanel";
155
+ import { LocalSourceEditBanner } from "@/components/design/LocalSourceEditBanner";
156
+ import {
157
+ MotionDock,
158
+ type MotionDockTrack,
159
+ } from "@/components/design/MotionDock";
135
160
  import {
136
161
  MultiScreenCanvas,
137
162
  OVERVIEW_FRAME_WIDTH,
138
163
  type CanvasPrimitiveInsert,
139
164
  } from "@/components/design/MultiScreenCanvas";
140
165
  import { QuestionFlow } from "@/components/design/QuestionFlow";
166
+ import type { ReviewPanelProps } from "@/components/design/ReviewPanel";
141
167
  import type { ElementInfo, DeviceFrameType } from "@/components/design/types";
142
168
  import {
143
169
  DEVICE_FRAME_VIEWPORTS,
@@ -148,6 +174,14 @@ import type { UploadedFile } from "@/components/editor/PromptDialog";
148
174
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
149
175
  import { Button } from "@/components/ui/button";
150
176
  import { Checkbox } from "@/components/ui/checkbox";
177
+ import {
178
+ Dialog,
179
+ DialogContent,
180
+ DialogDescription,
181
+ DialogFooter,
182
+ DialogHeader,
183
+ DialogTitle,
184
+ } from "@/components/ui/dialog";
151
185
  import {
152
186
  DropdownMenu,
153
187
  DropdownMenuContent,
@@ -263,6 +297,32 @@ export function getSelectedScreenIdsForEditorState(args: {
263
297
  return activeFileId ? [activeFileId] : [];
264
298
  }
265
299
 
300
+ function fileIdFromLayerSelectionId(
301
+ layerId: string,
302
+ fileIds: Set<string>,
303
+ ): string | null {
304
+ const normalized = layerId.startsWith("code:")
305
+ ? layerId.slice("code:".length)
306
+ : layerId;
307
+ return fileIds.has(normalized) ? normalized : null;
308
+ }
309
+
310
+ export function getOverviewScreenIdsFromLayerSelection(args: {
311
+ fileIds: string[];
312
+ layerIds: string[];
313
+ }) {
314
+ const fileIds = new Set(args.fileIds);
315
+ const seen = new Set<string>();
316
+ const selectedScreenIds: string[] = [];
317
+ args.layerIds.forEach((layerId) => {
318
+ const fileId = fileIdFromLayerSelectionId(layerId, fileIds);
319
+ if (!fileId || seen.has(fileId)) return;
320
+ seen.add(fileId);
321
+ selectedScreenIds.push(fileId);
322
+ });
323
+ return selectedScreenIds;
324
+ }
325
+
266
326
  export function getOverviewEnterTarget(args: {
267
327
  activeFileId: string | null | undefined;
268
328
  overviewSelectedScreenIds: string[];
@@ -334,6 +394,71 @@ export function getDesignEditorShareUrl(
334
394
  return new URL(pathname, origin).toString();
335
395
  }
336
396
 
397
+ export function getLocalhostRouteSourceFile(args: {
398
+ sourceFile?: string;
399
+ source?: string;
400
+ }): string | undefined {
401
+ if (args.sourceFile?.trim()) return args.sourceFile;
402
+ const raw = args.source;
403
+ if (!raw) return undefined;
404
+ try {
405
+ const parsed = JSON.parse(raw) as unknown;
406
+ if (
407
+ parsed &&
408
+ typeof parsed === "object" &&
409
+ "file" in parsed &&
410
+ typeof (parsed as Record<string, unknown>).file === "string"
411
+ ) {
412
+ return (parsed as Record<string, string>).file;
413
+ }
414
+ } catch {
415
+ if (raw.length > 0) return raw;
416
+ }
417
+ return undefined;
418
+ }
419
+
420
+ export function getLayerMoveSourceContent(args: {
421
+ sourceFileId: string;
422
+ activeFileId?: string | null;
423
+ activeContent: string;
424
+ sourceFileContent?: string;
425
+ sourceContentMap: ReadonlyMap<string, string>;
426
+ }) {
427
+ return (
428
+ args.sourceContentMap.get(args.sourceFileId) ??
429
+ (args.sourceFileId === args.activeFileId
430
+ ? args.activeContent
431
+ : args.sourceFileContent) ??
432
+ ""
433
+ );
434
+ }
435
+
436
+ export function getFreshActiveFileContent(args: {
437
+ activeContent: string;
438
+ latestContent?: string | null;
439
+ lastLocalContent?: string | null;
440
+ }) {
441
+ return args.latestContent ?? args.lastLocalContent ?? args.activeContent;
442
+ }
443
+
444
+ export function getFreshScreenContent(args: {
445
+ screenId: string;
446
+ activeFileId?: string | null;
447
+ freshActiveContent: string;
448
+ fileContentById: ReadonlyMap<string, string>;
449
+ }) {
450
+ return args.screenId === args.activeFileId
451
+ ? args.freshActiveContent
452
+ : (args.fileContentById.get(args.screenId) ?? "");
453
+ }
454
+
455
+ export function getLayerMoveIterationOrder<T>(
456
+ orderedIds: readonly T[],
457
+ placement: "before" | "after" | "inside",
458
+ ): T[] {
459
+ return placement === "after" ? [...orderedIds].reverse() : [...orderedIds];
460
+ }
461
+
337
462
  function resolveZoomUpdate(update: SetStateAction<number>, current: number) {
338
463
  return typeof update === "function" ? update(current) : update;
339
464
  }
@@ -367,6 +492,27 @@ export function shouldEscapeToOverview(args: {
367
492
  );
368
493
  }
369
494
 
495
+ /**
496
+ * Build the set of all node ids (both projection ids and data-agent-native-node-id
497
+ * attribute values) that exist in the given projection. Used by handleGroupSelection
498
+ * and handleUngroupSelection to filter selectedLayerIdsState to the active file's
499
+ * nodes before passing them to wrapNodes / unwrap, preventing cross-file stale ids
500
+ * from causing spurious "conflict" errors.
501
+ *
502
+ * Exported for unit testing.
503
+ */
504
+ export function buildActiveFileNodeIdSet(
505
+ projection: CodeLayerProjection,
506
+ ): Set<string> {
507
+ const ids = new Set<string>();
508
+ for (const n of projection.nodes) {
509
+ ids.add(n.id);
510
+ const attrId = n.dataAttributes["data-agent-native-node-id"];
511
+ if (attrId) ids.add(attrId);
512
+ }
513
+ return ids;
514
+ }
515
+
370
516
  let html2CanvasColorContext: CanvasRenderingContext2D | null | undefined;
371
517
 
372
518
  interface FileContentSaveRequest {
@@ -603,6 +749,12 @@ interface GeometryHistoryEntry {
603
749
  after: CanvasFrameGeometryById;
604
750
  }
605
751
 
752
+ interface ContentHistoryEntry {
753
+ fileId: string;
754
+ before: string;
755
+ after: string;
756
+ }
757
+
606
758
  type PatchProofStatus =
607
759
  | "runtime"
608
760
  | "queued"
@@ -812,6 +964,113 @@ function applyInlineStylesToHtml(
812
964
  }
813
965
  }
814
966
 
967
+ const CSS_PROPERTY_UTILITY_STEMS: Record<string, string[]> = {
968
+ color: ["text-color"],
969
+ "background-color": ["background-color"],
970
+ background: ["background-color", "background-image"],
971
+ "font-size": ["font-size"],
972
+ "font-weight": ["font-weight"],
973
+ "font-family": ["font-family"],
974
+ "text-align": ["text-align"],
975
+ display: ["display"],
976
+ position: ["position"],
977
+ width: ["w"],
978
+ height: ["h"],
979
+ opacity: ["opacity"],
980
+ "border-radius": ["rounded"],
981
+ padding: ["p"],
982
+ "padding-left": ["px", "pl"],
983
+ "padding-right": ["px", "pr"],
984
+ "padding-top": ["py", "pt"],
985
+ "padding-bottom": ["py", "pb"],
986
+ margin: ["m"],
987
+ "margin-left": ["mx", "ml"],
988
+ "margin-right": ["mx", "mr"],
989
+ "margin-top": ["my", "mt"],
990
+ "margin-bottom": ["my", "mb"],
991
+ gap: ["gap"],
992
+ "column-gap": ["gap-x"],
993
+ "row-gap": ["gap-y"],
994
+ };
995
+
996
+ const DEFAULT_STATES_PANEL_BREAKPOINTS = [
997
+ { id: "bp-mobile", label: "Mobile", widthPx: 390 },
998
+ { id: "bp-tablet", label: "Tablet", widthPx: 768 },
999
+ { id: "bp-desktop", label: "Desktop", widthPx: 1280 },
1000
+ ] as const;
1001
+
1002
+ function normalizeCssPropertyName(property: string): string {
1003
+ return property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
1004
+ }
1005
+
1006
+ function looksLikeTailwindUtility(value: string): boolean {
1007
+ const trimmed = value.trim();
1008
+ if (!trimmed || /\s/.test(trimmed)) return false;
1009
+ if (/[;{}]/.test(trimmed) || /\/\*/.test(trimmed)) return false;
1010
+ if (/^(?:#|rgb\(|rgba\(|hsl\(|hsla\(|var\(|calc\()/i.test(trimmed)) {
1011
+ return false;
1012
+ }
1013
+ if (trimmed.includes(":")) return false;
1014
+ return /^[!-]?[a-z0-9][a-z0-9[\]()./%_-]*$/i.test(trimmed);
1015
+ }
1016
+
1017
+ function responsiveUtilityMatchesStyleProperty(
1018
+ property: string,
1019
+ value: string,
1020
+ ): boolean {
1021
+ if (!looksLikeTailwindUtility(value)) return false;
1022
+ const normalizedProperty = normalizeCssPropertyName(property);
1023
+ const stem = utilityStem(value.trim());
1024
+ const allowed = CSS_PROPERTY_UTILITY_STEMS[normalizedProperty];
1025
+ return allowed ? allowed.includes(stem) : stem === normalizedProperty;
1026
+ }
1027
+
1028
+ interface DesignStatePreviewRow {
1029
+ captureData?: Record<string, unknown> | null;
1030
+ fixtureData?: Record<string, unknown> | null;
1031
+ }
1032
+
1033
+ const STATE_PREVIEW_HTML_KEYS = [
1034
+ "domHtml",
1035
+ "domSnapshot",
1036
+ "documentHtml",
1037
+ "html",
1038
+ "content",
1039
+ "markup",
1040
+ ] as const;
1041
+
1042
+ function looksLikePreviewHtml(value: string): boolean {
1043
+ return /<!doctype|<html\b|<body\b|<[a-zA-Z][\s>]/i.test(value);
1044
+ }
1045
+
1046
+ function findStatePreviewHtml(value: unknown, depth = 0): string | null {
1047
+ if (typeof value === "string") {
1048
+ return looksLikePreviewHtml(value) ? value : null;
1049
+ }
1050
+ if (!value || typeof value !== "object" || Array.isArray(value) || depth > 2)
1051
+ return null;
1052
+ const record = value as Record<string, unknown>;
1053
+ for (const key of STATE_PREVIEW_HTML_KEYS) {
1054
+ const hit = findStatePreviewHtml(record[key], depth + 1);
1055
+ if (hit) return hit;
1056
+ }
1057
+ for (const entry of Object.values(record)) {
1058
+ const hit = findStatePreviewHtml(entry, depth + 1);
1059
+ if (hit) return hit;
1060
+ }
1061
+ return null;
1062
+ }
1063
+
1064
+ function designStatePreviewHtml(
1065
+ row: DesignStatePreviewRow | undefined,
1066
+ ): string | null {
1067
+ if (!row) return null;
1068
+ return (
1069
+ findStatePreviewHtml(row.captureData) ??
1070
+ findStatePreviewHtml(row.fixtureData)
1071
+ );
1072
+ }
1073
+
815
1074
  function escapeHtmlAttributeValue(value: string): string {
816
1075
  return value
817
1076
  .replace(/&/g, "&amp;")
@@ -820,6 +1079,44 @@ function escapeHtmlAttributeValue(value: string): string {
820
1079
  .replace(/>/g, "&gt;");
821
1080
  }
822
1081
 
1082
+ const ABS_POSITION_PROPS = [
1083
+ "position",
1084
+ "left",
1085
+ "top",
1086
+ "right",
1087
+ "bottom",
1088
+ ] as const;
1089
+
1090
+ /**
1091
+ * Remove absolute-positioning style properties from the element identified by
1092
+ * `data-agent-native-node-id` so that it becomes a flow child after being
1093
+ * reparented into a container. Returns the updated HTML, or the original HTML
1094
+ * if the node cannot be found or parsing is unavailable.
1095
+ *
1096
+ * Uses DOMParser + CSSStyleDeclaration.removeProperty() rather than
1097
+ * applyVisualEdit({kind:"style",value:""}) because the substrate rejects
1098
+ * empty-string values in isSafeStyleValue, making that approach a silent no-op.
1099
+ */
1100
+ function removeAbsolutePositioningFromNodeInHtml(
1101
+ content: string,
1102
+ nodeAttrId: string,
1103
+ ): string {
1104
+ if (typeof window === "undefined") return content;
1105
+ try {
1106
+ const doc = new DOMParser().parseFromString(content, "text/html");
1107
+ const element = doc.querySelector(
1108
+ `[data-agent-native-node-id="${CSS.escape(nodeAttrId)}"]`,
1109
+ ) as HTMLElement | null;
1110
+ if (!element) return content;
1111
+ for (const prop of ABS_POSITION_PROPS) {
1112
+ element.style.removeProperty(prop);
1113
+ }
1114
+ return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
1115
+ } catch {
1116
+ return content;
1117
+ }
1118
+ }
1119
+
823
1120
  function escapeHtmlText(value: string): string {
824
1121
  return value
825
1122
  .replace(/&/g, "&amp;")
@@ -1147,12 +1444,20 @@ function appendCanvasPrimitiveToHtml(
1147
1444
  element.style.transform = `rotate(${geometry.rotation}deg)`;
1148
1445
  }
1149
1446
 
1447
+ // Use the shared canvas-primitive-style module so committed output is
1448
+ // pixel-identical to the draft preview (fixes B5 color jump, B6 ellipse
1449
+ // radius jump). User-supplied fill/stroke/strokeWidth override the
1450
+ // canonical defaults so hand-chosen colours are preserved.
1451
+ const canonical = canvasPrimitiveVisual(
1452
+ primitive.kind === "rectangle" ? "rect" : primitive.kind,
1453
+ );
1150
1454
  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";
1455
+ element.style.background = primitive.fill ?? canonical.background;
1456
+ element.style.border =
1457
+ primitive.stroke !== undefined || primitive.strokeWidth !== undefined
1458
+ ? `${primitive.strokeWidth ?? 1}px dashed ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
1459
+ : canonical.border;
1460
+ element.style.borderRadius = canonical.borderRadius;
1156
1461
  element.style.overflow = "hidden";
1157
1462
  } else if (primitive.kind === "text") {
1158
1463
  element.textContent = primitive.text ?? "Text";
@@ -1164,18 +1469,23 @@ function appendCanvasPrimitiveToHtml(
1164
1469
  element.style.fontSize = "16px";
1165
1470
  element.style.lineHeight = "1.2";
1166
1471
  element.style.whiteSpace = "pre-wrap";
1472
+ element.style.border = canonical.border;
1473
+ element.style.borderRadius = canonical.borderRadius;
1167
1474
  } 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%";
1475
+ element.style.background = primitive.fill ?? canonical.background;
1476
+ element.style.border =
1477
+ primitive.stroke !== undefined || primitive.strokeWidth !== undefined
1478
+ ? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
1479
+ : canonical.border;
1480
+ element.style.borderRadius = canonical.borderRadius; // "50%"
1173
1481
  } 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";
1482
+ // rect / rectangle / frame fallthrough
1483
+ element.style.background = primitive.fill ?? canonical.background;
1484
+ element.style.border =
1485
+ primitive.stroke !== undefined || primitive.strokeWidth !== undefined
1486
+ ? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
1487
+ : canonical.border;
1488
+ element.style.borderRadius = canonical.borderRadius;
1179
1489
  }
1180
1490
 
1181
1491
  doc.body.appendChild(element);
@@ -1918,6 +2228,78 @@ function collectCodeLayerAncestors(
1918
2228
  return [];
1919
2229
  }
1920
2230
 
2231
+ export function sortCodeLayerIdsByTreeOrder(
2232
+ ids: readonly string[],
2233
+ tree: readonly CodeLayerTreeNode[],
2234
+ ): string[] {
2235
+ const treeOrder = new Map<string, number>();
2236
+ let index = 0;
2237
+ const visit = (nodes: readonly CodeLayerTreeNode[]) => {
2238
+ for (const node of nodes) {
2239
+ treeOrder.set(node.id, index);
2240
+ index += 1;
2241
+ visit(node.children);
2242
+ }
2243
+ };
2244
+ visit(tree);
2245
+
2246
+ const originalOrder = new Map(
2247
+ ids.map((id, originalIndex) => [id, originalIndex]),
2248
+ );
2249
+ return [...ids].sort((a, b) => {
2250
+ const aOrder = treeOrder.get(a);
2251
+ const bOrder = treeOrder.get(b);
2252
+ if (aOrder === undefined && bOrder === undefined) {
2253
+ return (originalOrder.get(a) ?? 0) - (originalOrder.get(b) ?? 0);
2254
+ }
2255
+ if (aOrder === undefined) return 1;
2256
+ if (bOrder === undefined) return -1;
2257
+ return aOrder - bOrder;
2258
+ });
2259
+ }
2260
+
2261
+ function findCodeLayerNodeInProjection(
2262
+ projection: CodeLayerProjection,
2263
+ previousNode: CodeLayerNode,
2264
+ ): CodeLayerNode | null {
2265
+ const stableSourceIds = [
2266
+ previousNode.dataAttributes["data-agent-native-node-id"],
2267
+ previousNode.dataAttributes["data-code-layer-id"],
2268
+ previousNode.dataAttributes["data-layer-id"],
2269
+ previousNode.dataAttributes["data-builder-id"],
2270
+ previousNode.dataAttributes["data-loc"],
2271
+ typeof previousNode.attributes.id === "string"
2272
+ ? previousNode.attributes.id
2273
+ : undefined,
2274
+ ].filter((id): id is string => Boolean(id));
2275
+
2276
+ for (const sourceId of stableSourceIds) {
2277
+ const stableMatch = projection.nodes.find(
2278
+ (node) =>
2279
+ node.dataAttributes["data-agent-native-node-id"] === sourceId ||
2280
+ node.dataAttributes["data-code-layer-id"] === sourceId ||
2281
+ node.dataAttributes["data-layer-id"] === sourceId ||
2282
+ node.dataAttributes["data-builder-id"] === sourceId ||
2283
+ node.dataAttributes["data-loc"] === sourceId ||
2284
+ node.attributes.id === sourceId,
2285
+ );
2286
+ if (stableMatch) return stableMatch;
2287
+ }
2288
+
2289
+ const exactMatch = projection.nodes.find(
2290
+ (node) => node.id === previousNode.id,
2291
+ );
2292
+ if (exactMatch) return exactMatch;
2293
+
2294
+ const fallbackMatches = projection.nodes.filter(
2295
+ (node) =>
2296
+ node.tag === previousNode.tag &&
2297
+ node.layerName === previousNode.layerName &&
2298
+ (node.textSnippet ?? "") === (previousNode.textSnippet ?? ""),
2299
+ );
2300
+ return fallbackMatches.length === 1 ? (fallbackMatches[0] ?? null) : null;
2301
+ }
2302
+
1921
2303
  function AgentNativeMenuMark({ className }: { className?: string }) {
1922
2304
  return (
1923
2305
  <svg
@@ -2599,6 +2981,11 @@ function buildAuthoritativeTweakSelections(
2599
2981
  ? persistedSelections[tweak.id]
2600
2982
  : tweak.defaultValue;
2601
2983
  }
2984
+ for (const [key, value] of Object.entries(persistedSelections)) {
2985
+ if (/^--[-_a-zA-Z0-9]+$/.test(key)) {
2986
+ selections[key] = value;
2987
+ }
2988
+ }
2602
2989
  return selections;
2603
2990
  }
2604
2991
 
@@ -2815,6 +3202,9 @@ export default function DesignEditor() {
2815
3202
  const [hiddenLayerIds, setHiddenLayerIds] = useState<Set<string>>(
2816
3203
  () => new Set(),
2817
3204
  );
3205
+ const layerStateOverridesRef = useRef<
3206
+ Map<string, { hidden?: boolean; locked?: boolean }>
3207
+ >(new Map());
2818
3208
  const [overviewSelectAllRequest, setOverviewSelectAllRequest] = useState(0);
2819
3209
  const [overviewClearSelectionRequest, setOverviewClearSelectionRequest] =
2820
3210
  useState(0);
@@ -2828,6 +3218,34 @@ export default function DesignEditor() {
2828
3218
  const spaceHandPreviousToolRef = useRef<DesignTool | null>(null);
2829
3219
  const hasSelectedElement = Boolean(selectedElement);
2830
3220
 
3221
+ // ── Motion dock state (§6.3) ────────────────────────────────────────────────
3222
+ // The MotionDock is mounted below the canvas and shown when motionDockOpen.
3223
+ // Tracks and durationMs are local state; "Write to CSS" calls applyMotionEdit.
3224
+ const [motionDockOpen, setMotionDockOpen] = useState(false);
3225
+ const [motionTracks, setMotionTracks] = useState<MotionDockTrack[]>([]);
3226
+ const [motionDurationMs, setMotionDurationMs] = useState(1000);
3227
+ const [shaderFillPreview, setShaderFillPreview] = useState<{
3228
+ selector?: string;
3229
+ nodeId?: string;
3230
+ css: string;
3231
+ } | null>(null);
3232
+
3233
+ // ── Breakpoint preview state (§6.4) ─────────────────────────────────────────
3234
+ // Active breakpoint width for the current design (pixels). Controls which
3235
+ // side-by-side frame is focused. undefined = no frame selected (overview mode).
3236
+ const [activeBreakpointWidthState, setActiveBreakpointWidthState] = useState<
3237
+ number | undefined
3238
+ >(undefined);
3239
+
3240
+ // ── Design state selection (§6.4 / §8) ───────────────────────────────────────
3241
+ // null = Default (live) view; a string id = one of the design_state rows.
3242
+ const [selectedStateId, setSelectedStateId] = useState<string | null>(null);
3243
+ const [reviewFileId, setReviewFileId] = useState<string | null>(null);
3244
+ const [reviewFindings, setReviewFindings] = useState<A11yFinding[]>([]);
3245
+ const [reviewAuditLoading, setReviewAuditLoading] = useState(false);
3246
+ const [reviewAuditedAt, setReviewAuditedAt] = useState<string | null>(null);
3247
+ const [reviewAuditError, setReviewAuditError] = useState<string | null>(null);
3248
+
2831
3249
  useEffect(() => {
2832
3250
  if (!isBuilderDesignEmbed) return;
2833
3251
  // Announce ready to Builder. The trusted origin is not yet known at this
@@ -2955,6 +3373,9 @@ export default function DesignEditor() {
2955
3373
  const [canUndo, setCanUndo] = useState(false);
2956
3374
  const [canRedo, setCanRedo] = useState(false);
2957
3375
  const undoManagerRef = useRef<Y.UndoManager | null>(null);
3376
+ const contentUndoStackRef = useRef<ContentHistoryEntry[]>([]);
3377
+ const contentRedoStackRef = useRef<ContentHistoryEntry[]>([]);
3378
+ const suppressContentHistoryRef = useRef(false);
2958
3379
  const geometryUndoStackRef = useRef<GeometryHistoryEntry[]>([]);
2959
3380
  const geometryRedoStackRef = useRef<GeometryHistoryEntry[]>([]);
2960
3381
  const historyOrderRef = useRef<Array<"content" | "geometry">>([]);
@@ -2963,17 +3384,30 @@ export default function DesignEditor() {
2963
3384
  const undoManager = undoManagerRef.current;
2964
3385
  setCanUndo(
2965
3386
  Boolean(undoManager?.canUndo()) ||
3387
+ contentUndoStackRef.current.length > 0 ||
2966
3388
  geometryUndoStackRef.current.length > 0,
2967
3389
  );
2968
3390
  setCanRedo(
2969
3391
  Boolean(undoManager?.canRedo()) ||
3392
+ contentRedoStackRef.current.length > 0 ||
2970
3393
  geometryRedoStackRef.current.length > 0,
2971
3394
  );
2972
3395
  }, []);
3396
+ const clearLocalUndoRedoStacks = useCallback(() => {
3397
+ contentUndoStackRef.current = [];
3398
+ contentRedoStackRef.current = [];
3399
+ geometryUndoStackRef.current = [];
3400
+ geometryRedoStackRef.current = [];
3401
+ historyOrderRef.current = [];
3402
+ redoOrderRef.current = [];
3403
+ }, []);
2973
3404
  const persistedSelectionStateRef = useRef<string | null>(null);
2974
3405
  const designSelectionOwnerIdRef = useRef(`${TAB_ID}:${generateTabId()}`);
2975
3406
  const frameGeometrySaveTimerRef = useRef<number | null>(null);
2976
3407
  const [tweakSaveActive, setTweakSaveActive] = useState(false);
3408
+ // Dismissible localhost-source banner (reset per session).
3409
+ const [localSourceBannerDismissed, setLocalSourceBannerDismissed] =
3410
+ useState(false);
2977
3411
  // Shared visual-editor annotate overlays. drawMode owns the send toolbar,
2978
3412
  // while pinMode temporarily routes canvas clicks to comment pins that queue
2979
3413
  // into the same agent submission.
@@ -3292,6 +3726,58 @@ export default function DesignEditor() {
3292
3726
  designAccessRole === "owner" || designAccessRole === "admin";
3293
3727
  const canEditDesign = canShareDesign || designAccessRole === "editor";
3294
3728
  const canEditDesignRef = useRef(canEditDesign);
3729
+ const pendingLocalFileContentsRef = useRef<
3730
+ Map<
3731
+ string,
3732
+ { content: string; startedAt: number; baseUpdatedAt?: string | null }
3733
+ >
3734
+ >(new Map());
3735
+ const [
3736
+ pendingLocalFileContentsRevision,
3737
+ setPendingLocalFileContentsRevision,
3738
+ ] = useState(0);
3739
+
3740
+ const markPendingLocalFileContent = useCallback(
3741
+ (fileId: string, content: string, baseUpdatedAt?: string | null) => {
3742
+ const current = pendingLocalFileContentsRef.current.get(fileId);
3743
+ if (current?.content === content) {
3744
+ if (
3745
+ baseUpdatedAt !== undefined &&
3746
+ current.baseUpdatedAt === undefined
3747
+ ) {
3748
+ pendingLocalFileContentsRef.current.set(fileId, {
3749
+ ...current,
3750
+ baseUpdatedAt,
3751
+ });
3752
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
3753
+ }
3754
+ return;
3755
+ }
3756
+ pendingLocalFileContentsRef.current.set(fileId, {
3757
+ content,
3758
+ startedAt: Date.now(),
3759
+ baseUpdatedAt,
3760
+ });
3761
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
3762
+ },
3763
+ [],
3764
+ );
3765
+
3766
+ const clearPendingLocalFileContent = useCallback(
3767
+ (fileId: string, expectedContent?: string) => {
3768
+ const current = pendingLocalFileContentsRef.current.get(fileId);
3769
+ if (!current) return;
3770
+ if (
3771
+ expectedContent !== undefined &&
3772
+ current.content !== expectedContent
3773
+ ) {
3774
+ return;
3775
+ }
3776
+ pendingLocalFileContentsRef.current.delete(fileId);
3777
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
3778
+ },
3779
+ [],
3780
+ );
3295
3781
 
3296
3782
  useEffect(() => {
3297
3783
  canEditDesignRef.current = canEditDesign;
@@ -3332,6 +3818,45 @@ export default function DesignEditor() {
3332
3818
  const duplicateDesignMutation = useActionMutation("duplicate-design");
3333
3819
  const exportHtmlMutation = useActionMutation("export-html");
3334
3820
  const exportZipMutation = useActionMutation("export-zip");
3821
+ const applyMotionEditMutation = useActionMutation("apply-motion-edit");
3822
+ // §6.4 breakpoint mutations — wired to MultiScreenCanvas + affordance
3823
+ const addBreakpointMutation = useActionMutation("add-breakpoint");
3824
+ const setActiveBreakpointMutation = useActionMutation(
3825
+ "set-active-breakpoint",
3826
+ );
3827
+
3828
+ // §6.1 — promote a selection into a reusable component instance.
3829
+ const createComponentMutation = useActionMutation("create-component");
3830
+ // §6.1 — jump to a component instance's source (selects the root + navigates).
3831
+ const openComponentSourceMutation = useActionMutation(
3832
+ "open-component-source",
3833
+ );
3834
+
3835
+ // §6.6 — "Make it real" migration flow (migrate-inline-design-to-app).
3836
+ // The mutation stays unconditional; the dialog gates on isSignedIn.
3837
+ const migrateMutation = useActionMutation("migrate-inline-design-to-app");
3838
+
3839
+ // Dialog open/close state for the "Make this a real app" flow.
3840
+ const [makeRealDialogOpen, setMakeRealDialogOpen] = useState(false);
3841
+
3842
+ // Result payload returned by migrate-inline-design-to-app on success.
3843
+ // `null` = not yet migrated; populated once the Builder agent accepts the job.
3844
+ const [migrationResult, setMigrationResult] = useState<{
3845
+ branchName?: string;
3846
+ url?: string;
3847
+ versionId?: string;
3848
+ seedFileCount?: number;
3849
+ status?: string;
3850
+ projectId?: string;
3851
+ cta?: {
3852
+ kind: string;
3853
+ label: string;
3854
+ description: string;
3855
+ connectUrl?: string;
3856
+ primaryAction: string;
3857
+ };
3858
+ } | null>(null);
3859
+
3335
3860
  const [shareExportFormat, setShareExportFormat] =
3336
3861
  useState<ShareExportFormat>("html");
3337
3862
  const [codingHandoffResult, setCodingHandoffResult] =
@@ -3356,6 +3881,7 @@ export default function DesignEditor() {
3356
3881
  const saveFileContent = useCallback(
3357
3882
  (pending: FileContentSaveRequest) => {
3358
3883
  if (!canEditDesignRef.current) return;
3884
+ markPendingLocalFileContent(pending.id, pending.content);
3359
3885
  latestFileSaveForUnloadRef.current[pending.id] = pending;
3360
3886
  const previous =
3361
3887
  fileSaveChainsRef.current[pending.id] ?? Promise.resolve();
@@ -3374,6 +3900,7 @@ export default function DesignEditor() {
3374
3900
  : prev,
3375
3901
  );
3376
3902
  } catch (error) {
3903
+ clearPendingLocalFileContent(pending.id, pending.content);
3377
3904
  setPatchProof((prev) =>
3378
3905
  prev && prev.fileId === pending.id && prev.status === "queued"
3379
3906
  ? {
@@ -3395,7 +3922,12 @@ export default function DesignEditor() {
3395
3922
  }
3396
3923
  });
3397
3924
  },
3398
- [t, updateFileMutation],
3925
+ [
3926
+ clearPendingLocalFileContent,
3927
+ markPendingLocalFileContent,
3928
+ t,
3929
+ updateFileMutation,
3930
+ ],
3399
3931
  );
3400
3932
 
3401
3933
  const queueFileContentSave = useCallback(
@@ -3410,6 +3942,7 @@ export default function DesignEditor() {
3410
3942
  content,
3411
3943
  syncCollab: options.syncCollab ?? true,
3412
3944
  };
3945
+ markPendingLocalFileContent(fileId, content);
3413
3946
  latestFileSaveForUnloadRef.current[fileId] = pending;
3414
3947
  if (options.immediate) {
3415
3948
  const timer = fileSaveTimersRef.current[fileId];
@@ -3434,7 +3967,7 @@ export default function DesignEditor() {
3434
3967
  saveFileContent(pending);
3435
3968
  }, 400);
3436
3969
  },
3437
- [saveFileContent],
3970
+ [markPendingLocalFileContent, saveFileContent],
3438
3971
  );
3439
3972
 
3440
3973
  useEffect(() => {
@@ -3679,7 +4212,38 @@ export default function DesignEditor() {
3679
4212
  updateDesignMutation,
3680
4213
  ]);
3681
4214
 
3682
- const files = design?.files ?? [];
4215
+ const serverFiles = design?.files ?? [];
4216
+ useEffect(() => {
4217
+ if (pendingLocalFileContentsRef.current.size === 0) return;
4218
+ let changed = false;
4219
+ for (const file of serverFiles) {
4220
+ const pending = pendingLocalFileContentsRef.current.get(file.id);
4221
+ if (pending && (file.content ?? "") === pending.content) {
4222
+ if (
4223
+ pending.baseUpdatedAt !== undefined &&
4224
+ file.updatedAt === pending.baseUpdatedAt
4225
+ ) {
4226
+ continue;
4227
+ }
4228
+ pendingLocalFileContentsRef.current.delete(file.id);
4229
+ changed = true;
4230
+ }
4231
+ }
4232
+ if (changed) {
4233
+ setPendingLocalFileContentsRevision((revision) => revision + 1);
4234
+ }
4235
+ }, [serverFiles]);
4236
+ const pendingLocalFileContentsSnapshot = useMemo(
4237
+ () => new Map(pendingLocalFileContentsRef.current),
4238
+ [pendingLocalFileContentsRevision],
4239
+ );
4240
+ const files = useMemo(() => {
4241
+ if (pendingLocalFileContentsSnapshot.size === 0) return serverFiles;
4242
+ return serverFiles.map((file) => {
4243
+ const pending = pendingLocalFileContentsSnapshot.get(file.id);
4244
+ return pending ? { ...file, content: pending.content } : file;
4245
+ });
4246
+ }, [pendingLocalFileContentsSnapshot, serverFiles]);
3683
4247
  const designDataJson = useMemo(
3684
4248
  () => parseDesignDataJson(design?.data),
3685
4249
  [design?.data],
@@ -3699,6 +4263,38 @@ export default function DesignEditor() {
3699
4263
  designDataJson,
3700
4264
  "screenMetadata",
3701
4265
  );
4266
+ // §6.4 — breakpoint set stored in designs.data.breakpointSet as a
4267
+ // BreakpointSet { id, breakpoints: BreakpointDefinition[] }.
4268
+ // Each BreakpointDefinition has { id, label, widthPx, prefix }.
4269
+ const breakpointSet = (() => {
4270
+ try {
4271
+ const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
4272
+ if (
4273
+ raw &&
4274
+ typeof raw === "object" &&
4275
+ !Array.isArray(raw) &&
4276
+ Array.isArray((raw as Record<string, unknown>).breakpoints)
4277
+ ) {
4278
+ return raw as {
4279
+ id: string;
4280
+ breakpoints: Array<{
4281
+ id: string;
4282
+ widthPx: number;
4283
+ label?: string;
4284
+ prefix?: string;
4285
+ }>;
4286
+ };
4287
+ }
4288
+ } catch {
4289
+ // ignore
4290
+ }
4291
+ return undefined;
4292
+ })();
4293
+ const bpWidths =
4294
+ breakpointSet && breakpointSet.breakpoints.length > 0
4295
+ ? breakpointSet.breakpoints.map((bp) => bp.widthPx)
4296
+ : undefined;
4297
+
3702
4298
  return files.map((file) => {
3703
4299
  const metadata = getDesignDataRecord(metadataByFileId, file.id);
3704
4300
  const stringValue = (key: string) =>
@@ -3716,6 +4312,7 @@ export default function DesignEditor() {
3716
4312
  updatedAt: file.updatedAt,
3717
4313
  sourceType: stringValue("sourceType"),
3718
4314
  source: stringValue("source"),
4315
+ sourceFile: stringValue("sourceFile"),
3719
4316
  lod: stringValue("lod"),
3720
4317
  previewState: stringValue("previewState"),
3721
4318
  status: stringValue("status"),
@@ -3724,9 +4321,19 @@ export default function DesignEditor() {
3724
4321
  height: numberValue("height"),
3725
4322
  url: stringValue("url"),
3726
4323
  previewUrl: stringValue("previewUrl"),
4324
+ // Breakpoint preview widths (§6.4). When non-empty, MultiScreenCanvas
4325
+ // renders one iframe per width to the right of the primary frame.
4326
+ breakpointWidths: bpWidths,
4327
+ // Active breakpoint width tracked in component state; shared across all
4328
+ // screens (a design has one active breakpoint set at a time in v1).
4329
+ activeBreakpointWidth: bpWidths?.includes(
4330
+ activeBreakpointWidthState ?? -1,
4331
+ )
4332
+ ? activeBreakpointWidthState
4333
+ : undefined,
3727
4334
  };
3728
4335
  });
3729
- }, [designDataJson, files]);
4336
+ }, [designDataJson, files, activeBreakpointWidthState]);
3730
4337
  const queueFrameGeometrySave = useCallback(
3731
4338
  (geometryById: CanvasFrameGeometryById) => {
3732
4339
  if (!id || !canEditDesignRef.current) return;
@@ -3838,6 +4445,61 @@ export default function DesignEditor() {
3838
4445
  [syncUndoRedoState, writeFrameGeometrySnapshot],
3839
4446
  );
3840
4447
 
4448
+ // §6.6 — "Make this a real app" handler.
4449
+ // Opens the dialog; actual migration fires when the user confirms.
4450
+ const handleOpenMakeReal = useCallback(() => {
4451
+ setMigrationResult(null);
4452
+ setMakeRealDialogOpen(true);
4453
+ }, []);
4454
+
4455
+ // Fires when the user clicks "Start migration" in the dialog.
4456
+ // Calls migrate-inline-design-to-app, then on success flips sourceType to
4457
+ // "fusion" in the design data blob so gated panels light up.
4458
+ const handleConfirmMakeReal = useCallback(async () => {
4459
+ if (!id) return;
4460
+ try {
4461
+ const result = await migrateMutation.mutateAsync({ designId: id } as any);
4462
+ const r = result as any;
4463
+ setMigrationResult({
4464
+ branchName: r?.branchName,
4465
+ url: r?.url,
4466
+ versionId: r?.versionId,
4467
+ seedFileCount: r?.seedFileCount,
4468
+ status: r?.status,
4469
+ projectId: r?.projectId,
4470
+ cta: r?.cta,
4471
+ });
4472
+
4473
+ // When the Builder agent accepted the job (status = "processing"),
4474
+ // flip the design data to sourceType "fusion" so capability-gated
4475
+ // panels (branches, deploy) light up on refresh.
4476
+ if (r?.status === "processing" && r?.url) {
4477
+ const nextData = {
4478
+ ...designDataJsonRef.current,
4479
+ sourceType: "fusion",
4480
+ fusionBranchName: r.branchName,
4481
+ fusionUrl: r.url,
4482
+ fusionProjectId: r.projectId,
4483
+ };
4484
+ updateDesignMutation.mutate(
4485
+ { id, data: JSON.stringify(nextData) } as any,
4486
+ {
4487
+ onSuccess: () => {
4488
+ queryClient.invalidateQueries({
4489
+ queryKey: ["action", "get-design"],
4490
+ });
4491
+ },
4492
+ },
4493
+ );
4494
+ } else if (r?.status === "not-configured") {
4495
+ // Builder not connected — leave dialog open to show the CTA.
4496
+ }
4497
+ } catch (err) {
4498
+ const message = err instanceof Error ? err.message : "Migration failed";
4499
+ toast.error(message);
4500
+ }
4501
+ }, [id, migrateMutation, updateDesignMutation, queryClient]);
4502
+
3841
4503
  generationOutputReadyRef.current = files.length > 0;
3842
4504
 
3843
4505
  useEffect(() => {
@@ -3947,11 +4609,21 @@ export default function DesignEditor() {
3947
4609
  }, [files, activeFileId]);
3948
4610
 
3949
4611
  const activeFile = files.find((f) => f.id === activeFileId) ?? files[0];
4612
+ useEffect(() => {
4613
+ if (!reviewFileId || reviewFileId === activeFile?.id) return;
4614
+ setReviewFileId(null);
4615
+ setReviewFindings([]);
4616
+ setReviewAuditedAt(null);
4617
+ setReviewAuditError(null);
4618
+ setReviewAuditLoading(false);
4619
+ }, [activeFile?.id, reviewFileId]);
4620
+
3950
4621
  const initialGenerationReadOnly = shouldLockInspectorForInitialGeneration({
3951
4622
  fileCount: files.length,
3952
4623
  generating,
3953
4624
  pendingGenerationActive,
3954
4625
  });
4626
+
3955
4627
  const selectedScreenIds = useMemo(
3956
4628
  () =>
3957
4629
  getSelectedScreenIdsForEditorState({
@@ -4340,6 +5012,7 @@ export default function DesignEditor() {
4340
5012
  // The last content this client itself wrote into the Y.Doc (inline-style
4341
5013
  // edits) — so the reconcile/observe doesn't treat our own echo as external.
4342
5014
  const lastLocalContentRef = useRef<string | null>(null);
5015
+ const latestActiveContentRef = useRef<string | null>(null);
4343
5016
  // Freshest known DB `updatedAt` for the active file, kept in a ref so the
4344
5017
  // Yjs observe handler can advance the reconcile watermark without re-subscribing.
4345
5018
  const documentFileUpdatedAtRef = useRef<string | null>(null);
@@ -4383,7 +5056,10 @@ export default function DesignEditor() {
4383
5056
  setCollabContentFileId(null);
4384
5057
  lastAppliedFileUpdatedAtRef.current = null;
4385
5058
  lastLocalContentRef.current = null;
5059
+ latestActiveContentRef.current = null;
5060
+ clearLocalUndoRedoStacks();
4386
5061
  clearStaleAgentCollabRecovery();
5062
+ syncUndoRedoState();
4387
5063
  return;
4388
5064
  }
4389
5065
  if (activeFileId !== prevActiveFileIdRef.current) {
@@ -4392,9 +5068,18 @@ export default function DesignEditor() {
4392
5068
  setCollabContentFileId(null);
4393
5069
  lastAppliedFileUpdatedAtRef.current = null;
4394
5070
  lastLocalContentRef.current = null;
5071
+ latestActiveContentRef.current = null;
5072
+ clearLocalUndoRedoStacks();
4395
5073
  clearStaleAgentCollabRecovery();
5074
+ syncUndoRedoState();
4396
5075
  }
4397
- }, [activeFileId, clearStaleAgentCollabRecovery, viewMode]);
5076
+ }, [
5077
+ activeFileId,
5078
+ clearLocalUndoRedoStacks,
5079
+ clearStaleAgentCollabRecovery,
5080
+ syncUndoRedoState,
5081
+ viewMode,
5082
+ ]);
4398
5083
 
4399
5084
  useEffect(() => {
4400
5085
  return clearStaleAgentCollabRecovery;
@@ -4406,6 +5091,20 @@ export default function DesignEditor() {
4406
5091
  const fileId = activeFileId;
4407
5092
  const ytext = ydoc.getText("content");
4408
5093
  const text = ytext.toString();
5094
+ const pendingLocalContent =
5095
+ pendingLocalFileContentsRef.current.get(fileId)?.content;
5096
+ if (pendingLocalContent && text !== pendingLocalContent) {
5097
+ setCollabContent(pendingLocalContent);
5098
+ setCollabContentFileId(fileId);
5099
+ lastLocalContentRef.current = pendingLocalContent;
5100
+ latestActiveContentRef.current = pendingLocalContent;
5101
+ setContentRenderRevision((revision) => revision + 1);
5102
+ ydoc.transact(() => {
5103
+ ytext.delete(0, ytext.length);
5104
+ ytext.insert(0, pendingLocalContent);
5105
+ }, TAB_ID);
5106
+ return;
5107
+ }
4409
5108
  if (text.length > 0) {
4410
5109
  const storedContent = activeFile?.content ?? "";
4411
5110
  if (
@@ -4418,6 +5117,7 @@ export default function DesignEditor() {
4418
5117
  setCollabContent(storedContent);
4419
5118
  setCollabContentFileId(fileId);
4420
5119
  lastLocalContentRef.current = storedContent;
5120
+ latestActiveContentRef.current = storedContent;
4421
5121
  setContentRenderRevision((revision) => revision + 1);
4422
5122
  ydoc.transact(() => {
4423
5123
  ytext.delete(0, ytext.length);
@@ -4430,9 +5130,17 @@ export default function DesignEditor() {
4430
5130
  // confirms or applies the current DB content.
4431
5131
  setCollabContent(text);
4432
5132
  setCollabContentFileId(fileId);
5133
+ latestActiveContentRef.current = text;
4433
5134
  setContentRenderRevision((revision) => revision + 1);
4434
5135
  }
4435
- }, [ydoc, isSynced, activeFileId, activeFile?.content, activeFile?.fileType]);
5136
+ }, [
5137
+ ydoc,
5138
+ isSynced,
5139
+ activeFileId,
5140
+ activeFile?.content,
5141
+ activeFile?.fileType,
5142
+ pendingLocalFileContentsRevision,
5143
+ ]);
4436
5144
 
4437
5145
  // Keep the freshest DB `updatedAt` in a ref the observe handler can read.
4438
5146
  useEffect(() => {
@@ -4453,14 +5161,29 @@ export default function DesignEditor() {
4453
5161
  const ytext = ydoc.getText("content");
4454
5162
  const handler = (_event: unknown, transaction?: { origin?: unknown }) => {
4455
5163
  const next = ytext.toString();
4456
- setCollabContent(next);
4457
- setCollabContentFileId(fileId);
4458
5164
  // UndoManager fires with itself as the origin; treat those as local too
4459
5165
  // so the reconcile watermark and stale-selection fix are consistent.
4460
5166
  const isLocalEdit =
4461
5167
  transaction?.origin === TAB_ID ||
4462
5168
  transaction?.origin === LOCAL_EDIT_ORIGIN ||
4463
5169
  transaction?.origin === undoManagerRef.current;
5170
+ const pendingLocalContent =
5171
+ pendingLocalFileContentsRef.current.get(fileId)?.content;
5172
+ if (pendingLocalContent && next !== pendingLocalContent && !isLocalEdit) {
5173
+ setCollabContent(pendingLocalContent);
5174
+ setCollabContentFileId(fileId);
5175
+ lastLocalContentRef.current = pendingLocalContent;
5176
+ latestActiveContentRef.current = pendingLocalContent;
5177
+ setContentRenderRevision((revision) => revision + 1);
5178
+ ydoc.transact(() => {
5179
+ ytext.delete(0, ytext.length);
5180
+ ytext.insert(0, pendingLocalContent);
5181
+ }, TAB_ID);
5182
+ return;
5183
+ }
5184
+ setCollabContent(next);
5185
+ setCollabContentFileId(fileId);
5186
+ latestActiveContentRef.current = next;
4464
5187
  if (isLocalEdit) {
4465
5188
  lastLocalContentRef.current = next;
4466
5189
  } else {
@@ -4565,6 +5288,7 @@ export default function DesignEditor() {
4565
5288
  setCollabContent(dbContent);
4566
5289
  setCollabContentFileId(activeFile.id);
4567
5290
  lastLocalContentRef.current = dbContent;
5291
+ latestActiveContentRef.current = dbContent;
4568
5292
  if (dbUpdatedAt) lastAppliedFileUpdatedAtRef.current = dbUpdatedAt;
4569
5293
  setContentRenderRevision((revision) => revision + 1);
4570
5294
 
@@ -4618,6 +5342,7 @@ export default function DesignEditor() {
4618
5342
  setCollabContent(expectedContent);
4619
5343
  setCollabContentFileId(expectedFileId);
4620
5344
  lastLocalContentRef.current = expectedContent;
5345
+ latestActiveContentRef.current = expectedContent;
4621
5346
  lastAppliedFileUpdatedAtRef.current = expectedUpdatedAt;
4622
5347
  setContentRenderRevision((revision) => revision + 1);
4623
5348
 
@@ -4643,6 +5368,7 @@ export default function DesignEditor() {
4643
5368
  setCollabContent(dbContent);
4644
5369
  setCollabContentFileId(activeFile.id);
4645
5370
  lastLocalContentRef.current = dbContent;
5371
+ latestActiveContentRef.current = dbContent;
4646
5372
  if (dbUpdatedAt) lastAppliedFileUpdatedAtRef.current = dbUpdatedAt;
4647
5373
  setContentRenderRevision((revision) => revision + 1);
4648
5374
 
@@ -4688,6 +5414,72 @@ export default function DesignEditor() {
4688
5414
  const canvasContextMenuRef = useRef<CanvasContextMenuHandle | null>(null);
4689
5415
  const canvasContainerRef = useRef<HTMLDivElement>(null);
4690
5416
 
5417
+ // Live handle to the active DesignCanvas preview iframe. DesignCanvas owns the
5418
+ // <iframe> internally (tagged data-design-preview-iframe) and does not forward
5419
+ // its ref, so we resolve the element lazily from the DOM at read time. The
5420
+ // MotionDock reads `.current` only when scrubbing, so this always returns the
5421
+ // currently-mounted iframe even after content swaps recreate the element.
5422
+ const canvasIframeRef = useMemo<React.RefObject<HTMLIFrameElement | null>>(
5423
+ () => ({
5424
+ get current() {
5425
+ return document.querySelector<HTMLIFrameElement>(
5426
+ "iframe[data-design-preview-iframe]",
5427
+ );
5428
+ },
5429
+ }),
5430
+ [],
5431
+ );
5432
+
5433
+ const handleRunDesignAudit = useCallback(async () => {
5434
+ if (!id || !activeFile?.id) return;
5435
+ const auditFileId = activeFile.id;
5436
+ setReviewFileId(auditFileId);
5437
+ setReviewAuditLoading(true);
5438
+ setReviewAuditError(null);
5439
+ try {
5440
+ const result = await callAction<{
5441
+ findings: A11yFinding[];
5442
+ auditedAt: string;
5443
+ }>("run-design-audit", {
5444
+ designId: id,
5445
+ fileId: auditFileId,
5446
+ } as any);
5447
+ setReviewFileId(auditFileId);
5448
+ setReviewFindings(Array.isArray(result.findings) ? result.findings : []);
5449
+ setReviewAuditedAt(result.auditedAt ?? new Date().toISOString());
5450
+ } catch (error) {
5451
+ const message =
5452
+ error instanceof Error
5453
+ ? error.message
5454
+ : t("designEditor.toasts.auditRunFailed");
5455
+ setReviewAuditError(message);
5456
+ toast.error(message);
5457
+ } finally {
5458
+ setReviewAuditLoading(false);
5459
+ }
5460
+ }, [activeFile?.id, id, t]);
5461
+
5462
+ const handleReviewFindingClick = useCallback(
5463
+ (finding: A11yFinding) => {
5464
+ const selector =
5465
+ finding.selector ??
5466
+ (finding.nodeId
5467
+ ? `[data-agent-native-node-id="${finding.nodeId.replace(/"/g, '\\"')}"]`
5468
+ : null);
5469
+ if (!selector) return;
5470
+ canvasIframeRef.current?.contentWindow?.postMessage(
5471
+ {
5472
+ type: "select-element",
5473
+ selector,
5474
+ nodeId: finding.nodeId ?? undefined,
5475
+ },
5476
+ "*",
5477
+ );
5478
+ if (finding.nodeId) setSelectedLayerIdsState([finding.nodeId]);
5479
+ },
5480
+ [canvasIframeRef],
5481
+ );
5482
+
4691
5483
  // Broadcast pointer position (normalized to canvas container) and
4692
5484
  // selected element selector so peers can see where the user is working.
4693
5485
  const handleCanvasPointerMove = useCallback(
@@ -4801,26 +5593,45 @@ export default function DesignEditor() {
4801
5593
  // Resolve the content to render: prefer collab content only after the
4802
5594
  // per-file reconcile state has reset for the current active file. Otherwise a
4803
5595
  // file switch can render one frame with the previous file's Yjs text.
5596
+ // Always resolve to a string — a non-string source (e.g. a collab value that
5597
+ // is not yet a plain string, or a not-yet-loaded file) must never reach the
5598
+ // many `content.trim()` / projection callers below, which would crash render.
4804
5599
  const activeCollabFileReady =
4805
5600
  viewMode === "single" && activeFileId === prevActiveFileIdRef.current;
4806
- const activeContent =
4807
- activeCollabFileReady &&
5601
+ const pendingActiveFileContent = activeFile?.id
5602
+ ? pendingLocalFileContentsSnapshot.get(activeFile.id)?.content
5603
+ : undefined;
5604
+ const activeContentSource =
5605
+ pendingActiveFileContent ??
5606
+ (activeCollabFileReady &&
4808
5607
  collabContentFileId === activeFile?.id &&
4809
5608
  collabContent !== null
4810
5609
  ? collabContent
4811
- : (activeFile?.content ?? "");
5610
+ : (activeFile?.content ?? ""));
5611
+ const activeContent =
5612
+ typeof activeContentSource === "string" ? activeContentSource : "";
5613
+ useLayoutEffect(() => {
5614
+ latestActiveContentRef.current = activeContent;
5615
+ }, [activeContent]);
4812
5616
  const fileContentById = useMemo(() => {
4813
5617
  const map = new Map<string, string>();
4814
5618
  for (const file of files) {
4815
- map.set(file.id, file.content ?? "");
5619
+ map.set(file.id, typeof file.content === "string" ? file.content : "");
4816
5620
  }
4817
5621
  return map;
4818
5622
  }, [files]);
4819
5623
  const getScreenContent = useCallback(
4820
5624
  (screenId: string) =>
4821
- screenId === activeFile?.id
4822
- ? activeContent
4823
- : (fileContentById.get(screenId) ?? ""),
5625
+ getFreshScreenContent({
5626
+ screenId,
5627
+ activeFileId: activeFile?.id,
5628
+ freshActiveContent: getFreshActiveFileContent({
5629
+ activeContent,
5630
+ latestContent: latestActiveContentRef.current,
5631
+ lastLocalContent: lastLocalContentRef.current,
5632
+ }),
5633
+ fileContentById,
5634
+ }),
4824
5635
  [activeContent, activeFile?.id, fileContentById],
4825
5636
  );
4826
5637
  const pageStyles = useMemo(
@@ -4846,24 +5657,227 @@ export default function DesignEditor() {
4846
5657
  return selectedElement?.selector ? [selectedElement.selector] : [];
4847
5658
  }, [selectedCodeLayerNode, selectedElement?.selector]);
4848
5659
  const selectedCanvasSelector = selectedCanvasSelectorCandidates[0] ?? null;
4849
- const hoveredCodeLayerNode = useMemo(() => {
4850
- if (!hoveredElement) return null;
4851
- if (isScreenRootElementInfo(hoveredElement)) return null;
4852
- return resolveCodeLayerNodeFromElementInfo(
4853
- activeCodeLayerProjection,
4854
- hoveredElement,
4855
- );
4856
- }, [activeCodeLayerProjection, hoveredElement]);
4857
- const hoveredCanvasSelectorCandidates = useMemo(() => {
4858
- if (isScreenRootElementInfo(hoveredElement)) return [];
4859
- if (hoveredCodeLayerNode) {
4860
- return codeLayerSelectorAliases(hoveredCodeLayerNode);
4861
- }
4862
- return hoveredElement?.selector ? [hoveredElement.selector] : [];
4863
- }, [hoveredCodeLayerNode, hoveredElement]);
4864
- const hoveredCanvasSelector = hoveredCanvasSelectorCandidates[0] ?? null;
4865
- const hoveredElementIsScreenRoot = isScreenRootElementInfo(hoveredElement);
4866
- const hoveredScreenRootId = hoveredElementIsScreenRoot
5660
+
5661
+ const handleDesignStateSelect = useCallback(
5662
+ (stateId: string | null, row?: DesignStatePreviewRow) => {
5663
+ setSelectedStateId(stateId);
5664
+ const win = canvasIframeRef.current?.contentWindow;
5665
+ if (!win) return;
5666
+
5667
+ if (stateId === null) {
5668
+ win.postMessage(
5669
+ {
5670
+ type: "replace-document-content",
5671
+ content: activeContent,
5672
+ forceFullDocument: true,
5673
+ },
5674
+ "*",
5675
+ );
5676
+ return;
5677
+ }
5678
+
5679
+ const html = designStatePreviewHtml(row);
5680
+ if (!html) return;
5681
+ win.postMessage(
5682
+ {
5683
+ type: "replace-document-content",
5684
+ content: html,
5685
+ forceFullDocument: true,
5686
+ },
5687
+ "*",
5688
+ );
5689
+ },
5690
+ [activeContent, canvasIframeRef],
5691
+ );
5692
+
5693
+ // ── Inspector header quick actions (Create component / Inspect code) ───────
5694
+ // Resolve the design-level source type + capability map so the inspector can
5695
+ // gate the real-app affordances (jump-to-source, prop write-back).
5696
+ const designSourceType = useMemo(
5697
+ () =>
5698
+ normalizeDesignSourceType(designDataJson.sourceType as unknown) ??
5699
+ "inline",
5700
+ [designDataJson.sourceType],
5701
+ );
5702
+ const sourceCapabilities = useMemo(() => {
5703
+ const caps = resolveSourceCapabilities(designSourceType);
5704
+ return DESIGN_CAPABILITY_NAMES.filter((name) => hasCapability(caps, name));
5705
+ }, [designSourceType]);
5706
+
5707
+ // Builder-hosted preview URL for fusion-source designs, written into the
5708
+ // design data blob by the "Make it real" migration. Threaded into DesignCanvas
5709
+ // so the fusion preview renders (and so the bridge trust check can validate
5710
+ // the frame's origin against it).
5711
+ const designFusionUrl = useMemo(() => {
5712
+ const raw = (designDataJson as { fusionUrl?: unknown }).fusionUrl;
5713
+ return typeof raw === "string" && raw ? raw : undefined;
5714
+ }, [designDataJson]);
5715
+
5716
+ // §6.1 — open a component instance's source. open-component-source selects the
5717
+ // component root in the editor and emits a navigate app-state; for real-app
5718
+ // (localhost / fusion) sources it also resolves the external file location.
5719
+ const handleComponentSourceJump = useCallback(
5720
+ ({ nodeId }: { nodeId: string; componentName: string }) => {
5721
+ if (!id || !nodeId) return;
5722
+ openComponentSourceMutation.mutate(
5723
+ { designId: id, nodeId, fileId: activeFileId ?? undefined } as any,
5724
+ {
5725
+ onError: () => {
5726
+ toast.error(
5727
+ "Could not open component source" /* i18n-ignore edge-case jump failure */,
5728
+ );
5729
+ },
5730
+ },
5731
+ );
5732
+ },
5733
+ [id, activeFileId, openComponentSourceMutation],
5734
+ );
5735
+
5736
+ // The selected node id, when it already is a recognised component instance —
5737
+ // unlocks the contextual Component section at the top of the Design tab.
5738
+ const selectedComponentNodeId = useMemo(() => {
5739
+ if (!selectedCodeLayerNode) return undefined;
5740
+ return isComponentInstance(selectedCodeLayerNode)
5741
+ ? bridgeSourceIdForCodeLayerNode(selectedCodeLayerNode)
5742
+ : undefined;
5743
+ }, [selectedCodeLayerNode]);
5744
+
5745
+ useEffect(() => {
5746
+ setShaderFillPreview(null);
5747
+ }, [activeFile?.id, selectedElement?.selector, selectedElement?.sourceId]);
5748
+
5749
+ // A friendly default name for the create-component dialog, derived from the
5750
+ // selected element's layer name / tag.
5751
+ const defaultComponentName = useMemo(() => {
5752
+ if (selectedCodeLayerNode?.layerName)
5753
+ return selectedCodeLayerNode.layerName;
5754
+ if (selectedElement?.tagName) {
5755
+ const tag = selectedElement.tagName;
5756
+ return tag.charAt(0).toUpperCase() + tag.slice(1);
5757
+ }
5758
+ return "Component";
5759
+ }, [selectedCodeLayerNode?.layerName, selectedElement?.tagName]);
5760
+
5761
+ // Outer HTML of the selection — backs the inline/Alpine "Inspect code" view.
5762
+ const selectedElementOuterHtml = useMemo(() => {
5763
+ if (!selectedElement?.selector) return null;
5764
+ return (
5765
+ selectedElement.htmlContent ??
5766
+ getElementOuterHtml(activeContent, selectedElement.selector)
5767
+ );
5768
+ }, [activeContent, selectedElement?.selector, selectedElement?.htmlContent]);
5769
+
5770
+ // §6.3 — the motion-dock target: the selected element's literal
5771
+ // `data-agent-native-node-id` (the value the motion compiler + preview bridge
5772
+ // match on, NOT the hashed projection id) plus a friendly label. Single-screen
5773
+ // mode auto-stamps every selectable node with this attribute (see the
5774
+ // ensureCodeLayerNodeIdsInHtml effect), so a selection reliably resolves to a
5775
+ // stable node id here. `null` when nothing animatable is selected — the dock
5776
+ // then disables its "Add track" affordance.
5777
+ const motionSelectedTarget = useMemo<{
5778
+ nodeId: string;
5779
+ label: string;
5780
+ } | null>(() => {
5781
+ if (!selectedCodeLayerNode) return null;
5782
+ const nodeId =
5783
+ selectedCodeLayerNode.dataAttributes["data-agent-native-node-id"]?.trim();
5784
+ if (!nodeId) return null;
5785
+ const label =
5786
+ selectedCodeLayerNode.layerName ||
5787
+ selectedElement?.tagName ||
5788
+ "Selected element";
5789
+ return { nodeId, label };
5790
+ }, [selectedCodeLayerNode, selectedElement?.tagName]);
5791
+
5792
+ // Serialisable subset of the dock's tracks for the DesignCanvas motion-preview
5793
+ // bridge. Strips the UI-only `label` field. Only populated while the dock is
5794
+ // open so a closed dock never leaves preview overrides on the canvas; an empty
5795
+ // array makes DesignCanvas send `motion-preview-clear`. Scrubbing previews
5796
+ // these tracks live in the iframe — it never writes (that is "Write to CSS").
5797
+ const motionTracksWire = useMemo<MotionTrackWire[]>(() => {
5798
+ if (!motionDockOpen || motionTracks.length === 0) return [];
5799
+ return motionTracks.map(({ label: _label, ...track }) => track);
5800
+ }, [motionDockOpen, motionTracks]);
5801
+
5802
+ const inspectCodeData = useMemo<InspectCodeData | undefined>(() => {
5803
+ if (!selectedElement) return undefined;
5804
+ // Inline/Alpine: the design HTML is the source — show the element's HTML.
5805
+ // Real-app source resolution (vscode:// deep link) requires the
5806
+ // resolveNodeToFile bridge op, which is wired through open-component-source
5807
+ // when an external file path is available; until that round-trip is hooked
5808
+ // up here the popover shows the projected HTML for all source types.
5809
+ return { html: selectedElementOuterHtml, sourceLocation: null };
5810
+ }, [selectedElement, selectedElementOuterHtml]);
5811
+
5812
+ const handleCreateComponent = useCallback(
5813
+ (name: string) => {
5814
+ if (!id || !selectedElement) return;
5815
+ const nodeId = selectedElementLayerId ?? undefined;
5816
+ const selector = selectedCanvasSelector ?? selectedElement.selector;
5817
+ createComponentMutation.mutate(
5818
+ { designId: id, nodeId, selector, name } as any,
5819
+ {
5820
+ onSuccess: () => {
5821
+ queryClient.invalidateQueries({
5822
+ queryKey: ["action", "get-design"],
5823
+ });
5824
+ toast.success(t("designEditor.toasts.componentCreated"));
5825
+ },
5826
+ onError: () => {
5827
+ toast.error(t("designEditor.toasts.componentCreateFailed"));
5828
+ },
5829
+ },
5830
+ );
5831
+
5832
+ // Follow-up: ask the Design agent to extract props and replace repeated
5833
+ // instances with this component. The deterministic annotate above is the
5834
+ // core; this is an enhancement that runs in the agent chat.
5835
+ sendToAgentChat({
5836
+ message: `Extract props for the "${name}" component and replace repeated instances on this design with it.`,
5837
+ context: [
5838
+ `Design id: "${id}".`,
5839
+ selectedElement.selector
5840
+ ? `Component root selector: ${selectedElement.selector}.`
5841
+ : "",
5842
+ nodeId ? `Component root node id: ${nodeId}.` : "",
5843
+ `The element was just annotated with data-agent-native-component="${name}".`,
5844
+ "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.",
5845
+ ]
5846
+ .filter(Boolean)
5847
+ .join("\n"),
5848
+ submit: true,
5849
+ openSidebar: true,
5850
+ });
5851
+ },
5852
+ [
5853
+ id,
5854
+ selectedElement,
5855
+ selectedElementLayerId,
5856
+ selectedCanvasSelector,
5857
+ createComponentMutation,
5858
+ queryClient,
5859
+ t,
5860
+ ],
5861
+ );
5862
+
5863
+ const hoveredCodeLayerNode = useMemo(() => {
5864
+ if (!hoveredElement) return null;
5865
+ if (isScreenRootElementInfo(hoveredElement)) return null;
5866
+ return resolveCodeLayerNodeFromElementInfo(
5867
+ activeCodeLayerProjection,
5868
+ hoveredElement,
5869
+ );
5870
+ }, [activeCodeLayerProjection, hoveredElement]);
5871
+ const hoveredCanvasSelectorCandidates = useMemo(() => {
5872
+ if (isScreenRootElementInfo(hoveredElement)) return [];
5873
+ if (hoveredCodeLayerNode) {
5874
+ return codeLayerSelectorAliases(hoveredCodeLayerNode);
5875
+ }
5876
+ return hoveredElement?.selector ? [hoveredElement.selector] : [];
5877
+ }, [hoveredCodeLayerNode, hoveredElement]);
5878
+ const hoveredCanvasSelector = hoveredCanvasSelectorCandidates[0] ?? null;
5879
+ const hoveredElementIsScreenRoot = isScreenRootElementInfo(hoveredElement);
5880
+ const hoveredScreenRootId = hoveredElementIsScreenRoot
4867
5881
  ? hoveredElementScreenId
4868
5882
  : null;
4869
5883
  const hoveredChildScreenId = hoveredElementIsScreenRoot
@@ -4923,9 +5937,44 @@ export default function DesignEditor() {
4923
5937
  } = {},
4924
5938
  ) => {
4925
5939
  if (!activeFile || !canEditDesignRef.current) return;
5940
+ const previousContent =
5941
+ collabContentFileIdRef.current === activeFile.id &&
5942
+ typeof collabContentRef.current === "string"
5943
+ ? collabContentRef.current
5944
+ : (activeFile.content ?? "");
5945
+ const yjsHistoryAvailable = Boolean(
5946
+ ydoc && isSynced && undoManagerRef.current,
5947
+ );
5948
+ if (
5949
+ !suppressContentHistoryRef.current &&
5950
+ !yjsHistoryAvailable &&
5951
+ previousContent !== nextContent
5952
+ ) {
5953
+ contentUndoStackRef.current = [
5954
+ ...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
5955
+ {
5956
+ fileId: activeFile.id,
5957
+ before: previousContent,
5958
+ after: nextContent,
5959
+ },
5960
+ ];
5961
+ contentRedoStackRef.current = [];
5962
+ historyOrderRef.current = [
5963
+ ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
5964
+ "content",
5965
+ ];
5966
+ redoOrderRef.current = [];
5967
+ syncUndoRedoState();
5968
+ }
5969
+ markPendingLocalFileContent(
5970
+ activeFile.id,
5971
+ nextContent,
5972
+ activeFile.updatedAt,
5973
+ );
4926
5974
  setCollabContent(nextContent);
4927
5975
  setCollabContentFileId(activeFile.id);
4928
5976
  lastLocalContentRef.current = nextContent;
5977
+ latestActiveContentRef.current = nextContent;
4929
5978
  if (id) {
4930
5979
  queryClient.setQueryData(
4931
5980
  ["action", "get-design", { id }],
@@ -4976,9 +6025,11 @@ export default function DesignEditor() {
4976
6025
  activeFile,
4977
6026
  id,
4978
6027
  isSynced,
6028
+ markPendingLocalFileContent,
4979
6029
  queryClient,
4980
6030
  queueFileContentSave,
4981
6031
  replacePreviewContent,
6032
+ syncUndoRedoState,
4982
6033
  ydoc,
4983
6034
  ],
4984
6035
  );
@@ -4994,6 +6045,8 @@ export default function DesignEditor() {
4994
6045
  applyLocalContentUpdate(nextContent, options);
4995
6046
  return;
4996
6047
  }
6048
+ const previousFile = files.find((file) => file.id === fileId);
6049
+ markPendingLocalFileContent(fileId, nextContent, previousFile?.updatedAt);
4997
6050
  queryClient.setQueryData(["action", "get-design", { id }], (old: any) => {
4998
6051
  if (!old || typeof old !== "object" || !Array.isArray(old.files)) {
4999
6052
  return old;
@@ -5011,9 +6064,79 @@ export default function DesignEditor() {
5011
6064
  syncCollab: true,
5012
6065
  });
5013
6066
  },
5014
- [activeFile?.id, applyLocalContentUpdate, id, queryClient, saveFileContent],
6067
+ [
6068
+ activeFile?.id,
6069
+ applyLocalContentUpdate,
6070
+ files,
6071
+ id,
6072
+ markPendingLocalFileContent,
6073
+ queryClient,
6074
+ saveFileContent,
6075
+ ],
6076
+ );
6077
+
6078
+ const handleComponentPropApplied = useCallback(
6079
+ (fileId: string, nextContent: string) => {
6080
+ applyFileContentUpdate(fileId, nextContent, {
6081
+ refreshPreview: fileId === activeFile?.id,
6082
+ });
6083
+ },
6084
+ [activeFile?.id, applyFileContentUpdate],
6085
+ );
6086
+
6087
+ const handleReviewFixApplied = useCallback(
6088
+ (
6089
+ _finding: A11yFinding,
6090
+ result?: { fileId?: string; patchedContent?: string },
6091
+ ) => {
6092
+ setReviewFindings((prev) =>
6093
+ prev.filter((finding) => finding.id !== _finding.id),
6094
+ );
6095
+ if (
6096
+ typeof result?.fileId === "string" &&
6097
+ typeof result.patchedContent === "string"
6098
+ ) {
6099
+ applyFileContentUpdate(result.fileId, result.patchedContent, {
6100
+ refreshPreview: result.fileId === activeFile?.id,
6101
+ });
6102
+ }
6103
+ void handleRunDesignAudit();
6104
+ },
6105
+ [activeFile?.id, applyFileContentUpdate, handleRunDesignAudit],
5015
6106
  );
5016
6107
 
6108
+ const resolvedReviewPanelProps = useMemo<
6109
+ Omit<ReviewPanelProps, "className"> | undefined
6110
+ >(() => {
6111
+ if (!id || !activeFile) return undefined;
6112
+ const reviewMatchesActiveFile = reviewFileId === activeFile.id;
6113
+ return {
6114
+ findings: reviewMatchesActiveFile ? reviewFindings : [],
6115
+ auditLoading: reviewMatchesActiveFile ? reviewAuditLoading : false,
6116
+ auditedAt: reviewMatchesActiveFile ? reviewAuditedAt : null,
6117
+ auditError: reviewMatchesActiveFile ? reviewAuditError : null,
6118
+ onRunAudit: handleRunDesignAudit,
6119
+ onFindingClick: handleReviewFindingClick,
6120
+ fixSource: {
6121
+ designId: id,
6122
+ fileId: activeFile.id,
6123
+ filename: activeFile.filename,
6124
+ },
6125
+ onFixApplied: handleReviewFixApplied,
6126
+ };
6127
+ }, [
6128
+ activeFile,
6129
+ handleReviewFindingClick,
6130
+ handleReviewFixApplied,
6131
+ handleRunDesignAudit,
6132
+ id,
6133
+ reviewAuditError,
6134
+ reviewAuditLoading,
6135
+ reviewAuditedAt,
6136
+ reviewFileId,
6137
+ reviewFindings,
6138
+ ]);
6139
+
5017
6140
  const handleCreatePrimitive = useCallback(
5018
6141
  (screenId: string, primitive: CanvasPrimitiveInsert) => {
5019
6142
  if (!canEditDesign) return false;
@@ -5085,8 +6208,12 @@ export default function DesignEditor() {
5085
6208
 
5086
6209
  const handlePrimitiveCreated = useCallback(
5087
6210
  (screenId: string, nodeId: string) => {
6211
+ // B2/B4 fix: stay in overview mode after drawing a primitive. The user
6212
+ // drew a shape on the board — they should remain on the board with the
6213
+ // new primitive selected, matching Figma behaviour. We activate the
6214
+ // target screen (so the layers panel shows its content) and select the
6215
+ // new node, but do NOT switch to single/full view.
5088
6216
  pendingOverviewScreenSelectionRef.current = null;
5089
- viewModeRef.current = "single";
5090
6217
  setActiveFileId(screenId);
5091
6218
  setSelectedElement(null);
5092
6219
  setHoveredElement(null);
@@ -5094,7 +6221,7 @@ export default function DesignEditor() {
5094
6221
  setOverviewSelectedScreenIds([]);
5095
6222
  setActiveTool("move");
5096
6223
  setMode("edit");
5097
- setViewMode("single");
6224
+ // viewMode stays at "overview" — no setViewMode("single") call here.
5098
6225
  },
5099
6226
  [],
5100
6227
  );
@@ -5329,6 +6456,58 @@ export default function DesignEditor() {
5329
6456
  mode,
5330
6457
  activeTool,
5331
6458
  inspectorTab: activeInspectorTab,
6459
+ // §8 DesignNavigationState additions — dock + breakpoint context
6460
+ dock: { kind: "motion" as const, open: motionDockOpen },
6461
+ motion: {
6462
+ previewing: false,
6463
+ playheadMs: 0,
6464
+ timelineId: undefined as string | undefined,
6465
+ selectedTrackId: undefined as string | undefined,
6466
+ selectedKeyframeId: undefined as string | undefined,
6467
+ },
6468
+ // §8 breakpoint fields — "auto" = no specific breakpoint focused.
6469
+ breakpoint: (activeBreakpointWidthState != null
6470
+ ? activeBreakpointWidthState < 500
6471
+ ? "mobile"
6472
+ : activeBreakpointWidthState < 1024
6473
+ ? "tablet"
6474
+ : "desktop"
6475
+ : "auto") as "auto" | "mobile" | "tablet" | "desktop",
6476
+ activeBreakpointId: (() => {
6477
+ if (activeBreakpointWidthState == null) return undefined;
6478
+ try {
6479
+ const raw = (designDataJson as Record<string, unknown>)
6480
+ ?.breakpointSet;
6481
+ if (
6482
+ raw &&
6483
+ typeof raw === "object" &&
6484
+ Array.isArray((raw as Record<string, unknown>).breakpoints)
6485
+ ) {
6486
+ const bps = (
6487
+ raw as { breakpoints: Array<{ id: string; widthPx: number }> }
6488
+ ).breakpoints;
6489
+ return bps.find((b) => b.widthPx === activeBreakpointWidthState)
6490
+ ?.id;
6491
+ }
6492
+ } catch {
6493
+ // ignore
6494
+ }
6495
+ return undefined;
6496
+ })(),
6497
+ breakpointSetId: (() => {
6498
+ try {
6499
+ const raw = (designDataJson as Record<string, unknown>)
6500
+ ?.breakpointSet;
6501
+ if (raw && typeof raw === "object") {
6502
+ return (raw as Record<string, unknown>).id as string | undefined;
6503
+ }
6504
+ } catch {
6505
+ // ignore
6506
+ }
6507
+ return undefined;
6508
+ })(),
6509
+ // §8 — active design state (null = Default / live view)
6510
+ selectedStateId,
5332
6511
  };
5333
6512
  (window as any).__designSelection = selection;
5334
6513
  const persistedSelection = {
@@ -5344,6 +6523,12 @@ export default function DesignEditor() {
5344
6523
  mode: selection.mode,
5345
6524
  activeTool: selection.activeTool,
5346
6525
  inspectorTab: selection.inspectorTab,
6526
+ dock: selection.dock,
6527
+ motion: selection.motion,
6528
+ breakpoint: selection.breakpoint,
6529
+ activeBreakpointId: selection.activeBreakpointId,
6530
+ breakpointSetId: selection.breakpointSetId,
6531
+ selectedStateId: selection.selectedStateId,
5347
6532
  ownerId: designSelectionOwnerIdRef.current,
5348
6533
  };
5349
6534
  const persistedKey = JSON.stringify(persistedSelection);
@@ -5381,6 +6566,10 @@ export default function DesignEditor() {
5381
6566
  overviewSelectedScreenIds,
5382
6567
  viewMode,
5383
6568
  zoom,
6569
+ motionDockOpen,
6570
+ activeBreakpointWidthState,
6571
+ designDataJson,
6572
+ selectedStateId,
5384
6573
  ]);
5385
6574
 
5386
6575
  useEffect(() => {
@@ -5440,6 +6629,15 @@ export default function DesignEditor() {
5440
6629
  mode,
5441
6630
  activeTool,
5442
6631
  tweakValues: tweakSelections,
6632
+ onShaderFillPreview: (_descriptor, css) => {
6633
+ setShaderFillPreview({
6634
+ selector: selectedElement?.selector ?? undefined,
6635
+ nodeId:
6636
+ selectedElement?.sourceId ?? selectedCodeLayerNode?.id ?? undefined,
6637
+ css,
6638
+ });
6639
+ },
6640
+ onShaderFillPreviewClear: () => setShaderFillPreview(null),
5443
6641
  }),
5444
6642
  [
5445
6643
  activeFile?.filename,
@@ -5452,6 +6650,7 @@ export default function DesignEditor() {
5452
6650
  mode,
5453
6651
  overviewSelectedScreenIds,
5454
6652
  selectedElement,
6653
+ selectedCodeLayerNode?.id,
5455
6654
  selectedScreenIds,
5456
6655
  tweakSelections,
5457
6656
  viewMode,
@@ -5676,7 +6875,10 @@ export default function DesignEditor() {
5676
6875
  // advance lastLocalContentRef.current to resolvedNextContent below, the
5677
6876
  // next synchronous call reads the previous call's result and the patches
5678
6877
  // compose. Falls back to activeContent when the ref is unset (file switch).
5679
- const baseContent = lastLocalContentRef.current ?? activeContent;
6878
+ const baseContent =
6879
+ latestActiveContentRef.current ??
6880
+ lastLocalContentRef.current ??
6881
+ activeContent;
5680
6882
  const [firstProperty, firstValue] = entries[0];
5681
6883
  const projection = buildCodeLayerProjection(baseContent);
5682
6884
  const targetInfo = options.elementInfo ?? selectedElement;
@@ -5743,20 +6945,71 @@ export default function DesignEditor() {
5743
6945
  });
5744
6946
  const sendStyleChange = (window as any).__designCanvasSendStyle;
5745
6947
  if (!options.runtimeApplied && typeof sendStyleChange === "function") {
6948
+ const selectorCandidates = targetNode
6949
+ ? codeLayerSelectorAliases(targetNode)
6950
+ : selector
6951
+ ? [selector]
6952
+ : [];
6953
+ const nodeId = targetNode
6954
+ ? bridgeSourceIdForCodeLayerNode(targetNode)
6955
+ : targetInfo?.sourceId;
5746
6956
  entries.forEach(([property, value]) => {
5747
- sendStyleChange(selector, property, value);
6957
+ sendStyleChange(selector, property, value, {
6958
+ selectorCandidates,
6959
+ nodeId,
6960
+ });
5748
6961
  });
5749
6962
  }
5750
6963
 
5751
6964
  const nextContent = applyInlineStylesToHtml(baseContent, selector, {
5752
6965
  ...Object.fromEntries(entries),
5753
6966
  });
6967
+ // §6.4 — Breakpoint-scoped class editing. Reuses the `projection` and
6968
+ // `targetNode` resolved above for the patch-proof block (same baseContent).
6969
+ // When an active non-base breakpoint frame is set, attempt to route class
6970
+ // edits through `kind: "responsive-class"` so the write targets only that
6971
+ // breakpoint prefix (e.g. "md:text-lg" instead of "text-lg"). This fires
6972
+ // when the element has a `responsive-class` EditCapability, which signals
6973
+ // that its values come from Tailwind class tokens and can carry a prefix.
6974
+ // Falls back to `kind: "style"` (inline attribute) for any entry that
6975
+ // fails the responsive path (e.g. raw CSS values with no Tailwind utility).
6976
+ const activeBreakpointPrefix =
6977
+ activeBreakpointWidthState != null
6978
+ ? widthToPrefix(activeBreakpointWidthState)
6979
+ : null;
6980
+ // `responsive-class` is a code-layer EditCapability kind not yet reflected
6981
+ // in the ElementInfo type union (types.ts); cast to string for the check.
6982
+ const hasResponsiveCapability =
6983
+ activeBreakpointPrefix != null &&
6984
+ activeBreakpointPrefix !== "base" &&
6985
+ selectedElement?.editCapabilities?.some(
6986
+ (cap) => (cap.kind as string) === "responsive-class",
6987
+ );
5754
6988
  const stylePatch = entries.reduce<{
5755
6989
  content: string;
5756
6990
  failed: string | null;
5757
6991
  }>(
5758
6992
  (current, [property, value]) => {
5759
6993
  if (current.failed) return current;
6994
+ // Try responsive-class path first when appropriate.
6995
+ if (hasResponsiveCapability && activeBreakpointPrefix) {
6996
+ const utility = value.trim();
6997
+ if (responsiveUtilityMatchesStyleProperty(property, utility)) {
6998
+ const rcPatch = applyVisualEdit(current.content, {
6999
+ kind: "responsive-class",
7000
+ target: targetNode ? { nodeId: targetNode.id } : { selector },
7001
+ prefix: activeBreakpointPrefix,
7002
+ operation: "replace",
7003
+ utility,
7004
+ stem: utilityStem(utility),
7005
+ });
7006
+ if (rcPatch.result.status === "applied") {
7007
+ return { content: rcPatch.content, failed: null };
7008
+ }
7009
+ }
7010
+ // Responsive-class path didn't apply (e.g. value is a raw CSS value,
7011
+ // not a Tailwind utility); fall through to the inline-style path.
7012
+ }
5760
7013
  const patch = applyVisualEdit(current.content, {
5761
7014
  kind: "style",
5762
7015
  target: targetNode ? { nodeId: targetNode.id } : { selector },
@@ -5826,6 +7079,7 @@ export default function DesignEditor() {
5826
7079
  // Mark as our own write so the get-design reconcile + Yjs observe don't
5827
7080
  // treat the echo as an external edit and fight the live value.
5828
7081
  lastLocalContentRef.current = resolvedNextContent;
7082
+ latestActiveContentRef.current = resolvedNextContent;
5829
7083
  // Write the edit into the shared Y.Doc so other open clients see it live
5830
7084
  // through Yjs (not only via the slower update-file → applyText round-trip).
5831
7085
  // Use LOCAL_EDIT_ORIGIN so the UndoManager captures this transaction.
@@ -5865,6 +7119,7 @@ export default function DesignEditor() {
5865
7119
  [
5866
7120
  activeContent,
5867
7121
  activeFile,
7122
+ activeBreakpointWidthState,
5868
7123
  canEditDesign,
5869
7124
  queueFileContentSave,
5870
7125
  selectedElement,
@@ -5884,7 +7139,10 @@ export default function DesignEditor() {
5884
7139
  ) {
5885
7140
  const sendStyleChange = (window as any).__designCanvasSendStyle;
5886
7141
  if (typeof sendStyleChange === "function") {
5887
- sendStyleChange(selector, property, value);
7142
+ sendStyleChange(selector, property, value, {
7143
+ selectorCandidates: selectedCanvasSelectorCandidates,
7144
+ nodeId: selectedElement?.sourceId,
7145
+ });
5888
7146
  return;
5889
7147
  }
5890
7148
  }
@@ -5893,6 +7151,8 @@ export default function DesignEditor() {
5893
7151
  [
5894
7152
  commitVisualStyles,
5895
7153
  selectedElement?.selector,
7154
+ selectedElement?.sourceId,
7155
+ selectedCanvasSelectorCandidates,
5896
7156
  textEditingState.active,
5897
7157
  textEditingState.hasRange,
5898
7158
  textEditingState.selector,
@@ -5911,6 +7171,16 @@ export default function DesignEditor() {
5911
7171
  [commitVisualStyles, selectedElement?.selector],
5912
7172
  );
5913
7173
 
7174
+ const getFreshActiveContent = useCallback(
7175
+ () =>
7176
+ getFreshActiveFileContent({
7177
+ activeContent,
7178
+ latestContent: latestActiveContentRef.current,
7179
+ lastLocalContent: lastLocalContentRef.current,
7180
+ }),
7181
+ [activeContent],
7182
+ );
7183
+
5914
7184
  const handleVisualStyleChange = useCallback(
5915
7185
  (
5916
7186
  selector: string,
@@ -5939,7 +7209,8 @@ export default function DesignEditor() {
5939
7209
  ) => {
5940
7210
  if (!canEditDesign) return false;
5941
7211
  if (!activeFile) return false;
5942
- const projection = buildCodeLayerProjection(activeContent);
7212
+ const baseContent = getFreshActiveContent();
7213
+ const projection = buildCodeLayerProjection(baseContent);
5943
7214
  const resolveBridgeNode = (targetSelector: string, sourceId?: string) =>
5944
7215
  resolveCodeLayerNodeFromBridge(projection, targetSelector, sourceId);
5945
7216
  const targetInfo = elementInfo
@@ -5956,7 +7227,7 @@ export default function DesignEditor() {
5956
7227
  anchorSelector,
5957
7228
  details?.anchorSourceId,
5958
7229
  );
5959
- const patch = applyVisualEdit(activeContent, {
7230
+ const patch = applyVisualEdit(baseContent, {
5960
7231
  kind: "moveNode",
5961
7232
  target: targetNode ? { nodeId: targetNode.id } : { selector },
5962
7233
  anchor: anchorNode
@@ -6004,7 +7275,13 @@ export default function DesignEditor() {
6004
7275
  }
6005
7276
  return true;
6006
7277
  },
6007
- [activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
7278
+ [
7279
+ activeFile,
7280
+ applyLocalContentUpdate,
7281
+ canEditDesign,
7282
+ getFreshActiveContent,
7283
+ t,
7284
+ ],
6008
7285
  );
6009
7286
 
6010
7287
  const handleVisualDuplicateChange = useCallback(
@@ -6021,7 +7298,8 @@ export default function DesignEditor() {
6021
7298
  ) => {
6022
7299
  if (!canEditDesign) return false;
6023
7300
  if (!activeFile) return false;
6024
- const projection = buildCodeLayerProjection(activeContent);
7301
+ const baseContent = getFreshActiveContent();
7302
+ const projection = buildCodeLayerProjection(baseContent);
6025
7303
  const targetInfo = elementInfo
6026
7304
  ? {
6027
7305
  ...elementInfo,
@@ -6041,7 +7319,7 @@ export default function DesignEditor() {
6041
7319
  details?.anchorSelector,
6042
7320
  details?.anchorSourceId,
6043
7321
  );
6044
- const nextContent = insertClonedHtmlLayer(activeContent, cloneHtml, {
7322
+ const nextContent = insertClonedHtmlLayer(baseContent, cloneHtml, {
6045
7323
  targetSelectors: targetNode
6046
7324
  ? codeLayerSelectorAliases(targetNode)
6047
7325
  : [selector],
@@ -6075,7 +7353,13 @@ export default function DesignEditor() {
6075
7353
  }
6076
7354
  return true;
6077
7355
  },
6078
- [activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
7356
+ [
7357
+ activeFile,
7358
+ applyLocalContentUpdate,
7359
+ canEditDesign,
7360
+ getFreshActiveContent,
7361
+ t,
7362
+ ],
6079
7363
  );
6080
7364
 
6081
7365
  const handleTextContentChange = useCallback(
@@ -6087,7 +7371,8 @@ export default function DesignEditor() {
6087
7371
  ) => {
6088
7372
  if (!canEditDesign) return;
6089
7373
  if (!activeFile) return;
6090
- const projection = buildCodeLayerProjection(activeContent);
7374
+ const baseContent = getFreshActiveContent();
7375
+ const projection = buildCodeLayerProjection(baseContent);
6091
7376
  const targetInfo = elementInfo ? { ...elementInfo, selector } : null;
6092
7377
  const targetNode = targetInfo
6093
7378
  ? resolveCodeLayerNodeFromElementInfo(projection, targetInfo)
@@ -6095,10 +7380,10 @@ export default function DesignEditor() {
6095
7380
  const isEmpty = value.trim().length === 0;
6096
7381
  const removedContent =
6097
7382
  isEmpty && targetNode
6098
- ? removeCodeLayerNodeFromHtml(activeContent, targetNode)
7383
+ ? removeCodeLayerNodeFromHtml(baseContent, targetNode)
6099
7384
  : null;
6100
7385
  const patch = !removedContent
6101
- ? applyVisualEdit(activeContent, {
7386
+ ? applyVisualEdit(baseContent, {
6102
7387
  kind: "textContent",
6103
7388
  target: targetNode ? { nodeId: targetNode.id } : { selector },
6104
7389
  value,
@@ -6108,12 +7393,7 @@ export default function DesignEditor() {
6108
7393
  const nextContent =
6109
7394
  removedContent ??
6110
7395
  (patch?.result.status === "applied" ? patch.content : null) ??
6111
- updateElementContentInHtml(
6112
- activeContent,
6113
- selector,
6114
- value,
6115
- details?.html,
6116
- );
7396
+ updateElementContentInHtml(baseContent, selector, value, details?.html);
6117
7397
  if (!nextContent) {
6118
7398
  toast.error(
6119
7399
  codeLayerPatchMessage(
@@ -6162,7 +7442,13 @@ export default function DesignEditor() {
6162
7442
  : previous;
6163
7443
  });
6164
7444
  },
6165
- [activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
7445
+ [
7446
+ activeFile,
7447
+ applyLocalContentUpdate,
7448
+ canEditDesign,
7449
+ getFreshActiveContent,
7450
+ t,
7451
+ ],
6166
7452
  );
6167
7453
 
6168
7454
  const handleScreenVisualStyleChange = useCallback(
@@ -6483,22 +7769,71 @@ export default function DesignEditor() {
6483
7769
 
6484
7770
  const handleCopySelection = useCallback(async () => {
6485
7771
  if (!selectedElement?.selector) return;
6486
- const html = getElementOuterHtml(activeContent, selectedElement.selector);
7772
+ const html = getElementOuterHtml(
7773
+ getFreshActiveContent(),
7774
+ selectedElement.selector,
7775
+ );
6487
7776
  if (!html) return;
6488
7777
  copiedLayerHtmlRef.current = html;
6489
7778
  pasteCascadeRef.current = 0;
6490
7779
  setHasCanvasClipboard(true);
6491
7780
  try {
6492
7781
  await navigator.clipboard.writeText(html);
6493
- toast.success(t("designEditor.toasts.copied"));
6494
7782
  } catch {
6495
7783
  toast.error(t("designEditor.toasts.clipboardBlocked"));
6496
7784
  }
6497
- }, [activeContent, selectedElement, t]);
7785
+ }, [getFreshActiveContent, selectedElement, t]);
6498
7786
 
6499
7787
  const handlePasteSelection = useCallback(
6500
7788
  (position?: { x: number; y: number }) => {
6501
7789
  if (!activeFile || !canEditDesign || !copiedLayerHtmlRef.current) return;
7790
+ const baseContent = getFreshActiveContent();
7791
+
7792
+ // B7 fix: when an element is selected and no explicit canvas position was
7793
+ // given, insert the clone as an in-flow sibling right AFTER the selected
7794
+ // element. Strip any position/left/top from the clone so it participates
7795
+ // in normal document flow instead of being an absolutely-positioned body
7796
+ // child. Fall back to the old position-based clone when nothing is
7797
+ // selected or a "Paste here" position is provided.
7798
+ if (!position && selectedElement?.selector) {
7799
+ const selector = selectedCanvasSelector ?? selectedElement.selector;
7800
+ // Strip position properties from the pasted clone so it becomes an
7801
+ // in-flow sibling (not absolute).
7802
+ const strippedHtml = (() => {
7803
+ try {
7804
+ const parser = new DOMParser();
7805
+ const tmp = parser.parseFromString(
7806
+ `<template>${copiedLayerHtmlRef.current!}</template>`,
7807
+ "text/html",
7808
+ );
7809
+ const root =
7810
+ tmp.querySelector("template")?.content.firstElementChild ??
7811
+ tmp.body.firstElementChild;
7812
+ if (root && root instanceof HTMLElement) {
7813
+ root.style.position = "";
7814
+ root.style.left = "";
7815
+ root.style.top = "";
7816
+ root.style.right = "";
7817
+ root.style.bottom = "";
7818
+ }
7819
+ return root?.outerHTML ?? copiedLayerHtmlRef.current!;
7820
+ } catch {
7821
+ return copiedLayerHtmlRef.current!;
7822
+ }
7823
+ })();
7824
+
7825
+ const nextContent = insertClonedHtmlLayer(baseContent, strippedHtml, {
7826
+ targetSelectors: [selector],
7827
+ placement: "after",
7828
+ });
7829
+ if (nextContent) {
7830
+ pasteCascadeRef.current += 1;
7831
+ applyLocalContentUpdate(nextContent);
7832
+ return;
7833
+ }
7834
+ // Fall through to position-based clone if insert failed.
7835
+ }
7836
+
6502
7837
  // Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
6503
7838
  // Keyboard pastes land near the source layer and cascade so repeats don't
6504
7839
  // stack exactly.
@@ -6512,53 +7847,86 @@ export default function DesignEditor() {
6512
7847
  : { x: 120 + offset, y: 120 + offset };
6513
7848
  })();
6514
7849
  const nextContent = cloneHtmlLayerAtPosition(
6515
- activeContent,
7850
+ baseContent,
6516
7851
  copiedLayerHtmlRef.current,
6517
7852
  targetPosition,
6518
7853
  );
6519
7854
  if (!nextContent) return;
6520
7855
  if (!position) pasteCascadeRef.current += 1;
6521
7856
  applyLocalContentUpdate(nextContent);
6522
- toast.success(t("designEditor.toasts.pasted"), { duration: 3000 });
6523
7857
  },
6524
- [activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
7858
+ [
7859
+ activeFile,
7860
+ applyLocalContentUpdate,
7861
+ canEditDesign,
7862
+ getFreshActiveContent,
7863
+ selectedCanvasSelector,
7864
+ selectedElement,
7865
+ ],
6525
7866
  );
6526
7867
 
6527
7868
  const handlePasteOverSelection = useCallback(() => {
6528
7869
  if (!activeFile || !copiedLayerHtmlRef.current) return;
7870
+ const baseContent = getFreshActiveContent();
6529
7871
  if (selectedElement?.boundingRect) {
6530
7872
  const { x, y } = selectedElement.boundingRect;
6531
7873
  const nextContent = cloneHtmlLayerAtPosition(
6532
- activeContent,
7874
+ baseContent,
6533
7875
  copiedLayerHtmlRef.current,
6534
7876
  { x, y },
6535
7877
  );
6536
7878
  if (!nextContent) return;
6537
7879
  applyLocalContentUpdate(nextContent);
6538
- toast.success(t("designEditor.toasts.pasted"));
6539
7880
  } else {
6540
7881
  handlePasteSelection();
6541
7882
  }
6542
7883
  }, [
6543
- activeContent,
6544
7884
  activeFile,
6545
7885
  applyLocalContentUpdate,
7886
+ getFreshActiveContent,
6546
7887
  handlePasteSelection,
6547
7888
  selectedElement,
6548
- t,
6549
7889
  ]);
6550
7890
 
6551
7891
  const handleDuplicateSelection = useCallback(() => {
6552
7892
  if (!canEditDesign) return;
6553
7893
  if (selectedElement?.selector) {
6554
- 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;
7894
+ const baseContent = getFreshActiveContent();
7895
+ const html = getElementOuterHtml(baseContent, selectedElement.selector);
7896
+ if (!html) {
7897
+ toast.error(t("designEditor.toasts.duplicateElementFailed"));
7898
+ return;
7899
+ }
7900
+ // B7 fix: duplicate inserts the clone as an in-flow sibling right AFTER
7901
+ // the original — not as an absolutely-positioned body child. Strip
7902
+ // position/left/top so it joins normal document flow.
7903
+ const selector = selectedCanvasSelector ?? selectedElement.selector;
7904
+ const strippedHtml = (() => {
7905
+ try {
7906
+ const parser = new DOMParser();
7907
+ const tmp = parser.parseFromString(
7908
+ `<template>${html}</template>`,
7909
+ "text/html",
7910
+ );
7911
+ const root =
7912
+ tmp.querySelector("template")?.content.firstElementChild ??
7913
+ tmp.body.firstElementChild;
7914
+ if (root && root instanceof HTMLElement) {
7915
+ root.style.position = "";
7916
+ root.style.left = "";
7917
+ root.style.top = "";
7918
+ root.style.right = "";
7919
+ root.style.bottom = "";
7920
+ }
7921
+ return root?.outerHTML ?? html;
7922
+ } catch {
7923
+ return html;
7924
+ }
7925
+ })();
7926
+ const nextContent = insertClonedHtmlLayer(baseContent, strippedHtml, {
7927
+ targetSelectors: [selector],
7928
+ placement: "after",
7929
+ });
6562
7930
  if (nextContent) {
6563
7931
  applyLocalContentUpdate(nextContent);
6564
7932
  } else {
@@ -6568,16 +7936,19 @@ export default function DesignEditor() {
6568
7936
  }
6569
7937
  if (activeFile) handleDuplicateScreen(activeFile.id);
6570
7938
  }, [
6571
- activeContent,
6572
7939
  activeFile,
6573
7940
  applyLocalContentUpdate,
6574
7941
  canEditDesign,
7942
+ getFreshActiveContent,
6575
7943
  handleDuplicateScreen,
7944
+ selectedCanvasSelector,
6576
7945
  selectedElement,
7946
+ t,
6577
7947
  ]);
6578
7948
 
6579
7949
  const handleDeleteSelection = useCallback(() => {
6580
7950
  if (!canEditDesign) return;
7951
+ const baseContent = getFreshActiveContent();
6581
7952
  // Multi-select delete: when several DOM/code layers are selected in the
6582
7953
  // panel, remove all of them — not just the single focused element. Compose
6583
7954
  // the removals against the running content (re-projecting each pass) so
@@ -6589,7 +7960,7 @@ export default function DesignEditor() {
6589
7960
  !files.some((file) => file.id === layerId),
6590
7961
  );
6591
7962
  if (candidateIds.length > 1) {
6592
- let content = activeContent;
7963
+ let content = baseContent;
6593
7964
  const removedSelectors: string[] = [];
6594
7965
  for (const layerId of candidateIds) {
6595
7966
  const projection = buildCodeLayerProjection(content);
@@ -6603,7 +7974,7 @@ export default function DesignEditor() {
6603
7974
  if (selector) removedSelectors.push(selector);
6604
7975
  content = next;
6605
7976
  }
6606
- if (content !== activeContent) {
7977
+ if (content !== baseContent) {
6607
7978
  removedSelectors.forEach((selector) => deleteRuntimeElement(selector));
6608
7979
  applyLocalContentUpdate(content, { refreshPreview: false });
6609
7980
  setSelectedElement(null);
@@ -6614,48 +7985,452 @@ export default function DesignEditor() {
6614
7985
  }
6615
7986
 
6616
7987
  if (!selectedElement?.selector) return;
6617
- const projection = buildCodeLayerProjection(activeContent);
7988
+ const projection = buildCodeLayerProjection(baseContent);
6618
7989
  const targetNode = resolveCodeLayerNodeFromElementInfo(
6619
7990
  projection,
6620
7991
  selectedElement,
6621
7992
  );
6622
7993
  const nextContent =
6623
7994
  (targetNode
6624
- ? removeCodeLayerNodeFromHtml(activeContent, targetNode)
6625
- : null) ??
6626
- removeElementFromHtml(activeContent, selectedElement.selector);
7995
+ ? removeCodeLayerNodeFromHtml(baseContent, targetNode)
7996
+ : null) ?? removeElementFromHtml(baseContent, selectedElement.selector);
6627
7997
  if (!nextContent) return;
6628
7998
  deleteRuntimeElement(selectedElement.selector);
6629
7999
  applyLocalContentUpdate(nextContent, { refreshPreview: false });
6630
8000
  setSelectedElement(null);
6631
8001
  setSelectedLayerIdsState([]);
6632
8002
  }, [
6633
- activeContent,
6634
8003
  applyLocalContentUpdate,
6635
8004
  canEditDesign,
6636
8005
  deleteRuntimeElement,
6637
8006
  files,
8007
+ getFreshActiveContent,
6638
8008
  selectedElement,
6639
8009
  selectedLayerIdsState,
6640
8010
  ]);
6641
8011
 
6642
- const handleCutSelection = useCallback(async () => {
6643
- if (!selectedElement?.selector) return;
6644
- // Copy first (populates the internal clipboard ref even if the async
6645
- // navigator.clipboard write is blocked — handleCopySelection swallows that
6646
- // error) then remove the element so a subsequent paste can re-insert it.
6647
- await handleCopySelection();
6648
- handleDeleteSelection();
6649
- }, [handleCopySelection, handleDeleteSelection, selectedElement]);
6650
-
6651
- const handleDeleteOverviewSelection = useCallback(
6652
- (selectedIds: string[]) => {
6653
- if (!canEditDesign) return false;
6654
- if (!selectedIds.length || files.length <= 1) return false;
8012
+ // Wrap the current multi-layer selection into a new group container.
8013
+ const handleGroupSelection = useCallback(() => {
8014
+ if (!canEditDesign || !activeFile) return;
8015
+ const baseContent = getFreshActiveContent();
8016
+ // Collect the DOM-node layer ids that belong to the active screen.
8017
+ // Build a set of ids present in the active content so stale ids from
8018
+ // other files (which can persist in selectedLayerIdsState after a
8019
+ // cross-screen layers-panel selection) are excluded before wrapNodes
8020
+ // runs against activeContent. Without this filter, cross-file ids
8021
+ // cause wrapNodes to return "conflict" even for a valid same-file
8022
+ // selection.
8023
+ const fileIds = new Set(files.map((f) => f.id));
8024
+ const activeNodeIdSet = buildActiveFileNodeIdSet(
8025
+ buildCodeLayerProjection(baseContent),
8026
+ );
8027
+ const nodeIds = selectedLayerIdsState.filter(
8028
+ (id) =>
8029
+ !id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
8030
+ );
8031
+ if (nodeIds.length < 2) return;
8032
+ const patch = applyVisualEdit(baseContent, {
8033
+ kind: "wrapNodes",
8034
+ targetIds: nodeIds,
8035
+ autoLayout: false,
8036
+ });
8037
+ if (patch.result.status !== "applied") {
8038
+ toast.error(
8039
+ codeLayerPatchMessage(
8040
+ patch.result.message,
8041
+ t("designEditor.toasts.layerMoveFailed"),
8042
+ ),
8043
+ { duration: 4000 },
8044
+ );
8045
+ return;
8046
+ }
8047
+ applyLocalContentUpdate(patch.content, { skipPreview: true });
8048
+ // Select the new wrapper node if the substrate reported its id.
8049
+ const wrapperId = patch.result.wrapperNodeId;
8050
+ if (wrapperId) {
8051
+ // Find the projection node whose data-agent-native-node-id matches.
8052
+ const wrapperNode = patch.projection.nodes.find(
8053
+ (n) => n.dataAttributes["data-agent-native-node-id"] === wrapperId,
8054
+ );
8055
+ if (wrapperNode) {
8056
+ setSelectedLayerIdsState([wrapperNode.id]);
8057
+ setSelectedElement(elementInfoFromCodeLayerNode(wrapperNode));
8058
+ }
8059
+ }
8060
+ }, [
8061
+ activeFile,
8062
+ applyLocalContentUpdate,
8063
+ canEditDesign,
8064
+ files,
8065
+ getFreshActiveContent,
8066
+ selectedLayerIdsState,
8067
+ t,
8068
+ ]);
6655
8069
 
6656
- const selectedIdSet = new Set(selectedIds);
6657
- const selectedFiles = files.filter((file) => selectedIdSet.has(file.id));
6658
- if (!selectedFiles.length) return false;
8070
+ // Unwrap the currently selected single-container layer.
8071
+ const handleUngroupSelection = useCallback(() => {
8072
+ if (!canEditDesign || !activeFile) return;
8073
+ const baseContent = getFreshActiveContent();
8074
+ // Filter to active-file nodes only (mirrors handleGroupSelection fix).
8075
+ // A stale id from another file must not be passed to unwrap or it will
8076
+ // fail with "conflict" even though the actual selection is valid.
8077
+ const fileIds = new Set(files.map((f) => f.id));
8078
+ const activeNodeIdSet = buildActiveFileNodeIdSet(
8079
+ buildCodeLayerProjection(baseContent),
8080
+ );
8081
+ const nodeIds = selectedLayerIdsState.filter(
8082
+ (id) =>
8083
+ !id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
8084
+ );
8085
+ const targetId = nodeIds[0];
8086
+ if (!targetId) return;
8087
+ const patch = applyVisualEdit(baseContent, {
8088
+ kind: "unwrap",
8089
+ targetId,
8090
+ });
8091
+ if (patch.result.status !== "applied") {
8092
+ toast.error(
8093
+ codeLayerPatchMessage(
8094
+ patch.result.message,
8095
+ t("designEditor.toasts.layerMoveFailed"),
8096
+ ),
8097
+ { duration: 4000 },
8098
+ );
8099
+ return;
8100
+ }
8101
+ applyLocalContentUpdate(patch.content, { skipPreview: true });
8102
+ setSelectedElement(null);
8103
+ setSelectedLayerIdsState([]);
8104
+ }, [
8105
+ activeFile,
8106
+ applyLocalContentUpdate,
8107
+ canEditDesign,
8108
+ files,
8109
+ getFreshActiveContent,
8110
+ selectedLayerIdsState,
8111
+ t,
8112
+ ]);
8113
+
8114
+ /**
8115
+ * Convert the selected container to full auto-layout. Applies the
8116
+ * { kind: "autoLayout", enabled: true } substrate intent which sets
8117
+ * display:flex on the target AND strips position:absolute/left/top/right/bottom
8118
+ * from its direct children so they become flow children.
8119
+ */
8120
+ const handleAutoLayoutConvert = useCallback(
8121
+ (
8122
+ targetNodeId: string,
8123
+ opts?: { direction?: "row" | "column"; gap?: string },
8124
+ ) => {
8125
+ if (!canEditDesign || !activeFile) return;
8126
+ const baseContent = getFreshActiveContent();
8127
+ const patch = applyVisualEdit(baseContent, {
8128
+ kind: "autoLayout",
8129
+ targetId: targetNodeId,
8130
+ enabled: true,
8131
+ direction: opts?.direction ?? "row",
8132
+ gap: opts?.gap ?? "8px",
8133
+ });
8134
+ if (patch.result.status !== "applied") {
8135
+ toast.error(
8136
+ codeLayerPatchMessage(
8137
+ patch.result.message,
8138
+ t("designEditor.toasts.layerMoveFailed"),
8139
+ ),
8140
+ { duration: 4000 },
8141
+ );
8142
+ return;
8143
+ }
8144
+ applyLocalContentUpdate(patch.content, { skipPreview: true });
8145
+ // Re-select the container so the inspector refreshes its layout state.
8146
+ const containerNode = patch.projection.nodes.find(
8147
+ (n) =>
8148
+ n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
8149
+ n.id === targetNodeId,
8150
+ );
8151
+ if (containerNode) {
8152
+ setSelectedLayerIdsState([containerNode.id]);
8153
+ setSelectedElement(elementInfoFromCodeLayerNode(containerNode));
8154
+ }
8155
+ },
8156
+ [
8157
+ activeFile,
8158
+ applyLocalContentUpdate,
8159
+ canEditDesign,
8160
+ getFreshActiveContent,
8161
+ t,
8162
+ ],
8163
+ );
8164
+
8165
+ /**
8166
+ * Handle a primitive being drag-dropped onto another primitive in the
8167
+ * MultiScreenCanvas overview (CONTRACT: onPrimitiveReparent prop).
8168
+ *
8169
+ * Same-screen: applies a moveNode intent then strips the moved node's
8170
+ * absolute positioning so it becomes a flow child of the container.
8171
+ * Cross-screen: uses moveNodeBetweenDocuments and persists both files.
8172
+ */
8173
+ const handleOverviewPrimitiveReparent = useCallback(
8174
+ ({
8175
+ sourceNodeId,
8176
+ sourceScreenId,
8177
+ targetNodeId,
8178
+ targetScreenId,
8179
+ }: {
8180
+ sourceNodeId: string;
8181
+ sourceScreenId: string;
8182
+ targetNodeId: string;
8183
+ targetScreenId: string;
8184
+ placement: "inside";
8185
+ }) => {
8186
+ if (!canEditDesign) return;
8187
+
8188
+ if (sourceScreenId === targetScreenId) {
8189
+ // --- Same-screen reparent ---
8190
+ const baseContent = getScreenContent(sourceScreenId);
8191
+ if (!baseContent) return;
8192
+
8193
+ // 1. Move the node inside the target container.
8194
+ const movePatch = applyVisualEdit(baseContent, {
8195
+ kind: "moveNode",
8196
+ target: { nodeId: sourceNodeId },
8197
+ anchor: { nodeId: targetNodeId },
8198
+ placement: "inside",
8199
+ });
8200
+ if (movePatch.result.status !== "applied") {
8201
+ toast.error(
8202
+ codeLayerPatchMessage(
8203
+ movePatch.result.message,
8204
+ t("designEditor.toasts.layerMoveFailed"),
8205
+ ),
8206
+ { duration: 4000 },
8207
+ );
8208
+ return;
8209
+ }
8210
+
8211
+ // 2. Strip absolute positioning from the moved node so it flows naturally.
8212
+ // Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because the
8213
+ // applyVisualEdit substrate rejects empty-string values in isSafeStyleValue,
8214
+ // making applyVisualEdit({kind:"style",value:""}) a silent no-op.
8215
+ const movedNodeAttrId =
8216
+ movePatch.projection.nodes.find(
8217
+ (n) =>
8218
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
8219
+ n.id === sourceNodeId,
8220
+ )?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
8221
+ const strippedContent = removeAbsolutePositioningFromNodeInHtml(
8222
+ movePatch.content,
8223
+ movedNodeAttrId,
8224
+ );
8225
+
8226
+ applyFileContentUpdate(sourceScreenId, strippedContent, {
8227
+ skipPreview: true,
8228
+ });
8229
+
8230
+ // Re-select the moved node.
8231
+ const nextProjection = buildCodeLayerProjection(strippedContent);
8232
+ const movedNodeAfter = nextProjection.nodes.find(
8233
+ (n) =>
8234
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
8235
+ n.id === sourceNodeId,
8236
+ );
8237
+ if (movedNodeAfter) {
8238
+ setSelectedLayerIdsState([movedNodeAfter.id]);
8239
+ setSelectedElement(elementInfoFromCodeLayerNode(movedNodeAfter));
8240
+ }
8241
+ return;
8242
+ }
8243
+
8244
+ // --- Cross-screen reparent ---
8245
+ const sourceContent = getScreenContent(sourceScreenId);
8246
+ const destContent = getScreenContent(targetScreenId);
8247
+ if (!sourceContent || !destContent) return;
8248
+
8249
+ // Resolve data-agent-native-node-id attributes for moveNodeBetweenDocuments.
8250
+ const sourceProjection = buildCodeLayerProjection(sourceContent);
8251
+ const destProjection = buildCodeLayerProjection(destContent);
8252
+ const sourceNode = sourceProjection.nodes.find(
8253
+ (n) =>
8254
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
8255
+ n.id === sourceNodeId,
8256
+ );
8257
+ const anchorNode = destProjection.nodes.find(
8258
+ (n) =>
8259
+ n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
8260
+ n.id === targetNodeId,
8261
+ );
8262
+ const nodeAttrId =
8263
+ sourceNode?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
8264
+ const anchorAttrId =
8265
+ anchorNode?.dataAttributes["data-agent-native-node-id"] ?? targetNodeId;
8266
+
8267
+ const result = moveNodeBetweenDocuments(sourceContent, destContent, {
8268
+ nodeId: nodeAttrId,
8269
+ anchorNodeId: anchorAttrId,
8270
+ placement: "inside",
8271
+ });
8272
+ if (result.status !== "applied") {
8273
+ toast.error(
8274
+ codeLayerPatchMessage(
8275
+ result.message,
8276
+ t("designEditor.toasts.layerMoveFailed"),
8277
+ ),
8278
+ { duration: 4000 },
8279
+ );
8280
+ return;
8281
+ }
8282
+
8283
+ // Strip absolute positioning from the moved node in the destination.
8284
+ // Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because
8285
+ // applyVisualEdit({kind:"style",value:""}) is a silent no-op (the
8286
+ // substrate rejects empty-string values in isSafeStyleValue). Use
8287
+ // result.movedNodeId (the final id in destHtml, which may differ from
8288
+ // nodeAttrId when a collision triggered an id re-stamp) so the strip and
8289
+ // re-selection always find the correct element.
8290
+ const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
8291
+ const strippedDest = removeAbsolutePositioningFromNodeInHtml(
8292
+ result.destHtml,
8293
+ destNodeAttrId,
8294
+ );
8295
+
8296
+ applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
8297
+ refreshPreview: true,
8298
+ });
8299
+ applyFileContentUpdate(targetScreenId, strippedDest, {
8300
+ refreshPreview: true,
8301
+ });
8302
+
8303
+ // Re-select the moved node in the destination.
8304
+ const finalProjection = buildCodeLayerProjection(strippedDest);
8305
+ const movedNodeFinal = finalProjection.nodes.find(
8306
+ (n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
8307
+ );
8308
+ if (movedNodeFinal) {
8309
+ setSelectedLayerIdsState([movedNodeFinal.id]);
8310
+ setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
8311
+ }
8312
+ },
8313
+ [applyFileContentUpdate, canEditDesign, getScreenContent, t],
8314
+ );
8315
+
8316
+ /**
8317
+ * Cross-screen element drag-drop handler (CONTRACT: onCrossScreenElementDrop
8318
+ * prop on MultiScreenCanvas).
8319
+ *
8320
+ * The bridge in the source screen's iframe posts phase:"end" with the
8321
+ * selector / sourceNodeId of the dragged element. MultiScreenCanvas maps
8322
+ * the board point to a target screen and calls this handler. We resolve
8323
+ * both screens' content, identify the node by its data-agent-native-node-id
8324
+ * (falling back to a projection lookup by selector when only the selector is
8325
+ * available), call moveNodeBetweenDocuments to move it into the target
8326
+ * screen's <body>, persist both files, switch the active screen to the
8327
+ * target, and select the moved node — keeping viewMode "overview" throughout.
8328
+ */
8329
+ const handleCrossScreenElementDrop = useCallback(
8330
+ ({
8331
+ sourceSelector,
8332
+ sourceNodeId,
8333
+ sourceScreenId,
8334
+ targetScreenId,
8335
+ }: {
8336
+ sourceSelector: string;
8337
+ sourceNodeId?: string;
8338
+ sourceScreenId: string;
8339
+ targetScreenId: string;
8340
+ }) => {
8341
+ if (!canEditDesign) return;
8342
+ if (sourceScreenId === targetScreenId) return;
8343
+
8344
+ const sourceContent = getScreenContent(sourceScreenId);
8345
+ const destContent = getScreenContent(targetScreenId);
8346
+ if (!sourceContent || !destContent) return;
8347
+
8348
+ // Resolve the data-agent-native-node-id that moveNodeBetweenDocuments
8349
+ // uses as a stable key. Prefer the bridge-supplied sourceNodeId when it
8350
+ // looks like a node-attr id; otherwise look up via selector projection.
8351
+ const sourceProjection = buildCodeLayerProjection(sourceContent);
8352
+ const resolvedSourceNode = sourceNodeId
8353
+ ? (sourceProjection.nodes.find(
8354
+ (n) =>
8355
+ n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
8356
+ n.id === sourceNodeId,
8357
+ ) ??
8358
+ resolveCodeLayerNodeFromBridge(
8359
+ sourceProjection,
8360
+ sourceSelector,
8361
+ sourceNodeId,
8362
+ ))
8363
+ : resolveCodeLayerNodeFromBridge(sourceProjection, sourceSelector);
8364
+ const nodeAttrId =
8365
+ resolvedSourceNode?.dataAttributes["data-agent-native-node-id"] ??
8366
+ sourceNodeId ??
8367
+ sourceSelector;
8368
+
8369
+ const result = moveNodeBetweenDocuments(sourceContent, destContent, {
8370
+ nodeId: nodeAttrId,
8371
+ placement: "inside",
8372
+ });
8373
+ if (result.status !== "applied") {
8374
+ toast.error(
8375
+ codeLayerPatchMessage(
8376
+ result.message,
8377
+ t("designEditor.toasts.layerMoveFailed"),
8378
+ ),
8379
+ { duration: 4000 },
8380
+ );
8381
+ return;
8382
+ }
8383
+
8384
+ // Strip absolute positioning from the moved node in the destination so
8385
+ // it flows naturally as a body child (mirrors handleOverviewPrimitiveReparent).
8386
+ // Use result.movedNodeId (the final id in destHtml, which may differ from
8387
+ // nodeAttrId when a collision triggered an id re-stamp) so the strip and
8388
+ // re-selection always find the correct element.
8389
+ const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
8390
+ const strippedDest = removeAbsolutePositioningFromNodeInHtml(
8391
+ result.destHtml,
8392
+ destNodeAttrId,
8393
+ );
8394
+
8395
+ applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
8396
+ refreshPreview: true,
8397
+ });
8398
+ applyFileContentUpdate(targetScreenId, strippedDest, {
8399
+ refreshPreview: true,
8400
+ });
8401
+
8402
+ // Switch active screen to the target and select the moved node; viewMode
8403
+ // stays "overview" (no setViewMode call).
8404
+ setActiveFileId(targetScreenId);
8405
+ const finalProjection = buildCodeLayerProjection(strippedDest);
8406
+ const movedNodeFinal = finalProjection.nodes.find(
8407
+ (n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
8408
+ );
8409
+ if (movedNodeFinal) {
8410
+ setSelectedLayerIdsState([movedNodeFinal.id]);
8411
+ setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
8412
+ }
8413
+ },
8414
+ [applyFileContentUpdate, canEditDesign, getScreenContent, t],
8415
+ );
8416
+
8417
+ const handleCutSelection = useCallback(async () => {
8418
+ if (!selectedElement?.selector) return;
8419
+ // Copy first (populates the internal clipboard ref even if the async
8420
+ // navigator.clipboard write is blocked — handleCopySelection swallows that
8421
+ // error) then remove the element so a subsequent paste can re-insert it.
8422
+ await handleCopySelection();
8423
+ handleDeleteSelection();
8424
+ }, [handleCopySelection, handleDeleteSelection, selectedElement]);
8425
+
8426
+ const handleDeleteOverviewSelection = useCallback(
8427
+ (selectedIds: string[]) => {
8428
+ if (!canEditDesign) return false;
8429
+ if (!selectedIds.length || files.length <= 1) return false;
8430
+
8431
+ const selectedIdSet = new Set(selectedIds);
8432
+ const selectedFiles = files.filter((file) => selectedIdSet.has(file.id));
8433
+ if (!selectedFiles.length) return false;
6659
8434
 
6660
8435
  const maxDeleteCount =
6661
8436
  selectedFiles.length >= files.length
@@ -6737,8 +8512,7 @@ export default function DesignEditor() {
6737
8512
  textAlign: selectedElement.computedStyles.textAlign,
6738
8513
  };
6739
8514
  setHasPropsClipboard(true);
6740
- toast.success(t("designEditor.toasts.propsCopied"));
6741
- }, [selectedElement, t]);
8515
+ }, [selectedElement]);
6742
8516
 
6743
8517
  const handlePasteProps = useCallback(() => {
6744
8518
  if (!canEditDesign) return;
@@ -6749,8 +8523,7 @@ export default function DesignEditor() {
6749
8523
  ),
6750
8524
  );
6751
8525
  commitVisualStyles(selectedElement.selector, styles);
6752
- toast.success(t("designEditor.toasts.propsPasted"));
6753
- }, [canEditDesign, commitVisualStyles, selectedElement, t]);
8526
+ }, [canEditDesign, commitVisualStyles, selectedElement]);
6754
8527
 
6755
8528
  const changeSelectedZIndex = useCallback(
6756
8529
  (mode: "forward" | "front" | "backward" | "back") => {
@@ -6810,31 +8583,67 @@ export default function DesignEditor() {
6810
8583
  if (!canEditDesign) return;
6811
8584
  const um = undoManagerRef.current;
6812
8585
  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);
8586
+ if (um?.canUndo()) {
8587
+ um.undo();
8588
+ if (ydoc && activeFile) {
8589
+ const next = ydoc.getText("content").toString();
8590
+ markPendingLocalFileContent(
8591
+ activeFile.id,
8592
+ next,
8593
+ activeFile.updatedAt,
8594
+ );
8595
+ lastLocalContentRef.current = next;
8596
+ queueFileContentSave(activeFile.id, next, {
8597
+ syncCollab: !(ydoc && isSynced),
8598
+ });
8599
+ if (!replacePreviewContent(next)) {
8600
+ setContentRenderRevision((revision) => revision + 1);
8601
+ }
8602
+ // Clear stale selection if the undo removed the selected element.
8603
+ setSelectedElement((prev) => {
8604
+ if (!prev) return prev;
8605
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8606
+ });
8607
+ setHoveredElement((prev) => {
8608
+ if (!prev) return prev;
8609
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8610
+ });
6823
8611
  }
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
- });
8612
+ redoOrderRef.current = [
8613
+ ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8614
+ "content",
8615
+ ];
8616
+ return true;
6833
8617
  }
8618
+
8619
+ if (!activeFile) return false;
8620
+ const entry = contentUndoStackRef.current.pop();
8621
+ if (!entry || entry.fileId !== activeFile.id) return false;
8622
+ contentRedoStackRef.current = [
8623
+ ...contentRedoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8624
+ entry,
8625
+ ];
6834
8626
  redoOrderRef.current = [
6835
8627
  ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
6836
8628
  "content",
6837
8629
  ];
8630
+ suppressContentHistoryRef.current = true;
8631
+ try {
8632
+ applyLocalContentUpdate(entry.before, {
8633
+ refreshPreview: false,
8634
+ immediateSave: true,
8635
+ });
8636
+ } finally {
8637
+ suppressContentHistoryRef.current = false;
8638
+ }
8639
+ setSelectedElement((prev) => {
8640
+ if (!prev) return prev;
8641
+ return elementInfoExistsInContent(entry.before, prev) ? prev : null;
8642
+ });
8643
+ setHoveredElement((prev) => {
8644
+ if (!prev) return prev;
8645
+ return elementInfoExistsInContent(entry.before, prev) ? prev : null;
8646
+ });
6838
8647
  return true;
6839
8648
  };
6840
8649
  const undoGeometry = () => {
@@ -6870,8 +8679,10 @@ export default function DesignEditor() {
6870
8679
  }, [
6871
8680
  ydoc,
6872
8681
  activeFile,
8682
+ applyLocalContentUpdate,
6873
8683
  canEditDesign,
6874
8684
  isSynced,
8685
+ markPendingLocalFileContent,
6875
8686
  queueFileContentSave,
6876
8687
  replacePreviewContent,
6877
8688
  syncUndoRedoState,
@@ -6882,31 +8693,67 @@ export default function DesignEditor() {
6882
8693
  if (!canEditDesign) return;
6883
8694
  const um = undoManagerRef.current;
6884
8695
  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);
8696
+ if (um?.canRedo()) {
8697
+ um.redo();
8698
+ if (ydoc && activeFile) {
8699
+ const next = ydoc.getText("content").toString();
8700
+ markPendingLocalFileContent(
8701
+ activeFile.id,
8702
+ next,
8703
+ activeFile.updatedAt,
8704
+ );
8705
+ lastLocalContentRef.current = next;
8706
+ queueFileContentSave(activeFile.id, next, {
8707
+ syncCollab: !(ydoc && isSynced),
8708
+ });
8709
+ if (!replacePreviewContent(next)) {
8710
+ setContentRenderRevision((revision) => revision + 1);
8711
+ }
8712
+ // Clear stale selection if the redo removed the selected element.
8713
+ setSelectedElement((prev) => {
8714
+ if (!prev) return prev;
8715
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8716
+ });
8717
+ setHoveredElement((prev) => {
8718
+ if (!prev) return prev;
8719
+ return elementInfoExistsInContent(next, prev) ? prev : null;
8720
+ });
6895
8721
  }
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
- });
8722
+ historyOrderRef.current = [
8723
+ ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8724
+ "content",
8725
+ ];
8726
+ return true;
6905
8727
  }
8728
+
8729
+ if (!activeFile) return false;
8730
+ const entry = contentRedoStackRef.current.pop();
8731
+ if (!entry || entry.fileId !== activeFile.id) return false;
8732
+ contentUndoStackRef.current = [
8733
+ ...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
8734
+ entry,
8735
+ ];
6906
8736
  historyOrderRef.current = [
6907
8737
  ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
6908
8738
  "content",
6909
8739
  ];
8740
+ suppressContentHistoryRef.current = true;
8741
+ try {
8742
+ applyLocalContentUpdate(entry.after, {
8743
+ refreshPreview: false,
8744
+ immediateSave: true,
8745
+ });
8746
+ } finally {
8747
+ suppressContentHistoryRef.current = false;
8748
+ }
8749
+ setSelectedElement((prev) => {
8750
+ if (!prev) return prev;
8751
+ return elementInfoExistsInContent(entry.after, prev) ? prev : null;
8752
+ });
8753
+ setHoveredElement((prev) => {
8754
+ if (!prev) return prev;
8755
+ return elementInfoExistsInContent(entry.after, prev) ? prev : null;
8756
+ });
6910
8757
  return true;
6911
8758
  };
6912
8759
  const redoGeometry = () => {
@@ -6942,8 +8789,10 @@ export default function DesignEditor() {
6942
8789
  }, [
6943
8790
  ydoc,
6944
8791
  activeFile,
8792
+ applyLocalContentUpdate,
6945
8793
  canEditDesign,
6946
8794
  isSynced,
8795
+ markPendingLocalFileContent,
6947
8796
  queueFileContentSave,
6948
8797
  replacePreviewContent,
6949
8798
  syncUndoRedoState,
@@ -7382,6 +9231,8 @@ export default function DesignEditor() {
7382
9231
  setTitleDraft(design?.title ?? "");
7383
9232
  setTitleEditing(true);
7384
9233
  },
9234
+ onGroup: canEditDesign ? handleGroupSelection : undefined,
9235
+ onUngroup: canEditDesign ? handleUngroupSelection : undefined,
7385
9236
  onSelectAll: handleSelectAllFrames,
7386
9237
  onUndo: canEditDesign ? handleUndo : undefined,
7387
9238
  onRedo: canEditDesign ? handleRedo : undefined,
@@ -8234,14 +10085,29 @@ ${serializedHtml}
8234
10085
  )
8235
10086
  .map((node) => node.id),
8236
10087
  );
10088
+ const allLayerIds = new Set([
10089
+ ...fileIds,
10090
+ ...allCodeLayerNodes.map((node) => node.id),
10091
+ ]);
8237
10092
  const reconcile = (
8238
10093
  current: Set<string>,
8239
10094
  sourceIds: Set<string>,
10095
+ kind: "hidden" | "locked",
8240
10096
  ): Set<string> => {
8241
10097
  const next = new Set(sourceIds);
8242
10098
  current.forEach((id) => {
8243
10099
  if (fileIds.has(id)) next.add(id);
8244
10100
  });
10101
+ layerStateOverridesRef.current.forEach((override, id) => {
10102
+ if (!allLayerIds.has(id)) {
10103
+ layerStateOverridesRef.current.delete(id);
10104
+ return;
10105
+ }
10106
+ const value = override[kind];
10107
+ if (value === undefined) return;
10108
+ if (value) next.add(id);
10109
+ else next.delete(id);
10110
+ });
8245
10111
  if (
8246
10112
  next.size === current.size &&
8247
10113
  Array.from(next).every((id) => current.has(id))
@@ -8251,8 +10117,12 @@ ${serializedHtml}
8251
10117
  return next;
8252
10118
  };
8253
10119
 
8254
- setLockedLayerIds((current) => reconcile(current, lockedFromSource));
8255
- setHiddenLayerIds((current) => reconcile(current, hiddenFromSource));
10120
+ setLockedLayerIds((current) =>
10121
+ reconcile(current, lockedFromSource, "locked"),
10122
+ );
10123
+ setHiddenLayerIds((current) =>
10124
+ reconcile(current, hiddenFromSource, "hidden"),
10125
+ );
8256
10126
  }, [codeLayerModelsByFile, files]);
8257
10127
  const lockedLayerSelectors = useMemo(() => {
8258
10128
  const selectors = Array.from(lockedLayerIds)
@@ -8458,6 +10328,59 @@ ${serializedHtml}
8458
10328
  );
8459
10329
  }, [activeContent, activeFile?.id, builderPreviewUrl, overviewScreens]);
8460
10330
 
10331
+ // §6.4 / §8 — Breakpoints list for the StatesPanel, derived from
10332
+ // designs.data.breakpointSet. Returns a stable empty array when none are set.
10333
+ const statesPanelBreakpoints = useMemo<
10334
+ Array<{ id: string; label: string; widthPx: number }>
10335
+ >(() => {
10336
+ try {
10337
+ const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
10338
+ if (
10339
+ raw &&
10340
+ typeof raw === "object" &&
10341
+ !Array.isArray(raw) &&
10342
+ Array.isArray((raw as Record<string, unknown>).breakpoints)
10343
+ ) {
10344
+ const bps = (
10345
+ raw as {
10346
+ breakpoints: Array<{
10347
+ id: string;
10348
+ widthPx: number;
10349
+ label?: string;
10350
+ }>;
10351
+ }
10352
+ ).breakpoints;
10353
+ return bps.map((bp) => ({
10354
+ id: bp.id,
10355
+ widthPx: bp.widthPx,
10356
+ label:
10357
+ bp.label ??
10358
+ (bp.widthPx >= 1024
10359
+ ? "Desktop"
10360
+ : bp.widthPx >= 600
10361
+ ? "Tablet"
10362
+ : "Mobile"),
10363
+ }));
10364
+ }
10365
+ } catch {
10366
+ // ignore
10367
+ }
10368
+ return [];
10369
+ }, [designDataJson]);
10370
+
10371
+ // Active breakpoint id for the StatesPanel — "auto" when no frame is focused.
10372
+ const statesPanelActiveBreakpointId = useMemo<string>(() => {
10373
+ if (activeBreakpointWidthState == null) return "auto";
10374
+ const match = statesPanelBreakpoints.find(
10375
+ (bp) => bp.widthPx === activeBreakpointWidthState,
10376
+ );
10377
+ if (match) return match.id;
10378
+ const defaultMatch = DEFAULT_STATES_PANEL_BREAKPOINTS.find(
10379
+ (bp) => bp.widthPx === activeBreakpointWidthState,
10380
+ );
10381
+ return defaultMatch?.id ?? "auto";
10382
+ }, [activeBreakpointWidthState, statesPanelBreakpoints]);
10383
+
8461
10384
  const handleOpenDesignPreview = useCallback(() => {
8462
10385
  if (activeScreenPreviewUrl) {
8463
10386
  window.open(activeScreenPreviewUrl, "_blank", "noopener,noreferrer");
@@ -8479,6 +10402,16 @@ ${serializedHtml}
8479
10402
  selectedElementLayerId ??
8480
10403
  activeFile?.id ??
8481
10404
  "";
10405
+ const selectedElementFullViewScreenId =
10406
+ viewMode === "overview" && selectedElement
10407
+ ? selectedElementLayerId
10408
+ ? (codeLayerOwnerByNodeId.get(selectedElementLayerId)?.fileId ??
10409
+ activeFileId)
10410
+ : activeFileId
10411
+ : null;
10412
+ const fullViewScreenIds = selectedElementFullViewScreenId
10413
+ ? [selectedElementFullViewScreenId]
10414
+ : [];
8482
10415
  const activeLayerLocked = Boolean(
8483
10416
  activeLayerId && effectiveCodeLayerState.lockedIds.has(activeLayerId),
8484
10417
  );
@@ -8486,6 +10419,35 @@ ${serializedHtml}
8486
10419
  activeLayerId && effectiveCodeLayerState.hiddenIds.has(activeLayerId),
8487
10420
  );
8488
10421
 
10422
+ // Detect if the active screen is a localhost/local source so we can show a banner.
10423
+ const activeScreenIsLocalSource =
10424
+ viewMode === "single" &&
10425
+ Boolean(activeFile) &&
10426
+ activeOverviewScreen?.sourceType === "localhost";
10427
+ const activeScreenRouteSourceFile = activeScreenIsLocalSource
10428
+ ? getLocalhostRouteSourceFile({
10429
+ sourceFile: activeOverviewScreen?.sourceFile,
10430
+ source: activeOverviewScreen?.source,
10431
+ })
10432
+ : undefined;
10433
+
10434
+ // canGroup: 2+ DOM-node layers selected in the active screen (not file rows).
10435
+ const fileIdSet = new Set(files.map((f) => f.id));
10436
+ const selectedDomLayerIds = selectedLayerIds.filter(
10437
+ (id) => !id.startsWith("__") && !fileIdSet.has(id),
10438
+ );
10439
+ const canGroup =
10440
+ canEditDesign &&
10441
+ viewMode === "single" &&
10442
+ Boolean(activeFile) &&
10443
+ selectedDomLayerIds.length >= 2;
10444
+ // canUngroup: exactly one DOM-node layer selected.
10445
+ const canUngroup =
10446
+ canEditDesign &&
10447
+ viewMode === "single" &&
10448
+ Boolean(activeFile) &&
10449
+ selectedDomLayerIds.length === 1;
10450
+
8489
10451
  const canMoveLayer = useCallback(
8490
10452
  (intent: LayersPanelMoveIntent) => {
8491
10453
  const targetOwner = codeLayerOwnerByNodeId.get(intent.targetId);
@@ -8498,17 +10460,24 @@ ${serializedHtml}
8498
10460
  }
8499
10461
  return intent.draggedIds.some((draggedId) => {
8500
10462
  const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
8501
- return (
8502
- draggedId !== intent.targetId &&
8503
- !!draggedOwner &&
8504
- draggedOwner.fileId === targetOwner.fileId &&
8505
- !collectCodeLayerAncestors(
10463
+ if (
10464
+ draggedId === intent.targetId ||
10465
+ !draggedOwner ||
10466
+ effectiveCodeLayerState.lockedIds.has(draggedId) ||
10467
+ effectiveCodeLayerState.hiddenIds.has(draggedId)
10468
+ ) {
10469
+ return false;
10470
+ }
10471
+ // Same-file move: also exclude ancestor drags (would orphan the node).
10472
+ if (draggedOwner.fileId === targetOwner.fileId) {
10473
+ return !collectCodeLayerAncestors(
8506
10474
  targetOwner.tree,
8507
10475
  intent.targetId,
8508
- ).includes(draggedId) &&
8509
- !effectiveCodeLayerState.lockedIds.has(draggedId) &&
8510
- !effectiveCodeLayerState.hiddenIds.has(draggedId)
8511
- );
10476
+ ).includes(draggedId);
10477
+ }
10478
+ // Cross-file move: allowed as long as neither side is locked/hidden
10479
+ // (already checked above). File-row ids are excluded by the owner check.
10480
+ return true;
8512
10481
  });
8513
10482
  },
8514
10483
  [codeLayerOwnerByNodeId, effectiveCodeLayerState],
@@ -8526,26 +10495,62 @@ ${serializedHtml}
8526
10495
  ) {
8527
10496
  return;
8528
10497
  }
8529
- const sourceFile = files.find((file) => file.id === targetOwner.fileId);
8530
- const sourceContent =
10498
+ const freshActiveContent = getFreshActiveContent();
10499
+ const destFile = files.find((file) => file.id === targetOwner.fileId);
10500
+ const destContent =
8531
10501
  targetOwner.fileId === activeFile?.id
8532
- ? activeContent
8533
- : (sourceFile?.content ?? "");
8534
- if (!sourceContent) return;
8535
- let nextContent = sourceContent;
8536
- let moved = false;
10502
+ ? freshActiveContent
10503
+ : (destFile?.content ?? "");
10504
+ if (!destContent) return;
10505
+
10506
+ // Group dragged ids by source file so we can handle same-file and
10507
+ // cross-file moves independently.
10508
+ const sameFileDragIds: string[] = [];
10509
+ const crossFileDrags: Array<{ draggedId: string; sourceFileId: string }> =
10510
+ [];
10511
+ const movedNodeSnapshots = new Map<string, CodeLayerNode>();
8537
10512
  for (const draggedId of intent.draggedIds) {
8538
10513
  const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
8539
10514
  if (
8540
10515
  draggedId === intent.targetId ||
8541
10516
  !draggedOwner ||
8542
- draggedOwner.fileId !== targetOwner.fileId ||
8543
10517
  effectiveCodeLayerState.lockedIds.has(draggedId) ||
8544
10518
  effectiveCodeLayerState.hiddenIds.has(draggedId)
8545
10519
  ) {
8546
10520
  continue;
8547
10521
  }
8548
- const patch = applyVisualEdit(nextContent, {
10522
+ movedNodeSnapshots.set(draggedId, draggedOwner.node);
10523
+ if (draggedOwner.fileId === targetOwner.fileId) {
10524
+ sameFileDragIds.push(draggedId);
10525
+ } else {
10526
+ crossFileDrags.push({
10527
+ draggedId,
10528
+ sourceFileId: draggedOwner.fileId,
10529
+ });
10530
+ }
10531
+ }
10532
+
10533
+ const targetTreeForMove =
10534
+ targetOwner.fileId === activeFile?.id
10535
+ ? buildCodeLayerTree(buildCodeLayerProjection(freshActiveContent))
10536
+ : targetOwner.tree;
10537
+ const orderedSameFileDragIds = sortCodeLayerIdsByTreeOrder(
10538
+ sameFileDragIds,
10539
+ targetTreeForMove,
10540
+ );
10541
+ const movedIdOrder = [
10542
+ ...orderedSameFileDragIds,
10543
+ ...crossFileDrags.map((drag) => drag.draggedId),
10544
+ ];
10545
+
10546
+ // --- Same-file moves (existing path) ---
10547
+ let nextDestContent = destContent;
10548
+ let moved = false;
10549
+ for (const draggedId of getLayerMoveIterationOrder(
10550
+ orderedSameFileDragIds,
10551
+ intent.placement,
10552
+ )) {
10553
+ const patch = applyVisualEdit(nextDestContent, {
8549
10554
  kind: "moveNode",
8550
10555
  target: { nodeId: draggedId },
8551
10556
  anchor: { nodeId: intent.targetId },
@@ -8561,23 +10566,119 @@ ${serializedHtml}
8561
10566
  );
8562
10567
  continue;
8563
10568
  }
8564
- nextContent = patch.content;
10569
+ nextDestContent = patch.content;
8565
10570
  moved = true;
8566
10571
  }
8567
- if (!moved || nextContent === sourceContent) return;
8568
- applyFileContentUpdate(targetOwner.fileId, nextContent, {
8569
- refreshPreview: true,
8570
- });
10572
+
10573
+ // --- Cross-file moves: use moveNodeBetweenDocuments ---
10574
+ // Group by source file so multiple nodes from the same source are
10575
+ // applied sequentially against the running source content.
10576
+ const sourceContentMap = new Map<string, string>();
10577
+ for (const { draggedId, sourceFileId } of crossFileDrags) {
10578
+ const srcFile = files.find((f) => f.id === sourceFileId);
10579
+ if (!srcFile) continue;
10580
+ const currentSourceContent = getLayerMoveSourceContent({
10581
+ sourceFileId,
10582
+ activeFileId: activeFile?.id,
10583
+ activeContent: freshActiveContent,
10584
+ sourceFileContent: srcFile.content,
10585
+ sourceContentMap,
10586
+ });
10587
+
10588
+ // The dragged node's data-agent-native-node-id is the node id tracked
10589
+ // by code-layer. Look up the actual attribute value from the owner.
10590
+ const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
10591
+ const nodeAttrId =
10592
+ draggedOwner?.node.dataAttributes["data-agent-native-node-id"] ??
10593
+ draggedId;
10594
+ const anchorAttrId =
10595
+ codeLayerOwnerByNodeId.get(intent.targetId)?.node.dataAttributes[
10596
+ "data-agent-native-node-id"
10597
+ ] ?? intent.targetId;
10598
+
10599
+ const result = moveNodeBetweenDocuments(
10600
+ currentSourceContent,
10601
+ nextDestContent,
10602
+ {
10603
+ nodeId: nodeAttrId,
10604
+ anchorNodeId: anchorAttrId,
10605
+ placement: intent.placement,
10606
+ },
10607
+ );
10608
+ if (result.status !== "applied") {
10609
+ toast.error(
10610
+ codeLayerPatchMessage(
10611
+ result.message,
10612
+ t("designEditor.toasts.layerMoveFailed"),
10613
+ ),
10614
+ { duration: 4000 },
10615
+ );
10616
+ continue;
10617
+ }
10618
+ sourceContentMap.set(sourceFileId, result.sourceHtml);
10619
+ nextDestContent = result.destHtml;
10620
+ moved = true;
10621
+ }
10622
+
10623
+ if (!moved) return;
10624
+
10625
+ const finalDestProjection =
10626
+ nextDestContent !== destContent
10627
+ ? buildCodeLayerProjection(nextDestContent)
10628
+ : null;
10629
+ const finalDestTree = finalDestProjection
10630
+ ? buildCodeLayerTree(finalDestProjection)
10631
+ : [];
10632
+ const movedNodesAfterMove = movedIdOrder
10633
+ .map((draggedId) => movedNodeSnapshots.get(draggedId))
10634
+ .map((node) =>
10635
+ node && finalDestProjection
10636
+ ? findCodeLayerNodeInProjection(finalDestProjection, node)
10637
+ : null,
10638
+ )
10639
+ .filter((node): node is CodeLayerNode => Boolean(node));
10640
+
10641
+ if (movedNodesAfterMove.length > 0) {
10642
+ setSelectedLayerIdsState(movedNodesAfterMove.map((node) => node.id));
10643
+ const lastMovedNode =
10644
+ movedNodesAfterMove[movedNodesAfterMove.length - 1];
10645
+ if (lastMovedNode && targetOwner.fileId === activeFile?.id) {
10646
+ setSelectedElement(elementInfoFromCodeLayerNode(lastMovedNode));
10647
+ }
10648
+ const movedAncestorIds = movedNodesAfterMove.flatMap((node) =>
10649
+ collectCodeLayerAncestors(finalDestTree, node.id),
10650
+ );
10651
+ setExpandedLayerIds((current) => {
10652
+ const next = new Set(current);
10653
+ next.add(targetOwner.fileId);
10654
+ movedAncestorIds.forEach((ancestorId) => next.add(ancestorId));
10655
+ return next.size === current.length ? current : Array.from(next);
10656
+ });
10657
+ }
10658
+
10659
+ // Persist source files that changed.
10660
+ for (const [sourceFileId, newSourceContent] of sourceContentMap) {
10661
+ applyFileContentUpdate(sourceFileId, newSourceContent, {
10662
+ refreshPreview: false,
10663
+ });
10664
+ }
10665
+
10666
+ // Persist dest file (which may also be the active file).
10667
+ if (nextDestContent !== destContent) {
10668
+ applyFileContentUpdate(targetOwner.fileId, nextDestContent, {
10669
+ refreshPreview: false,
10670
+ });
10671
+ }
8571
10672
  },
8572
10673
  [
8573
- activeContent,
8574
10674
  activeFile?.id,
8575
10675
  applyFileContentUpdate,
8576
10676
  canEditDesign,
8577
10677
  canMoveLayer,
8578
10678
  codeLayerOwnerByNodeId,
8579
- files,
8580
10679
  effectiveCodeLayerState,
10680
+ files,
10681
+ getFreshActiveContent,
8581
10682
  t,
8582
10683
  ],
8583
10684
  );
@@ -8616,6 +10717,21 @@ ${serializedHtml}
8616
10717
  ? currentLayerIds.filter((layerId) => layerId !== intent.id)
8617
10718
  : [...currentLayerIds, intent.id];
8618
10719
  setSelectedLayerIdsState(additiveLayerIds);
10720
+ if (viewModeRef.current === "overview") {
10721
+ const fileIds = files.map((file) => file.id);
10722
+ const selectedScreenIds = getOverviewScreenIdsFromLayerSelection({
10723
+ fileIds,
10724
+ layerIds: additiveLayerIds,
10725
+ });
10726
+ const toggledScreen =
10727
+ getOverviewScreenIdsFromLayerSelection({
10728
+ fileIds,
10729
+ layerIds: [intent.id],
10730
+ }).length > 0;
10731
+ if (toggledScreen || selectedScreenIds.length > 0) {
10732
+ setOverviewSelectedScreenIds(selectedScreenIds);
10733
+ }
10734
+ }
8619
10735
  setSelectedElement(null);
8620
10736
  focusDesignInspectorForSelection();
8621
10737
  setActiveTool("move");
@@ -8712,7 +10828,7 @@ ${serializedHtml}
8712
10828
  const sourceFile = files.find((file) => file.id === owner.fileId);
8713
10829
  const sourceContent =
8714
10830
  owner.fileId === activeFile?.id
8715
- ? activeContent
10831
+ ? getFreshActiveContent()
8716
10832
  : (sourceFile?.content ?? "");
8717
10833
  if (!sourceContent) return;
8718
10834
  const nextContent = setCodeLayerAttributeInHtml(
@@ -8728,12 +10844,12 @@ ${serializedHtml}
8728
10844
  setSelectedLayerIdsState([layerId]);
8729
10845
  },
8730
10846
  [
8731
- activeContent,
8732
10847
  activeFile?.id,
8733
10848
  applyFileContentUpdate,
8734
10849
  canEditDesign,
8735
10850
  codeLayerOwnerByNodeId,
8736
10851
  files,
10852
+ getFreshActiveContent,
8737
10853
  queryClient,
8738
10854
  t,
8739
10855
  updateFileMutation,
@@ -8743,6 +10859,10 @@ ${serializedHtml}
8743
10859
  const handleToggleLayerLocked = useCallback(
8744
10860
  (layerId: string, locked: boolean) => {
8745
10861
  if (!canEditDesign) return;
10862
+ layerStateOverridesRef.current.set(layerId, {
10863
+ ...layerStateOverridesRef.current.get(layerId),
10864
+ locked,
10865
+ });
8746
10866
  const applyLockedState = () => {
8747
10867
  setLockedLayerIds((current) => {
8748
10868
  const next = new Set(current);
@@ -8757,38 +10877,47 @@ ${serializedHtml}
8757
10877
  }
8758
10878
  const owner = codeLayerOwnerByNodeId.get(layerId);
8759
10879
  const node = owner?.node;
8760
- if (!owner || !node) return;
10880
+ if (!owner || !node) {
10881
+ applyLockedState();
10882
+ return;
10883
+ }
8761
10884
  const sourceFile = files.find((file) => file.id === owner.fileId);
8762
10885
  const sourceContent =
8763
10886
  owner.fileId === activeFile?.id
8764
- ? activeContent
10887
+ ? getFreshActiveContent()
8765
10888
  : (sourceFile?.content ?? "");
8766
- if (!sourceContent) return;
8767
- const nextContent = setCodeLayerAttributeInHtml(
8768
- sourceContent,
8769
- node,
8770
- "data-agent-native-locked",
8771
- locked ? "true" : null,
8772
- );
8773
- if (!nextContent || nextContent === sourceContent) return;
8774
- applyFileContentUpdate(owner.fileId, nextContent, {
8775
- refreshPreview: false,
8776
- });
10889
+ if (sourceContent) {
10890
+ const nextContent = setCodeLayerAttributeInHtml(
10891
+ sourceContent,
10892
+ node,
10893
+ "data-agent-native-locked",
10894
+ locked ? "true" : null,
10895
+ );
10896
+ if (nextContent && nextContent !== sourceContent) {
10897
+ applyFileContentUpdate(owner.fileId, nextContent, {
10898
+ refreshPreview: false,
10899
+ });
10900
+ }
10901
+ }
8777
10902
  applyLockedState();
8778
10903
  },
8779
10904
  [
8780
- activeContent,
8781
10905
  activeFile?.id,
8782
10906
  applyFileContentUpdate,
8783
10907
  canEditDesign,
8784
10908
  codeLayerOwnerByNodeId,
8785
10909
  files,
10910
+ getFreshActiveContent,
8786
10911
  ],
8787
10912
  );
8788
10913
 
8789
10914
  const handleToggleLayerHidden = useCallback(
8790
10915
  (layerId: string, hidden: boolean) => {
8791
10916
  if (!canEditDesign) return;
10917
+ layerStateOverridesRef.current.set(layerId, {
10918
+ ...layerStateOverridesRef.current.get(layerId),
10919
+ hidden,
10920
+ });
8792
10921
  const applyHiddenState = () => {
8793
10922
  setHiddenLayerIds((current) => {
8794
10923
  const next = new Set(current);
@@ -8803,32 +10932,37 @@ ${serializedHtml}
8803
10932
  }
8804
10933
  const owner = codeLayerOwnerByNodeId.get(layerId);
8805
10934
  const node = owner?.node;
8806
- if (!owner || !node) return;
10935
+ if (!owner || !node) {
10936
+ applyHiddenState();
10937
+ return;
10938
+ }
8807
10939
  const sourceFile = files.find((file) => file.id === owner.fileId);
8808
10940
  const sourceContent =
8809
10941
  owner.fileId === activeFile?.id
8810
- ? activeContent
10942
+ ? getFreshActiveContent()
8811
10943
  : (sourceFile?.content ?? "");
8812
- if (!sourceContent) return;
8813
- const nextContent = setCodeLayerAttributeInHtml(
8814
- sourceContent,
8815
- node,
8816
- "data-agent-native-hidden",
8817
- hidden ? "true" : null,
8818
- );
8819
- if (!nextContent || nextContent === sourceContent) return;
8820
- applyFileContentUpdate(owner.fileId, nextContent, {
8821
- refreshPreview: false,
8822
- });
10944
+ if (sourceContent) {
10945
+ const nextContent = setCodeLayerAttributeInHtml(
10946
+ sourceContent,
10947
+ node,
10948
+ "data-agent-native-hidden",
10949
+ hidden ? "true" : null,
10950
+ );
10951
+ if (nextContent && nextContent !== sourceContent) {
10952
+ applyFileContentUpdate(owner.fileId, nextContent, {
10953
+ refreshPreview: false,
10954
+ });
10955
+ }
10956
+ }
8823
10957
  applyHiddenState();
8824
10958
  },
8825
10959
  [
8826
- activeContent,
8827
10960
  activeFile?.id,
8828
10961
  applyFileContentUpdate,
8829
10962
  canEditDesign,
8830
10963
  codeLayerOwnerByNodeId,
8831
10964
  files,
10965
+ getFreshActiveContent,
8832
10966
  ],
8833
10967
  );
8834
10968
 
@@ -9096,6 +11230,19 @@ ${serializedHtml}
9096
11230
  ? t("designEditor.stopPinningComments")
9097
11231
  : t("designEditor.pinComment")}
9098
11232
  </DropdownMenuItem>
11233
+ {isSignedIn && (
11234
+ <>
11235
+ <DropdownMenuSeparator />
11236
+ <DropdownMenuItem
11237
+ onClick={() => {
11238
+ handleOpenMakeReal();
11239
+ }}
11240
+ >
11241
+ <IconRocket className="mr-2 h-4 w-4" />
11242
+ {"Make this a real app" /* i18n-ignore */}
11243
+ </DropdownMenuItem>
11244
+ </>
11245
+ )}
9099
11246
  </DropdownMenuContent>
9100
11247
  </DropdownMenu>
9101
11248
  );
@@ -9254,6 +11401,28 @@ ${serializedHtml}
9254
11401
  <TooltipContent>{t("designEditor.designPreview")}</TooltipContent>
9255
11402
  </Tooltip>
9256
11403
 
11404
+ {/* §6.6 — "Make this a real app" shortcut button (signed-in only).
11405
+ Surfaces the migration CTA without requiring the project menu. */}
11406
+ {isSignedIn && (
11407
+ <Tooltip>
11408
+ <TooltipTrigger asChild>
11409
+ <Button
11410
+ variant="ghost"
11411
+ size="icon"
11412
+ className="size-8 cursor-pointer rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
11413
+ onClick={handleOpenMakeReal}
11414
+ disabled={migrateMutation.isPending}
11415
+ aria-label={"Make this a real app" /* i18n-ignore */}
11416
+ >
11417
+ <IconRocket className="size-4" />
11418
+ </Button>
11419
+ </TooltipTrigger>
11420
+ <TooltipContent>
11421
+ {"Make this a real app" /* i18n-ignore */}
11422
+ </TooltipContent>
11423
+ </Tooltip>
11424
+ )}
11425
+
9257
11426
  {isSignedIn ? (
9258
11427
  <ShareButton
9259
11428
  resourceType="design"
@@ -9447,6 +11616,19 @@ ${serializedHtml}
9447
11616
  ? t("designEditor.stopPinningComments")
9448
11617
  : t("designEditor.pinComment")}
9449
11618
  </DropdownMenuItem>
11619
+ {isSignedIn && (
11620
+ <>
11621
+ <DropdownMenuSeparator />
11622
+ <DropdownMenuItem
11623
+ onClick={() => {
11624
+ handleOpenMakeReal();
11625
+ }}
11626
+ >
11627
+ <IconRocket className="mr-2 h-4 w-4" />
11628
+ {"Make this a real app" /* i18n-ignore */}
11629
+ </DropdownMenuItem>
11630
+ </>
11631
+ )}
9450
11632
  </DropdownMenuContent>
9451
11633
  </DropdownMenu>
9452
11634
  {titleEditing && canEditDesign ? (
@@ -9786,7 +11968,9 @@ ${serializedHtml}
9786
11968
  canEditDesign && hasPropsClipboard && Boolean(selectedElement)
9787
11969
  }
9788
11970
  canCopyAsCode={Boolean(selectedElement?.selector)}
9789
- hiddenActions={["group", "ungroup", "rename"]}
11971
+ canGroup={canGroup}
11972
+ canUngroup={canUngroup}
11973
+ hiddenActions={["rename"]}
9790
11974
  getCanvasPoint={getContextCanvasPoint}
9791
11975
  onPasteHere={(details) =>
9792
11976
  handlePasteSelection(
@@ -9818,310 +12002,393 @@ ${serializedHtml}
9818
12002
  handleToggleLayerHidden(activeLayerId, !activeLayerHidden);
9819
12003
  }
9820
12004
  }}
12005
+ onGroup={canGroup ? handleGroupSelection : undefined}
12006
+ onUngroup={canUngroup ? handleUngroupSelection : undefined}
9821
12007
  onCopyProps={handleCopyProps}
9822
12008
  onPasteProps={handlePasteProps}
9823
12009
  onCopyAsCode={handleCopySelection}
9824
12010
  >
9825
12011
  {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
12012
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
12013
+ {/* Banner for screens connected to a local dev server: edits
12014
+ route through the agent rather than being applied inline. */}
12015
+ {activeScreenIsLocalSource &&
12016
+ !localSourceBannerDismissed &&
12017
+ id && (
12018
+ <LocalSourceEditBanner
12019
+ designId={id}
12020
+ fileId={activeFile.id}
12021
+ routeSourceFile={activeScreenRouteSourceFile}
12022
+ onDismiss={() => setLocalSourceBannerDismissed(true)}
12023
+ />
12024
+ )}
12025
+ <div
12026
+ ref={canvasContainerRef}
12027
+ className="relative mx-1 min-w-0 flex-1 overflow-hidden rounded-xl bg-[var(--design-editor-canvas-bg)]"
12028
+ onPointerMove={handleCanvasPointerMove}
12029
+ >
12030
+ {/* Transparent shield that blocks pointer events reaching the
9832
12031
  iframe when a portaled Radix popover (e.g. color picker) is
9833
12032
  open. The iframe has its own event context so it receives
9834
12033
  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)
12034
+ {inspectorPopoverOpen && (
12035
+ <div
12036
+ aria-hidden="true"
12037
+ style={{
12038
+ position: "absolute",
12039
+ inset: 0,
12040
+ zIndex: 10,
12041
+ pointerEvents: "auto",
12042
+ }}
12043
+ />
12044
+ )}
12045
+ {viewMode === "overview" ? (
12046
+ <MultiScreenCanvas
12047
+ screens={overviewScreens}
12048
+ zoom={overviewCanvasZoom}
12049
+ onZoomChange={setOverviewCanvasZoom}
12050
+ activeId={activeFileId}
12051
+ selectedScreenIds={overviewSelectedScreenIds}
12052
+ fullViewScreenIds={fullViewScreenIds}
12053
+ activeScreenHasHoveredChild={
12054
+ Boolean(hoveredElement) &&
12055
+ !hoveredElementIsScreenRoot &&
12056
+ hoveredElementScreenId === activeFileId
12057
+ }
12058
+ hoveredChildScreenId={hoveredChildScreenId}
12059
+ directlyHoveredScreenId={hoveredScreenRootId}
12060
+ previewDeviceFrame={deviceFrame}
12061
+ activeTool={activeTool}
12062
+ onActiveToolChange={(tool) =>
12063
+ setActiveTool(tool === "rectangle" ? "rect" : tool)
12064
+ }
12065
+ selectAllRequest={overviewSelectAllRequest}
12066
+ clearSelectionRequest={overviewClearSelectionRequest}
12067
+ onScreenSelectionChange={
12068
+ handleOverviewScreenSelectionChange
12069
+ }
12070
+ geometryById={canvasFrameGeometryById}
12071
+ onGeometryChange={queueFrameGeometrySave}
12072
+ onGeometryCommit={handleGeometryCommit}
12073
+ onCreatePrimitive={handleCreatePrimitive}
12074
+ onPrimitiveCreated={handlePrimitiveCreated}
12075
+ onPrimitiveReparent={handleOverviewPrimitiveReparent}
12076
+ onCrossScreenElementDrop={handleCrossScreenElementDrop}
12077
+ onCreateScreenFrame={handleCreateScreenFrame}
12078
+ onDeleteSelection={handleDeleteOverviewSelection}
12079
+ onSelectionChange={setOverviewSelectedScreenIds}
12080
+ onPick={(id) => {
12081
+ pendingOverviewScreenSelectionRef.current = null;
12082
+ setSelectedElement(null);
12083
+ setHoveredElement(null);
12084
+ setSelectedLayerIdsState([id]);
12085
+ setActiveFileId(id);
12086
+ setActiveTool("move");
12087
+ setMode("edit");
12088
+ }}
12089
+ onEdit={enterSingleScreen}
12090
+ onDuplicate={handleDuplicateScreen}
12091
+ onAddBreakpoint={(screenId, widthPx) => {
12092
+ if (!id) return;
12093
+ const breakpointLabel =
12094
+ widthPx <= 480
12095
+ ? "Mobile"
12096
+ : widthPx <= 1024
12097
+ ? "Tablet"
12098
+ : "Desktop";
12099
+ void addBreakpointMutation.mutateAsync({
12100
+ designId: id,
12101
+ label: breakpointLabel,
12102
+ widthPx,
12103
+ });
12104
+ }}
12105
+ onActiveBreakpointChange={(_screenId, widthPx) => {
12106
+ setActiveBreakpointWidthState(widthPx);
12107
+ if (!id) return;
12108
+ const bpSet = (() => {
12109
+ try {
12110
+ const raw = (
12111
+ designDataJson as Record<string, unknown>
12112
+ )?.breakpointSet;
12113
+ if (
12114
+ raw &&
12115
+ typeof raw === "object" &&
12116
+ Array.isArray(
12117
+ (raw as Record<string, unknown>).breakpoints,
12118
+ )
12119
+ ) {
12120
+ return raw as {
12121
+ breakpoints: Array<{
12122
+ id: string;
12123
+ widthPx: number;
12124
+ }>;
12125
+ };
12126
+ }
12127
+ } catch {
12128
+ // Ignore malformed design data; the mutation below
12129
+ // can still clear back to auto.
9966
12130
  }
9967
- onIframeHotkey={handleIframeHotkey}
9968
- onIframeContextMenu={handleIframeContextMenu}
9969
- onVisualStyleChange={(selector, styles, info) =>
9970
- handleScreenVisualStyleChange(
12131
+ return null;
12132
+ })();
12133
+ const bp = bpSet?.breakpoints.find(
12134
+ (b) => b.widthPx === widthPx,
12135
+ );
12136
+ void setActiveBreakpointMutation.mutateAsync({
12137
+ designId: id,
12138
+ breakpointId:
12139
+ widthPx !== undefined && bp ? bp.id : "auto",
12140
+ });
12141
+ }}
12142
+ renderScreenContent={(screen, metadata, geometry) => {
12143
+ const screenIsActive = screen.id === activeFile?.id;
12144
+ const screenContent = getScreenContent(screen.id);
12145
+ const screenContentKey = screenIsActive
12146
+ ? [screen.id, contentRenderRevision].join(":")
12147
+ : [
9971
12148
  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(
12149
+ screen.updatedAt ?? "",
12150
+ getContentSignature(screenContent),
12151
+ 0,
12152
+ ].join(":");
12153
+
12154
+ return (
12155
+ <DesignCanvas
12156
+ content={screenContent}
12157
+ contentKey={screenContentKey}
12158
+ zoom={100}
12159
+ deviceFrame="none"
12160
+ sourceType={designSourceType}
12161
+ fusionUrl={designFusionUrl}
12162
+ onComponentSourceJump={handleComponentSourceJump}
12163
+ embeddedFrame={{
12164
+ viewportWidth: Math.max(
12165
+ 1,
12166
+ Math.round(geometry.width),
12167
+ ),
12168
+ viewportHeight: Math.max(
12169
+ 1,
12170
+ Math.round(geometry.height),
12171
+ ),
12172
+ displayWidth: Math.max(
12173
+ 1,
12174
+ Math.round(geometry.width),
12175
+ ),
12176
+ displayHeight: Math.max(
12177
+ 1,
12178
+ Math.round(geometry.height),
12179
+ ),
12180
+ fluid: true,
12181
+ }}
12182
+ editorChromeScaleX={overviewCanvasZoom / 100}
12183
+ editorChromeScaleY={overviewCanvasZoom / 100}
12184
+ editMode={mode === "edit"}
12185
+ interactMode={false}
12186
+ readOnly={!canEditDesign}
12187
+ scaleMode={screenIsActive && activeTool === "scale"}
12188
+ clearSelectionRequest={
12189
+ overviewClearSelectionRequest
12190
+ }
12191
+ registerRuntimeBridge={screenIsActive}
12192
+ selectedSelector={
12193
+ screenIsActive ? selectedCanvasSelector : null
12194
+ }
12195
+ selectedSelectorCandidates={
12196
+ screenIsActive
12197
+ ? selectedCanvasSelectorCandidates
12198
+ : []
12199
+ }
12200
+ hoveredSelector={
12201
+ hoveredElementScreenId === screen.id
12202
+ ? hoveredCanvasSelector
12203
+ : null
12204
+ }
12205
+ hoveredSelectorCandidates={
12206
+ hoveredElementScreenId === screen.id
12207
+ ? hoveredCanvasSelectorCandidates
12208
+ : []
12209
+ }
12210
+ lockedSelectors={getLayerSelectorsForFile(
9985
12211
  screen.id,
12212
+ lockedLayerIds,
12213
+ )}
12214
+ hiddenSelectors={getLayerSelectorsForFile(
12215
+ screen.id,
12216
+ hiddenLayerIds,
12217
+ )}
12218
+ onElementSelect={(info) =>
12219
+ handleScreenElementSelect(screen.id, info)
12220
+ }
12221
+ onElementHover={(info) =>
12222
+ handleScreenElementHover(screen.id, info)
12223
+ }
12224
+ onClearSelection={() =>
12225
+ handleScreenElementClear(screen.id)
12226
+ }
12227
+ onIframeHotkey={handleIframeHotkey}
12228
+ onIframeContextMenu={handleIframeContextMenu}
12229
+ onVisualStyleChange={(selector, styles, info) =>
12230
+ handleScreenVisualStyleChange(
12231
+ screen.id,
12232
+ selector,
12233
+ styles,
12234
+ info,
12235
+ )
12236
+ }
12237
+ onVisualStructureChange={(
9986
12238
  selector,
9987
12239
  anchorSelector,
9988
12240
  placement,
9989
12241
  info,
9990
12242
  details,
9991
- )
9992
- }
9993
- onVisualDuplicateChange={(
9994
- selector,
9995
- cloneHtml,
9996
- info,
9997
- details,
9998
- ) =>
9999
- handleScreenVisualDuplicateChange(
10000
- screen.id,
12243
+ ) =>
12244
+ handleScreenVisualStructureChange(
12245
+ screen.id,
12246
+ selector,
12247
+ anchorSelector,
12248
+ placement,
12249
+ info,
12250
+ details,
12251
+ )
12252
+ }
12253
+ onVisualDuplicateChange={(
10001
12254
  selector,
10002
12255
  cloneHtml,
10003
12256
  info,
10004
12257
  details,
10005
- )
10006
- }
10007
- onTextContentChange={(
10008
- selector,
10009
- value,
10010
- info,
10011
- details,
10012
- ) =>
10013
- handleScreenTextContentChange(
10014
- screen.id,
12258
+ ) =>
12259
+ handleScreenVisualDuplicateChange(
12260
+ screen.id,
12261
+ selector,
12262
+ cloneHtml,
12263
+ info,
12264
+ details,
12265
+ )
12266
+ }
12267
+ onTextContentChange={(
10015
12268
  selector,
10016
12269
  value,
10017
12270
  info,
10018
12271
  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,
12272
+ ) =>
12273
+ handleScreenTextContentChange(
12274
+ screen.id,
12275
+ selector,
12276
+ value,
12277
+ info,
12278
+ details,
12279
+ )
12280
+ }
12281
+ onTextEditingStateChange={setTextEditingState}
12282
+ onElementDblClickText={(info) =>
12283
+ handleScreenElementDblClickText(screen.id, info)
12284
+ }
12285
+ tweakValues={cssVarValues}
12286
+ drawMode={false}
12287
+ pinMode={false}
12288
+ designId={id}
12289
+ designTitle={design?.title}
12290
+ commentContextId={`${id}:${screen.id}`}
12291
+ commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(screen.filename)}`}
12292
+ />
10107
12293
  );
10108
- if (match) {
10109
- viewModeRef.current = "single";
10110
- setScreenZoom(FOCUSED_SCREEN_ZOOM);
10111
- setViewMode("single");
10112
- setActiveFileId(match.id);
10113
- }
10114
12294
  }}
10115
12295
  />
10116
- {/* Presence: live cursor overlay for remote participants */}
10117
- {others.length > 0 && (
10118
- <LiveCursorOverlay
10119
- others={others}
10120
- containerRef={canvasContainerRef}
12296
+ ) : (
12297
+ <>
12298
+ <DesignCanvas
12299
+ content={activeContent}
12300
+ contentKey={`${activeFile.id}:${contentRenderRevision}`}
12301
+ zoom={zoom}
12302
+ onZoomChange={setZoom}
12303
+ deviceFrame={deviceFrame}
12304
+ sourceType={designSourceType}
12305
+ fusionUrl={designFusionUrl}
12306
+ previewWidthPx={activeBreakpointWidthState}
12307
+ shaderFillPreview={shaderFillPreview}
12308
+ onComponentSourceJump={handleComponentSourceJump}
12309
+ motionTracks={motionTracksWire}
12310
+ editMode={mode === "edit"}
12311
+ interactMode={mode === "interact"}
12312
+ readOnly={!canEditDesign}
12313
+ scaleMode={activeTool === "scale"}
12314
+ clearSelectionRequest={overviewClearSelectionRequest}
12315
+ selectedSelector={selectedCanvasSelector}
12316
+ selectedSelectorCandidates={
12317
+ selectedCanvasSelectorCandidates
12318
+ }
12319
+ hoveredSelector={hoveredCanvasSelector}
12320
+ hoveredSelectorCandidates={
12321
+ hoveredCanvasSelectorCandidates
12322
+ }
12323
+ lockedSelectors={lockedLayerSelectors}
12324
+ hiddenSelectors={hiddenLayerSelectors}
12325
+ onElementSelect={handleElementSelect}
12326
+ onElementHover={handleElementHover}
12327
+ onClearSelection={() => {
12328
+ setSelectedElement(null);
12329
+ setHoveredElement(null);
12330
+ setHoveredElementScreenId(null);
12331
+ setSelectedLayerIdsState([]);
12332
+ }}
12333
+ onIframeHotkey={handleIframeHotkey}
12334
+ onIframeContextMenu={handleIframeContextMenu}
12335
+ onVisualStyleChange={handleVisualStyleChange}
12336
+ onVisualStructureChange={handleVisualStructureChange}
12337
+ onVisualDuplicateChange={handleVisualDuplicateChange}
12338
+ onTextContentChange={handleTextContentChange}
12339
+ onTextEditingStateChange={setTextEditingState}
12340
+ onElementDblClickText={handleElementDblClickText}
12341
+ tweakValues={cssVarValues}
12342
+ drawMode={drawMode}
12343
+ onExitDrawMode={() => {
12344
+ setDrawMode(false);
12345
+ setPinMode(false);
12346
+ setActiveTool("move");
12347
+ setMode("edit");
12348
+ }}
12349
+ pinMode={pinMode}
12350
+ onExitPinMode={() => {
12351
+ setPinMode(false);
12352
+ if (mode === "annotate") {
12353
+ setActiveTool("draw");
12354
+ }
12355
+ }}
12356
+ designId={id}
12357
+ designTitle={design?.title}
12358
+ commentContextId={`${id}:${activeFile.id}`}
12359
+ commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
12360
+ onPrototypeNavigate={(screen) => {
12361
+ if (!screen) return;
12362
+ const norm = (s: string) =>
12363
+ s
12364
+ .replace(/^\.?\//, "")
12365
+ .replace(/\.html?$/i, "")
12366
+ .toLowerCase();
12367
+ const target = norm(screen);
12368
+ if (!target) return;
12369
+ // Exact (normalized) filename match only — a substring match
12370
+ // could send "board" to "dashboard.html".
12371
+ const match = files.find(
12372
+ (f) => norm(f.filename) === target,
12373
+ );
12374
+ if (match) {
12375
+ viewModeRef.current = "single";
12376
+ setScreenZoom(FOCUSED_SCREEN_ZOOM);
12377
+ setViewMode("single");
12378
+ setActiveFileId(match.id);
12379
+ }
12380
+ }}
10121
12381
  />
10122
- )}
10123
- </>
10124
- )}
12382
+ {/* Presence: live cursor overlay for remote participants */}
12383
+ {others.length > 0 && (
12384
+ <LiveCursorOverlay
12385
+ others={others}
12386
+ containerRef={canvasContainerRef}
12387
+ />
12388
+ )}
12389
+ </>
12390
+ )}
12391
+ </div>
10125
12392
  </div>
10126
12393
  ) : (
10127
12394
  <div className="flex flex-1 items-center justify-center">
@@ -10140,7 +12407,7 @@ ${serializedHtml}
10140
12407
  </p>
10141
12408
  {retryablePrompt ? (
10142
12409
  <p className="mx-auto mb-4 max-w-sm text-xs italic text-muted-foreground/70">
10143
- "{retryablePrompt.prompt}"
12410
+ {`"${retryablePrompt.prompt}"`}
10144
12411
  </p>
10145
12412
  ) : null}
10146
12413
  <div className="flex items-center justify-center gap-2">
@@ -10206,6 +12473,51 @@ ${serializedHtml}
10206
12473
  tweakValues={tweakSelections}
10207
12474
  extensionContext={designExtensionContext}
10208
12475
  readOnly={initialGenerationReadOnly}
12476
+ onComponentPropApplied={handleComponentPropApplied}
12477
+ onTokensApplied={(resolvedCssVars) => {
12478
+ if (!canEditDesign || !id) return;
12479
+ setTweakSelections((prev) => ({
12480
+ ...prev,
12481
+ ...resolvedCssVars,
12482
+ }));
12483
+ queryClient.setQueryData(
12484
+ ["action", "get-design", { id }],
12485
+ (old: any) => {
12486
+ if (!old || typeof old !== "object") return old;
12487
+ let currentData: Record<string, unknown> = {};
12488
+ if (typeof old.data === "string" && old.data) {
12489
+ try {
12490
+ const parsed = JSON.parse(old.data);
12491
+ if (
12492
+ parsed &&
12493
+ typeof parsed === "object" &&
12494
+ !Array.isArray(parsed)
12495
+ ) {
12496
+ currentData = parsed;
12497
+ }
12498
+ } catch {
12499
+ currentData = {};
12500
+ }
12501
+ }
12502
+ const currentSelections =
12503
+ currentData.tweakSelections &&
12504
+ typeof currentData.tweakSelections === "object" &&
12505
+ !Array.isArray(currentData.tweakSelections)
12506
+ ? currentData.tweakSelections
12507
+ : {};
12508
+ return {
12509
+ ...old,
12510
+ data: JSON.stringify({
12511
+ ...currentData,
12512
+ tweakSelections: {
12513
+ ...currentSelections,
12514
+ ...resolvedCssVars,
12515
+ },
12516
+ }),
12517
+ };
12518
+ },
12519
+ );
12520
+ }}
10209
12521
  onTweakChange={(tweakId, value) =>
10210
12522
  setTweakSelections((prev) => {
10211
12523
  if (!canEditDesign) return prev;
@@ -10217,8 +12529,67 @@ ${serializedHtml}
10217
12529
  onRequestTweaks={handleRequestTweaks}
10218
12530
  onStyleChange={handleStyleChange}
10219
12531
  onStylesChange={handleStylesChange}
12532
+ onAutoLayoutConvert={handleAutoLayoutConvert}
10220
12533
  onExport={handleInspectorExport}
10221
12534
  exporting={pngExporting || svgExporting}
12535
+ designId={id}
12536
+ fileId={activeFile?.id}
12537
+ filename={activeFile?.filename}
12538
+ componentNodeId={selectedComponentNodeId}
12539
+ sourceCapabilities={sourceCapabilities}
12540
+ onCreateComponent={
12541
+ id && selectedElement ? handleCreateComponent : undefined
12542
+ }
12543
+ defaultComponentName={defaultComponentName}
12544
+ inspectCode={inspectCodeData}
12545
+ statesPanelProps={
12546
+ id
12547
+ ? {
12548
+ // §6.4 / §8 — active state and breakpoint wired into
12549
+ // the StatesPanel so selection is agent-visible.
12550
+ activeStateId: selectedStateId,
12551
+ activeBreakpointId: statesPanelActiveBreakpointId,
12552
+ breakpoints: statesPanelBreakpoints,
12553
+ onStateSelect: handleDesignStateSelect,
12554
+ onBreakpointSelect: (breakpointId) => {
12555
+ // "auto" = clear the active breakpoint (overview).
12556
+ if (breakpointId === "auto") {
12557
+ setActiveBreakpointWidthState(undefined);
12558
+ if (id) {
12559
+ void setActiveBreakpointMutation.mutateAsync({
12560
+ designId: id,
12561
+ breakpointId: "auto",
12562
+ });
12563
+ }
12564
+ return;
12565
+ }
12566
+ const bp =
12567
+ statesPanelBreakpoints.find(
12568
+ (b) => b.id === breakpointId,
12569
+ ) ??
12570
+ DEFAULT_STATES_PANEL_BREAKPOINTS.find(
12571
+ (b) => b.id === breakpointId,
12572
+ );
12573
+ if (!bp) return;
12574
+ setActiveBreakpointWidthState(bp.widthPx);
12575
+ if (id) {
12576
+ void setActiveBreakpointMutation.mutateAsync({
12577
+ designId: id,
12578
+ breakpointId,
12579
+ });
12580
+ }
12581
+ },
12582
+ onAddBreakpoint: () => {
12583
+ // Delegate to the MultiScreenCanvas affordance by
12584
+ // navigating to overview where the "+" button lives.
12585
+ if (viewMode !== "overview") {
12586
+ setViewMode("overview");
12587
+ }
12588
+ },
12589
+ }
12590
+ : undefined
12591
+ }
12592
+ reviewPanelProps={resolvedReviewPanelProps}
10222
12593
  />
10223
12594
  </div>
10224
12595
  ) : (
@@ -10228,6 +12599,55 @@ ${serializedHtml}
10228
12599
  ) : null}
10229
12600
  </div>
10230
12601
 
12602
+ {/* Motion dock (§6.3) — collapsible bottom dock; visible when activeFile
12603
+ is open and the user has opened it. Canvas remains visible above.
12604
+ Preview-only scrubbing fires a motion-preview postMessage to the
12605
+ canvas iframe (via canvasIframeRef); "Write to CSS" fires
12606
+ apply-motion-edit. */}
12607
+ {!embedded && activeFile ? (
12608
+ <MotionDock
12609
+ tracks={motionTracks}
12610
+ durationMs={motionDurationMs}
12611
+ open={motionDockOpen}
12612
+ onOpenChange={setMotionDockOpen}
12613
+ onTracksChange={setMotionTracks}
12614
+ onDurationChange={setMotionDurationMs}
12615
+ canvasIframeRef={canvasIframeRef}
12616
+ selectedTarget={motionSelectedTarget}
12617
+ onApply={(tracks, durationMs) => {
12618
+ if (!id) return;
12619
+ applyMotionEditMutation.mutate(
12620
+ {
12621
+ designId: id,
12622
+ fileId: activeFile.id,
12623
+ tracks: tracks.map(({ label: _label, ...t }) => t),
12624
+ durationMs,
12625
+ includeContent: true,
12626
+ },
12627
+ {
12628
+ onSuccess: (result) => {
12629
+ const response = result as {
12630
+ fileId?: unknown;
12631
+ patchedContent?: unknown;
12632
+ };
12633
+ if (
12634
+ typeof response.fileId === "string" &&
12635
+ typeof response.patchedContent === "string"
12636
+ ) {
12637
+ applyFileContentUpdate(
12638
+ response.fileId,
12639
+ response.patchedContent,
12640
+ { refreshPreview: response.fileId === activeFile.id },
12641
+ );
12642
+ }
12643
+ },
12644
+ },
12645
+ );
12646
+ }}
12647
+ applying={applyMotionEditMutation.isPending}
12648
+ />
12649
+ ) : null}
12650
+
10231
12651
  <PromptPopover
10232
12652
  open={showPrompt}
10233
12653
  onOpenChange={handlePromptOpenChange}
@@ -10313,6 +12733,183 @@ ${serializedHtml}
10313
12733
  loading={generating || pendingGenerationActive}
10314
12734
  anchorRef={tweakPromptAnchorRef}
10315
12735
  />
12736
+
12737
+ {/* §6.6 — "Make this a real app" dialog.
12738
+ Three states:
12739
+ 1. Idle — confirm prompt with description of what will happen.
12740
+ 2. Migrating — spinner while the Builder cloud agent accepts the job.
12741
+ 3. Success — branchName + url; sourceType already flipped to fusion.
12742
+ 4. Not-configured — CTA to connect Builder.io.
12743
+ */}
12744
+ <Dialog
12745
+ open={makeRealDialogOpen}
12746
+ onOpenChange={(open) => {
12747
+ if (!migrateMutation.isPending) setMakeRealDialogOpen(open);
12748
+ }}
12749
+ >
12750
+ <DialogContent className="sm:max-w-md">
12751
+ {/* Not-configured: Builder not connected or no project ID */}
12752
+ {migrationResult?.status === "not-configured" &&
12753
+ migrationResult.cta ? (
12754
+ <>
12755
+ <DialogHeader>
12756
+ <DialogTitle className="flex items-center gap-2">
12757
+ <IconRocket className="size-5 text-muted-foreground" />
12758
+ {migrationResult.cta.label}
12759
+ </DialogTitle>
12760
+ <DialogDescription>
12761
+ {migrationResult.cta.description}
12762
+ </DialogDescription>
12763
+ </DialogHeader>
12764
+ <DialogFooter className="flex-col gap-2 sm:flex-row">
12765
+ <Button
12766
+ variant="outline"
12767
+ onClick={() => setMakeRealDialogOpen(false)}
12768
+ className="cursor-pointer"
12769
+ >
12770
+ Cancel
12771
+ </Button>
12772
+ {migrationResult.cta.connectUrl ? (
12773
+ <Button asChild className="cursor-pointer">
12774
+ <a
12775
+ href={migrationResult.cta.connectUrl}
12776
+ target="_blank"
12777
+ rel="noopener noreferrer"
12778
+ >
12779
+ {migrationResult.cta.primaryAction}
12780
+ <IconExternalLink className="ml-1.5 size-3.5" />
12781
+ </a>
12782
+ </Button>
12783
+ ) : null}
12784
+ </DialogFooter>
12785
+ </>
12786
+ ) : migrationResult?.status === "processing" ? (
12787
+ /* Success: Builder accepted the migration job */
12788
+ <>
12789
+ <DialogHeader>
12790
+ <DialogTitle className="flex items-center gap-2">
12791
+ <IconCircleCheck className="size-5 text-green-500" />
12792
+ {"Migration started" /* i18n-ignore */}
12793
+ </DialogTitle>
12794
+ <DialogDescription>
12795
+ {
12796
+ "Builder is generating a React app branch from your design. The original inline design is preserved and recoverable." /* i18n-ignore */
12797
+ }
12798
+ </DialogDescription>
12799
+ </DialogHeader>
12800
+ <div className="space-y-3 py-2">
12801
+ {migrationResult.branchName && (
12802
+ <div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
12803
+ <span className="text-muted-foreground">
12804
+ {"Branch: " /* i18n-ignore */}
12805
+ </span>
12806
+ <span className="font-mono font-medium">
12807
+ {migrationResult.branchName}
12808
+ </span>
12809
+ </div>
12810
+ )}
12811
+ {migrationResult.url && (
12812
+ <a
12813
+ href={migrationResult.url}
12814
+ target="_blank"
12815
+ rel="noopener noreferrer"
12816
+ className="flex items-center gap-1.5 text-sm text-[var(--design-editor-accent-color)] hover:underline"
12817
+ >
12818
+ {"Open in Builder" /* i18n-ignore */}
12819
+ <IconExternalLink className="size-3.5" />
12820
+ </a>
12821
+ )}
12822
+ {migrationResult.seedFileCount !== undefined && (
12823
+ <p className="text-xs text-muted-foreground">
12824
+ {
12825
+ `${migrationResult.seedFileCount} design file${migrationResult.seedFileCount === 1 ? "" : "s"} included in migration seed.` /* i18n-ignore */
12826
+ }
12827
+ </p>
12828
+ )}
12829
+ </div>
12830
+ <DialogFooter>
12831
+ <Button
12832
+ onClick={() => setMakeRealDialogOpen(false)}
12833
+ className="cursor-pointer"
12834
+ >
12835
+ {"Done" /* i18n-ignore */}
12836
+ </Button>
12837
+ </DialogFooter>
12838
+ </>
12839
+ ) : (
12840
+ /* Idle or migrating */
12841
+ <>
12842
+ <DialogHeader>
12843
+ <DialogTitle className="flex items-center gap-2">
12844
+ <IconRocket className="size-5" />
12845
+ {"Make this a real app" /* i18n-ignore */}
12846
+ </DialogTitle>
12847
+ <DialogDescription>
12848
+ {
12849
+ "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 */
12850
+ }
12851
+ </DialogDescription>
12852
+ </DialogHeader>
12853
+ <div className="space-y-2 py-1 text-sm text-muted-foreground">
12854
+ <p>{"What happens:" /* i18n-ignore */}</p>
12855
+ <ul className="list-disc pl-4 space-y-1">
12856
+ <li>
12857
+ {
12858
+ "Your design HTML and tokens are sent to the Builder cloud agent" /* i18n-ignore */
12859
+ }
12860
+ </li>
12861
+ <li>
12862
+ {
12863
+ "A React + Tailwind branch is generated in Builder" /* i18n-ignore */
12864
+ }
12865
+ </li>
12866
+ <li>
12867
+ {
12868
+ "The editor switches to fusion source mode — gated panels light up" /* i18n-ignore */
12869
+ }
12870
+ </li>
12871
+ <li>
12872
+ {
12873
+ "The original inline design is saved as a restorable snapshot" /* i18n-ignore */
12874
+ }
12875
+ </li>
12876
+ </ul>
12877
+ <p className="pt-1 text-xs">
12878
+ {
12879
+ "Requires Builder.io to be connected with a branch project configured." /* i18n-ignore */
12880
+ }
12881
+ </p>
12882
+ </div>
12883
+ <DialogFooter className="flex-col gap-2 sm:flex-row">
12884
+ <Button
12885
+ variant="outline"
12886
+ onClick={() => setMakeRealDialogOpen(false)}
12887
+ disabled={migrateMutation.isPending}
12888
+ className="cursor-pointer"
12889
+ >
12890
+ Cancel
12891
+ </Button>
12892
+ <Button
12893
+ onClick={() => void handleConfirmMakeReal()}
12894
+ disabled={migrateMutation.isPending}
12895
+ className="cursor-pointer"
12896
+ >
12897
+ {
12898
+ migrateMutation.isPending ? (
12899
+ <>
12900
+ <Spinner className="mr-2 size-3.5" />
12901
+ {"Starting migration…" /* i18n-ignore */}
12902
+ </>
12903
+ ) : (
12904
+ "Start migration"
12905
+ ) /* i18n-ignore */
12906
+ }
12907
+ </Button>
12908
+ </DialogFooter>
12909
+ </>
12910
+ )}
12911
+ </DialogContent>
12912
+ </Dialog>
10316
12913
  </div>
10317
12914
  );
10318
12915
  }