@agent-native/core 0.80.7 → 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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -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/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
  7. package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
  8. package/corpus/templates/design/AGENTS.md +4 -2
  9. package/corpus/templates/design/actions/edit-design.ts +6 -3
  10. package/corpus/templates/design/actions/generate-screens.ts +5 -3
  11. package/corpus/templates/design/actions/update-design.ts +47 -10
  12. package/corpus/templates/design/actions/update-file.ts +33 -8
  13. package/corpus/templates/design/actions/view-screen.ts +14 -0
  14. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
  15. package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
  16. package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
  17. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
  18. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
  19. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
  20. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
  21. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
  22. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
  23. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
  24. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
  25. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
  26. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
  27. package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
  28. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  29. package/corpus/templates/design/app/i18n-data.ts +10 -0
  30. package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
  31. package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
  32. package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
  33. package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
  34. package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
  35. package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
  36. package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
  37. package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
  38. package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
  39. package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
  40. package/corpus/templates/design/e2e/README.md +1 -0
  41. package/corpus/templates/design/e2e/global-setup.ts +13 -1
  42. package/corpus/templates/design/playwright.config.ts +10 -1
  43. package/corpus/templates/design/shared/color-utils.ts +159 -0
  44. package/corpus/templates/design/shared/generation-session.ts +1 -0
  45. package/dist/client/AssistantChat.d.ts.map +1 -1
  46. package/dist/client/AssistantChat.js +54 -24
  47. package/dist/client/AssistantChat.js.map +1 -1
  48. package/dist/client/analytics.d.ts.map +1 -1
  49. package/dist/client/analytics.js +2 -1
  50. package/dist/client/analytics.js.map +1 -1
  51. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  52. package/dist/notifications/routes.d.ts +2 -2
  53. package/dist/observability/routes.d.ts +8 -8
  54. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  55. package/package.json +1 -1
@@ -480,7 +480,7 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
480
480
  mixBlendMode: cs.mixBlendMode,
481
481
  zIndex: cs.zIndex,
482
482
  },
483
- boundingRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
483
+ boundingRect: { x: rect.x + (window.scrollX || window.pageXOffset || 0), y: rect.y + (window.scrollY || window.pageYOffset || 0), width: rect.width, height: rect.height },
484
484
  textContent: el.textContent ? el.textContent.slice(0, 200) : undefined,
485
485
  htmlContent: el.innerHTML && el.innerHTML !== el.textContent ? el.innerHTML.slice(0, 4000) : undefined,
486
486
  isFlexContainer: cs.display === 'flex' || cs.display === 'inline-flex',
@@ -789,13 +789,34 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
789
789
  overlay.style.display = 'none';
790
790
  return;
791
791
  }
792
+ // For the selection overlay, prefer the CSS box + rotation transform so
793
+ // the handles hug the rotated element rather than its inflated AABB.
794
+ if (overlay === selectionOverlay) {
795
+ var elCs = window.getComputedStyle(el);
796
+ var elLeft = readPx(el.style.left || elCs.left);
797
+ var elTop = readPx(el.style.top || elCs.top);
798
+ var elW = readPx(el.style.width || elCs.width);
799
+ var elH = readPx(el.style.height || elCs.height);
800
+ var elRot = currentRotation(el);
801
+ // Convert element-local left/top to viewport coords by walking to the
802
+ // nearest positioned ancestor (same reference frame as getBoundingClientRect).
803
+ var parentRect = (el.offsetParent || document.documentElement).getBoundingClientRect();
804
+ overlay.style.display = 'block';
805
+ overlay.style.left = (parentRect.left + elLeft) + 'px';
806
+ overlay.style.top = (parentRect.top + elTop) + 'px';
807
+ overlay.style.width = elW + 'px';
808
+ overlay.style.height = elH + 'px';
809
+ overlay.style.transform = elRot ? 'rotate(' + elRot + 'deg)' : '';
810
+ overlay.style.transformOrigin = '0 0';
811
+ updatePaddingOverlay(el);
812
+ return;
813
+ }
792
814
  var rect = el.getBoundingClientRect();
793
815
  overlay.style.display = 'block';
