@1agh/maude 0.40.0 → 0.41.0

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 (73) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -3
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/annotate.mjs +576 -34
  9. package/apps/studio/bin/canvas-rects.sh +152 -0
  10. package/apps/studio/bin/fetch-asset.sh +34 -0
  11. package/apps/studio/bin/read-annotations.mjs +138 -7
  12. package/apps/studio/bin/smoke.sh +42 -6
  13. package/apps/studio/build.ts +21 -0
  14. package/apps/studio/canvas-comment-mount.tsx +138 -4
  15. package/apps/studio/canvas-edit.ts +744 -11
  16. package/apps/studio/canvas-lib.tsx +219 -2
  17. package/apps/studio/canvas-shell.tsx +487 -20
  18. package/apps/studio/client/app.jsx +1451 -22
  19. package/apps/studio/client/comments-overlay.css +130 -126
  20. package/apps/studio/client/github.js +8 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +48 -0
  22. package/apps/studio/comments-overlay.tsx +148 -41
  23. package/apps/studio/context-menu.tsx +15 -5
  24. package/apps/studio/contextual-toolbar.tsx +262 -4
  25. package/apps/studio/cursors-overlay.tsx +4 -4
  26. package/apps/studio/dist/client.bundle.js +20 -20
  27. package/apps/studio/dist/comment-mount.js +59 -1
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/dom-selection.ts +127 -1
  30. package/apps/studio/drag-state.ts +24 -0
  31. package/apps/studio/equal-spacing-detector.ts +205 -0
  32. package/apps/studio/export-dialog.tsx +1 -1
  33. package/apps/studio/history.ts +47 -1
  34. package/apps/studio/http.ts +223 -0
  35. package/apps/studio/input-router.tsx +12 -0
  36. package/apps/studio/marquee-overlay.tsx +1 -1
  37. package/apps/studio/measure-overlay.tsx +241 -0
  38. package/apps/studio/participants-chrome.tsx +3 -3
  39. package/apps/studio/sizing-mode.ts +117 -0
  40. package/apps/studio/spacing-handles.ts +166 -0
  41. package/apps/studio/test/annotate-write.test.ts +890 -0
  42. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  43. package/apps/studio/test/canvas-edit.test.ts +50 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
  45. package/apps/studio/test/canvas-rects.test.ts +198 -0
  46. package/apps/studio/test/comments-overlay.test.ts +117 -0
  47. package/apps/studio/test/dom-selection.test.ts +130 -0
  48. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  50. package/apps/studio/test/element-resize.test.ts +136 -0
  51. package/apps/studio/test/element-structural-api.test.ts +360 -0
  52. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  53. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  54. package/apps/studio/test/history-rollback.test.ts +26 -0
  55. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  56. package/apps/studio/test/read-annotations.test.ts +154 -0
  57. package/apps/studio/test/sizing-mode.test.ts +102 -0
  58. package/apps/studio/test/spacing-handles.test.ts +138 -0
  59. package/apps/studio/test/specimen-select.test.ts +88 -0
  60. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  61. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  62. package/apps/studio/tool-palette.tsx +122 -2
  63. package/apps/studio/undo-hud.tsx +2 -2
  64. package/apps/studio/use-element-resize.tsx +732 -0
  65. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  66. package/apps/studio/use-selection-set.tsx +14 -0
  67. package/apps/studio/use-spacing-handles.tsx +388 -0
  68. package/apps/studio/whats-new.json +28 -0
  69. package/cli/commands/design.mjs +6 -1
  70. package/cli/commands/design.test.mjs +49 -1
  71. package/cli/lib/fetch-asset.test.mjs +213 -0
  72. package/package.json +8 -8
  73. package/plugins/design/dependencies.json +10 -2
@@ -76,6 +76,7 @@ import {
76
76
  selectorIndex,
77
77
  shortText,
78
78
  } from './dom-selection.ts';
79
+ import { setElementDragActive } from './drag-state.ts';
79
80
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
80
81
  import { ExportDialogProvider } from './export-dialog.tsx';
