@agent-native/core 0.85.6 → 0.85.7

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 (80) 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/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  IconSearch,
13
13
  } from "@tabler/icons-react";
14
14
  import {
15
+ memo,
15
16
  useCallback,
16
17
  useEffect,
17
18
  useLayoutEffect,
@@ -22,6 +23,7 @@ import {
22
23
  type DragEvent,
23
24
  type MouseEvent,
24
25
  type ReactNode,
26
+ type RefObject,
25
27
  } from "react";
26
28
 
27
29
  import { Button } from "@/components/ui/button";
@@ -40,9 +42,6 @@ import {
40
42
  } from "@/components/ui/tooltip";
41
43
  import { cn } from "@/lib/utils";
42
44
 
43
- const LAYER_ROW_BASE_INDENT = 4;
44
- const LAYER_ROW_DEPTH_INDENT = 18;
45
-
46
45
  export type LayersPanelNodeType =
47
46
  | "file"
48
47
  | "screen"
@@ -215,10 +214,13 @@ let activeDropIntent: LayersPanelMoveIntent | null = null;
215
214
 
216
215
  const ROW_BASE_INDENT = 4;
217
216
  const ROW_INDENT_STEP = 28;
218
- const ROW_MAX_INDENT = 96;
219
217
 
218
+ // No indent cap: deeply nested trees (Figma-style component instances easily
219
+ // exceed 4 levels) must stay visually distinguishable by depth. A previous
220
+ // 96px cap made every row at depth >= 4 render at the same indent, making
221
+ // nested structure ambiguous in the panel.
220
222
  function rowIndent(depth: number): number {
221
- return Math.min(ROW_BASE_INDENT + depth * ROW_INDENT_STEP, ROW_MAX_INDENT);
223
+ return ROW_BASE_INDENT + depth * ROW_INDENT_STEP;
222
224
  }
223
225
 
224
226
  function defaultLabels(t: ReturnType<typeof useT>): LayersPanelLabels {
@@ -348,7 +350,24 @@ function filterNode(
348
350
  return null;
349
351
  }
350
352
 
351
- function flattenRows(
353
+ // ORDER CONVENTION (L5): the panel's top row within a sibling group is the
354
+ // topmost-RENDERED layer, matching Figma. LayersPanelNode.children arrives in
355
+ // DOM order (first array element = first DOM child = bottom of the paint
356
+ // stack for overlapping siblings; last DOM child = topmost paint). So the
357
+ // panel must display each sibling group in REVERSE DOM order. This is the
358
+ // single place that convention is applied — everything else (drop-placement
359
+ // mapping in dropPlacementForEvent/handleDrop callers, and the CL:2769
360
+ // moveNode "inside" insertion point) is written to agree with it:
361
+ // - drop "above row X" (before X in the reversed panel list) => DOM order
362
+ // "after" X (closer to the paint-top), i.e. inserted after X in the DOM.
363
+ // - drop "below row X" (after X in the reversed panel list) => DOM order
364
+ // "before" X, i.e. inserted before X in the DOM.
365
+ // - "inside" a container drops at the END of the panel's child list, i.e.
366
+ // the FIRST DOM child position (contentStart), so the dropped node
367
+ // becomes the bottom-most-painted / top-of-panel-list child. See
368
+ // mapPanelPlacementToDomPlacement below and its use at the LP/DE drop
369
+ // boundary.
370
+ export function flattenRows(
352
371
  nodes: LayersPanelNode[],
353
372
  expandedIds: ReadonlySet<string>,
354
373
  forceExpanded: boolean,
@@ -357,7 +376,8 @@ function flattenRows(
357
376
  ancestorIds: string[] = [],
358
377
  rows: FlatLayerRow[] = [],
359
378
  ) {
360
- nodes.forEach((node, index) => {
379
+ const displayOrder = [...nodes].reverse();
380
+ displayOrder.forEach((node, index) => {
361
381
  const children = node.children ?? [];
362
382
  const hasChildren = children.length > 0;
363
383
  const canAcceptChildren = CONTAINER_TYPES.has(node.type);
@@ -385,6 +405,33 @@ function flattenRows(
385
405
  return rows;
386
406
  }
387
407
 
408
+ /**
409
+ * Maps a panel-order drop placement (computed from where the user dropped
410
+ * relative to a row's position in the reversed, top-row-is-topmost panel
411
+ * list) to the DOM-order placement the underlying moveNode/applyMoveNodeEdit
412
+ * primitive expects (see CL applyMoveNodeEdit: "before" = anchor.start,
413
+ * "after" = anchor.end, "inside" = anchor.contentEnd i.e. last DOM child).
414
+ *
415
+ * Because the panel displays each sibling group in reverse DOM order:
416
+ * - "before" in the panel (drop above row X, i.e. towards the top/topmost)
417
+ * means the moved node should render ABOVE X, i.e. paint AFTER X in the
418
+ * DOM => DOM placement "after".
419
+ * - "after" in the panel (drop below row X, towards the bottom/backmost)
420
+ * means the moved node should render BELOW X, i.e. paint BEFORE X in the
421
+ * DOM => DOM placement "before".
422
+ * - "inside" is unchanged in kind, but the DOM primitive already inserts at
423
+ * contentEnd (last DOM child), which is exactly the panel's "top of this
424
+ * group's list" — i.e. inside-drops naturally land at the top of the
425
+ * panel's child list with no further mapping needed.
426
+ */
427
+ export function mapPanelPlacementToDomPlacement(
428
+ placement: LayersPanelMoveIntent["placement"],
429
+ ): LayersPanelMoveIntent["placement"] {
430
+ if (placement === "before") return "after";
431
+ if (placement === "after") return "before";
432
+ return "inside";
433
+ }
434
+
388
435
  function nextExpandedIds(
389
436
  ids: readonly string[],
390
437
  nodeId: string,
@@ -399,6 +446,34 @@ function nextExpandedIds(
399
446
  return Array.from(next);
400
447
  }
401
448
 
449
+ /**
450
+ * L1: pure computation for the auto-expand-ancestors-of-selection effect.
451
+ * Given the current selection's ancestor ids and the CURRENT expanded set,
452
+ * returns the next expanded id list with any missing ancestors added, or
453
+ * null if nothing needs to change. Extracted as a pure function (mirroring
454
+ * shouldResyncLayerSelectionAnchor) so the auto-expand decision is testable
455
+ * without mounting the component. The caller is responsible for only
456
+ * invoking this once per NEW selection signature — see the
457
+ * lastAutoExpandedSelectionRef gate in the effect below, which is what
458
+ * actually fixes the collapse-bounces-back-instantly bug (this function
459
+ * itself is a straightforward set-union and isn't where that bug lived).
460
+ */
461
+ export function nextAutoExpandedIds(args: {
462
+ selectedAncestorIds: readonly string[];
463
+ expandedIds: readonly string[];
464
+ }): string[] | null {
465
+ if (args.selectedAncestorIds.length === 0) return null;
466
+ const next = new Set(args.expandedIds);
467
+ let changed = false;
468
+ args.selectedAncestorIds.forEach((id) => {
469
+ if (!next.has(id)) {
470
+ next.add(id);
471
+ changed = true;
472
+ }
473
+ });
474
+ return changed ? Array.from(next) : null;
475
+ }
476
+
402
477
  function collectAncestorIds(
403
478
  nodes: LayersPanelNode[],
404
479
  targetIds: ReadonlySet<string>,
@@ -424,6 +499,26 @@ function collectAncestorIds(
424
499
  return Array.from(ancestors);
425
500
  }
426
501
 
502
+ // Full-tree ancestor map (id -> ancestor id chain from root), independent of
503
+ // expand/collapse or search-filter state. Used at drag start to correctly
504
+ // identify selected descendants even when their row is currently not
505
+ // rendered in visibleRows (e.g. inside a collapsed ancestor) — see
506
+ // getDraggedLayerIdsForRows below.
507
+ export function buildAncestorIdMap(
508
+ nodes: LayersPanelNode[],
509
+ ): Map<string, string[]> {
510
+ const map = new Map<string, string[]>();
511
+
512
+ function visit(node: LayersPanelNode, ancestorIds: string[]) {
513
+ map.set(node.id, ancestorIds);
514
+ const children = node.children ?? [];
515
+ children.forEach((child) => visit(child, [...ancestorIds, node.id]));
516
+ }
517
+
518
+ nodes.forEach((node) => visit(node, []));
519
+ return map;
520
+ }
521
+
427
522
  export function getLayerSelectionAnchorFromExternalSelection(args: {
428
523
  selectedIds: readonly string[];
429
524
  selectableVisibleIds: readonly string[];
@@ -451,20 +546,48 @@ export function getTreeOrderedLayerIds(
451
546
  ];
452
547
  }
453
548
 
549
+ // Shift-range selection is a straight slice through the flattened visible
550
+ // rows, so when the range spans an expanded parent AND some of its children,
551
+ // both end up selected. Figma normalizes this away: selecting an ancestor
552
+ // already implies its descendants for move/visual purposes, so a descendant
553
+ // whose ancestor is also in the resulting set should be dropped from the
554
+ // selection (the ancestor "wins"). Order-preserving.
555
+ export function dropDescendantsOfSelectedAncestors(
556
+ ids: readonly string[],
557
+ visibleRows: readonly FlatLayerRow[],
558
+ ): string[] {
559
+ const idSet = new Set(ids);
560
+ const ancestorIdsById = new Map(
561
+ visibleRows.map((row) => [row.node.id, row.ancestorIds] as const),
562
+ );
563
+ return ids.filter((id) => {
564
+ const ancestorIds = ancestorIdsById.get(id);
565
+ return !ancestorIds?.some((ancestorId) => idSet.has(ancestorId));
566
+ });
567
+ }
568
+
454
569
  export function getDraggedLayerIdsForRows(args: {
455
570
  selectedIds: readonly string[];
456
571
  nodeId: string;
457
572
  visibleRows: readonly FlatLayerRow[];
573
+ // Full-tree ancestor map (see buildAncestorIdMap), independent of
574
+ // expand/collapse or search-filter state. Required to correctly drop
575
+ // selected descendants whose row is currently not in visibleRows (e.g.
576
+ // inside a collapsed dragged parent) — falling back to visibleRows-only
577
+ // ancestor lookup would treat those as separate top-level drags and
578
+ // extract them from the parent being dragged. Optional only for
579
+ // call-site/back-compat convenience; always pass it in the real panel.
580
+ ancestorIdMap?: ReadonlyMap<string, string[]>;
458
581
  }): string[] {
459
582
  const rawDraggedIds = args.selectedIds.includes(args.nodeId)
460
583
  ? getTreeOrderedLayerIds(args.selectedIds, args.visibleRows)
461
584
  : [args.nodeId];
462
585
  const draggedIdSet = new Set(rawDraggedIds);
463
586
  return rawDraggedIds.filter((id) => {
464
- const rowForId = args.visibleRows.find((row) => row.node.id === id);
465
- return !rowForId?.ancestorIds.some((ancestorId) =>
466
- draggedIdSet.has(ancestorId),
467
- );
587
+ const ancestorIds =
588
+ args.ancestorIdMap?.get(id) ??
589
+ args.visibleRows.find((row) => row.node.id === id)?.ancestorIds;
590
+ return !ancestorIds?.some((ancestorId) => draggedIdSet.has(ancestorId));
468
591
  });
469
592
  }
470
593
 
@@ -491,7 +614,15 @@ function layerCanShowBadge(node: LayersPanelNode) {
491
614
  );
492
615
  }
493
616
 
494
- export function LayersPanel({
617
+ // PF8: DesignEditor re-renders on many state changes unrelated to the layers
618
+ // tree (drag gestures, zoom, canvas hover, etc). All of LayersPanel's call-site
619
+ // props are already stabilized (useMemo/useCallback/plain state — see
620
+ // DesignEditor.tsx's layerPanelFiles/overviewLayerPanelFiles/
621
+ // activeLayerPanelNodes/boardElements and the onXxx handlers passed to
622
+ // <LayersPanel>), so a default shallow-prop comparator is sufficient here;
623
+ // no custom comparator is needed or wanted since it would risk silently
624
+ // ignoring a genuinely-changed prop.
625
+ export const LayersPanel = memo(function LayersPanel({
495
626
  screens,
496
627
  activeScreenId,
497
628
  screenOverviewActive = false,
@@ -524,11 +655,35 @@ export function LayersPanel({
524
655
  const labels = useMemo(() => mergeLabels(labelsProp, t), [labelsProp, t]);
525
656
  const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
526
657
  const selectedIdsRef = useRef<readonly string[]>(selectedIds);
658
+ // Rows need the full visible-row order for keyboard navigation and
659
+ // multi-drag payload ordering, but that's whole-tree state, not a per-row
660
+ // primitive. Route it through a stable ref instead of a prop so passing it
661
+ // to LayerRow doesn't defeat React.memo (the ref object identity never
662
+ // changes; only .current does).
663
+ const visibleRowsRef = useRef<FlatLayerRow[]>([]);
664
+ // Full (unfiltered-by-expand/collapse) root nodes, threaded the same way so
665
+ // drag start can build a full-tree ancestor map (see buildAncestorIdMap)
666
+ // without adding a per-row array prop that would defeat React.memo.
667
+ const rootsRef = useRef<LayersPanelNode[]>([]);
668
+ // Same idea for expandedIds: onToggleExpanded needs the current expanded
669
+ // set to compute the next one, but reading it from a ref lets the
670
+ // per-row callback stay referentially stable across renders.
671
+ const expandedIdsRef = useRef<readonly string[]>(expandedIds);
672
+ expandedIdsRef.current = expandedIds;
527
673
  const lastPanelSelectionSignatureRef = useRef(selectedIds.join("\0"));
528
674
  const expandedIdSet = useMemo(() => new Set(expandedIds), [expandedIds]);
529
675
  const lastSelectionAnchorRef = useRef<string | null>(selectedIds[0] ?? null);
530
676
  const searchInputRef = useRef<HTMLInputElement>(null);
531
677
  const rowElementRefs = useRef(new Map<string, HTMLDivElement>());
678
+ // L20: edge auto-scroll during a row drag. scrollContainerRef is the
679
+ // scrollable rows list; autoScrollFrameRef holds the active rAF handle (or
680
+ // null when idle); autoScrollDirectionRef holds the current scroll
681
+ // direction/speed so the rAF loop keeps scrolling smoothly across frames
682
+ // without needing dragover to fire every frame (dragover cadence is
683
+ // browser-throttled and not reliable enough on its own for smooth scroll).
684
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
685
+ const autoScrollFrameRef = useRef<number | null>(null);
686
+ const autoScrollSpeedRef = useRef(0);
532
687
  const [renamingId, setRenamingId] = useState<string | null>(null);
533
688
  const [renameDraft, setRenameDraft] = useState("");
534
689
  const renameOriginalNameRef = useRef<string>("");
@@ -570,6 +725,13 @@ export function LayersPanel({
570
725
  [visibleRows],
571
726
  );
572
727
 
728
+ // Keep the row-facing refs current every render. This runs during render
729
+ // (not an effect) so event handlers created during this same commit already
730
+ // see the latest arrays; it never triggers a re-render itself since only
731
+ // `.current` is written.
732
+ visibleRowsRef.current = visibleRows;
733
+ rootsRef.current = roots;
734
+
573
735
  useLayoutEffect(() => {
574
736
  selectedIdsRef.current = selectedIds;
575
737
  const signature = selectedIds.join("\0");
@@ -591,18 +753,28 @@ export function LayersPanel({
591
753
  });
592
754
  }, [selectableVisibleIds, selectedIds]);
593
755
 
756
+ // Auto-expand ancestors of the current selection. This must run only when
757
+ // the SELECTION changes (a new selection signature), not whenever
758
+ // expandedIds changes — otherwise collapsing an ancestor of the selected
759
+ // layer (which changes expandedIds but not the selection) would
760
+ // immediately re-expand it, since selectedAncestorIds still contains it.
761
+ // Track the selection signature we last auto-expanded for in a ref so the
762
+ // effect can bail out on every render triggered purely by a collapse.
763
+ const lastAutoExpandedSelectionRef = useRef<string | null>(null);
594
764
  useEffect(() => {
595
- if (selectedAncestorIds.length === 0) return;
596
- const next = new Set(expandedIds);
597
- let changed = false;
598
- selectedAncestorIds.forEach((id) => {
599
- if (!next.has(id)) {
600
- next.add(id);
601
- changed = true;
602
- }
765
+ const signature = selectedIds.join("\0");
766
+ if (lastAutoExpandedSelectionRef.current === signature) return;
767
+ lastAutoExpandedSelectionRef.current = signature;
768
+ const nextExpanded = nextAutoExpandedIds({
769
+ selectedAncestorIds,
770
+ expandedIds: expandedIdsRef.current,
603
771
  });
604
- if (changed) onExpandedIdsChange(Array.from(next));
605
- }, [expandedIds, onExpandedIdsChange, selectedAncestorIds]);
772
+ if (nextExpanded) onExpandedIdsChange(nextExpanded);
773
+ // Intentionally NOT depending on expandedIds: this effect must only react
774
+ // to selection changes (selectedIds / selectedAncestorIds), and reads the
775
+ // current expanded set from expandedIdsRef so a collapse doesn't retrigger it.
776
+ // eslint-disable-next-line react-hooks/exhaustive-deps
777
+ }, [onExpandedIdsChange, selectedAncestorIds, selectedIds]);
606
778
 
607
779
  const selectedScrollId = selectedIds[selectedIds.length - 1] ?? null;
608
780
  const selectedScrollRowKey = useMemo(() => {
@@ -656,9 +828,17 @@ export function LayersPanel({
656
828
  if (from >= 0 && to >= 0) {
657
829
  const [start, end] = from < to ? [from, to] : [to, from];
658
830
  const rangeIds = selectableVisibleIds.slice(start, end + 1);
659
- nextIds = options.additive
831
+ const merged = options.additive
660
832
  ? Array.from(new Set([...currentSelectedIds, ...rangeIds]))
661
833
  : rangeIds;
834
+ // A range that spans an expanded parent and some of its children
835
+ // would otherwise co-select both. Normalize so a selected
836
+ // descendant whose ancestor is also selected gets dropped — the
837
+ // ancestor selection already implies it.
838
+ nextIds = dropDescendantsOfSelectedAncestors(
839
+ merged,
840
+ visibleRowsRef.current,
841
+ );
662
842
  } else {
663
843
  nextIds = [id];
664
844
  }
@@ -718,6 +898,24 @@ export function LayersPanel({
718
898
  [],
719
899
  );
720
900
 
901
+ // Id-first, stable callbacks for LayerRow. Each reads current
902
+ // expandedIds/onExpandedIdsChange/onRename from refs/closure-captured
903
+ // props at call time rather than recreating a fresh per-row closure every
904
+ // render — this keeps LayerRow's props referentially stable so
905
+ // React.memo(LayerRow) actually skips re-renders.
906
+ const handleToggleExpanded = useCallback(
907
+ (id: string, expanded: boolean) => {
908
+ onExpandedIdsChange(
909
+ nextExpandedIds(expandedIdsRef.current, id, expanded),
910
+ );
911
+ },
912
+ [onExpandedIdsChange],
913
+ );
914
+
915
+ const handleCancelRename = useCallback(() => {
916
+ setRenamingId(null);
917
+ }, []);
918
+
721
919
  const hasAnyRows = roots.length > 0;
722
920
  const screenRows = screens ?? files ?? [];
723
921
  const openSearch = useCallback(() => {
@@ -745,6 +943,65 @@ export function LayersPanel({
745
943
  );
746
944
  }, [collapseTargetId, expandedIds, onExpandedIdsChange]);
747
945
 
946
+ // L20: auto-scroll the rows list while dragging near the top/bottom edge.
947
+ // Runs a rAF loop so the scroll speed stays smooth and independent of the
948
+ // browser's dragover event cadence.
949
+ const AUTO_SCROLL_EDGE_PX = 40;
950
+ const AUTO_SCROLL_MAX_SPEED_PX = 14;
951
+
952
+ const stopAutoScroll = useCallback(() => {
953
+ if (autoScrollFrameRef.current !== null) {
954
+ window.cancelAnimationFrame(autoScrollFrameRef.current);
955
+ autoScrollFrameRef.current = null;
956
+ }
957
+ autoScrollSpeedRef.current = 0;
958
+ }, []);
959
+
960
+ const runAutoScrollFrame = useCallback(() => {
961
+ const container = scrollContainerRef.current;
962
+ const speed = autoScrollSpeedRef.current;
963
+ if (!container || speed === 0) {
964
+ autoScrollFrameRef.current = null;
965
+ return;
966
+ }
967
+ container.scrollTop += speed;
968
+ autoScrollFrameRef.current =
969
+ window.requestAnimationFrame(runAutoScrollFrame);
970
+ }, []);
971
+
972
+ const handleRowsDragOver = useCallback(
973
+ (event: DragEvent<HTMLDivElement>) => {
974
+ if (!activeDragState) {
975
+ stopAutoScroll();
976
+ return;
977
+ }
978
+ const container = scrollContainerRef.current;
979
+ if (!container) return;
980
+ const rect = container.getBoundingClientRect();
981
+ const offsetFromTop = event.clientY - rect.top;
982
+ const offsetFromBottom = rect.bottom - event.clientY;
983
+ let speed = 0;
984
+ if (offsetFromTop < AUTO_SCROLL_EDGE_PX) {
985
+ const intensity = 1 - Math.max(0, offsetFromTop) / AUTO_SCROLL_EDGE_PX;
986
+ speed = -Math.ceil(intensity * AUTO_SCROLL_MAX_SPEED_PX);
987
+ } else if (offsetFromBottom < AUTO_SCROLL_EDGE_PX) {
988
+ const intensity =
989
+ 1 - Math.max(0, offsetFromBottom) / AUTO_SCROLL_EDGE_PX;
990
+ speed = Math.ceil(intensity * AUTO_SCROLL_MAX_SPEED_PX);
991
+ }
992
+ autoScrollSpeedRef.current = speed;
993
+ if (speed !== 0 && autoScrollFrameRef.current === null) {
994
+ autoScrollFrameRef.current =
995
+ window.requestAnimationFrame(runAutoScrollFrame);
996
+ } else if (speed === 0) {
997
+ stopAutoScroll();
998
+ }
999
+ },
1000
+ [runAutoScrollFrame, stopAutoScroll],
1001
+ );
1002
+
1003
+ useEffect(() => stopAutoScroll, [stopAutoScroll]);
1004
+
748
1005
  return (
749
1006
  <aside
750
1007
  className={cn(
@@ -866,55 +1123,71 @@ export function LayersPanel({
866
1123
  </div>
867
1124
  ) : null}
868
1125
 
869
- <div className="min-h-0 flex-1 overflow-auto overscroll-contain py-2">
1126
+ <div
1127
+ ref={scrollContainerRef}
1128
+ className="min-h-0 flex-1 overflow-auto overscroll-contain py-2"
1129
+ onDragOver={handleRowsDragOver}
1130
+ onDrop={stopAutoScroll}
1131
+ onDragEnd={stopAutoScroll}
1132
+ >
870
1133
  {visibleRows.length ? (
871
1134
  <div
872
1135
  className="w-max min-w-full px-2"
873
1136
  role="tree"
874
1137
  aria-label={labels.title}
875
1138
  >
876
- {visibleRows.map((row) => (
877
- <LayerRow
878
- key={row.rowKey}
879
- row={row}
880
- labels={labels}
881
- isExpanded={expandedIdSet.has(row.node.id)}
882
- isSelected={selectedIdSet.has(row.node.id)}
883
- isInSelectedSubtree={row.ancestorIds.some((id) =>
884
- selectedIdSet.has(id),
885
- )}
886
- isActiveScreen={
887
- row.node.id === activeScreenId &&
888
- (row.node.type === "file" ||
889
- row.node.type === "screen" ||
890
- row.node.type === "frame")
891
- }
892
- isRenaming={renamingId === row.node.id}
893
- rowRef={(element) => registerRowElement(row.rowKey, element)}
894
- renameDraft={renameDraft}
895
- onRenameDraftChange={setRenameDraft}
896
- onCommitRename={commitRename}
897
- onCancelRename={() => setRenamingId(null)}
898
- onStartRename={startRename}
899
- onRename={onRename}
900
- onSelect={selectNode}
901
- onToggleExpanded={(expanded) =>
902
- onExpandedIdsChange(
903
- nextExpandedIds(expandedIds, row.node.id, expanded),
904
- )
905
- }
906
- onToggleLocked={onToggleLocked}
907
- onToggleHidden={onToggleHidden}
908
- onHoverLayer={onHoverLayer}
909
- onLeaveLayer={onLeaveLayer}
910
- onMoveLayer={onMoveLayer}
911
- canMoveLayer={canMoveLayer}
912
- dropIndicator={dropIndicator}
913
- onDropIndicatorChange={setDropIndicator}
914
- selectedIds={selectedIds}
915
- visibleRows={visibleRows}
916
- />
917
- ))}
1139
+ {visibleRows.map((row) => {
1140
+ // Per-row primitives computed here (not inside LayerRow) so the
1141
+ // row only receives booleans/strings it needs — no whole-tree
1142
+ // arrays that would force a re-render every time any other
1143
+ // row's selection state changes.
1144
+ const isSelected = selectedIdSet.has(row.node.id);
1145
+ const isInSelectedSubtree = row.ancestorIds.some((id) =>
1146
+ selectedIdSet.has(id),
1147
+ );
1148
+ const isActiveScreen =
1149
+ row.node.id === activeScreenId &&
1150
+ (row.node.type === "file" ||
1151
+ row.node.type === "screen" ||
1152
+ row.node.type === "frame");
1153
+ const isRenaming = renamingId === row.node.id;
1154
+ const activeDropPlacement =
1155
+ dropIndicator?.targetId === row.node.id
1156
+ ? dropIndicator.placement
1157
+ : null;
1158
+ return (
1159
+ <LayerRow
1160
+ key={row.rowKey}
1161
+ row={row}
1162
+ labels={labels}
1163
+ isExpanded={expandedIdSet.has(row.node.id)}
1164
+ isSelected={isSelected}
1165
+ isInSelectedSubtree={isInSelectedSubtree}
1166
+ isActiveScreen={isActiveScreen}
1167
+ isRenaming={isRenaming}
1168
+ renameDraft={isRenaming ? renameDraft : ""}
1169
+ registerRowElement={registerRowElement}
1170
+ onRenameDraftChange={setRenameDraft}
1171
+ onCommitRename={commitRename}
1172
+ onCancelRename={handleCancelRename}
1173
+ onStartRename={startRename}
1174
+ onRename={onRename}
1175
+ onSelect={selectNode}
1176
+ onToggleExpanded={handleToggleExpanded}
1177
+ onToggleLocked={onToggleLocked}
1178
+ onToggleHidden={onToggleHidden}
1179
+ onHoverLayer={onHoverLayer}
1180
+ onLeaveLayer={onLeaveLayer}
1181
+ onMoveLayer={onMoveLayer}
1182
+ canMoveLayer={canMoveLayer}
1183
+ activeDropPlacement={activeDropPlacement}
1184
+ onDropIndicatorChange={setDropIndicator}
1185
+ selectedIdsRef={selectedIdsRef}
1186
+ visibleRowsRef={visibleRowsRef}
1187
+ rootsRef={rootsRef}
1188
+ />
1189
+ );
1190
+ })}
918
1191
  </div>
919
1192
  ) : (
920
1193
  <div className="px-3 py-8 text-center !text-[11px] text-muted-foreground">
@@ -925,36 +1198,9 @@ export function LayersPanel({
925
1198
  {footer ? <div className="shrink-0">{footer}</div> : null}
926
1199
  </aside>
927
1200
  );
928
- }
1201
+ });
929
1202
 
930
- function LayerRow({
931
- row,
932
- labels,
933
- isExpanded,
934
- isSelected,
935
- isInSelectedSubtree,
936
- isActiveScreen,
937
- isRenaming,
938
- rowRef,
939
- renameDraft,
940
- onRenameDraftChange,
941
- onCommitRename,
942
- onCancelRename,
943
- onStartRename,
944
- onRename,
945
- onSelect,
946
- onToggleExpanded,
947
- onToggleLocked,
948
- onToggleHidden,
949
- onHoverLayer,
950
- onLeaveLayer,
951
- onMoveLayer,
952
- canMoveLayer,
953
- dropIndicator,
954
- onDropIndicatorChange,
955
- selectedIds,
956
- visibleRows,
957
- }: {
1203
+ interface LayerRowProps {
958
1204
  row: FlatLayerRow;
959
1205
  labels: LayersPanelLabels;
960
1206
  isExpanded: boolean;
@@ -962,11 +1208,11 @@ function LayerRow({
962
1208
  isInSelectedSubtree: boolean;
963
1209
  isActiveScreen: boolean;
964
1210
  isRenaming: boolean;
965
- rowRef: (element: HTMLDivElement | null) => void;
1211
+ registerRowElement: (rowKey: string, element: HTMLDivElement | null) => void;
966
1212
  renameDraft: string;
967
1213
  onRenameDraftChange: (value: string) => void;
968
1214
  onCommitRename: (id: string) => void;
969
- onCancelRename: () => void;
1215
+ onCancelRename: (id: string) => void;
970
1216
  onStartRename: (node: LayersPanelNode) => void;
971
1217
  onRename?: (id: string, name: string) => void;
972
1218
  onSelect: (
@@ -978,39 +1224,108 @@ function LayerRow({
978
1224
  source: "keyboard" | "pointer";
979
1225
  },
980
1226
  ) => void;
981
- onToggleExpanded: (expanded: boolean) => void;
1227
+ onToggleExpanded: (id: string, expanded: boolean) => void;
982
1228
  onToggleLocked?: (id: string, locked: boolean) => void;
983
1229
  onToggleHidden?: (id: string, hidden: boolean) => void;
984
1230
  onHoverLayer?: (id: string) => void;
985
1231
  onLeaveLayer?: (id: string) => void;
986
1232
  onMoveLayer?: (intent: LayersPanelMoveIntent) => void;
987
1233
  canMoveLayer?: (intent: LayersPanelMoveIntent) => boolean;
988
- dropIndicator: LayersPanelMoveIntent | null;
1234
+ // Only this row's own drop-indicator placement ("before" | "after" |
1235
+ // "inside" | null) — not the whole dropIndicator object, so a dragover on
1236
+ // one row doesn't force every other row to re-render.
1237
+ activeDropPlacement: LayersPanelMoveIntent["placement"] | null;
989
1238
  onDropIndicatorChange: (intent: LayersPanelMoveIntent | null) => void;
990
- selectedIds: readonly string[];
991
- visibleRows: FlatLayerRow[];
992
- }) {
1239
+ // Whole-tree state needed for keyboard nav / multi-drag ordering, threaded
1240
+ // through stable refs instead of arrays so it never defeats memo — see the
1241
+ // comment where these refs are created in LayersPanel.
1242
+ selectedIdsRef: RefObject<readonly string[]>;
1243
+ visibleRowsRef: RefObject<FlatLayerRow[]>;
1244
+ // Full (unfiltered) root nodes, used only at drag start to build a
1245
+ // full-tree ancestor map — see buildAncestorIdMap and L13 in the drag-start
1246
+ // handler below.
1247
+ rootsRef: RefObject<LayersPanelNode[]>;
1248
+ }
1249
+
1250
+ const LayerRow = memo(function LayerRow({
1251
+ row,
1252
+ labels,
1253
+ isExpanded,
1254
+ isSelected,
1255
+ isInSelectedSubtree,
1256
+ isActiveScreen,
1257
+ isRenaming,
1258
+ registerRowElement,
1259
+ renameDraft,
1260
+ onRenameDraftChange,
1261
+ onCommitRename,
1262
+ onCancelRename,
1263
+ onStartRename,
1264
+ onRename,
1265
+ onSelect,
1266
+ onToggleExpanded,
1267
+ onToggleLocked,
1268
+ onToggleHidden,
1269
+ onHoverLayer,
1270
+ onLeaveLayer,
1271
+ onMoveLayer,
1272
+ canMoveLayer,
1273
+ activeDropPlacement,
1274
+ onDropIndicatorChange,
1275
+ selectedIdsRef,
1276
+ visibleRowsRef,
1277
+ rootsRef,
1278
+ }: LayerRowProps) {
993
1279
  const { node, depth, hasChildren, canAcceptChildren } = row;
994
1280
  const isComponentLayer = layerNodeIsComponent(node);
995
1281
  const selectable = node.selectable !== false;
996
1282
  const lockable = node.lockable !== false && Boolean(onToggleLocked);
997
1283
  const hideable = node.hideable !== false && Boolean(onToggleHidden);
998
- const dragEligible = selectable && !node.locked && !node.hidden;
999
- const draggable = dragEligible && Boolean(onMoveLayer);
1284
+ // L8: being a valid DRAG SOURCE and being a valid DROP ANCHOR (before/
1285
+ // after/inside target) are different concerns and must not share one gate.
1286
+ // - dragSourceEligible: only "locked" should block picking this row up to
1287
+ // drag it — locked means "don't let me move", not "don't let me be
1288
+ // referenced". Hidden no longer blocks dragging a row: visibility is
1289
+ // orthogonal to whether the layer can be reordered.
1290
+ // - anchorEligible: locked/hidden rows must still be usable as before/
1291
+ // after/inside drop targets so the user can position new layers next to
1292
+ // or inside a locked/hidden one without having to unlock/show it first.
1293
+ // (The corresponding DE:15181 canMoveLayer gate is fixed separately by
1294
+ // the DesignEditor owner; this only removes LP's OWN early-return that
1295
+ // would otherwise block reaching canMoveLayer at all.)
1296
+ const dragSourceEligible = selectable && !node.locked;
1297
+ const anchorEligible = selectable;
1298
+ const draggable = dragSourceEligible && Boolean(onMoveLayer);
1000
1299
  const canDropInside = layerCanDropInside(
1001
1300
  node,
1002
1301
  hasChildren,
1003
1302
  canAcceptChildren,
1004
1303
  );
1005
- const activeDrop =
1006
- dropIndicator?.targetId === node.id ? dropIndicator.placement : null;
1304
+ // L10: whether this row's bottom hover zone should resolve to "inside"
1305
+ // instead of "after" see dropPlacementForEvent.
1306
+ const isExpandedWithChildren = hasChildren && isExpanded;
1307
+ const activeDrop = activeDropPlacement;
1007
1308
  // Tracks whether the user pressed Escape to cancel rename so that the
1008
1309
  // subsequent blur event does not commit the edit.
1009
1310
  const renameCancelledRef = useRef(false);
1311
+ // L20: spring-loaded expand. Tracks the pending timer id for "hovering
1312
+ // this collapsed container during a drag" so a sustained hover expands it
1313
+ // (Figma-style) without requiring the user to drop and re-drag. Cleared on
1314
+ // drag-leave/drop/dragend and whenever the hover target/placement changes.
1315
+ const springLoadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1316
+ const SPRING_LOAD_DELAY_MS = 600;
1317
+ const clearSpringLoadTimer = () => {
1318
+ if (springLoadTimerRef.current !== null) {
1319
+ clearTimeout(springLoadTimerRef.current);
1320
+ springLoadTimerRef.current = null;
1321
+ }
1322
+ };
1323
+ useEffect(() => clearSpringLoadTimer, []);
1010
1324
 
1011
1325
  const readSelectedIdsFromTree = (target: HTMLElement): string[] => {
1012
1326
  const tree = target.closest('[role="tree"]');
1013
- if (!tree) return selectedIds.filter((id) => !id.startsWith("__"));
1327
+ if (!tree)
1328
+ return selectedIdsRef.current.filter((id) => !id.startsWith("__"));
1014
1329
  return Array.from(
1015
1330
  tree.querySelectorAll<HTMLElement>(
1016
1331
  '[role="treeitem"][aria-selected="true"] [data-layer-row-button][data-layer-node-id]',
@@ -1061,7 +1376,7 @@ function LayerRow({
1061
1376
  target.focus();
1062
1377
  return true;
1063
1378
  };
1064
- const currentIndex = visibleRows.findIndex(
1379
+ const currentIndex = visibleRowsRef.current.findIndex(
1065
1380
  (visibleRow) => visibleRow.rowKey === row.rowKey,
1066
1381
  );
1067
1382
  const focusableButtons = getFocusableButtons();
@@ -1105,7 +1420,7 @@ function LayerRow({
1105
1420
  }
1106
1421
  if (event.key === "ArrowRight" && hasChildren && !isExpanded) {
1107
1422
  event.preventDefault();
1108
- onToggleExpanded(true);
1423
+ onToggleExpanded(node.id, true);
1109
1424
  return;
1110
1425
  }
1111
1426
  if (event.key === "ArrowRight" && hasChildren && isExpanded) {
@@ -1113,7 +1428,9 @@ function LayerRow({
1113
1428
  const nextButton = focusableButtons[currentFocusableIndex + 1];
1114
1429
  const childId = nextButton?.dataset.layerNodeId;
1115
1430
  const child = childId
1116
- ? visibleRows.find((visibleRow) => visibleRow.node.id === childId)
1431
+ ? visibleRowsRef.current.find(
1432
+ (visibleRow) => visibleRow.node.id === childId,
1433
+ )
1117
1434
  : null;
1118
1435
  if (child?.ancestorIds.includes(node.id)) {
1119
1436
  focusVisibleButton(currentFocusableIndex + 1);
@@ -1122,7 +1439,7 @@ function LayerRow({
1122
1439
  }
1123
1440
  if (event.key === "ArrowLeft" && hasChildren && isExpanded) {
1124
1441
  event.preventDefault();
1125
- onToggleExpanded(false);
1442
+ onToggleExpanded(node.id, false);
1126
1443
  return;
1127
1444
  }
1128
1445
  if (event.key === "ArrowLeft" && row.ancestorIds.length > 0) {
@@ -1137,10 +1454,18 @@ function LayerRow({
1137
1454
  event.preventDefault();
1138
1455
  return;
1139
1456
  }
1457
+ // Build the full-tree ancestor map once, here at drag start, rather than
1458
+ // on every render — this is O(tree size) so it must not run per-row per
1459
+ // render. It uses the FULL root tree (not visibleRows) so a selected
1460
+ // descendant nested inside a currently-collapsed dragged ancestor is
1461
+ // still correctly recognized as a descendant and excluded from the drag
1462
+ // payload (see L13 / buildAncestorIdMap).
1463
+ const ancestorIdMap = buildAncestorIdMap(rootsRef.current);
1140
1464
  const draggedIds = getDraggedLayerIdsForRows({
1141
- selectedIds,
1465
+ selectedIds: selectedIdsRef.current,
1142
1466
  nodeId: node.id,
1143
- visibleRows,
1467
+ visibleRows: visibleRowsRef.current,
1468
+ ancestorIdMap,
1144
1469
  });
1145
1470
  event.dataTransfer.effectAllowed = "move";
1146
1471
  event.dataTransfer.setData("application/x-design-layer-id", node.id);
@@ -1153,31 +1478,81 @@ function LayerRow({
1153
1478
  activeDragState = { sourceId: node.id, draggedIds };
1154
1479
  };
1155
1480
 
1481
+ // A dragover that is rejected (any early-return path below) must clear a
1482
+ // stale indicator that a PRIOR dragover on this same row left behind — e.g.
1483
+ // hovering near the row edge changes the placement from "inside" to
1484
+ // "before", which canMoveLayer may now reject even though the previous
1485
+ // placement was accepted. Without this, the indicator line/ring lingers on
1486
+ // a row that no longer has a valid drop target.
1487
+ const clearStaleIndicatorForThisRow = () => {
1488
+ if (activeDropIntent?.targetId === node.id) activeDropIntent = null;
1489
+ if (activeDropPlacement !== null) onDropIndicatorChange(null);
1490
+ clearSpringLoadTimer();
1491
+ };
1492
+
1156
1493
  const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
1157
- if (!onMoveLayer || !dragEligible) return;
1494
+ if (!onMoveLayer || !anchorEligible) {
1495
+ clearStaleIndicatorForThisRow();
1496
+ return;
1497
+ }
1158
1498
  // dataTransfer.getData() always returns "" during dragover per spec.
1159
1499
  // Read from the module-level activeDragState set in handleDragStart instead.
1160
- if (!activeDragState) return;
1500
+ if (!activeDragState) {
1501
+ clearStaleIndicatorForThisRow();
1502
+ return;
1503
+ }
1161
1504
  const { sourceId, draggedIds } = activeDragState;
1162
- if (sourceId === node.id) return;
1505
+ if (sourceId === node.id) {
1506
+ clearStaleIndicatorForThisRow();
1507
+ return;
1508
+ }
1163
1509
  const cleanedIds = draggedIds.filter(
1164
1510
  (id) => id && id !== node.id && !id.startsWith("__"),
1165
1511
  );
1166
- if (cleanedIds.length === 0) return;
1512
+ if (cleanedIds.length === 0) {
1513
+ clearStaleIndicatorForThisRow();
1514
+ return;
1515
+ }
1167
1516
  const intent = {
1168
1517
  draggedIds: cleanedIds,
1169
1518
  targetId: node.id,
1170
- placement: dropPlacementForEvent(event, canDropInside),
1519
+ placement: dropPlacementForEvent(
1520
+ event,
1521
+ canDropInside,
1522
+ isExpandedWithChildren,
1523
+ ),
1171
1524
  } satisfies LayersPanelMoveIntent;
1172
- if (canMoveLayer && !canMoveLayer(intent)) return;
1525
+ if (canMoveLayer && !canMoveLayer(intent)) {
1526
+ clearStaleIndicatorForThisRow();
1527
+ return;
1528
+ }
1173
1529
  event.preventDefault();
1174
1530
  event.dataTransfer.dropEffect = "move";
1175
1531
  activeDropIntent = intent;
1176
1532
  onDropIndicatorChange(intent);
1533
+
1534
+ // L20 spring-loaded expand: sustained "inside" hover over a collapsed
1535
+ // container expands it after a delay so the user can drop into nested
1536
+ // children without a separate drop-then-redrag step. Only arm the timer
1537
+ // once per qualifying hover — if a timer is already pending for this row
1538
+ // we leave it running rather than resetting it on every dragover tick.
1539
+ if (
1540
+ intent.placement === "inside" &&
1541
+ hasChildren &&
1542
+ !isExpanded &&
1543
+ springLoadTimerRef.current === null
1544
+ ) {
1545
+ springLoadTimerRef.current = setTimeout(() => {
1546
+ springLoadTimerRef.current = null;
1547
+ onToggleExpanded(node.id, true);
1548
+ }, SPRING_LOAD_DELAY_MS);
1549
+ } else if (intent.placement !== "inside" || !hasChildren || isExpanded) {
1550
+ clearSpringLoadTimer();
1551
+ }
1177
1552
  };
1178
1553
 
1179
1554
  const handleDrop = (event: DragEvent<HTMLDivElement>) => {
1180
- if (!onMoveLayer || !dragEligible) {
1555
+ if (!onMoveLayer || !anchorEligible) {
1181
1556
  onDropIndicatorChange(null);
1182
1557
  return;
1183
1558
  }
@@ -1216,7 +1591,11 @@ function LayerRow({
1216
1591
  : ({
1217
1592
  draggedIds: cleanedIds,
1218
1593
  targetId: node.id,
1219
- placement: dropPlacementForEvent(event, canDropInside),
1594
+ placement: dropPlacementForEvent(
1595
+ event,
1596
+ canDropInside,
1597
+ isExpandedWithChildren,
1598
+ ),
1220
1599
  } satisfies LayersPanelMoveIntent);
1221
1600
  if (!canMoveLayer || canMoveLayer(intent)) {
1222
1601
  onMoveLayer(intent);
@@ -1224,6 +1603,7 @@ function LayerRow({
1224
1603
  }
1225
1604
  activeDropIntent = null;
1226
1605
  onDropIndicatorChange(null);
1606
+ clearSpringLoadTimer();
1227
1607
  };
1228
1608
 
1229
1609
  const showContextMenu =
@@ -1234,7 +1614,7 @@ function LayerRow({
1234
1614
  <ContextMenu>
1235
1615
  <ContextMenuTrigger asChild disabled={!showContextMenu}>
1236
1616
  <div
1237
- ref={rowRef}
1617
+ ref={(element) => registerRowElement(row.rowKey, element)}
1238
1618
  role="treeitem"
1239
1619
  aria-expanded={hasChildren ? isExpanded : undefined}
1240
1620
  aria-level={depth + 1}
@@ -1253,12 +1633,14 @@ function LayerRow({
1253
1633
  return;
1254
1634
  if (activeDropIntent?.targetId === node.id) activeDropIntent = null;
1255
1635
  onDropIndicatorChange(null);
1636
+ clearSpringLoadTimer();
1256
1637
  }}
1257
1638
  onDrop={handleDrop}
1258
1639
  onDragEnd={() => {
1259
1640
  activeDragState = null;
1260
1641
  activeDropIntent = null;
1261
1642
  onDropIndicatorChange(null);
1643
+ clearSpringLoadTimer();
1262
1644
  }}
1263
1645
  onMouseEnter={() => onHoverLayer?.(node.id)}
1264
1646
  onMouseLeave={() => onLeaveLayer?.(node.id)}
@@ -1303,7 +1685,7 @@ function LayerRow({
1303
1685
  aria-label={isExpanded ? labels.collapse : labels.expand}
1304
1686
  onClick={(event) => {
1305
1687
  event.stopPropagation();
1306
- onToggleExpanded(!isExpanded);
1688
+ onToggleExpanded(node.id, !isExpanded);
1307
1689
  }}
1308
1690
  >
1309
1691
  {isExpanded ? (
@@ -1345,6 +1727,7 @@ function LayerRow({
1345
1727
  value={renameDraft}
1346
1728
  onClick={(event) => event.stopPropagation()}
1347
1729
  onChange={(event) => onRenameDraftChange(event.target.value)}
1730
+ onFocus={(event) => event.currentTarget.select()}
1348
1731
  onBlur={() => {
1349
1732
  // Escape sets renameCancelledRef before blur fires; skip commit.
1350
1733
  if (renameCancelledRef.current) {
@@ -1373,7 +1756,7 @@ function LayerRow({
1373
1756
  event.preventDefault();
1374
1757
  event.stopPropagation();
1375
1758
  renameCancelledRef.current = true;
1376
- onCancelRename();
1759
+ onCancelRename(node.id);
1377
1760
  }
1378
1761
  }}
1379
1762
  className="h-6 min-w-0 flex-1 rounded-[4px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-panel-bg)] px-1.5 text-[12px] text-foreground outline-none"
@@ -1510,7 +1893,7 @@ function LayerRow({
1510
1893
  ) : null}
1511
1894
  </ContextMenu>
1512
1895
  );
1513
- }
1896
+ });
1514
1897
 
1515
1898
  function IconTooltipButton({
1516
1899
  label,
@@ -2019,13 +2402,29 @@ function layerCanDropInside(
2019
2402
  );
2020
2403
  }
2021
2404
 
2022
- function dropPlacementForEvent(
2405
+ /**
2406
+ * L10: for an EXPANDED container that already has children, the bottom hover
2407
+ * zone sits (visually, in the panel) directly above that container's
2408
+ * first-listed child row — not above its next sibling. A plain "after"
2409
+ * placement there would target the position following the container's
2410
+ * ENTIRE subtree (anchor.end in applyMoveNodeEdit), which visually
2411
+ * contradicts the indicator's position between the container and its first
2412
+ * child. Resolve that zone to "inside" instead (still targeting the
2413
+ * container), which inserts at contentEnd — under the L5 reversed-order
2414
+ * convention that is exactly the container's first-panel-row / topmost-paint
2415
+ * child slot, matching what the indicator visually promises.
2416
+ */
2417
+ export function dropPlacementForEvent(
2023
2418
  event: DragEvent<HTMLDivElement>,
2024
2419
  canDropInside: boolean,
2420
+ isExpandedWithChildren = false,
2025
2421
  ): LayersPanelMoveIntent["placement"] {
2026
2422
  const rect = event.currentTarget.getBoundingClientRect();
2027
2423
  const offset = event.clientY - rect.top;
2028
2424
  if (offset < rect.height * 0.3) return "before";
2029
- if (offset > rect.height * 0.7) return "after";
2425
+ if (offset > rect.height * 0.7) {
2426
+ if (canDropInside && isExpandedWithChildren) return "inside";
2427
+ return "after";
2428
+ }
2030
2429
  return canDropInside ? "inside" : "after";
2031
2430
  }