794
816
  overlay.style.top = rect.top + 'px';
795
817
  overlay.style.left = rect.left + 'px';
796
818
  overlay.style.width = rect.width + 'px';
797
819
  overlay.style.height = rect.height + 'px';
798
- if (overlay === selectionOverlay) updatePaddingOverlay(el);
799
820
  }
800
821
 
801
822
  function refreshOverlays() {
@@ -1381,10 +1402,10 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1381
1402
  }
1382
1403
  }
1383
1404
 
1384
- function postVisualStructureChange(el, target) {
1405
+ function postVisualStructureChange(el, target, origin) {
1385
1406
  if (!el || !target || !target.anchor) return;
1386
1407
  var requestId = 'move-' + Date.now() + '-' + Math.random().toString(16).slice(2);
1387
- pendingStructureMove = { requestId: requestId, el: el, target: target };
1408
+ pendingStructureMove = { requestId: requestId, el: el, target: target, origin: origin || null };
1388
1409
  window.parent.postMessage({
1389
1410
  type: 'visual-structure-change',
1390
1411
  requestId: requestId,
@@ -1458,11 +1479,15 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1458
1479
  applyRuntimeReorder(reorderEl, currentTarget);
1459
1480
  postVisualDuplicateChange(originalSelectedEl, reorderEl, currentTarget);
1460
1481
  } else {
1482
+ // Capture the pre-drag DOM anchor so we can revert if the parent
1483
+ // reports applied===false on the structure-ack.
1484
+ var prevParent = reorderEl.parentElement;
1485
+ var prevNextSibling = reorderEl.nextSibling;
1461
1486
  // Optimistically apply the reorder in the DOM for immediate
1462
1487
  // visual feedback; the visual-structure-ack handler will confirm
1463
1488
  // or revert once the parent processes the change.
1464
1489
  applyRuntimeReorder(reorderEl, currentTarget);
1465
- postVisualStructureChange(reorderEl, currentTarget);
1490
+ postVisualStructureChange(reorderEl, currentTarget, { prevParent: prevParent, prevNextSibling: prevNextSibling });
1466
1491
  }
1467
1492
  }
1468
1493
  document.addEventListener('mousemove', onReorderMove, true);
@@ -1479,7 +1504,12 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1479
1504
  // clear-selection postMessage cannot swap selectedEl mid-drag and cause
1480
1505
  // mutations on the wrong element or a null-deref in onUp.
1481
1506
  var dragEl = selectedEl;
1507
+ var moved = false;
1508
+ var DRAG_THRESHOLD = 3;
1482
1509
  function onMove(ev) {
1510
+ if (!moved && Math.hypot(ev.clientX - startX, ev.clientY - startY) > DRAG_THRESHOLD) {
1511
+ moved = true;
1512
+ }
1483
1513
  var nextLeft = originLeft + ev.clientX - startX;
1484
1514
  var nextTop = originTop + ev.clientY - startY;
1485
1515
  dragEl.style.left = Math.round(nextLeft) + 'px';
@@ -1492,6 +1522,14 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1492
1522
  document.removeEventListener('mouseup', onUp, true);
1493
1523
  hideTransformBadge();
1494
1524
  if (!dragEl) return;
1525
+ if (duplicatedForDrag && !moved) {
1526
+ // Alt-click with no real drag — remove the premature clone and restore the original selection.
1527
+ if (dragEl.parentElement) dragEl.parentElement.removeChild(dragEl);
1528
+ selectedEl = originalSelectedEl;
1529
+ positionOverlay(selectionOverlay, selectedEl);
1530
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
1531
+ return;
1532
+ }
1495
1533
  if (duplicatedForDrag) {
1496
1534
  postVisualDuplicateChange(originalSelectedEl, dragEl);
1497
1535
  } else {
@@ -1535,9 +1573,19 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1535
1573
  // Snapshot the element so a concurrent clear-selection postMessage cannot
1536
1574
  // cause a null-deref in onMove/onUp.
1537
1575
  var resizeEl = selectedEl;
1576
+ // Capture the element rotation once at drag-start so per-move projection is
1577
+ // cheap and consistent even if the transform changes during the drag.
1578
+ var resizeTheta = currentRotation(resizeEl) * Math.PI / 180;
1538
1579
  function nextRect(ev) {
1539
- var dx = ev.clientX - startX;
1540
- var dy = ev.clientY - startY;
1580
+ var screenDx = ev.clientX - startX;
1581
+ var screenDy = ev.clientY - startY;
1582
+ // Project the screen-space pointer delta into the element's local
1583
+ // (un-rotated) coordinate frame so handles behave relative to the
1584
+ // visible rotated box rather than screen axes.
1585
+ var cosT = Math.cos(resizeTheta);
1586
+ var sinT = Math.sin(resizeTheta);
1587
+ var dx = screenDx * cosT + screenDy * sinT;
1588
+ var dy = -screenDx * sinT + screenDy * cosT;
1541
1589
  var left = origin.left;
1542
1590
  var top = origin.top;
1543
1591
  var width = origin.width;
@@ -1570,14 +1618,13 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1570
1618
  }
1571
1619
  }
1572
1620
  // Clamp after ratio correction.
1573
- var rawWidth = width;
1574
- var rawHeight = height;
1575
1621
  width = Math.max(8, width);
1576
1622
  height = Math.max(8, height);
1577
- // Re-anchor the pinned edge for w/n handles after clamping so the
1578
- // opposite edge doesn't drift when the element hits minimum size.
1579
- if (handle.indexOf('w') !== -1) left += (rawWidth - width);
1580
- if (handle.indexOf('n') !== -1) top += (rawHeight - height);
1623
+ // Re-anchor the pinned edge for w/n handles after aspect-ratio lock and
1624
+ // clamping so the opposite (e/s) edge stays fixed regardless of whether
1625
+ // the dimension change was driven by raw dx/dy or by the ratio lock.
1626
+ if (handle.indexOf('w') !== -1) left = origin.left + (origin.width - width);
1627
+ if (handle.indexOf('n') !== -1) top = origin.top + (origin.height - height);
1581
1628
  if (ev.altKey) {
1582
1629
  if (handle.indexOf('w') !== -1 || handle.indexOf('e') !== -1) left = origin.left - (width - origin.width) / 2;
1583
1630
  if (handle.indexOf('n') !== -1 || handle.indexOf('s') !== -1) top = origin.top - (height - origin.height) / 2;
@@ -1958,10 +2005,20 @@ const EDITOR_CHROME_BRIDGE_SCRIPT = `
1958
2005
  var move = pendingStructureMove;
1959
2006
  pendingStructureMove = null;
1960
2007
  if (e.data.applied) {
1961
- applyRuntimeReorder(move.el, move.target);
1962
- selectedEl = move.el;
1963
- positionOverlay(selectionOverlay, selectedEl);
1964
- window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2008
+ if (move.el && move.el.isConnected) {
2009
+ applyRuntimeReorder(move.el, move.target);
2010
+ selectedEl = move.el;
2011
+ positionOverlay(selectionOverlay, selectedEl);
2012
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2013
+ }
2014
+ } else {
2015
+ // Revert the optimistic reorder to its pre-drag position.
2016
+ if (move.el && move.el.isConnected && move.origin && move.origin.prevParent && move.origin.prevParent.isConnected) {
2017
+ move.origin.prevParent.insertBefore(move.el, move.origin.prevNextSibling);
2018
+ selectedEl = move.el;
2019
+ positionOverlay(selectionOverlay, selectedEl);
2020
+ window.parent.postMessage({ type: 'element-select', payload: getElementInfo(selectedEl) }, '*');
2021
+ }
1965
2022
  }
1966
2023
  return;
1967
2024
  }
@@ -2660,6 +2717,7 @@ export function DesignCanvas({
2660
2717
  visible={!!drawMode}
2661
2718
  canvasInteractive={!pinMode}
2662
2719
  queuedAnnotationCount={queuedAnnotationPins.length}
2720
+ zoom={zoom}
2663
2721
  onClose={() => onExitDrawMode?.()}
2664
2722
  onSend={(annotations, instruction, canvasSize) => {
2665
2723
  const summary = annotations
@@ -137,10 +137,23 @@ interface EditPanelProps {
137
137
  * valid CSS length. Lets users type "32" and get the expected "32px" when
138
138
  * the field is committed.
139
139
  */
140
- function normalizeLengthValue(raw: string, defaultUnit: string): string {
140
+ function normalizeLengthValue(raw: string, defaultUnit: string): string | null {
141
141
  const trimmed = raw.trim();
142
- if (!trimmed) return "";
142
+ // Empty / invalid input returns null so the caller reverts the field instead
143
+ // of committing an empty or garbage CSS value (e.g. fontSize:"" or
144
+ // flexBasis:"abc") to the element's inline style.
145
+ if (!trimmed) return null;
143
146
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) return `${trimmed}${defaultUnit}`;
147
+ // Validate free-form CSS so junk text never reaches the style. Fall back to
148
+ // accepting the value when CSS.supports is unavailable (SSR/tests) to keep
149
+ // prior behavior in non-DOM environments.
150
+ if (typeof CSS !== "undefined" && typeof CSS.supports === "function") {
151
+ const ok =
152
+ CSS.supports("width", trimmed) ||
153
+ CSS.supports("font-size", trimmed) ||
154
+ CSS.supports("flex-basis", trimmed);
155
+ return ok ? trimmed : null;
156
+ }
144
157
  return trimmed;
145
158
  }
146
159
 
@@ -180,6 +193,11 @@ function PropInput({
180
193
  return;
181
194
  }
182
195
  const next = normalizeLengthValue(draft, defaultUnit);
196
+ if (next === null) {
197
+ // Invalid or empty — revert the field to the last committed value.
198
+ setDraft(value);
199
+ return;
200
+ }
183
201
  if (next !== draft) setDraft(next);
184
202
  if (next !== value) onChange(next);
185
203
  };
@@ -745,7 +763,11 @@ function gradientTypeFromCss(
745
763
  layer: string,
746
764
  ): DesignGradientType {
747
765
  if (functionName.toLowerCase() === "conic") return "angular";
748
- if (/closest-corner/i.test(layer)) return "diamond";
766
+ // Recognize both diamond serializations — EditPanel's "closest-corner" and
767
+ // GradientEditor's "ellipse closest-side" — so a diamond authored in either
768
+ // place round-trips as diamond instead of flipping to radial.
769
+ if (/closest-corner/i.test(layer) || /ellipse\s+closest-side/i.test(layer))
770
+ return "diamond";
749
771
  if (functionName.toLowerCase() === "radial") return "radial";
750
772
  return "linear";
751
773
  }
@@ -1170,9 +1192,39 @@ function resolveLineHeight(
1170
1192
  return Number.isFinite(numeric) && numeric > 0 ? numeric : 1.2;
1171
1193
  }
1172
1194
 
1195
+ // Matches a 2D rotate()/rotateZ() with any CSS angle unit (not rotateX/Y/3d).
1196
+ const ROTATE_FN_PATTERN =
1197
+ /rotate[Zz]?\(\s*([+-]?[\d.]+(?:e[+-]?\d+)?)(deg|rad|turn|grad)?\s*\)/i;
1198
+
1173
1199
  function parseRotationValue(transform: string | undefined): number {
1174
- const match = transform?.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
1175
- return match ? Number(match[1]) : 0;
1200
+ if (!transform || transform === "none") return 0;
1201
+ const match = transform.match(ROTATE_FN_PATTERN);
1202
+ if (match) {
1203
+ const value = Number(match[1]);
1204
+ if (Number.isFinite(value)) {
1205
+ const unit = (match[2] || "deg").toLowerCase();
1206
+ const deg =
1207
+ unit === "rad"
1208
+ ? value * (180 / Math.PI)
1209
+ : unit === "turn"
1210
+ ? value * 360
1211
+ : unit === "grad"
1212
+ ? value * 0.9
1213
+ : value;
1214
+ return Math.round(deg * 10) / 10;
1215
+ }
1216
+ }
1217
+ // Fallback for rotate3d()/matrix()/skew composites: read the 2D rotation
1218
+ // component off the resolved matrix so the panel doesn't report 0.
1219
+ if (typeof DOMMatrixReadOnly !== "undefined") {
1220
+ try {
1221
+ const m = new DOMMatrixReadOnly(transform);
1222
+ return Math.round(((Math.atan2(m.b, m.a) * 180) / Math.PI) * 10) / 10;
1223
+ } catch {
1224
+ // Unparseable transform — fall through to 0.
1225
+ }
1226
+ }
1227
+ return 0;
1176
1228
  }
1177
1229
 
1178
1230
  /**
@@ -1191,8 +1243,10 @@ function parseScaleValue(value: string | undefined): [number, number] {
1191
1243
  function mergeRotationValue(transform: string | undefined, degrees: number) {
1192
1244
  const nextRotate = `rotate(${Math.round(degrees * 10) / 10}deg)`;
1193
1245
  if (!transform || transform === "none") return nextRotate;
1194
- if (/rotate\((-?\d+(?:\.\d+)?)deg\)/.test(transform)) {
1195
- return transform.replace(/rotate\((-?\d+(?:\.\d+)?)deg\)/, nextRotate);
1246
+ // Replace an existing rotate()/rotateZ() in ANY unit so we don't append a
1247
+ // second rotate() (which would compound, e.g. "rotate(0.5turn) rotate(30deg)").
1248
+ if (ROTATE_FN_PATTERN.test(transform)) {
1249
+ return transform.replace(ROTATE_FN_PATTERN, nextRotate);
1196
1250
  }
1197
1251
  return `${transform} ${nextRotate}`;
1198
1252
  }
@@ -2025,10 +2079,10 @@ function StrokeLayerControl({
2025
2079
  const nextPrefix = next === "outside" ? "outline" : "border";
2026
2080
  onStyleChange(`${nextPrefix}Color`, color);
2027
2081
  onStyleChange(`${nextPrefix}Width`, width || "1px");
2028
- onStyleChange(
2029
- `${nextPrefix}Style`,
2030
- styleValue === "none" ? "solid" : styleValue,
2031
- );
2082
+ // Preserve the original border-style so a hidden stroke (style:none, kept
2083
+ // visible as a row because width>0) stays hidden when its position moves
2084
+ // between inside/outside. Only default to solid when there's no style at all.
2085
+ onStyleChange(`${nextPrefix}Style`, styleValue || "solid");
2032
2086
  onRemove();
2033
2087
  };
2034
2088
 
@@ -2149,7 +2203,12 @@ function parseShadowLayer(layer: string, index: number): ShadowLayer {
2149
2203
  const tokens = splitCssTokens(layer);
2150
2204
  const inset = tokens.includes("inset");
2151
2205
  const colorToken =
2152
- tokens.find((token) => parseCssColor(token) || token === "transparent") ||
2206
+ tokens.find((token) => parseCssColor(token) || token === "transparent") ??
2207
+ // Preserve a color we don't parse into RGBA (currentColor, var(--x), or any
2208
+ // unrecognized keyword): the color is the non-inset token that doesn't look
2209
+ // like a numeric length. Without this, tweaking x/y/blur would reset it to
2210
+ // the hardcoded default below.
2211
+ tokens.find((token) => token !== "inset" && !/^[-+]?[\d.]/.test(token)) ??
2153
2212
  "rgba(0, 0, 0, 0.25)";
2154
2213
  const numericTokens = tokens
2155
2214
  .filter((token) => token !== "inset" && token !== colorToken)
@@ -3408,6 +3467,12 @@ function FillProperties({
3408
3467
  const [hiddenFillStash, setHiddenFillStash] = useState<
3409
3468
  Record<string, string>
3410
3469
  >({});
3470
+ // Same non-destructive idea for background gradient/image layers: stash the
3471
+ // exact layer string on hide so per-stop opacity survives a hide→show toggle
3472
+ // instead of being flattened to all-0 then all-100.
3473
+ const [hiddenLayerStash, setHiddenLayerStash] = useState<
3474
+ Record<string, string>
3475
+ >({});
3411
3476
  const stashKey = `${elementIdentityKey(element)}:${fillProperty}`;
3412
3477
  const isHidden = fillValue === "transparent" || fillValue === "";
3413
3478
  const handleFillVisibilityToggle = () => {
@@ -3664,16 +3729,50 @@ function FillProperties({
3664
3729
  }
3665
3730
  onClick={() => {
3666
3731
  if (!gradient) return;
3667
- replaceLayer(
3668
- buildGradientLayer(
3669
- gradient.type,
3670
- gradient.stops.map((stop) => ({
3671
- ...stop,
3672
- opacity: opacity <= 0 ? 100 : 0,
3673
- })),
3674
- gradient.prefix,
3675
- ),
3676
- );
3732
+ const layerStashKey = `${elementIdentityKey(
3733
+ element,
3734
+ )}:layer:${index}`;
3735
+ if (opacity <= 0) {
3736
+ // Show: restore the exact pre-hide layer if stashed,
3737
+ // otherwise fall back to forcing every stop opaque.
3738
+ const stashed = hiddenLayerStash[layerStashKey];
3739
+ if (stashed !== undefined) {
3740
+ replaceLayer(stashed);
3741
+ setHiddenLayerStash((prev) => {
3742
+ const next = { ...prev };
3743
+ delete next[layerStashKey];
3744
+ return next;
3745
+ });
3746
+ } else {
3747
+ replaceLayer(
3748
+ buildGradientLayer(
3749
+ gradient.type,
3750
+ gradient.stops.map((stop) => ({
3751
+ ...stop,
3752
+ opacity: 100,
3753
+ })),
3754
+ gradient.prefix,
3755
+ ),
3756
+ );
3757
+ }
3758
+ } else {
3759
+ // Hide: stash the current layer (with its real per-stop
3760
+ // opacities) before zeroing every stop's alpha.
3761
+ setHiddenLayerStash((prev) => ({
3762
+ ...prev,
3763
+ [layerStashKey]: layer,
3764
+ }));
3765
+ replaceLayer(
3766
+ buildGradientLayer(
3767
+ gradient.type,
3768
+ gradient.stops.map((stop) => ({
3769
+ ...stop,
3770
+ opacity: 0,
3771
+ })),
3772
+ gradient.prefix,
3773
+ ),
3774
+ );
3775
+ }
3677
3776
  }}
3678
3777
  >
3679
3778
  {opacity <= 0 ? (
@@ -161,8 +161,24 @@ interface FlatLayerRow {
161
161
  depth: number;
162
162
  ancestorIds: string[];
163
163
  hasChildren: boolean;
164
+ canAcceptChildren: boolean;
164
165
  }
165
166
 
167
+ // Node types that can contain children even when currently empty.
168
+ // Leaf / void types (text, image, shape, rectangle) are excluded so we don't
169
+ // offer an "inside" drop zone on genuinely non-container elements.
170
+ const CONTAINER_TYPES = new Set<LayersPanelNodeType | undefined>([
171
+ "file",
172
+ "screen",
173
+ "frame",
174
+ "group",
175
+ "section",
176
+ "component",
177
+ "instance",
178
+ "code",
179
+ "element",
180
+ ]);
181
+
166
182
  const SECTION_CODE_ID = "__design_layers_code__";
167
183
  const SECTION_ELEMENT_ID = "__design_layers_elements__";
168
184
 
@@ -170,6 +186,14 @@ const SECTION_ELEMENT_ID = "__design_layers_elements__";
170
186
  // per spec; the source row stores the drag payload here on dragstart instead.
171
187
  let activeDragState: { sourceId: string; draggedIds: string[] } | null = null;
172
188
 
189
+ const ROW_BASE_INDENT = 4;
190
+ const ROW_INDENT_STEP = 28;
191
+ const ROW_MAX_INDENT = 96;
192
+
193
+ function rowIndent(depth: number): number {
194
+ return Math.min(ROW_BASE_INDENT + depth * ROW_INDENT_STEP, ROW_MAX_INDENT);
195
+ }
196
+
173
197
  function defaultLabels(t: ReturnType<typeof useT>): LayersPanelLabels {
174
198
  return {
175
199
  title: t("layersPanel.title"),
@@ -307,8 +331,16 @@ function flattenRows(
307
331
  nodes.forEach((node, index) => {
308
332
  const children = node.children ?? [];
309
333
  const hasChildren = children.length > 0;
334
+ const canAcceptChildren = CONTAINER_TYPES.has(node.type);
310
335
  const rowKey = `${parentKey}/${node.id}:${index}`;
311
- rows.push({ node, rowKey, depth, ancestorIds, hasChildren });
336
+ rows.push({
337
+ node,
338
+ rowKey,
339
+ depth,
340
+ ancestorIds,
341
+ hasChildren,
342
+ canAcceptChildren,
343
+ });
312
344
  if (hasChildren && (forceExpanded || expandedIds.has(node.id))) {
313
345
  flattenRows(
314
346
  children,
@@ -487,9 +519,20 @@ export function LayersPanel({
487
519
  ) => {
488
520
  let nextIds: string[];
489
521
  if (options.range && lastSelectionAnchorRef.current) {
490
- const from = selectableVisibleIds.indexOf(
491
- lastSelectionAnchorRef.current,
492
- );
522
+ let anchor = lastSelectionAnchorRef.current;
523
+ if (selectableVisibleIds.indexOf(anchor) < 0) {
524
+ // Stale anchor (deleted / filtered / collapsed out of view): pivot from
525
+ // the last selected layer that is still visible & selectable, matching
526
+ // Figma's behavior instead of dropping the range to a single select.
527
+ const fallback = [...selectedIds]
528
+ .reverse()
529
+ .find((sid) => selectableVisibleIds.includes(sid));
530
+ if (fallback) {
531
+ anchor = fallback;
532
+ lastSelectionAnchorRef.current = fallback;
533
+ }
534
+ }
535
+ const from = selectableVisibleIds.indexOf(anchor);
493
536
  const to = selectableVisibleIds.indexOf(id);
494
537
  if (from >= 0 && to >= 0) {
495
538
  const [start, end] = from < to ? [from, to] : [to, from];
@@ -812,7 +855,7 @@ function LayerRow({
812
855
  selectedIds: readonly string[];
813
856
  visibleRows: FlatLayerRow[];
814
857
  }) {
815
- const { node, depth, hasChildren } = row;
858
+ const { node, depth, hasChildren, canAcceptChildren } = row;
816
859
  const selectable = node.selectable !== false;
817
860
  const lockable = node.lockable !== false && Boolean(onToggleLocked);
818
861
  const hideable = node.hideable !== false && Boolean(onToggleHidden);
@@ -959,7 +1002,7 @@ function LayerRow({
959
1002
  const intent = {
960
1003
  draggedIds: cleanedIds,
961
1004
  targetId: node.id,
962
- placement: dropPlacementForEvent(event, hasChildren),
1005
+ placement: dropPlacementForEvent(event, canAcceptChildren),
963
1006
  } satisfies LayersPanelMoveIntent;
964
1007
  if (canMoveLayer && !canMoveLayer(intent)) return;
965
1008
  event.preventDefault();
@@ -997,7 +1040,7 @@ function LayerRow({
997
1040
  const intent = {
998
1041
  draggedIds: cleanedIds,
999
1042
  targetId: node.id,
1000
- placement: dropPlacementForEvent(event, hasChildren),
1043
+ placement: dropPlacementForEvent(event, canAcceptChildren),
1001
1044
  } satisfies LayersPanelMoveIntent;
1002
1045
  if (!canMoveLayer || canMoveLayer(intent)) {
1003
1046
  onMoveLayer(intent);
@@ -1058,7 +1101,7 @@ function LayerRow({
1058
1101
  "text-foreground/90 hover:bg-[var(--design-editor-layer-hover-color)] hover:text-foreground",
1059
1102
  node.hidden && "text-muted-foreground",
1060
1103
  )}
1061
- style={{ paddingLeft: 4 + depth * 28 }}
1104
+ style={{ paddingLeft: rowIndent(depth) }}
1062
1105
  >
1063
1106
  {hasChildren ? (
1064
1107
  <Button