81
82
  import {
@@ -85,6 +86,7 @@ import {
85
86
  useInputRouter,
86
87
  } from './input-router.tsx';
87
88
  import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
89
+ import { MeasureOverlay } from './measure-overlay.tsx';
88
90
  import { ParticipantsChrome } from './participants-chrome.tsx';
89
91
  import { ToolPalette } from './tool-palette.tsx';
90
92
  import { UndoHud } from './undo-hud.tsx';
@@ -98,12 +100,14 @@ import { showCanvasToast } from './use-canvas-media-drop.tsx';
98
100
  import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
99
101
  import { useCollab } from './use-collab.tsx';
100
102
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
103
+ import { ElementResizeOverlay } from './use-element-resize.tsx';
101
104
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
102
105
  import {
103
106
  MaybeSelectionSetProvider,
104
107
  type Selection,
105
108
  useSelectionSet,
106
109
  } from './use-selection-set.tsx';
110
+ import { SpacingHandlesOverlay } from './use-spacing-handles.tsx';
107
111
  import { useToolMode } from './use-tool-mode.tsx';
108
112
  import { useUndoSinks, useUndoStack } from './use-undo-stack.tsx';
109
113
 
@@ -382,6 +386,11 @@ interface LayerNode {
382
386
  /** Occurrence index of this id within the artboard — matches the resolver's
383
387
  * `querySelectorAll([data-dc-screen=…] [data-cd-id=…])[index]`. */
384
388
  index: number;
389
+ /** Authored `style.display === 'none'` — the Layers-panel eye icon's source
390
+ * of truth. The panel's toggle persists through `/_api/edit-css` (same
391
+ * route CssKnobs uses), so this must reflect the SOURCE value, not a
392
+ * client-only preview flag (see app.jsx `toggleVisibility`). */
393
+ hidden: boolean;
385
394
  children: LayerNode[];
386
395
  }
387
396
 
@@ -423,6 +432,73 @@ function layerType(el: Element): string {
423
432
  return 'box';
424
433
  }
425
434
 
435
+ // Task L6 — the stamped-ancestor chain from an artboard body down to `el`
436
+ // (outermost first), mirroring `resolveHoverTarget`'s "top mode" climb
437
+ // (input-router.tsx) but returning every rung instead of only the outermost —
438
+ // deep-select needs the whole ladder to know what "one level deeper" means.
439
+ function stampedChainToBody(el: Element): Element[] {
440
+ const bodyEl = el.closest('.dc-artboard-body');
441
+ const chain: Element[] = [];
442
+ let cur: Element | null = el;
443
+ while (cur && cur !== bodyEl) {
444
+ if (cur.hasAttribute('data-cd-id')) chain.push(cur);
445
+ cur = cur.parentElement;
446
+ }
447
+ return chain.reverse();
448
+ }
449
+
450
+ // Task L6 — "Select layer" submenu: every DISTINCT `[data-cd-id]` ancestor in
451
+ // the full hit-stack under the cursor (topmost/frontmost first, matching
452
+ // Layers-panel order), so overlapping/deeply-nested elements a single
453
+ // elementFromPoint pick can't reach become individually selectable.
454
+ // `elementsFromPoint` (unlike `elementFromPoint`) walks the WHOLE z-order
455
+ // stack — including non-ancestor siblings visually underneath the frontmost
456
+ // hit — so this also disambiguates true overlaps, not just nesting depth.
457
+ function buildSelectLayerSubmenu(
458
+ target: ContextTarget,
459
+ selSet: { replace: (s: Selection | Selection[]) => void }
460
+ ): MenuItem[] {
461
+ if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
462
+ return [];
463
+ }
464
+ const hits = document.elementsFromPoint(target.clientX, target.clientY);
465
+ const seen = new Set<Element>();
466
+ const candidates: Element[] = [];
467
+ for (const hit of hits) {
468
+ const stamped = hit.closest?.('[data-cd-id]') ?? null;
469
+ if (!stamped || seen.has(stamped)) continue;
470
+ seen.add(stamped);
471
+ candidates.push(stamped);
472
+ }
473
+ if (candidates.length === 0) {
474
+ return [
475
+ {
476
+ id: 'select-layer-empty',
477
+ label: 'No stamped elements here',
478
+ disabled: true,
479
+ onSelect: () => {},
480
+ },
481
+ ];
482
+ }
483
+ return candidates.map((el, i) => {
484
+ const cdId = el.getAttribute('data-cd-id') ?? '';
485
+ const artboardId =
486
+ (el.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute('data-dc-screen') ??
487
+ null;
488
+ return {
489
+ id: `select-layer-${i}`,
490
+ label: `${layerLabel(el)} · ${layerType(el)}`,
491
+ onSelect: () => {
492
+ // `hoverTargetToSelection` computes the occurrence index itself
493
+ // (`selectorIndex` against the scoped selector) — no need to pass one.
494
+ selSet.replace(
495
+ hoverTargetToSelection({ el, cdId: cdId || null, artboardId } as HoverTarget)
496
+ );
497
+ },
498
+ };
499
+ });
500
+ }
501
+
426
502
  function serializeArtboardTree(root: Element): LayerNode[] {
427
503
  const seen = new Map<string, number>();
428
504
  function walk(el: Element): LayerNode[] {
@@ -440,6 +516,7 @@ function serializeArtboardTree(root: Element): LayerNode[] {
440
516
  label: layerLabel(child),
441
517
  type: layerType(child),
442
518
  index: idx,
519
+ hidden: (child as HTMLElement).style.display === 'none',
443
520
  children: walk(child),
444
521
  });
445
522
  } else {
@@ -1363,9 +1440,168 @@ function buildRegistry(deps: {
1363
1440
  }
1364
1441
  },
1365
1442
  },
1443
+ {
1444
+ id: 'select-layer',
1445
+ label: 'Select layer',
1446
+ // Task L6 — every DISTINCT stamped ancestor in the cursor's full hit
1447
+ // stack (elementsFromPoint sees through overlapping/nested elements a
1448
+ // single elementFromPoint pick can't reach), Layers-order (topmost
1449
+ // first), so overlapping/deeply-nested TSX becomes grabbable. A
1450
+ // function submenu — resolved per-click against `target`, since the
1451
+ // candidate set depends on where the user right-clicked.
1452
+ //
1453
+ // v1 scope: no live Stage-H local/shared badge on each candidate —
1454
+ // that resolver is server-side/main-origin-only (`GET /_api/edit-scope`,
1455
+ // canvas-edit.ts), unreachable from this iframe without a new
1456
+ // request/reply round-trip; deferred rather than blocking this task.
1457
+ onSelect: () => {
1458
+ /* parent of a submenu — never invoked directly */
1459
+ },
1460
+ submenu: (t) => buildSelectLayerSubmenu(t, selSet),
1461
+ },
1462
+ ],
1463
+ [
1464
+ {
1465
+ id: 'insert-element',
1466
+ label: 'Insert',
1467
+ // feature-element-editing-robustness Stage I3 — insert a synthesized
1468
+ // element AFTER the right-clicked one (main-origin write: request it
1469
+ // from the parent shell). Image insert needs an asset src (the Stage-F
1470
+ // AssetPicker), so only Div/Text are offered here for now.
1471
+ submenu: [
1472
+ {
1473
+ id: 'insert-div',
1474
+ label: 'Div',
1475
+ onSelect: (target) => {
1476
+ if (!target.cdId) return;
1477
+ try {
1478
+ window.parent.postMessage(
1479
+ { dgn: 'insert-request', refId: target.cdId, position: 'after', kind: 'div' },
1480
+ '*'
1481
+ );
1482
+ } catch {
1483
+ /* detached */
1484
+ }
1485
+ },
1486
+ },
1487
+ {
1488
+ id: 'insert-text',
1489
+ label: 'Text',
1490
+ onSelect: (target) => {
1491
+ if (!target.cdId) return;
1492
+ try {
1493
+ window.parent.postMessage(
1494
+ { dgn: 'insert-request', refId: target.cdId, position: 'after', kind: 'text' },
1495
+ '*'
1496
+ );
1497
+ } catch {
1498
+ /* detached */
1499
+ }
1500
+ },
1501
+ },
1502
+ {
1503
+ id: 'insert-image',
1504
+ label: 'Image…',
1505
+ // Image needs a contained asset src — the shell opens the AssetPicker.
1506
+ onSelect: (target) => {
1507
+ if (!target.cdId) return;
1508
+ try {
1509
+ window.parent.postMessage(
1510
+ { dgn: 'insert-image-request', refId: target.cdId, position: 'after' },
1511
+ '*'
1512
+ );
1513
+ } catch {
1514
+ /* detached */
1515
+ }
1516
+ },
1517
+ },
1518
+ ],
1519
+ onSelect: () => {},
1520
+ },
1521
+ {
1522
+ id: 'replace-media',
1523
+ label: 'Replace image…',
1524
+ // Stage F2 — re-point an authored <img>/<video> src. Only meaningful on
1525
+ // a media element; on anything else it's a no-op (the shell's edit-attr
1526
+ // will refuse a non-media / expression src). The Media inspector section
1527
+ // carries the primary, always-gated "Replace…" button.
1528
+ onSelect: (target) => {
1529
+ const tag = target.el?.tagName?.toLowerCase();
1530
+ if (!target.cdId || (tag !== 'img' && tag !== 'video')) return;
1531
+ // Read the authored src straight off the element so the shell has the
1532
+ // undo before-value (getAttribute is the literal content attr, not the
1533
+ // resolved URL).
1534
+ const before = target.el?.getAttribute('src') ?? null;
1535
+ try {
1536
+ window.parent.postMessage(
1537
+ { dgn: 'replace-media-request', id: target.cdId, before },
1538
+ '*'
1539
+ );
1540
+ } catch {
1541
+ /* detached */
1542
+ }
1543
+ },
1544
+ },
1366
1545
  ],
