@1agh/maude 0.39.1 → 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 (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -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';
@@ -94,15 +96,18 @@ import {
94
96
  useAnnotationSelectionOptional,
95
97
  } from './use-annotation-selection.tsx';
96
98
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
99
+ import { showCanvasToast } from './use-canvas-media-drop.tsx';
97
100
  import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
98
101
  import { useCollab } from './use-collab.tsx';
99
102
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
103
+ import { ElementResizeOverlay } from './use-element-resize.tsx';
100
104
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
101
105
  import {
102
106
  MaybeSelectionSetProvider,
103
107
  type Selection,
104
108
  useSelectionSet,
105
109
  } from './use-selection-set.tsx';
110
+ import { SpacingHandlesOverlay } from './use-spacing-handles.tsx';
106
111
  import { useToolMode } from './use-tool-mode.tsx';
107
112
  import { useUndoSinks, useUndoStack } from './use-undo-stack.tsx';
108
113
 
@@ -381,6 +386,11 @@ interface LayerNode {
381
386
  /** Occurrence index of this id within the artboard — matches the resolver's
382
387
  * `querySelectorAll([data-dc-screen=…] [data-cd-id=…])[index]`. */
383
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;
384
394
  children: LayerNode[];
385
395
  }
386
396
 
@@ -422,6 +432,73 @@ function layerType(el: Element): string {
422
432
  return 'box';
423
433
  }
424
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
+
425
502
  function serializeArtboardTree(root: Element): LayerNode[] {
426
503
  const seen = new Map<string, number>();
427
504
  function walk(el: Element): LayerNode[] {
@@ -439,6 +516,7 @@ function serializeArtboardTree(root: Element): LayerNode[] {
439
516
  label: layerLabel(child),
440
517
  type: layerType(child),
441
518
  index: idx,
519
+ hidden: (child as HTMLElement).style.display === 'none',
442
520
  children: walk(child),
443
521
  });
444
522
  } else {
@@ -1362,9 +1440,168 @@ function buildRegistry(deps: {
1362
1440
  }
1363
1441
  },
1364
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
+ },
1365
1545
  ],
1366
1546
  [exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
1367
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
+ },
1368
1605
  {
1369
1606
  id: 'hide',
1370
1607
  label: 'Hide',
@@ -1454,6 +1691,28 @@ function buildRegistry(deps: {
1454
1691
  },
1455
1692
  ],
1456
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
+ ],
1457
1716
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
1458
1717
  ],
1459
1718
  world: [
@@ -1477,6 +1736,63 @@ function boundsOf(el: HTMLElement) {
1477
1736
  };
1478
1737
  }
1479
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
+
1480
1796
  // ─────────────────────────────────────────────────────────────────────────────
1481
1797
  // Router wire-up
1482
1798
 
@@ -1541,6 +1857,36 @@ function CanvasRouter({
1541
1857
  // consumes.
1542
1858
  const chromeCtx = useChromeVisibility();
1543
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
+
1544
1890
  // Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
1545
1891
  // itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
1546
1892
  const [hoverEl, setHoverEl] = useState<Element | null>(null);
@@ -1624,7 +1970,15 @@ function CanvasRouter({
1624
1970
  artboardId: mm.artboardId ?? null,
1625
1971
  } as HoverTarget)
1626
1972
  );
1627
- (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
+ }
1628
1982
  }
1629
1983
  }
1630
1984
  return;
@@ -1766,7 +2120,7 @@ function CanvasRouter({
1766
2120
  };
1767
2121
  window.addEventListener('message', onMessage);
1768
2122
  return () => window.removeEventListener('message', onMessage);
1769
- }, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx]);
2123
+ }, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx, hostRef]);
1770
2124
 
1771
2125
  // Phase 12 (DDR-103) — double-click a LEAF-TEXT element (children all text
1772
2126
  // nodes) to edit its copy in place. Commit (blur / Enter) posts `dgn:edit-text`
