@agent-native/core 0.80.6 → 0.80.8

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 (71) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +70 -27
  5. package/corpus/core/src/client/analytics.ts +3 -1
  6. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
  7. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
  8. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
  9. package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
  10. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
  11. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  12. package/corpus/templates/analytics/app/i18n-data.ts +34 -0
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
  15. package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
  16. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  17. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  18. package/corpus/templates/design/AGENTS.md +4 -2
  19. package/corpus/templates/design/actions/edit-design.ts +6 -3
  20. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  21. package/corpus/templates/design/actions/update-design.ts +47 -10
  22. package/corpus/templates/design/actions/update-file.ts +33 -8
  23. package/corpus/templates/design/actions/view-screen.ts +14 -0
  24. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  25. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  26. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  27. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  28. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  29. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  30. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  31. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  32. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  33. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  34. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  35. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  36. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  37. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  38. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  39. package/corpus/templates/design/app/i18n-data.ts +10 -0
  40. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  41. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  42. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  43. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  44. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  45. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  46. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  47. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  48. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  49. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  50. package/corpus/templates/design/e2e/README.md +1 -0
  51. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  52. package/corpus/templates/design/playwright.config.ts +10 -1
  53. package/corpus/templates/design/shared/color-utils.ts +159 -0
  54. package/corpus/templates/design/shared/generation-session.ts +1 -0
  55. package/dist/client/AssistantChat.d.ts.map +1 -1
  56. package/dist/client/AssistantChat.js +54 -24
  57. package/dist/client/AssistantChat.js.map +1 -1
  58. package/dist/client/analytics.d.ts.map +1 -1
  59. package/dist/client/analytics.js +2 -1
  60. package/dist/client/analytics.js.map +1 -1
  61. package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
  62. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  63. package/dist/client/extensions/EmbeddedExtension.js +34 -3
  64. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  65. package/dist/collab/routes.d.ts +2 -2
  66. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  67. package/dist/notifications/routes.d.ts +2 -2
  68. package/dist/observability/routes.d.ts +5 -5
  69. package/dist/resources/handlers.d.ts +2 -2
  70. package/dist/server/transcribe-voice.d.ts +1 -1
  71. package/package.json +1 -1