1367
1546
  [exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
1368
1547
  [
1548
+ {
1549
+ id: 'duplicate-element',
1550
+ label: 'Duplicate',
1551
+ shortcut: '⌘D',
1552
+ // Task L3 — main-origin source duplicate; post to the parent shell.
1553
+ onSelect: (target) => {
1554
+ if (!target.cdId) return;
1555
+ try {
1556
+ window.parent.postMessage({ dgn: 'duplicate-request', id: target.cdId }, '*');
1557
+ } catch {
1558
+ /* detached / cross-origin */
1559
+ }
1560
+ },
1561
+ },
1562
+ {
1563
+ id: 'copy-style',
1564
+ label: 'Copy style',
1565
+ shortcut: '⌘⌥C',
1566
+ // Task L4 — the shell captures the current selection's authored style.
1567
+ onSelect: () => {
1568
+ try {
1569
+ window.parent.postMessage({ dgn: 'copy-style' }, '*');
1570
+ } catch {
1571
+ /* detached / cross-origin */
1572
+ }
1573
+ },
1574
+ },
1575
+ {
1576
+ id: 'paste-style',
1577
+ label: 'Paste style',
1578
+ shortcut: '⌘⌥V',
1579
+ onSelect: (target) => {
1580
+ if (!target.cdId) return;
1581
+ try {
1582
+ window.parent.postMessage({ dgn: 'paste-style', id: target.cdId }, '*');
1583
+ } catch {
1584
+ /* detached / cross-origin */
1585
+ }
1586
+ },
1587
+ },
1588
+ {
1589
+ id: 'delete-element',
1590
+ label: 'Delete',
1591
+ shortcut: '⌫',
1592
+ destructive: true,
1593
+ // feature-element-editing-robustness Stage I — request a source delete.
1594
+ // The write is main-origin-only (DDR-054): post to the parent shell,
1595
+ // which performs it (pinned to the active canvas) + records undo.
1596
+ onSelect: (target) => {
1597
+ if (!target.cdId) return;
1598
+ try {
1599
+ window.parent.postMessage({ dgn: 'delete-request', id: target.cdId }, '*');
1600
+ } catch {
1601
+ /* detached / cross-origin */
1602
+ }
1603
+ },
1604
+ },
1369
1605
  {
1370
1606
  id: 'hide',
1371
1607
  label: 'Hide',
@@ -1455,6 +1691,28 @@ function buildRegistry(deps: {
1455
1691
  },
1456
1692
  ],
1457
1693
  [themeItem],
1694
+ [
1695
+ {
1696
+ id: 'delete-artboard',
1697
+ label: 'Delete artboard',
1698
+ shortcut: '⌫',
1699
+ destructive: true,
1700
+ // Main-origin-only source delete (DDR-054): post to the parent shell,
1701
+ // which performs it (pinned to the active canvas) + records undo. The
1702
+ // engine refuses to delete the last artboard.
1703
+ onSelect: (target) => {
1704
+ if (!target.artboardId) return;
1705
+ try {
1706
+ window.parent.postMessage(
1707
+ { dgn: 'delete-artboard-request', artboardId: target.artboardId },
1708
+ '*'
1709
+ );
1710
+ } catch {
1711
+ /* detached / cross-origin */
1712
+ }
1713
+ },
1714
+ },
1715
+ ],
1458
1716
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
1459
1717
  ],
1460
1718
  world: [
@@ -1478,6 +1736,63 @@ function boundsOf(el: HTMLElement) {
1478
1736
  };
1479
1737
  }
1480
1738
 
1739
+ // ─────────────────────────────────────────────────────────────────────────────
1740
+ // Task A1 (feature-element-editing-robustness) — camera-aware reveal.
1741
+ //
1742
+ // The `select-by-id` channel (Layers-panel click, restored/echoed selection,
1743
+ // post-edit reselect) used to `scrollIntoView({block:'nearest'})` to reveal the
1744
+ // selected element. But `.dc-canvas` / `.dc-artboard-body` are `overflow:hidden`
1745
+ // engine clip layers, and the camera is the `.dc-world` transform — so a host
1746
+ // scroll shifted the layout out from under the camera model (a phantom right
1747
+ // strip, and the pan reset after an absolute-element move: Bugs A + B). Reveal
1748
+ // THROUGH the camera instead: measure the element's live screen box and, only
1749
+ // when it's off-screen, pan the world transform the minimal amount to bring the
1750
+ // nearest out-of-view edge in. Screen-space deltas → `controller.panBy` (which
1751
+ // reads the exact live viewport), so it's self-correcting regardless of any
1752
+ // model lag and never touches host scroll.
1753
+
1754
+ /** Distance (screen px) a canvas element is allowed to sit from the viewport
1755
+ * edge after a reveal — a small inset so a just-revealed element isn't flush
1756
+ * against the frame. */
1757
+ export const REVEAL_MARGIN_PX = 40;
1758
+
1759
+ /** Minimal 1-axis delta (screen px) to bring `[elStart, elEnd]` inside
1760
+ * `[viewStart + margin, viewEnd - margin]`. Returns 0 when already visible OR
1761
+ * when the element spans the viewport (bigger than the view — don't move,
1762
+ * matching `scrollIntoView({block:'nearest'})`). Sign matches `panBy`: a
1763
+ * positive delta pans the world content toward the end (down / right). */
1764
+ export function revealAxisDelta(
1765
+ elStart: number,
1766
+ elEnd: number,
1767
+ viewStart: number,
1768
+ viewEnd: number,
1769
+ margin: number
1770
+ ): number {
1771
+ const vs = viewStart + margin;
1772
+ const ve = viewEnd - margin;
1773
+ if (elStart >= vs && elEnd <= ve) return 0; // fully visible
1774
+ if (elStart <= vs && elEnd >= ve) return 0; // spans the viewport — leave it
1775
+ if (elStart < vs) return vs - elStart; // off the start edge → pan toward end
1776
+ if (elEnd > ve) return ve - elEnd; // off the end edge → pan toward start
1777
+ return 0;
1778
+ }
1779
+
1780
+ /** Pan the camera the minimal amount so `target` is visible inside `host`,
1781
+ * entirely through the world transform (never host scroll). No-op when the
1782
+ * element is already on-screen. Exported for the Task A3 regression test. */
1783
+ export function revealElementViaCamera(
1784
+ host: HTMLElement,
1785
+ target: HTMLElement,
1786
+ controller: ViewportControllerHandle
1787
+ ): void {
1788
+ const hostRect = host.getBoundingClientRect();
1789
+ const r = target.getBoundingClientRect();
1790
+ if (r.width <= 0 && r.height <= 0) return; // detached / display:none — nothing to reveal
1791
+ const dx = revealAxisDelta(r.left, r.right, hostRect.left, hostRect.right, REVEAL_MARGIN_PX);
1792
+ const dy = revealAxisDelta(r.top, r.bottom, hostRect.top, hostRect.bottom, REVEAL_MARGIN_PX);
1793
+ if (dx !== 0 || dy !== 0) controller.panBy(dx, dy);
1794
+ }
1795
+
1481
1796
  // ─────────────────────────────────────────────────────────────────────────────
1482
1797
  // Router wire-up
1483
1798
 
@@ -1542,6 +1857,36 @@ function CanvasRouter({
1542
1857
  // consumes.
1543
1858
  const chromeCtx = useChromeVisibility();
1544
1859
 
1860
+ // Task A2 (feature-element-editing-robustness) — host-scroll-0 invariant.
1861
+ // `.dc-canvas` (overflow:hidden) and each `.dc-artboard-body` are the engine's
1862
+ // clip layers; the camera lives entirely in the `.dc-world` transform, and
1863
+ // both `writeTransform` and `__maudeCanvasRects` assume these hosts sit at
1864
+ // scroll 0. A stray programmatic scroll (a residual scrollIntoView, focus()
1865
+ // auto-scroll of an overflow:hidden ancestor) would shift the layout and
1866
+ // desync the camera model — the Bug A/B class. Clamp any scroll on an ENGINE
1867
+ // clip layer straight back to 0. Author scroll containers deeper inside a mock
1868
+ // are left alone (the target check excludes them). `scroll` events don't
1869
+ // bubble, so capture:true is what lets the host observe a descendant clip's
1870
+ // scroll without also intercepting author-content scrolls (those fail the
1871
+ // engine-clip test and pass through).
1872
+ useEffect(() => {
1873
+ const host = hostRef.current;
1874
+ if (!host) return;
1875
+ const clamp = (e: Event) => {
1876
+ const t = e.target;
1877
+ if (!(t instanceof HTMLElement)) return;
1878
+ const isEngineClip =
1879
+ t === host ||
1880
+ t.classList.contains('dc-artboard-body') ||
1881
+ t.classList.contains('dc-artboard');
1882
+ if (!isEngineClip) return;
1883
+ if (t.scrollLeft !== 0) t.scrollLeft = 0;
1884
+ if (t.scrollTop !== 0) t.scrollTop = 0;
1885
+ };
1886
+ host.addEventListener('scroll', clamp, true);
1887
+ return () => host.removeEventListener('scroll', clamp, true);
1888
+ }, [hostRef]);
1889
+
1545
1890
  // Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
1546
1891
  // itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
1547
1892
  const [hoverEl, setHoverEl] = useState<Element | null>(null);
@@ -1625,7 +1970,15 @@ function CanvasRouter({
1625
1970
  artboardId: mm.artboardId ?? null,
1626
1971
  } as HoverTarget)
1627
1972
  );
1628
- (target as HTMLElement).scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
1973
+ // Task A1 reveal THROUGH the camera, not host scroll. The old
1974
+ // `scrollIntoView` scrolled the overflow:hidden `.dc-canvas` /
1975
+ // `.dc-artboard-body` clip layers, desyncing the `.dc-world`
1976
+ // transform (Bugs A + B). `revealElementViaCamera` pans the world
1977
+ // the minimal amount only when the element is off-screen.
1978
+ const host = hostRef.current;
1979
+ if (host && zoomController) {
1980
+ revealElementViaCamera(host, target as HTMLElement, zoomController);
1981
+ }
1629
1982
  }
1630
1983
  }
1631
1984
  return;
@@ -1767,7 +2120,7 @@ function CanvasRouter({
1767
2120
  };
1768
2121
  window.addEventListener('message', onMessage);
1769
2122
  return () => window.removeEventListener('message', onMessage);
1770
- }, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx]);
2123
+ }, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx, hostRef]);
1771
2124
 