@@ -1780,6 +2134,11 @@ function CanvasRouter({
1780
2134
  if (!host) return;
1781
2135
  let editing: HTMLElement | null = null;
1782
2136
  let original = '';
2137
+ // DDR-150 P1 — the committed-but-unconfirmed edit, so a shell rejection
2138
+ // (dynamic/mixed content the engine refuses) can revert the optimistic
2139
+ // contenteditable text instead of letting it silently vanish on the next
2140
+ // reload. Mirrors the reorder-failed revert below. Cleared on revert.
2141
+ let lastTextCommit: { el: HTMLElement; original: string } | null = null;
1783
2142
  const onBlur = (): void => commitEdit(true);
1784
2143
  const onKey = (e: KeyboardEvent): void => {
1785
2144
  if (e.key === 'Escape') {
@@ -1815,6 +2174,8 @@ function CanvasRouter({
1815
2174
  } catch {
1816
2175
  /* detached / cross-origin */
1817
2176
  }
2177
+ // Stash the optimistic edit so a shell rejection can revert it (DDR-150 P1).
2178
+ lastTextCommit = { el: elx, original };
1818
2179
  // Record onto the in-canvas undo stack so Cmd+Z reverts the rewrite. The
1819
2180
  // edit already posted above (record, don't re-run do()). before/after are
1820
2181
  // the trimmed bodies the edit-text endpoint persists.
@@ -1835,7 +2196,37 @@ function CanvasRouter({
1835
2196
  const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
1836
2197
  if (!stamped) return;
1837
2198
  const kids = Array.from(stamped.childNodes);
1838
- 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
+ }
1839
2230
  e.preventDefault();
1840
2231
  e.stopPropagation();
1841
2232
  editing = stamped;
@@ -1855,13 +2246,32 @@ function CanvasRouter({
1855
2246
  /* selection API unavailable */
1856
2247
  }
1857
2248
  };
2249
+ // The shell rejected the last inline text edit — revert the optimistic
2250
+ // contenteditable text so the canvas reflects the true (unsaved) source
2251
+ // rather than a change that silently disappears on the next reload (DDR-150 P1).
2252
+ const onEditReverted = (e: MessageEvent): void => {
2253
+ if (e.source !== window.parent) return;
2254
+ const m = e.data as { dgn?: string; op?: string; reason?: string } | null;
2255
+ if (m?.dgn !== 'edit-reverted' || m.op !== 'text' || !lastTextCommit) return;
2256
+ try {
2257
+ lastTextCommit.el.textContent = lastTextCommit.original;
2258
+ } catch {
2259
+ /* detached — a subsequent reload re-syncs */
2260
+ }
2261
+ showCanvasToast(
2262
+ `Couldn't save that text — ${m.reason || 'not editable inline'}. Edit it via chat.`
2263
+ );
2264
+ lastTextCommit = null;
2265
+ };
1858
2266
  // Capture phase so we beat the fit-to-view dblclick handler.
1859
2267
  host.addEventListener('dblclick', onDbl, true);
2268
+ window.addEventListener('message', onEditReverted);
1860
2269
  return () => {
1861
2270
  host.removeEventListener('dblclick', onDbl, true);
2271
+ window.removeEventListener('message', onEditReverted);
1862
2272
  if (editing) teardown(editing);
1863
2273
  };
1864
- }, [hostRef]);
2274
+ }, [hostRef, selSet]);
1865
2275
 
1866
2276
  // Cleanup any pending rAF on unmount.
1867
2277
  useEffect(
@@ -1955,10 +2365,13 @@ function CanvasRouter({
1955
2365
  <ElementMarqueeOverlay />
1956
2366
  <HoverHalo el={hoverEl} />
1957
2367
  <SelectionHalos />
2368
+ <ElementResizeOverlay />
2369
+ <SpacingHandlesOverlay />
1958
2370
  <ReorderDrag />
1959
2371
  <LayersLiveSync />
1960
2372
  <GroupBbox />
1961
2373
  <EqualSpacingHandles />
2374
+ <MeasureOverlay />
1962
2375
  <ContextualToolbar />
1963
2376
  <MultiArtboardToolbar
1964
2377
  distributeArtboards={distributeArtboards}
@@ -2500,6 +2913,15 @@ function ReorderDrag() {
2500
2913
  /** Stamped [data-cd-id] nodes in their ORIGINAL order — for computing a
2501
2914
  * reused instance's occurrence index even after the DOM reflowed. */
2502
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;
2503
2925
  };
2504
2926
  // Hover a stable spot this long and the layout reflows LIVE (the node moves
2505
2927
  // there while still floating with the cursor) — you see the result before
@@ -2511,6 +2933,9 @@ function ReorderDrag() {
2511
2933
  // The last committed drop, kept so a shell 'reorder-failed' can undo the
2512
2934
  // optimistic DOM move (the write was rejected → nothing persisted).
2513
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;
2514
2939
 
2515
2940
  const setHighlight = (el: HTMLElement | null) => {
2516
2941
  if (highlightEl === el) return;
@@ -2742,6 +3167,7 @@ function ReorderDrag() {
2742
3167
  hideDivider();
2743
3168
  suppressNextCanvasClick();
2744
3169
  drag = null;
3170
+ setElementDragActive(false);
2745
3171
  };
2746
3172
 
2747
3173
  const onKeyDown = (e: KeyboardEvent) => {
@@ -2764,6 +3190,7 @@ function ReorderDrag() {
2764
3190
  if (!(t instanceof Node) || !el.contains(t)) return;
2765
3191
  // Candidate — do NOT claim yet (a plain click must still work). We only
2766
3192
  // become a drag once the pointer crosses the threshold in onMove.
3193
+ const outOfFlowPos = getComputedStyle(el).position;
2767
3194
  drag = {
2768
3195
  pointerId: e.pointerId,
2769
3196
  startX: e.clientX,
@@ -2783,6 +3210,9 @@ function ReorderDrag() {
2783
3210
  settleKey: null,
2784
3211
  applied: null,
2785
3212
  snapshot: null,
3213
+ outOfFlow: outOfFlowPos === 'absolute' || outOfFlowPos === 'fixed',
3214
+ originLeft: 0,
3215
+ originTop: 0,
2786
3216
  };
2787
3217
  };
2788
3218
 
@@ -2792,6 +3222,7 @@ function ReorderDrag() {
2792
3222
  if (!d.dragging) {
2793
3223
  if (!crossedDragThreshold(d.startX, d.startY, e.clientX, e.clientY)) return;
2794
3224
  d.dragging = true;
3225
+ setElementDragActive(true);
2795
3226
  // Start floating: the element tracks the cursor via `transform`, so its
2796
3227
  // layout box stays reserved (origin shows empty space) and it stays in
2797
3228
  // the zoomed world (styling + scale intact). Divide the screen delta by
@@ -2807,7 +3238,18 @@ function ReorderDrag() {
2807
3238
  // reflect the pre-drag layout, not the reflowed one.
2808
3239
  d.snapshot = [...document.querySelectorAll('[data-cd-id]')];
2809
3240
  d.prevStyle = d.el.getAttribute('style');
2810
- 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
+ }
2811
3253
  d.el.style.zIndex = '9990';
2812
3254
  d.el.style.pointerEvents = 'none'; // so elementFromPoint sees what's beneath
2813
3255
  d.el.style.opacity = '0.9';
@@ -2821,6 +3263,11 @@ function ReorderDrag() {
2821
3263
  const tx = (e.clientX - d.startX) / d.zoom;
2822
3264
  const ty = (e.clientY - d.startY) / d.zoom;
2823
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;
2824
3271
  // Indicator + settle scheduling only — the DOM moves when a target stays
2825
3272
  // stable for SETTLE_MS (the live-reflow preview), never per-mousemove.
2826
3273
  const target = computeTarget(e.clientX, e.clientY, d);
@@ -2853,11 +3300,59 @@ function ReorderDrag() {
2853
3300
  const onUp = (e: PointerEvent) => {
2854
3301
  const d = drag;
2855
3302
  drag = null;
3303
+ setElementDragActive(false);
2856
3304
  setHighlight(null);
2857
3305
  hideDivider();
2858
3306
  if (!d || e.pointerId !== d.pointerId) return;
2859
3307
  if (!d.dragging) return; // a plain click — leave it to native handlers
2860
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
+ }
2861
3356
  // Un-float: restore the element's exact original inline style.
2862
3357
  if (d.prevStyle == null) d.el.removeAttribute('style');
2863
3358
  else d.el.setAttribute('style', d.prevStyle);
@@ -2876,21 +3371,6 @@ function ReorderDrag() {
2876
3371
  // never persisted doesn't linger until the next canvas switch.
2877
3372
  lastCommit = d.origin ? { el: d.el, parent: d.origin.parent, next: d.origin.next } : null;
2878
3373
  suppressNextCanvasClick();
2879
- // Occurrence index of each node among its like-id siblings, read from the
2880
- // PRE-drag snapshot (the live DOM already reflowed). For a reused component
2881
- // (many DOM nodes share one internal id) the server maps this to the
2882
- // parent's distinct <Component> usage; 0 for a normal element.
2883
- const occ = (node: Element | null): number => {
2884
- if (!node || !d.snapshot) return 0;
2885
- const id = node.getAttribute('data-cd-id');
2886
- const at = d.snapshot.indexOf(node);
2887
- if (!id || at < 0) return 0;
2888
- let c = 0;
2889
- for (let k = 0; k < at; k++) {
2890
- if (d.snapshot[k]?.getAttribute('data-cd-id') === id) c++;
2891
- }
2892
- return c;
2893
- };
2894
3374
  const refNode = d.target?.kind === 'inside' ? d.target.container : (d.target?.el ?? null);
2895
3375
  window.parent.postMessage(
2896
3376
  {
@@ -2918,15 +3398,29 @@ function ReorderDrag() {
2918
3398
  lastCommit = null;
2919
3399
  };
2920
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
+
2921
3412
  document.addEventListener('pointerdown', onDown, true);
2922
3413
  window.addEventListener('pointermove', onMove);
2923
3414
  window.addEventListener('pointerup', onUp);
2924
3415
  window.addEventListener('keydown', onKeyDown, true);
2925
3416
  window.addEventListener('message', onReorderFailed);
3417
+ window.addEventListener('message', onRepositionFailed);
2926
3418
  return () => {
2927
3419
  if (drag) clearSettle(drag);
2928
3420
  setHighlight(null);
3421
+ setElementDragActive(false);
2929
3422
  window.removeEventListener('message', onReorderFailed);
3423
+ window.removeEventListener('message', onRepositionFailed);
2930
3424
  if (dividerEl) {
2931
3425
  dividerEl.remove();
2932
3426
  dividerEl = null;