@@ -804,6 +804,20 @@ function uniqueLayerId(prefix: string): string {
804
804
  : `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
805
805
  }
806
806
 
807
+ /**
808
+ * Re-stamp every `data-agent-native-node-id` in duplicated screen content with a
809
+ * fresh unique id. Without this, a duplicated screen carries the SAME node ids as
810
+ * its source, which collapses the cross-file layer-owner map (selecting a layer
811
+ * in one screen resolves to the other) and can produce a malformed aggregate
812
+ * projection.
813
+ */
814
+ function reassignDuplicatedNodeIds(content: string): string {
815
+ return content.replace(
816
+ /data-agent-native-node-id="[^"]*"/g,
817
+ () => `data-agent-native-node-id="${uniqueLayerId("copy")}"`,
818
+ );
819
+ }
820
+
807
821
  function primitiveLayerName(primitive: CanvasPrimitiveInsert): string {
808
822
  switch (primitive.kind) {
809
823
  case "frame":
@@ -1353,12 +1367,29 @@ function codeLayerTreeToPanelNodes(
1353
1367
  hiddenIds: Set<string>,
1354
1368
  inheritedLocked = false,
1355
1369
  inheritedHidden = false,
1370
+ // Ancestor-path ids guarding against a cyclic projection (e.g. duplicate or
1371
+ // empty node ids like "an-" that make a node its own descendant) recursing
1372
+ // forever and crashing the whole editor with a stack overflow.
1373
+ ancestors: Set<string> = new Set(),
1356
1374
  ): LayersPanelNode[] {
1357
1375
  return nodes.map((node) => {
1358
1376
  const selfLocked = lockedIds.has(node.id);
1359
1377
  const selfHidden = hiddenIds.has(node.id);
1360
1378
  const locked = inheritedLocked || selfLocked;
1361
1379
  const hidden = inheritedHidden || selfHidden;
1380
+ let children: LayersPanelNode[] = [];
1381
+ if (!ancestors.has(node.id)) {
1382
+ ancestors.add(node.id);
1383
+ children = codeLayerTreeToPanelNodes(
1384
+ node.children,
1385
+ lockedIds,
1386
+ hiddenIds,
1387
+ locked,
1388
+ hidden,
1389
+ ancestors,
1390
+ );
1391
+ ancestors.delete(node.id);
1392
+ }
1362
1393
  return {
1363
1394
  id: node.id,
1364
1395
  name: node.name,
@@ -1372,13 +1403,7 @@ function codeLayerTreeToPanelNodes(
1372
1403
  hideable: true,
1373
1404
  locked,
1374
1405
  hidden,
1375
- children: codeLayerTreeToPanelNodes(
1376
- node.children,
1377
- lockedIds,
1378
- hiddenIds,
1379
- locked,
1380
- hidden,
1381
- ),
1406
+ children,
1382
1407
  };
1383
1408
  });
1384
1409
  }
@@ -1395,12 +1420,20 @@ function collectEffectiveCodeLayerState(
1395
1420
  inheritedLocked: boolean,
1396
1421
  inheritedHidden: boolean,
1397
1422
  state: EffectiveCodeLayerState,
1423
+ // Ids on the current ancestor path — guards against a malformed/cyclic
1424
+ // projection (e.g. a node that appears as its own descendant from duplicate
1425
+ // node ids) recursing forever and crashing the whole editor with a stack
1426
+ // overflow. A true cycle is skipped; duplicate ids in disjoint subtrees are
1427
+ // still visited.
1428
+ ancestors: Set<string> = new Set(),
1398
1429
  ): EffectiveCodeLayerState {
1399
1430
  nodes.forEach((node) => {
1431
+ if (ancestors.has(node.id)) return;
1400
1432
  const locked = inheritedLocked || lockedIds.has(node.id);
1401
1433
  const hidden = inheritedHidden || hiddenIds.has(node.id);
1402
1434
  if (locked) state.lockedIds.add(node.id);
1403
1435
  if (hidden) state.hiddenIds.add(node.id);
1436
+ ancestors.add(node.id);
1404
1437
  collectEffectiveCodeLayerState(
1405
1438
  node.children,
1406
1439
  lockedIds,
@@ -1408,7 +1441,9 @@ function collectEffectiveCodeLayerState(
1408
1441
  locked,
1409
1442
  hidden,
1410
1443
  state,
1444
+ ancestors,
1411
1445
  );
1446
+ ancestors.delete(node.id);
1412
1447
  });
1413
1448
  return state;
1414
1449
  }
@@ -2282,9 +2317,15 @@ export default function DesignEditor() {
2282
2317
  const [overviewSelectAllRequest, setOverviewSelectAllRequest] = useState(0);
2283
2318
  const [overviewClearSelectionRequest, setOverviewClearSelectionRequest] =
2284
2319
  useState(0);
2320
+ const [overviewSelectedScreenIds, setOverviewSelectedScreenIds] = useState<
2321
+ string[]
2322
+ >([]);
2285
2323
  const [hasCanvasClipboard, setHasCanvasClipboard] = useState(false);
2286
2324
  const [hasPropsClipboard, setHasPropsClipboard] = useState(false);
2287
2325
  const copiedLayerHtmlRef = useRef<string | null>(null);
2326
+ // Cascade offset for repeated keyboard pastes so successive clones don't stack
2327
+ // pixel-perfectly on top of each other. Reset on each fresh copy/cut.
2328
+ const pasteCascadeRef = useRef(0);
2288
2329
  const copiedStylePropsRef = useRef<Record<string, string> | null>(null);
2289
2330
  const spaceHandPreviousToolRef = useRef<DesignTool | null>(null);
2290
2331
  const hasSelectedElement = Boolean(selectedElement);
@@ -3360,7 +3401,12 @@ export default function DesignEditor() {
3360
3401
  ? command.screen
3361
3402
  : null;
3362
3403
  const targetFile = findDesignFileByScreenTarget(files, target);
3363
- if (target && !targetFile && files.length === 0) return false;
3404
+ // A navigate command can name a screen the agent just created that the
3405
+ // get-design query hasn't refetched yet. Treat any unresolved named target
3406
+ // as not-yet-applied (return false) so the app-state key is preserved and
3407
+ // re-applied on the next tick once the file loads — not just when there are
3408
+ // zero files. Otherwise the navigate is silently consumed and dropped.
3409
+ if (target && !targetFile) return false;
3364
3410
 
3365
3411
  const inspectorTab =
3366
3412
  command.inspectorTab === "design" ||
@@ -3475,7 +3521,7 @@ export default function DesignEditor() {
3475
3521
  {
3476
3522
  designId: id,
3477
3523
  filename,
3478
- content: source.content,
3524
+ content: reassignDuplicatedNodeIds(source.content),
3479
3525
  fileType: normalizedDesignFileType(source.fileType),
3480
3526
  } as any,
3481
3527
  {
@@ -4155,7 +4201,6 @@ export default function DesignEditor() {
4155
4201
  setCollabContent(nextContent);
4156
4202
  lastLocalContentRef.current = nextContent;
4157
4203
  if (id) {
4158
- const optimisticUpdatedAt = new Date().toISOString();
4159
4204
  queryClient.setQueryData(
4160
4205
  ["action", "get-design", { id }],
4161
4206
  (old: any) => {
@@ -4166,11 +4211,12 @@ export default function DesignEditor() {
4166
4211
  ...old,
4167
4212
  files: old.files.map((file: DesignFile) =>
4168
4213
  file.id === activeFile.id
4169
- ? {
4170
- ...file,
4171
- content: nextContent,
4172
- updatedAt: optimisticUpdatedAt,
4173
- }
4214
+ ? // Update content optimistically but keep the file's prior
4215
+ // (server-clock) updatedAt. Seeding the reconcile watermark
4216
+ // from a client-clock timestamp can, under clock skew, make a
4217
+ // later server-authored agent edit look "older" and get
4218
+ // dropped by the watermark gate (agent edit silently lost).
4219
+ { ...file, content: nextContent }
4174
4220
  : file,
4175
4221
  ),
4176
4222
  };
@@ -4433,7 +4479,7 @@ export default function DesignEditor() {
4433
4479
  files: UploadedFile[],
4434
4480
  options: PromptComposerSubmitOptions,
4435
4481
  ) => {
4436
- if (!design) return;
4482
+ if (!design || generating || pendingGenerationActive) return;
4437
4483
  const trimmed = prompt.trim();
4438
4484
  if (!trimmed) return;
4439
4485
  const fileContext = formatUploadedFileContext(files);
@@ -4499,7 +4545,13 @@ export default function DesignEditor() {
4499
4545
  fileType: file.fileType,
4500
4546
  })),
4501
4547
  selectedScreenIds:
4502
- viewMode === "overview" && activeFile?.id ? [activeFile.id] : [],
4548
+ viewMode === "overview"
4549
+ ? overviewSelectedScreenIds.length
4550
+ ? overviewSelectedScreenIds
4551
+ : activeFile?.id
4552
+ ? [activeFile.id]
4553
+ : []
4554
+ : [],
4503
4555
  selectedElement,
4504
4556
  hoveredElement,
4505
4557
  mode,
@@ -4553,6 +4605,7 @@ export default function DesignEditor() {
4553
4605
  mode,
4554
4606
  activeTool,
4555
4607
  activeInspectorTab,
4608
+ overviewSelectedScreenIds,
4556
4609
  viewMode,
4557
4610
  zoom,
4558
4611
  ]);
@@ -4610,7 +4663,13 @@ export default function DesignEditor() {
4610
4663
  fileType: file.fileType,
4611
4664
  })),
4612
4665
  selectedScreenIds:
4613
- viewMode === "overview" && activeFile?.id ? [activeFile.id] : [],
4666
+ viewMode === "overview"
4667
+ ? overviewSelectedScreenIds.length
4668
+ ? overviewSelectedScreenIds
4669
+ : activeFile?.id
4670
+ ? [activeFile.id]
4671
+ : []
4672
+ : [],
4614
4673
  selectedElement,
4615
4674
  mode,
4616
4675
  activeTool,
@@ -4625,6 +4684,7 @@ export default function DesignEditor() {
4625
4684
  files,
4626
4685
  id,
4627
4686
  mode,
4687
+ overviewSelectedScreenIds,
4628
4688
  selectedElement,
4629
4689
  tweakSelections,
4630
4690
  viewMode,
@@ -4699,6 +4759,16 @@ export default function DesignEditor() {
4699
4759
  ([, value]) => value !== undefined,
4700
4760
  );
4701
4761
  if (entries.length === 0) return;
4762
+ // Base every patch off the freshest known content, not the closed-over
4763
+ // render value. Handlers that fire several onStyleChange calls in one
4764
+ // synchronous user action (e.g. fixed-size text → width+height+whiteSpace,
4765
+ // constraints center → both axes, linked padding → 4 sides) would
4766
+ // otherwise each read the same pre-render `activeContent` and clobber one
4767
+ // another, so only the last property survived in the saved HTML. Since we
4768
+ // advance lastLocalContentRef.current to resolvedNextContent below, the
4769
+ // next synchronous call reads the previous call's result and the patches
4770
+ // compose. Falls back to activeContent when the ref is unset (file switch).
4771
+ const baseContent = lastLocalContentRef.current ?? activeContent;
4702
4772
  const [firstProperty, firstValue] = entries[0];
4703
4773
  const capability =
4704
4774
  selectedElement?.editCapabilities?.find((item) =>
@@ -4725,7 +4795,7 @@ export default function DesignEditor() {
4725
4795
  : entries
4726
4796
  .map(([property, value]) => `${property}: ${value}`)
4727
4797
  .join("; "),
4728
- previousContent: activeContent,
4798
+ previousContent: baseContent,
4729
4799
  capability: capability?.kind ?? "deterministic-style-edit",
4730
4800
  confidence: capability?.confidence ?? 0.92,
4731
4801
  status: "runtime",
@@ -4738,10 +4808,10 @@ export default function DesignEditor() {
4738
4808
  });
4739
4809
  }
4740
4810
 
4741
- const nextContent = applyInlineStylesToHtml(activeContent, selector, {
4811
+ const nextContent = applyInlineStylesToHtml(baseContent, selector, {
4742
4812
  ...Object.fromEntries(entries),
4743
4813
  });
4744
- const projection = buildCodeLayerProjection(activeContent);
4814
+ const projection = buildCodeLayerProjection(baseContent);
4745
4815
  const targetNode = resolveCodeLayerNodeFromBridge(
4746
4816
  projection,
4747
4817
  selector,
@@ -4769,7 +4839,7 @@ export default function DesignEditor() {
4769
4839
  }
4770
4840
  return { content: patch.content, failed: null };
4771
4841
  },
4772
- { content: activeContent, failed: null },
4842
+ { content: baseContent, failed: null },
4773
4843
  );
4774
4844
  const resolvedNextContent = stylePatch.failed
4775
4845
  ? nextContent
@@ -5140,6 +5210,7 @@ export default function DesignEditor() {
5140
5210
  const html = getElementOuterHtml(activeContent, selectedElement.selector);
5141
5211
  if (!html) return;
5142
5212
  copiedLayerHtmlRef.current = html;
5213
+ pasteCascadeRef.current = 0;
5143
5214
  setHasCanvasClipboard(true);
5144
5215
  try {
5145
5216
  await navigator.clipboard.writeText(html);
@@ -5152,32 +5223,67 @@ export default function DesignEditor() {
5152
5223
  const handlePasteSelection = useCallback(
5153
5224
  (position?: { x: number; y: number }) => {
5154
5225
  if (!activeFile || !copiedLayerHtmlRef.current) return;
5226
+ // Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
5227
+ // Keyboard pastes cascade by 16px each so repeats don't stack exactly.
5228
+ const targetPosition =
5229
+ position ??
5230
+ (() => {
5231
+ const offset = pasteCascadeRef.current * 16;
5232
+ return { x: 120 + offset, y: 120 + offset };
5233
+ })();
5155
5234
  const nextContent = cloneHtmlLayerAtPosition(
5156
5235
  activeContent,
5157
5236
  copiedLayerHtmlRef.current,
5158
- position ?? { x: 120, y: 120 },
5237
+ targetPosition,
5159
5238
  );
5160
5239
  if (!nextContent) return;
5240
+ if (!position) pasteCascadeRef.current += 1;
5161
5241
  applyLocalContentUpdate(nextContent);
5162
5242
  toast.success(t("designEditor.toasts.pasted"));
5163
5243
  },
5164
5244
  [activeContent, activeFile, applyLocalContentUpdate, t],
5165
5245
  );
5166
5246
 
5247
+ const handlePasteOverSelection = useCallback(() => {
5248
+ if (!activeFile || !copiedLayerHtmlRef.current) return;
5249
+ if (selectedElement?.boundingRect) {
5250
+ const { x, y } = selectedElement.boundingRect;
5251
+ const nextContent = cloneHtmlLayerAtPosition(
5252
+ activeContent,
5253
+ copiedLayerHtmlRef.current,
5254
+ { x, y },
5255
+ );
5256
+ if (!nextContent) return;
5257
+ applyLocalContentUpdate(nextContent);
5258
+ toast.success(t("designEditor.toasts.pasted"));
5259
+ } else {
5260
+ handlePasteSelection();
5261
+ }
5262
+ }, [
5263
+ activeContent,
5264
+ activeFile,
5265
+ applyLocalContentUpdate,
5266
+ handlePasteSelection,
5267
+ selectedElement,
5268
+ t,
5269
+ ]);
5270
+
5167
5271
  const handleDuplicateSelection = useCallback(() => {
5168
5272
  if (selectedElement?.selector) {
5169
5273
  const html = getElementOuterHtml(activeContent, selectedElement.selector);
5170
- if (html) {
5171
- const rect = selectedElement.boundingRect;
5172
- const nextContent = cloneHtmlLayerAtPosition(activeContent, html, {
5173
- x: rect.x + 16,
5174
- y: rect.y + 16,
5175
- });
5176
- if (nextContent) {
5177
- applyLocalContentUpdate(nextContent);
5178
- return;
5179
- }
5274
+ const rect = selectedElement.boundingRect;
5275
+ const nextContent = html
5276
+ ? cloneHtmlLayerAtPosition(activeContent, html, {
5277
+ x: rect.x + 16,
5278
+ y: rect.y + 16,
5279
+ })
5280
+ : null;
5281
+ if (nextContent) {
5282
+ applyLocalContentUpdate(nextContent);
5283
+ } else {
5284
+ toast.error(t("designEditor.toasts.duplicateElementFailed"));
5180
5285
  }
5286
+ return;
5181
5287
  }
5182
5288
  if (activeFile) handleDuplicateScreen(activeFile.id);
5183
5289
  }, [
@@ -5189,6 +5295,41 @@ export default function DesignEditor() {
5189
5295
  ]);
5190
5296
 
5191
5297
  const handleDeleteSelection = useCallback(() => {
5298
+ // Multi-select delete: when several DOM/code layers are selected in the
5299
+ // panel, remove all of them — not just the single focused element. Compose
5300
+ // the removals against the running content (re-projecting each pass) so
5301
+ // nested selections resolve correctly and earlier removals aren't clobbered.
5302
+ const candidateIds = selectedLayerIdsState.filter(
5303
+ (layerId) =>
5304
+ layerId &&
5305
+ !layerId.startsWith("__") &&
5306
+ !files.some((file) => file.id === layerId),
5307
+ );
5308
+ if (candidateIds.length > 1) {
5309
+ let content = activeContent;
5310
+ const removedSelectors: string[] = [];
5311
+ for (const layerId of candidateIds) {
5312
+ const projection = buildCodeLayerProjection(content);
5313
+ const node =
5314
+ projection.nodes.find((candidate) => candidate.id === layerId) ??
5315
+ resolveCodeLayerNodeFromBridge(projection, layerId, layerId);
5316
+ if (!node) continue;
5317
+ const next = removeCodeLayerNodeFromHtml(content, node);
5318
+ if (!next) continue;
5319
+ const selector = preferredCodeLayerSelector(node);
5320
+ if (selector) removedSelectors.push(selector);
5321
+ content = next;
5322
+ }
5323
+ if (content !== activeContent) {
5324
+ removedSelectors.forEach((selector) => deleteRuntimeElement(selector));
5325
+ applyLocalContentUpdate(content, { refreshPreview: false });
5326
+ setSelectedElement(null);
5327
+ setSelectedLayerIdsState([]);
5328
+ return;
5329
+ }
5330
+ // Nothing resolved (stale ids) — fall through to the single path.
5331
+ }
5332
+
5192
5333
  if (!selectedElement?.selector) return;
5193
5334
  const projection = buildCodeLayerProjection(activeContent);
5194
5335
  const targetNode = resolveCodeLayerNodeFromBridge(
@@ -5210,9 +5351,20 @@ export default function DesignEditor() {
5210
5351
  activeContent,
5211
5352
  applyLocalContentUpdate,
5212
5353
  deleteRuntimeElement,
5354
+ files,
5213
5355
  selectedElement,
5356
+ selectedLayerIdsState,
5214
5357
  ]);
5215
5358
 
5359
+ const handleCutSelection = useCallback(async () => {
5360
+ if (!selectedElement?.selector) return;
5361
+ // Copy first (populates the internal clipboard ref even if the async
5362
+ // navigator.clipboard write is blocked — handleCopySelection swallows that
5363
+ // error) then remove the element so a subsequent paste can re-insert it.
5364
+ await handleCopySelection();
5365
+ handleDeleteSelection();
5366
+ }, [handleCopySelection, handleDeleteSelection, selectedElement]);
5367
+
5216
5368
  const handleDeleteOverviewSelection = useCallback(
5217
5369
  (selectedIds: string[]) => {
5218
5370
  if (!selectedIds.length || files.length <= 1) return false;
@@ -5752,8 +5904,9 @@ export default function DesignEditor() {
5752
5904
  onCommentTool: handlePinToolToggle,
5753
5905
  onScaleTool: handleScaleTool,
5754
5906
  onCopy: handleCopySelection,
5907
+ onCut: handleCutSelection,
5755
5908
  onPaste: () => handlePasteSelection(),
5756
- onPasteOver: () => handlePasteSelection(),
5909
+ onPasteOver: handlePasteOverSelection,
5757
5910
  onCopyProps: handleCopyProps,
5758
5911
  onPasteProps: handlePasteProps,
5759
5912
  onCopyAsCode: handleCopySelection,
@@ -6853,6 +7006,27 @@ ${serializedHtml}
6853
7006
 
6854
7007
  const getContextCanvasPoint = useCallback(
6855
7008
  ({ clientX, clientY }: { clientX: number; clientY: number }) => {
7009
+ // In single-screen mode the iframe is inside a scale(zoom/100) wrapper
7010
+ // that also centers the content. Using the iframe's own
7011
+ // getBoundingClientRect() already incorporates centering/pan because the
7012
+ // rect is measured in screen space after the CSS transform. Dividing by
7013
+ // the zoom factor converts from post-scale screen-pixels back to the
7014
+ // document coordinate space written into left/top by cloneHtmlLayerAtPosition.
7015
+ if (viewMode === "single") {
7016
+ const iframe = canvasContainerRef.current?.querySelector<HTMLElement>(
7017
+ "[data-design-preview-iframe]",
7018
+ );
7019
+ if (iframe) {
7020
+ const iframeRect = iframe.getBoundingClientRect();
7021
+ const factor = zoom / 100;
7022
+ return {
7023
+ x: Math.max(0, (clientX - iframeRect.left) / factor),
7024
+ y: Math.max(0, (clientY - iframeRect.top) / factor),
7025
+ };
7026
+ }
7027
+ }
7028
+ // Overview mode: fall back to container-relative coords (overview uses its
7029
+ // own coordinate mapping for paste; this value is a best-effort fallback).
6856
7030
  const rect = canvasContainerRef.current?.getBoundingClientRect();
6857
7031
  if (!rect) return { x: 120, y: 120 };
6858
7032
  return {
@@ -6860,7 +7034,7 @@ ${serializedHtml}
6860
7034
  y: Math.max(0, clientY - rect.top),
6861
7035
  };
6862
7036
  },
6863
- [],
7037
+ [zoom, viewMode],
6864
7038
  );
6865
7039
 
6866
7040
  const zoomLabel = `${Math.round(zoom)}%`;
@@ -7785,8 +7959,10 @@ ${serializedHtml}
7785
7959
  canPasteHere={hasCanvasClipboard && Boolean(activeFile)}
7786
7960
  canSelectAll={files.length > 0}
7787
7961
  canZoomToFit={Boolean(activeFile)}
7788
- canZoomToSelection={Boolean(activeFile)}
7789
- canCopy={Boolean(activeFile)}
7962
+ canZoomToSelection={
7963
+ Boolean(selectedElement) || viewMode === "overview"
7964
+ }
7965
+ canCopy={Boolean(selectedElement?.selector)}
7790
7966
  canPaste={hasCanvasClipboard && Boolean(activeFile)}
7791
7967
  canPasteOver={hasCanvasClipboard && Boolean(activeFile)}
7792
7968
  canDuplicate={Boolean(activeFile)}
@@ -7799,7 +7975,7 @@ ${serializedHtml}
7799
7975
  canToggleHidden={Boolean(activeLayerId)}
7800
7976
  canCopyProps={Boolean(selectedElement)}
7801
7977
  canPasteProps={hasPropsClipboard && Boolean(selectedElement)}
7802
- canCopyAsCode={Boolean(activeFile)}
7978
+ canCopyAsCode={Boolean(selectedElement?.selector)}
7803
7979
  hiddenActions={["group", "ungroup", "rename"]}
7804
7980
  getCanvasPoint={getContextCanvasPoint}
7805
7981
  onPasteHere={(details) =>
@@ -7816,10 +7992,12 @@ ${serializedHtml}
7816
7992
  setActiveTool("move");
7817
7993
  setZoom(100);
7818
7994
  }}
7819
- onZoomToSelection={() => setZoom(150)}
7995
+ onZoomToSelection={() => {
7996
+ if (selectedElement || viewMode === "overview") setZoom(150);
7997
+ }}
7820
7998
  onCopy={handleCopySelection}
7821
7999
  onPaste={() => handlePasteSelection()}
7822
- onPasteOver={() => handlePasteSelection()}
8000
+ onPasteOver={handlePasteOverSelection}
7823
8001
  onDuplicate={handleDuplicateSelection}
7824
8002
  onDelete={handleDeleteSelection}
7825
8003
  onBringForward={() => changeSelectedZIndex("forward")}
@@ -7864,6 +8042,7 @@ ${serializedHtml}
7864
8042
  onCreatePrimitive={handleCreatePrimitive}
7865
8043
  onCreateScreenFrame={handleCreateScreenFrame}
7866
8044
  onDeleteSelection={handleDeleteOverviewSelection}
8045
+ onSelectionChange={setOverviewSelectedScreenIds}
7867
8046
  onPick={(id) => {
7868
8047
  setSelectedElement(null);
7869
8048
  setSelectedLayerIdsState([id]);
@@ -8186,6 +8365,7 @@ ${serializedHtml}
8186
8365
  title={t("designEditor.tweaksPromptTitle")}
8187
8366
  placeholder={t("designEditor.tweaksPlaceholder")}
8188
8367
  onSubmit={handleTweakPromptSubmit}
8368
+ loading={generating || pendingGenerationActive}
8189
8369
  anchorRef={tweakPromptAnchorRef}
8190
8370
  />
8191
8371
  </div>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Agent edits to a design are no longer occasionally dropped or corrupted when local and agent changes overlap.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Box-shadow and gradient colors keep named CSS colors (like red or navy) when edited, instead of resetting to black; the eyedropper now updates the selected gradient stop instead of replacing the whole gradient.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Cut (Cmd/Ctrl+X) now removes the selected element to the clipboard; pasting repeatedly cascades the copies instead of stacking them exactly on top.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Deleting with multiple layers selected in the Layers panel now removes all of them, not just the focused one.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Duplicating a screen now gives the copy its own layer ids, so selecting a layer in one screen no longer affects the other.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Hiding and re-showing a gradient fill now preserves each stop's opacity instead of flattening them.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Picking a design direction now submits to the agent right away and reliably saves, instead of leaving the message sitting unsent in the chat box or firing twice on a double-click.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ Style changes that set several properties at once (fixed-size text, constraints, padding, flip, stroke position) now all save reliably instead of dropping all but the last property.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-29
4
+ ---
5
+
6
+ The editor no longer crashes when a screen ends up with duplicate or malformed layer ids (e.g. from a bad agent edit); the layer tree is now resilient to it.
@@ -10,6 +10,7 @@ resizing, moving/reparenting, the layers panel, and the iframe bridge.
10
10
  pnpm e2e # headless
11
11
  pnpm e2e:headed # watch it drive a real browser
12
12
  pnpm e2e:ui # Playwright UI mode
13
+ E2E_BROWSER_CHANNEL=chrome pnpm e2e # run the suite in installed Google Chrome
13
14
  ```
14
15
 
15
16
  - `playwright.config.ts` starts its own dev server on **:9333** backed by a
@@ -18,6 +18,7 @@ export const SEED_TITLE = "E2E Seed Design";
18
18
  const AUTH_DIR = path.join(import.meta.dirname, ".auth");
19
19
  const STATE_PATH = path.join(AUTH_DIR, "state.json");
20
20
  const SEED_PATH = path.join(AUTH_DIR, "seed.json");
21
+ const BROWSER_CHANNEL = process.env.E2E_BROWSER_CHANNEL;
21
22
 
22
23
  /**
23
24
  * Fixture HTML with distinct, text-identifiable elements. Plain inline styles
@@ -40,6 +41,15 @@ export const FIXTURE_HTML = `<!doctype html>
40
41
  <button style="padding:14px 28px;border-radius:10px;border:0;background:#6366f1;color:#fff;font-size:16px">Alpha Button</button>
41
42
  <button style="padding:14px 28px;border-radius:10px;border:0;background:#22c55e;color:#06240f;font-size:16px">Beta Button</button>
42
43
  </div>
44
+ <div style="padding:8px;border:1px solid #27272a;border-radius:12px">
45
+ <div style="padding:8px;border:1px solid #3f3f46;border-radius:10px">
46
+ <div style="padding:8px;border:1px solid #52525b;border-radius:8px">
47
+ <div style="padding:8px;border:1px solid #71717a;border-radius:6px">
48
+ <button style="padding:10px 18px;border-radius:8px;border:0;background:#f59e0b;color:#111827;font-size:14px">Deep Layer Button</button>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
43
53
  <section style="margin-top:16px;padding:24px;border-radius:14px;background:#1a1d24">
44
54
  <h2 style="font-size:24px;margin:0 0 8px">Fixture Card Title</h2>
45
55
  <p style="margin:0;color:#a1a1aa">Card body text inside a nested container.</p>
@@ -72,7 +82,9 @@ export default async function globalSetup(config: FullConfig) {
72
82
  "http://127.0.0.1:9333";
73
83
  await mkdir(AUTH_DIR, { recursive: true });
74
84
 
75
- const browser = await chromium.launch();
85
+ const browser = await chromium.launch(
86
+ BROWSER_CHANNEL ? { channel: BROWSER_CHANNEL } : {},
87
+ );
76
88
  const context = await browser.newContext();
77
89
  const page = await context.newPage();
78
90
 
@@ -15,6 +15,7 @@ import { defineConfig, devices } from "@playwright/test";
15
15
  */
16
16
  const PORT = Number(process.env.E2E_PORT ?? 9333);
17
17
  const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`;
18
+ const BROWSER_CHANNEL = process.env.E2E_BROWSER_CHANNEL;
18
19
 
19
20
  export default defineConfig({
20
21
  testDir: "./e2e",
@@ -33,7 +34,15 @@ export default defineConfig({
33
34
  actionTimeout: 15_000,
34
35
  navigationTimeout: 30_000,
35
36
  },
36
- projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
37
+ projects: [
38
+ {
39
+ name: BROWSER_CHANNEL ? `chromium-${BROWSER_CHANNEL}` : "chromium",
40
+ use: {
41
+ ...devices["Desktop Chrome"],
42
+ ...(BROWSER_CHANNEL ? { channel: BROWSER_CHANNEL } : {}),
43
+ },
44
+ },
45
+ ],
37
46
  webServer: process.env.E2E_BASE_URL
38
47
  ? undefined
39
48
  : {