1772
2125
  // Phase 12 (DDR-103) — double-click a LEAF-TEXT element (children all text
1773
2126
  // nodes) to edit its copy in place. Commit (blur / Enter) posts `dgn:edit-text`
@@ -1843,7 +2196,37 @@ function CanvasRouter({
1843
2196
  const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
1844
2197
  if (!stamped) return;
1845
2198
  const kids = Array.from(stamped.childNodes);
1846
- if (kids.length === 0 || !kids.every((n) => n.nodeType === 3)) return; // leaf-text only
2199
+ const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
2200
+ if (!isLeafText) {
2201
+ // Task L6 — deep-select: a container (anything that isn't leaf-text)
2202
+ // double-clicked here used to just bail — this is the exact gap the
2203
+ // plan's deep-select task targets. Drill ONE level past whatever's
2204
+ // currently selected along this click's own stamped-ancestor chain
2205
+ // (Figma parity: repeated double-clicks walk progressively deeper);
2206
+ // default to the outermost rung when nothing in the chain is
2207
+ // currently selected. Mutually exclusive with text-edit below by
2208
+ // construction — same capture-phase listener, same branch gate.
2209
+ const chain = stampedChainToBody(stamped);
2210
+ if (chain.length > 0) {
2211
+ const sel = selSet.selected;
2212
+ const currentId = sel.length === 1 ? sel[0]?.id : undefined;
2213
+ const curIdx = currentId
2214
+ ? chain.findIndex((n) => n.getAttribute('data-cd-id') === currentId)
2215
+ : -1;
2216
+ const next = curIdx >= 0 && curIdx + 1 < chain.length ? chain[curIdx + 1] : chain[0];
2217
+ if (next) {
2218
+ e.preventDefault();
2219
+ e.stopPropagation();
2220
+ const cdId = next.getAttribute('data-cd-id');
2221
+ const artboardId =
2222
+ (next.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
2223
+ 'data-dc-screen'
2224
+ ) ?? null;
2225
+ selSet.replace(hoverTargetToSelection({ el: next, cdId, artboardId } as HoverTarget));
2226
+ }
2227
+ }
2228
+ return;
2229
+ }
1847
2230
  e.preventDefault();
1848
2231
  e.stopPropagation();
1849
2232
  editing = stamped;
@@ -1888,7 +2271,7 @@ function CanvasRouter({
1888
2271
  window.removeEventListener('message', onEditReverted);
1889
2272
  if (editing) teardown(editing);
1890
2273
  };
1891
- }, [hostRef]);
2274
+ }, [hostRef, selSet]);
1892
2275
 
1893
2276
  // Cleanup any pending rAF on unmount.
1894
2277
  useEffect(
@@ -1982,10 +2365,13 @@ function CanvasRouter({
1982
2365
  <ElementMarqueeOverlay />
1983
2366
  <HoverHalo el={hoverEl} />
1984
2367
  <SelectionHalos />
2368
+ <ElementResizeOverlay />
2369
+ <SpacingHandlesOverlay />
1985
2370
  <ReorderDrag />
1986
2371
  <LayersLiveSync />
1987
2372
  <GroupBbox />
1988
2373
  <EqualSpacingHandles />
2374
+ <MeasureOverlay />
1989
2375
  <ContextualToolbar />
1990
2376
  <MultiArtboardToolbar
1991
2377
  distributeArtboards={distributeArtboards}
@@ -2527,6 +2913,15 @@ function ReorderDrag() {
2527
2913
  /** Stamped [data-cd-id] nodes in their ORIGINAL order — for computing a
2528
2914
  * reused instance's occurrence index even after the DOM reflowed. */
2529
2915
  snapshot: Element[] | null;
2916
+ /** True when the dragged element is `position: absolute|fixed` — reorder
2917
+ * (sibling order) never changes where an out-of-flow element paints, so
2918
+ * these drags commit `left`/`top` instead of a tree move (see the
2919
+ * "absolute element snaps back" RCA). */
2920
+ outOfFlow: boolean;
2921
+ /** Pre-drag `left`/`top` (px, resolved via computed style) — only
2922
+ * meaningful when `outOfFlow`. */
2923
+ originLeft: number;
2924
+ originTop: number;
2530
2925
  };
2531
2926
  // Hover a stable spot this long and the layout reflows LIVE (the node moves
2532
2927
  // there while still floating with the cursor) — you see the result before
@@ -2538,6 +2933,9 @@ function ReorderDrag() {
2538
2933
  // The last committed drop, kept so a shell 'reorder-failed' can undo the
2539
2934
  // optimistic DOM move (the write was rejected → nothing persisted).
2540
2935
  let lastCommit: { el: HTMLElement; parent: HTMLElement; next: Element | null } | null = null;
2936
+ // The last committed out-of-flow reposition, kept so a shell
2937
+ // 'reposition-failed' can restore the pre-drag inline style.
2938
+ let lastPosition: { el: HTMLElement; prevStyle: string | null } | null = null;
2541
2939
 
2542
2940
  const setHighlight = (el: HTMLElement | null) => {
2543
2941
  if (highlightEl === el) return;
@@ -2769,6 +3167,7 @@ function ReorderDrag() {
2769
3167
  hideDivider();
2770
3168
  suppressNextCanvasClick();
2771
3169
  drag = null;
3170
+ setElementDragActive(false);
2772
3171
  };
2773
3172
 
2774
3173
  const onKeyDown = (e: KeyboardEvent) => {
@@ -2791,6 +3190,7 @@ function ReorderDrag() {
2791
3190
  if (!(t instanceof Node) || !el.contains(t)) return;
2792
3191
  // Candidate — do NOT claim yet (a plain click must still work). We only
2793
3192
  // become a drag once the pointer crosses the threshold in onMove.
3193
+ const outOfFlowPos = getComputedStyle(el).position;
2794
3194
  drag = {
2795
3195
  pointerId: e.pointerId,
2796
3196
  startX: e.clientX,
@@ -2810,6 +3210,9 @@ function ReorderDrag() {
2810
3210
  settleKey: null,
2811
3211
  applied: null,
2812
3212
  snapshot: null,
3213
+ outOfFlow: outOfFlowPos === 'absolute' || outOfFlowPos === 'fixed',
3214
+ originLeft: 0,
3215
+ originTop: 0,
2813
3216
  };
2814
3217
  };
2815
3218
 
@@ -2819,6 +3222,7 @@ function ReorderDrag() {
2819
3222
  if (!d.dragging) {
2820
3223
  if (!crossedDragThreshold(d.startX, d.startY, e.clientX, e.clientY)) return;
2821
3224
  d.dragging = true;
3225
+ setElementDragActive(true);
2822
3226
  // Start floating: the element tracks the cursor via `transform`, so its
2823
3227
  // layout box stays reserved (origin shows empty space) and it stays in
2824
3228
  // the zoomed world (styling + scale intact). Divide the screen delta by
@@ -2834,7 +3238,18 @@ function ReorderDrag() {
2834
3238
  // reflect the pre-drag layout, not the reflowed one.
2835
3239
  d.snapshot = [...document.querySelectorAll('[data-cd-id]')];
2836
3240
  d.prevStyle = d.el.getAttribute('style');
2837
- if (getComputedStyle(d.el).position === 'static') d.el.style.position = 'relative';
3241
+ if (d.outOfFlow) {
3242
+ // Resolved (used) value in px — matches the unit space `left`/`top`
3243
+ // are written back in, regardless of an ancestor's zoom transform
3244
+ // (a CSS `transform: scale()` on the world doesn't change a
3245
+ // descendant's own box-model values). `auto` (e.g. positioned via
3246
+ // `right`/`bottom` instead) falls back to 0 — a known v1 limit.
3247
+ const cs = getComputedStyle(d.el);
3248
+ d.originLeft = Number.parseFloat(cs.left) || 0;
3249
+ d.originTop = Number.parseFloat(cs.top) || 0;
3250
+ } else if (getComputedStyle(d.el).position === 'static') {
3251
+ d.el.style.position = 'relative';
3252
+ }
2838
3253
  d.el.style.zIndex = '9990';
2839
3254
  d.el.style.pointerEvents = 'none'; // so elementFromPoint sees what's beneath
2840
3255
  d.el.style.opacity = '0.9';
@@ -2848,6 +3263,11 @@ function ReorderDrag() {
2848
3263
  const tx = (e.clientX - d.startX) / d.zoom;
2849
3264
  const ty = (e.clientY - d.startY) / d.zoom;
2850
3265
  d.el.style.transform = `translate(${tx}px, ${ty}px)`;
3266
+ // Out-of-flow elements free-move with the cursor — there's no sibling
3267
+ // "before/after" to hunt for (reordering an absolute/fixed element's
3268
+ // JSX siblings wouldn't change where it paints), so skip the reorder
3269
+ // target/divider/settle machinery entirely; onUp commits `left`/`top`.
3270
+ if (d.outOfFlow) return;
2851
3271
  // Indicator + settle scheduling only — the DOM moves when a target stays
2852
3272
  // stable for SETTLE_MS (the live-reflow preview), never per-mousemove.
2853
3273
  const target = computeTarget(e.clientX, e.clientY, d);
@@ -2880,11 +3300,59 @@ function ReorderDrag() {
2880
3300
  const onUp = (e: PointerEvent) => {
2881
3301
  const d = drag;
2882
3302
  drag = null;
3303
+ setElementDragActive(false);
2883
3304
  setHighlight(null);
2884
3305
  hideDivider();
2885
3306
  if (!d || e.pointerId !== d.pointerId) return;
2886
3307
  if (!d.dragging) return; // a plain click — leave it to native handlers
2887
3308
  clearSettle(d);
3309
+ // Occurrence index of a node among its like-id siblings, read from the
3310
+ // PRE-drag snapshot (the live DOM already reflowed for an in-flow drop). For
3311
+ // a reused component (many DOM nodes share one internal id) the server maps
3312
+ // this to the parent's distinct <Component> USAGE; 0 for a normal element.
3313
+ // Shared by the reposition (Stage H3 — instance move stays local) + reorder
3314
+ // posts below, so both anchor to the same instance the user dragged.
3315
+ const occ = (node: Element | null): number => {
3316
+ if (!node || !d.snapshot) return 0;
3317
+ const nid = node.getAttribute('data-cd-id');
3318
+ const at = d.snapshot.indexOf(node);
3319
+ if (!nid || at < 0) return 0;
3320
+ let c = 0;
3321
+ for (let k = 0; k < at; k++) {
3322
+ if (d.snapshot[k]?.getAttribute('data-cd-id') === nid) c++;
3323
+ }
3324
+ return c;
3325
+ };
3326
+ if (d.outOfFlow) {
3327
+ // Un-float: restore the pre-drag inline style (drops the drag-only
3328
+ // props AND the cursor-follow transform), then overlay the new
3329
+ // left/top — same "unfloat" step as reorder, but the position
3330
+ // sticks instead of reverting to `prevStyle`'s original left/top.
3331
+ if (d.prevStyle == null) d.el.removeAttribute('style');
3332
+ else d.el.setAttribute('style', d.prevStyle);
3333
+ const dx = (d.lastX - d.startX) / d.zoom;
3334
+ const dy = (d.lastY - d.startY) / d.zoom;
3335
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; // negligible — nothing to persist
3336
+ const left = Math.round(d.originLeft + dx);
3337
+ const top = Math.round(d.originTop + dy);
3338
+ d.el.style.left = `${left}px`;
3339
+ d.el.style.top = `${top}px`;
3340
+ lastPosition = { el: d.el, prevStyle: d.prevStyle };
3341
+ suppressNextCanvasClick();
3342
+ window.parent.postMessage(
3343
+ {
3344
+ dgn: 'reposition-request',
3345
+ id: d.cdId,
3346
+ left,
3347
+ top,
3348
+ beforeLeft: Math.round(d.originLeft),
3349
+ beforeTop: Math.round(d.originTop),
3350
+ idIndex: occ(d.el),
3351
+ },
3352
+ '*'
3353
+ );
3354
+ return;
3355
+ }
2888
3356
  // Un-float: restore the element's exact original inline style.
2889
3357
  if (d.prevStyle == null) d.el.removeAttribute('style');
2890
3358
  else d.el.setAttribute('style', d.prevStyle);
@@ -2903,21 +3371,6 @@ function ReorderDrag() {
2903
3371
  // never persisted doesn't linger until the next canvas switch.
2904
3372
  lastCommit = d.origin ? { el: d.el, parent: d.origin.parent, next: d.origin.next } : null;
2905
3373
  suppressNextCanvasClick();
2906
- // Occurrence index of each node among its like-id siblings, read from the
2907
- // PRE-drag snapshot (the live DOM already reflowed). For a reused component
2908
- // (many DOM nodes share one internal id) the server maps this to the
2909
- // parent's distinct <Component> usage; 0 for a normal element.
2910
- const occ = (node: Element | null): number => {
2911
- if (!node || !d.snapshot) return 0;
2912
- const id = node.getAttribute('data-cd-id');
2913
- const at = d.snapshot.indexOf(node);
2914
- if (!id || at < 0) return 0;
2915
- let c = 0;
2916
- for (let k = 0; k < at; k++) {
2917
- if (d.snapshot[k]?.getAttribute('data-cd-id') === id) c++;
2918
- }
2919
- return c;
2920
- };
2921
3374
  const refNode = d.target?.kind === 'inside' ? d.target.container : (d.target?.el ?? null);
2922
3375
  window.parent.postMessage(
2923
3376
  {
@@ -2945,15 +3398,29 @@ function ReorderDrag() {
2945
3398
  lastCommit = null;
2946
3399
  };
2947
3400
 
3401
+ // The shell rejected the last reposition write — revert the optimistic
3402
+ // left/top back to the pre-drag inline style.
3403
+ const onRepositionFailed = (e: MessageEvent) => {
3404
+ if (e.source !== window.parent) return;
3405
+ const m = e.data as { dgn?: string } | null;
3406
+ if (m?.dgn !== 'reposition-failed' || !lastPosition) return;
3407
+ if (lastPosition.prevStyle == null) lastPosition.el.removeAttribute('style');
3408
+ else lastPosition.el.setAttribute('style', lastPosition.prevStyle);
3409
+ lastPosition = null;
3410
+ };
3411
+
2948
3412
  document.addEventListener('pointerdown', onDown, true);
2949
3413
  window.addEventListener('pointermove', onMove);
2950
3414
  window.addEventListener('pointerup', onUp);
2951
3415
  window.addEventListener('keydown', onKeyDown, true);
2952
3416
  window.addEventListener('message', onReorderFailed);
3417
+ window.addEventListener('message', onRepositionFailed);
2953
3418
  return () => {
2954
3419
  if (drag) clearSettle(drag);
2955
3420
  setHighlight(null);
3421
+ setElementDragActive(false);
2956
3422
  window.removeEventListener('message', onReorderFailed);
3423
+ window.removeEventListener('message', onRepositionFailed);
2957
3424
  if (dividerEl) {
2958
3425
  dividerEl.remove();
2959
3426
  dividerEl = null;