@1agh/maude 0.37.0 → 0.38.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 (61) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +683 -33
  17. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  18. package/apps/studio/client/panels/acp-runtime.js +227 -70
  19. package/apps/studio/client/panels/chat-context.js +124 -0
  20. package/apps/studio/client/panels/slash-commands.js +147 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  22. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  23. package/apps/studio/commands/reorder-command.ts +77 -0
  24. package/apps/studio/config.schema.json +6 -0
  25. package/apps/studio/dist/client.bundle.js +25 -53617
  26. package/apps/studio/dist/styles.css +1 -1
  27. package/apps/studio/hmr-broadcast.ts +27 -19
  28. package/apps/studio/http.ts +168 -26
  29. package/apps/studio/inspect.ts +108 -1
  30. package/apps/studio/paths.ts +32 -0
  31. package/apps/studio/readiness.ts +79 -30
  32. package/apps/studio/server.ts +11 -2
  33. package/apps/studio/test/acp-activity.test.ts +154 -0
  34. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  35. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  36. package/apps/studio/test/acp-commands.test.ts +108 -0
  37. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  39. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  40. package/apps/studio/test/acp-transcript.test.ts +53 -0
  41. package/apps/studio/test/active-state.test.ts +41 -0
  42. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  45. package/apps/studio/test/chat-context.test.ts +129 -0
  46. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  47. package/apps/studio/test/edit-suppress.test.ts +170 -0
  48. package/apps/studio/test/ensure-browser.test.ts +92 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  50. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  51. package/apps/studio/test/inspect-selections.test.ts +219 -0
  52. package/apps/studio/test/paths.test.ts +57 -0
  53. package/apps/studio/test/readiness.test.ts +83 -13
  54. package/apps/studio/test/reorder-api.test.ts +210 -0
  55. package/apps/studio/test/slash-commands.test.ts +117 -0
  56. package/apps/studio/undo-stack.ts +6 -0
  57. package/apps/studio/whats-new.json +45 -0
  58. package/apps/studio/ws.ts +37 -0
  59. package/cli/commands/design.mjs +1 -0
  60. package/package.json +8 -8
  61. package/plugins/design/dependencies.json +3 -3
@@ -54,6 +54,7 @@ import {
54
54
  type EditSourcePayload,
55
55
  } from './commands/edit-source-command.ts';
56
56
  import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
57
+ import { buildReorderRecord, type ReorderRevertFn } from './commands/reorder-command.ts';
57
58
  import {
58
59
  ContextMenuProvider,
59
60
  type ContextRegistry,
@@ -77,7 +78,12 @@ import {
77
78
  } from './dom-selection.ts';
78
79
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
79
80
  import { ExportDialogProvider } from './export-dialog.tsx';
80
- import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
81
+ import {
82
+ crossedDragThreshold,
83
+ type HoverTarget,
84
+ resolveHoverTarget,
85
+ useInputRouter,
86
+ } from './input-router.tsx';
81
87
  import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
82
88
  import { ParticipantsChrome } from './participants-chrome.tsx';
83
89
  import { ToolPalette } from './tool-palette.tsx';
@@ -317,6 +323,30 @@ const HALO_CSS = `
317
323
  border-radius: 1px;
318
324
  cursor: text;
319
325
  }
326
+ /* Phase 12.1 (DDR-138) — in-canvas drag-to-reorder (Figma-style). Dragging a
327
+ SELECTED element floats it with the cursor (via transform, applied inline —
328
+ its box stays reserved so the origin shows empty space, and it stays in the
329
+ zoomed world so styling + scale are preserved). A blue DIVIDER shows the
330
+ insertion point between siblings; a dashed RING shows a container it will nest
331
+ into. The DOM only changes on drop (no mid-drag reflow → no jank).
332
+ NOTE: keep this comment backtick-free — it is inside the HALO_CSS template
333
+ literal and a stray backtick closes it (DDR-067 §6). */
334
+ .dc-cv-reorder-divider {
335
+ position: fixed;
336
+ z-index: 8;
337
+ pointer-events: none;
338
+ display: none;
339
+ background: var(--maude-hud-accent, #0d99ff);
340
+ border-radius: 2px;
341
+ box-shadow: 0 0 0 1px var(--maude-chrome-bg-0, #ffffff);
342
+ }
343
+ /* Drop-target container ring — shown while the pointer is over the middle of a
344
+ container the dragged node would nest INTO. Outline only — no layout shift. */
345
+ [data-cd-id].dc-cv-reorder-into {
346
+ outline: 2px dashed var(--maude-hud-accent, #0d99ff);
347
+ outline-offset: -2px;
348
+ border-radius: 2px;
349
+ }
320
350
  `.trim();
321
351
 
322
352
  function ensureHaloStyles(): void {
@@ -421,10 +451,36 @@ function serializeArtboardTree(root: Element): LayerNode[] {
421
451
  return walk(root);
422
452
  }
423
453
 
454
+ // The artboard whose Layers tree the shell is currently showing. Set every time
455
+ // we post a tree (request-layers / selection); the live MutationObserver
456
+ // (LayersLiveSync) re-posts THIS artboard whenever its DOM structure changes, so
457
+ // the panel mirrors the canvas both ways (drag/keyboard reorder + HMR reload)
458
+ // with FRESH data-cd-ids — no stale-optimistic-tree drift.
459
+ //
460
+ // Stored on `window` (NOT a module `let`): a source edit triggers a SOFT HMR
461
+ // that re-evaluates this module — a module-scoped var would reset to null and
462
+ // the observer would bail right when it matters (post-reorder). The iframe
463
+ // window survives the soft HMR, so the tracked artboard persists across it.
464
+ function getLastLayersArtboardId(): string | null {
465
+ try {
466
+ return (
467
+ (window as unknown as { __maudeLastLayersArt?: string | null }).__maudeLastLayersArt ?? null
468
+ );
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
473
+
424
474
  function postLayersTree(artboardId: string | null | undefined): void {
425
475
  if (typeof document === 'undefined' || !artboardId) return;
426
476
  const root = document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"]`);
427
477
  if (!root) return;
478
+ try {
479
+ (window as unknown as { __maudeLastLayersArt?: string | null }).__maudeLastLayersArt =
480
+ artboardId;
481
+ } catch {
482
+ /* no window */
483
+ }
428
484
  try {
429
485
  window.parent.postMessage(
430
486
  { dgn: 'layers-tree', artboardId, tree: serializeArtboardTree(root) },
@@ -435,6 +491,45 @@ function postLayersTree(artboardId: string | null | undefined): void {
435
491
  }
436
492
  }
437
493
 
494
+ // Live-sync the Layers panel to the canvas DOM. A structural change anywhere in
495
+ // the artboard (an in-canvas reorder's live reflow, a drop, or the HMR remount
496
+ // after ANY source write) re-serializes + re-posts the tracked artboard's tree.
497
+ // Chrome-only churn (halo / divider / pins / activity rim, all appended to body)
498
+ // is ignored so we don't thrash. Debounced; the tree walk skips dev-server chrome
499
+ // already (serializeArtboardTree).
500
+ const LAYERS_CHROME_SEL =
501
+ '.dc-cv-halo, .dc-cv-group-bbox, .dgn-pin, .dc-cv-reorder-divider, .dc-cv-reorder-into, .dc-activity-rim, .dc-activity-scan, .dc-cv-reorder-lift';
502
+
503
+ function LayersLiveSync() {
504
+ useEffect(() => {
505
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return;
506
+ const isChrome = (n: Node): boolean =>
507
+ n.nodeType === 1 && !!(n as Element).closest?.(LAYERS_CHROME_SEL);
508
+ let timer: ReturnType<typeof setTimeout> | null = null;
509
+ const obs = new MutationObserver((records) => {
510
+ let structural = false;
511
+ for (const r of records) {
512
+ if (r.type !== 'childList') continue;
513
+ if (r.target.nodeType === 1 && (r.target as Element).closest?.(LAYERS_CHROME_SEL)) continue;
514
+ const touched = [...r.addedNodes, ...r.removedNodes];
515
+ if (touched.length && touched.every(isChrome)) continue; // pure chrome churn
516
+ structural = true;
517
+ break;
518
+ }
519
+ const art = getLastLayersArtboardId();
520
+ if (!structural || !art) return;
521
+ if (timer) clearTimeout(timer);
522
+ timer = setTimeout(() => postLayersTree(art), 90);
523
+ });
524
+ obs.observe(document.body, { childList: true, subtree: true });
525
+ return () => {
526
+ if (timer) clearTimeout(timer);
527
+ obs.disconnect();
528
+ };
529
+ }, []);
530
+ return null;
531
+ }
532
+
438
533
  // ─────────────────────────────────────────────────────────────────────────────
439
534
  // Shell
440
535
 
@@ -837,14 +932,23 @@ function CanvasCore({
837
932
  // inverse — artboards kept their own DS theme and following was opt-in — which
838
933
  // produced the recurring "canvas dark / chrome light" mismatch.)
839
934
  //
840
- // DS theme-wrapper conventions vary (`.mdcc[data-theme]`, `.app[data-theme]`,
841
- // bare `[data-theme]`, …) and there's no reliable config flag, so we DETECT it
842
- // with a hidden computed-style probe: stamp a throwaway nested <div> with each
843
- // candidate `<class>[data-theme=light|dark]` and keep the first whose resolved
844
- // `--bg-0` differs between light and dark. Because the probe tests a NON-root
845
- // element, "supported" is exactly "stamping one artboard will work" — a DS that
846
- // only themes `:root[data-theme]` correctly reports unsupported (you genuinely
847
- // can't theme a single artboard there).
935
+ // DS theme-wrapper conventions vary (`.maude[data-theme]`, `.mdcc[data-theme]`,
936
+ // `.app[data-theme]`, bare `[data-theme]`, …) and there's no reliable config
937
+ // flag, so we DETECT it with a hidden computed-style probe: stamp a throwaway
938
+ // nested <div> with each candidate `<class>[data-theme=light|dark]` and keep the
939
+ // first whose resolved `--bg-0` differs between light and dark. Because the probe
940
+ // tests a NON-root element, "supported" is exactly "stamping one artboard will
941
+ // work" — a DS that only themes `:root[data-theme]` correctly reports unsupported
942
+ // (you genuinely can't theme a single artboard there).
943
+ //
944
+ // Candidate classes are DERIVED FROM THE LIVE DOM (2026-07-02): the DS's own
945
+ // rootClass wrapper is already in the artboard content carrying `[data-theme]`
946
+ // (e.g. `<div class="maude" data-theme="dark">`), so we read those class strings
947
+ // straight off the page instead of guessing. A hardcoded guess list (`['', 'mdcc',
948
+ // 'app']`) missed EVERY DS whose rootClass wasn't in it — including this repo's own
949
+ // `.maude` — which greyed out Light/Dark on a DS that fully ships both themes. The
950
+ // historical guesses stay as a trailing fallback for a canvas whose wrapper hasn't
951
+ // mounted yet at probe time.
848
952
 
849
953
  interface DsThemeSupport {
850
954
  supported: boolean;
@@ -873,9 +977,23 @@ function detectDsThemeSupport(): DsThemeSupport {
873
977
  host.removeChild(el);
874
978
  return v;
875
979
  };
876
- // Bare attribute first (cleanest), then the common class conventions.
980
+ // Derive candidate wrapper classes from the DS wrappers already in the DOM
981
+ // (elements carrying `data-theme` are the DS rootClass wrappers), trying both
982
+ // the full className ("maude di-root") and each individual token ("maude",
983
+ // "di-root"). Bare `''` first (cleanest), historical guesses last as a
984
+ // pre-mount fallback. A Set de-dupes; insertion order keeps DOM-derived first.
985
+ const candidates = new Set<string>(['']);
986
+ for (const el of Array.from(document.querySelectorAll('[data-theme]'))) {
987
+ const cn = (el as HTMLElement).className;
988
+ if (typeof cn === 'string' && cn.trim()) {
989
+ candidates.add(cn.trim());
990
+ for (const tok of cn.trim().split(/\s+/)) candidates.add(tok);
991
+ }
992
+ }
993
+ candidates.add('mdcc');
994
+ candidates.add('app');
877
995
  let found = fallback;
878
- for (const cls of ['', 'mdcc', 'app']) {
996
+ for (const cls of candidates) {
879
997
  const light = read(cls, 'light');
880
998
  const dark = read(cls, 'dark');
881
999
  if (light && dark && light !== dark) {
@@ -1400,6 +1518,21 @@ function CanvasRouter({
1400
1518
  return () => undoSinks.setSink('editSourceApplyFn', undefined);
1401
1519
  }, [undoSinks]);
1402
1520
 
1521
+ // Phase 12.1 — reorder undo. Same origin-split shape as editSourceApplyFn:
1522
+ // the reorder-revert write is main-origin-only, so the command's do()/undo()
1523
+ // posts `dgn:'reorder-revert'` and the shell calls /_api/reorder-revert.
1524
+ useEffect(() => {
1525
+ const revertFn: ReorderRevertFn = (revert) => {
1526
+ try {
1527
+ window.parent.postMessage({ dgn: 'reorder-revert', ...revert }, '*');
1528
+ } catch {
1529
+ /* detached / cross-origin teardown — nothing to revert */
1530
+ }
1531
+ };
1532
+ undoSinks.setSink('reorderRevertFn', revertFn);
1533
+ return () => undoSinks.setSink('reorderRevertFn', undefined);
1534
+ }, [undoSinks]);
1535
+
1403
1536
  // Shell View-menu zoom bridge (dgn:'zoom') — same controller the zoom pill uses.
1404
1537
  const zoomController = useViewportControllerContext();
1405
1538
  // Shell View-menu chrome-visibility bridge (dgn:'view-chrome') — minimap /
@@ -1557,7 +1690,21 @@ function CanvasRouter({
1557
1690
  // a hostile canvas self-post must not be able to plant fake undo entries.
1558
1691
  if (m.dgn === 'record-edit') {
1559
1692
  if (e.source !== window.parent) return;
1560
- const p = (m as { payload?: Partial<EditSourcePayload> }).payload;
1693
+ const p = (m as { payload?: Partial<EditSourcePayload> & { seq?: number; label?: string } })
1694
+ .payload;
1695
+ // Phase 12.1 — a landed reorder (drag / keyboard move). The shell POSTed
1696
+ // /_api/reorder already; record so Cmd+Z reverts via the server's log.
1697
+ if (p && (p as { op?: string }).op === 'reorder') {
1698
+ if (typeof p.canvas === 'string' && typeof p.seq === 'number') {
1699
+ undoStack.record(
1700
+ buildReorderRecord(
1701
+ { canvas: p.canvas, seq: p.seq },
1702
+ typeof p.label === 'string' ? p.label : undefined
1703
+ )
1704
+ );
1705
+ }
1706
+ return;
1707
+ }
1561
1708
  if (
1562
1709
  p &&
1563
1710
  (p.op === 'css' || p.op === 'text' || p.op === 'attr') &&
@@ -1808,6 +1955,8 @@ function CanvasRouter({
1808
1955
  <ElementMarqueeOverlay />
1809
1956
  <HoverHalo el={hoverEl} />
1810
1957
  <SelectionHalos />
1958
+ <ReorderDrag />
1959
+ <LayersLiveSync />
1811
1960
  <GroupBbox />
1812
1961
  <EqualSpacingHandles />
1813
1962
  <ContextualToolbar />
@@ -2269,6 +2418,529 @@ function SelectionHalos() {
2269
2418
  return <div ref={containerRef} aria-hidden="true" />;
2270
2419
  }
2271
2420
 
2421
+ // ─────────────────────────────────────────────────────────────────────────────
2422
+ // ReorderDrag (Phase 12.1, DDR-138) — in-canvas drag-to-reorder, Figma-style.
2423
+ // Grab the SELECTED element and drag: it FLOATS with the cursor (a `transform`
2424
+ // applied inline, so its layout box stays reserved — the origin shows empty
2425
+ // space — and it stays inside the zoomed world, keeping its styling + scale; we
2426
+ // divide the screen delta by the element's own zoom so it tracks 1:1). The drop
2427
+ // zone is the WHOLE hovered element split 50/50 — top/left half inserts BEFORE,
2428
+ // bottom/right half AFTER (axis = parent flex direction) — so there's a wide hit
2429
+ // area, not a thin divider to aim at; a blue DIVIDER marks the resulting seam, a
2430
+ // dashed RING marks an EMPTY container it would nest INTO. Hover a stable target
2431
+ // for SETTLE_MS and the layout reflows LIVE
2432
+ // (the node moves there, still floating — you see the result before committing);
2433
+ // per-mousemove the DOM never changes, so there's no jank / ping-pong. DROP
2434
+ // commits via `dgn:'reorder-request'` to the shell (DDR-054: untrusted canvas
2435
+ // REQUESTS, main-origin shell WRITES → source write → HMR remount renders the
2436
+ // committed order); ESC aborts and restores the origin.
2437
+ //
2438
+ // Headless: a document-capture pointerdown that only ACTS when a bare drag
2439
+ // starts on the single selected element with the move tool — a plain click and
2440
+ // every other gesture (Cmd-select, marquee, pan, annotation, native button
2441
+ // clicks) pass through untouched. Threshold-gated so a click is never a drag.
2442
+
2443
+ /** Read a usable `data-cd-id` off a live node, skipping dev-server chrome. */
2444
+ function reorderCdId(node: Node | null): string | null {
2445
+ if (node?.nodeType !== 1) return null;
2446
+ const el = node as Element;
2447
+ if (el.closest('.dc-cv-halo, .dc-cv-group-bbox, .dgn-pin')) return null;
2448
+ return el.getAttribute('data-cd-id');
2449
+ }
2450
+
2451
+ /** Suppress the synthetic click that fires after a drag so the drag doesn't
2452
+ * also activate a button / link inside the dragged element. */
2453
+ function suppressNextCanvasClick(): void {
2454
+ const onClick = (e: MouseEvent) => {
2455
+ e.preventDefault();
2456
+ e.stopImmediatePropagation();
2457
+ cleanup();
2458
+ };
2459
+ const cleanup = () => {
2460
+ document.removeEventListener('click', onClick, true);
2461
+ clearTimeout(t);
2462
+ };
2463
+ const t = setTimeout(cleanup, 350);
2464
+ document.addEventListener('click', onClick, true);
2465
+ }
2466
+
2467
+ function ReorderDrag() {
2468
+ const { selected } = useSelectionSet();
2469
+ const { tool } = useToolMode();
2470
+ // The document pointerdown listener is stable; it reads the latest selection +
2471
+ // tool from refs so it doesn't need re-attaching on every selection change.
2472
+ const selRef = useRef(selected);
2473
+ selRef.current = selected;
2474
+ const toolRef = useRef(tool);
2475
+ toolRef.current = tool;
2476
+
2477
+ useEffect(() => {
2478
+ type Target = { kind: 'before' | 'after' | 'inside'; el: HTMLElement; container: HTMLElement };
2479
+ type Drag = {
2480
+ pointerId: number;
2481
+ startX: number;
2482
+ startY: number;
2483
+ lastX: number;
2484
+ lastY: number;
2485
+ grabDX: number;
2486
+ grabDY: number;
2487
+ el: HTMLElement;
2488
+ cdId: string;
2489
+ zoom: number;
2490
+ prevStyle: string | null;
2491
+ dragging: boolean;
2492
+ target: Target | null;
2493
+ /** Where the node lived at lift — for the Esc restore. */
2494
+ origin: { parent: HTMLElement; next: Element | null } | null;
2495
+ /** Pending settle timer — fires the live-reflow preview. */
2496
+ settle: ReturnType<typeof setTimeout> | null;
2497
+ settleKey: string | null;
2498
+ /** The last PREVIEWED (already reflowed) drop, committed on release. */
2499
+ applied: { refId: string; position: 'before' | 'after' | 'inside-end' } | null;
2500
+ /** Stamped [data-cd-id] nodes in their ORIGINAL order — for computing a
2501
+ * reused instance's occurrence index even after the DOM reflowed. */
2502
+ snapshot: Element[] | null;
2503
+ };
2504
+ // Hover a stable spot this long and the layout reflows LIVE (the node moves
2505
+ // there while still floating with the cursor) — you see the result before
2506
+ // committing. Drop commits; Esc restores the origin.
2507
+ const SETTLE_MS = 500;
2508
+ let drag: Drag | null = null;
2509
+ let highlightEl: HTMLElement | null = null;
2510
+ let dividerEl: HTMLDivElement | null = null;
2511
+ // The last committed drop, kept so a shell 'reorder-failed' can undo the
2512
+ // optimistic DOM move (the write was rejected → nothing persisted).
2513
+ let lastCommit: { el: HTMLElement; parent: HTMLElement; next: Element | null } | null = null;
2514
+
2515
+ const setHighlight = (el: HTMLElement | null) => {
2516
+ if (highlightEl === el) return;
2517
+ if (highlightEl) highlightEl.classList.remove('dc-cv-reorder-into');
2518
+ highlightEl = el;
2519
+ if (highlightEl) highlightEl.classList.add('dc-cv-reorder-into');
2520
+ };
2521
+
2522
+ const hideDivider = () => {
2523
+ if (dividerEl) dividerEl.style.display = 'none';
2524
+ };
2525
+ const showDivider = (t: Target) => {
2526
+ if (!dividerEl) {
2527
+ dividerEl = document.createElement('div');
2528
+ dividerEl.className = 'dc-cv-reorder-divider';
2529
+ dividerEl.setAttribute('aria-hidden', 'true');
2530
+ document.body.appendChild(dividerEl);
2531
+ }
2532
+ const r = t.el.getBoundingClientRect();
2533
+ const pcs = getComputedStyle(t.container);
2534
+ const isRow = pcs.display.includes('flex') && pcs.flexDirection.startsWith('row');
2535
+ const el = dividerEl;
2536
+ el.style.display = 'block';
2537
+ if (isRow) {
2538
+ const cx = t.kind === 'before' ? r.left : r.right;
2539
+ el.style.left = `${Math.round(cx) - 1}px`;
2540
+ el.style.top = `${Math.round(r.top)}px`;
2541
+ el.style.width = '2px';
2542
+ el.style.height = `${Math.round(r.height)}px`;
2543
+ } else {
2544
+ const cy = t.kind === 'before' ? r.top : r.bottom;
2545
+ el.style.left = `${Math.round(r.left)}px`;
2546
+ el.style.top = `${Math.round(cy) - 1}px`;
2547
+ el.style.width = `${Math.round(r.width)}px`;
2548
+ el.style.height = '2px';
2549
+ }
2550
+ };
2551
+
2552
+ // A genuinely EMPTY container (no element children, no text) is the only case
2553
+ // where before/after can't express "put it in here" — so the whole box nests
2554
+ // inside. Everything else splits 50/50 (see computeTarget).
2555
+ const isEmptyContainer = (el: HTMLElement): boolean =>
2556
+ el.childElementCount === 0 && !el.textContent?.trim();
2557
+
2558
+ // Where would the node drop right now? Pure read — never mutates the DOM
2559
+ // (that only happens on drop), which is what keeps the drag jank-free.
2560
+ // The drop zone is the WHOLE element split 50/50: top/left half → before,
2561
+ // bottom/right half → after (axis follows the parent's flex direction). No
2562
+ // thin central "nest" band eating the target — the entire element is an easy,
2563
+ // wide hit. (Nesting into a container that HAS content = drop onto one of its
2564
+ // children; nesting into an EMPTY one = drop anywhere on it; deep nesting =
2565
+ // the Layers panel's Alt+Shift+↑/↓.)
2566
+ const computeTarget = (x: number, y: number, d: Drag): Target | null => {
2567
+ let E = document.elementFromPoint(x, y)?.closest('[data-cd-id]') as HTMLElement | null;
2568
+ while (E && (E === d.el || d.el.contains(E))) {
2569
+ E = E.parentElement
2570
+ ? (E.parentElement.closest('[data-cd-id]') as HTMLElement | null)
2571
+ : null;
2572
+ }
2573
+ if (!E || !reorderCdId(E)) return null;
2574
+ // While inside the dragged node's own parent, climb to that parent's direct
2575
+ // child so hovering a sibling's inner content still targets the sibling
2576
+ // (no deep-hit). Outside it, E stays deep → reparent OUT / INTO others.
2577
+ let anchor: HTMLElement = E;
2578
+ const sib = d.el.parentElement;
2579
+ if (sib?.contains(E)) {
2580
+ let s: HTMLElement | null = E;
2581
+ while (s && s.parentElement !== sib) s = s.parentElement as HTMLElement | null;
2582
+ if (s && s !== d.el && reorderCdId(s)) anchor = s;
2583
+ }
2584
+ if (isEmptyContainer(anchor) && !d.el.contains(anchor)) {
2585
+ return { kind: 'inside', el: anchor, container: anchor };
2586
+ }
2587
+ const parent = anchor.parentElement;
2588
+ const r = anchor.getBoundingClientRect();
2589
+ const pcs = parent ? getComputedStyle(parent) : null;
2590
+ const isRow = !!pcs && pcs.display.includes('flex') && pcs.flexDirection.startsWith('row');
2591
+ const frac = isRow ? (x - r.left) / (r.width || 1) : (y - r.top) / (r.height || 1);
2592
+ // Nest zone: the middle third of a CONTAINER (element that holds children)
2593
+ // nests INTO it. This is the only way to drop a node into a non-empty
2594
+ // container — before/after only ever inserts as a SIBLING, so without it a
2595
+ // node accidentally lifted to the root could never be dragged back into a
2596
+ // child div. Leaves keep the full 50/50 (no nest band eating the target).
2597
+ if (anchor.childElementCount > 0 && frac >= 0.34 && frac <= 0.66 && !d.el.contains(anchor)) {
2598
+ return { kind: 'inside', el: anchor, container: anchor };
2599
+ }
2600
+ if (!parent || parent === d.el || d.el.contains(parent)) return null;
2601
+ return { kind: frac < 0.5 ? 'before' : 'after', el: anchor, container: parent };
2602
+ };
2603
+
2604
+ const targetToDrop = (
2605
+ t: Target
2606
+ ): { refId: string; position: 'before' | 'after' | 'inside-end' } | null => {
2607
+ if (t.kind === 'inside') {
2608
+ const id = reorderCdId(t.container);
2609
+ return id ? { refId: id, position: 'inside-end' } : null;
2610
+ }
2611
+ const id = reorderCdId(t.el);
2612
+ return id ? { refId: id, position: t.kind } : null;
2613
+ };
2614
+
2615
+ // FLIP the siblings a reflow displaces so they GLIDE to their new slots
2616
+ // instead of jumping: record first-positions, run the DOM move, invert via
2617
+ // transform, then play to zero. Scoped to direct children of the affected
2618
+ // parent(s); descendants ride along. The dragged node is excluded — it has
2619
+ // its own cursor-following transform. Deltas divide by each element's own
2620
+ // zoom (screen px → world px), same math as the float. Respects
2621
+ // prefers-reduced-motion (skips straight to the end state).
2622
+ const REFLOW_MS = 180;
2623
+ const animateReflow = (moved: HTMLElement, act: () => void) => {
2624
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
2625
+ act();
2626
+ return;
2627
+ }
2628
+ const first = new Map<HTMLElement, DOMRect>();
2629
+ const collect = (parent: Element | null) => {
2630
+ if (!parent) return;
2631
+ for (const c of Array.from(parent.children)) {
2632
+ const el = c as HTMLElement;
2633
+ if (el === moved || first.has(el)) continue;
2634
+ first.set(el, el.getBoundingClientRect());
2635
+ }
2636
+ };
2637
+ collect(moved.parentElement);
2638
+ act();
2639
+ collect(moved.parentElement); // reparent case — new siblings shift too
2640
+ const anims: Array<{ el: HTMLElement; prevTransform: string; prevTransition: string }> = [];
2641
+ for (const [el, r0] of first) {
2642
+ if (!el.isConnected) continue;
2643
+ const r1 = el.getBoundingClientRect();
2644
+ const dx = r0.left - r1.left;
2645
+ const dy = r0.top - r1.top;
2646
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
2647
+ const zoom = el.offsetWidth ? r1.width / el.offsetWidth : 1;
2648
+ anims.push({ el, prevTransform: el.style.transform, prevTransition: el.style.transition });
2649
+ el.style.transition = 'none';
2650
+ el.style.transform = `translate(${dx / zoom}px, ${dy / zoom}px)`;
2651
+ }
2652
+ if (!anims.length) return;
2653
+ void document.body.offsetWidth; // flush the inverted state
2654
+ for (const a of anims) {
2655
+ a.el.style.transition = `transform ${REFLOW_MS}ms ease`;
2656
+ a.el.style.transform = 'translate(0px, 0px)';
2657
+ }
2658
+ setTimeout(() => {
2659
+ for (const a of anims) {
2660
+ if (!a.el.isConnected) continue;
2661
+ a.el.style.transition = a.prevTransition;
2662
+ a.el.style.transform = a.prevTransform;
2663
+ }
2664
+ }, REFLOW_MS + 40);
2665
+ };
2666
+
2667
+ // Move the node to match the target (used by the settle preview AND the
2668
+ // final drop); the source write + HMR remount then re-render the committed
2669
+ // order.
2670
+ const applyDrop = (el: HTMLElement, t: Target) => {
2671
+ try {
2672
+ if (t.kind === 'inside') t.container.appendChild(el);
2673
+ else if (t.kind === 'before') t.container.insertBefore(el, t.el);
2674
+ else {
2675
+ let ref = t.el.nextElementSibling;
2676
+ if (ref === el) ref = el.nextElementSibling;
2677
+ t.container.insertBefore(el, ref);
2678
+ }
2679
+ } catch {
2680
+ /* detached — remount re-syncs */
2681
+ }
2682
+ };
2683
+
2684
+ // Is the target where the node ALREADY sits? True after a settle preview
2685
+ // moved it — without this the timer would re-fire forever and the divider
2686
+ // would point at the node's own slot.
2687
+ const isCurrentPosition = (t: Target, d: Drag): boolean => {
2688
+ if (t.kind === 'before')
2689
+ return t.container === d.el.parentElement && t.el === d.el.nextElementSibling;
2690
+ if (t.kind === 'after')
2691
+ return t.container === d.el.parentElement && t.el === d.el.previousElementSibling;
2692
+ return t.container === d.el.parentElement && t.container.lastElementChild === d.el;
2693
+ };
2694
+
2695
+ const clearSettle = (d: Drag) => {
2696
+ if (d.settle != null) clearTimeout(d.settle);
2697
+ d.settle = null;
2698
+ d.settleKey = null;
2699
+ };
2700
+
2701
+ // The live reflow: move the node to the hovered target NOW (the origin gap
2702
+ // closes, a gap opens at the target) while it keeps floating under the
2703
+ // cursor — re-anchor the transform to the node's new layout slot so it
2704
+ // doesn't visually jump.
2705
+ const applyPreview = (d: Drag) => {
2706
+ d.settle = null;
2707
+ d.settleKey = null;
2708
+ const t = d.target;
2709
+ if (!t?.el.isConnected) return;
2710
+ const drop = targetToDrop(t);
2711
+ if (!drop) return;
2712
+ animateReflow(d.el, () => applyDrop(d.el, t));
2713
+ d.applied = drop;
2714
+ d.el.style.transform = 'none';
2715
+ const rect = d.el.getBoundingClientRect();
2716
+ d.zoom = d.el.offsetWidth ? rect.width / d.el.offsetWidth : 1;
2717
+ const tx = (d.lastX - d.grabDX - rect.left) / d.zoom;
2718
+ const ty = (d.lastY - d.grabDY - rect.top) / d.zoom;
2719
+ d.el.style.transform = `translate(${tx}px, ${ty}px)`;
2720
+ // Keep onMove's (client - start)/zoom math consistent from the new anchor.
2721
+ d.startX = d.lastX - tx * d.zoom;
2722
+ d.startY = d.lastY - ty * d.zoom;
2723
+ setHighlight(null);
2724
+ hideDivider();
2725
+ };
2726
+
2727
+ // Esc — abort the drag: put the node back where it was lifted from and
2728
+ // restore its exact inline style. Nothing is committed.
2729
+ const cancelDrag = (d: Drag) => {
2730
+ clearSettle(d);
2731
+ const origin = d.origin;
2732
+ if (d.applied && origin) {
2733
+ try {
2734
+ animateReflow(d.el, () => origin.parent.insertBefore(d.el, origin.next));
2735
+ } catch {
2736
+ /* origin gone — remount re-syncs */
2737
+ }
2738
+ }
2739
+ if (d.prevStyle == null) d.el.removeAttribute('style');
2740
+ else d.el.setAttribute('style', d.prevStyle);
2741
+ setHighlight(null);
2742
+ hideDivider();
2743
+ suppressNextCanvasClick();
2744
+ drag = null;
2745
+ };
2746
+
2747
+ const onKeyDown = (e: KeyboardEvent) => {
2748
+ if (e.key !== 'Escape' || !drag || !drag.dragging) return;
2749
+ e.preventDefault();
2750
+ e.stopImmediatePropagation(); // don't also clear the selection
2751
+ cancelDrag(drag);
2752
+ };
2753
+
2754
+ const onDown = (e: PointerEvent) => {
2755
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
2756
+ if (toolRef.current !== 'move') return;
2757
+ const sel = selRef.current;
2758
+ const one = sel && sel.length === 1 ? sel[0] : null;
2759
+ const cdId = one && typeof one.id === 'string' ? one.id : null;
2760
+ if (!cdId || !one) return;
2761
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
2762
+ if (!el) return;
2763
+ const t = e.target;
2764
+ if (!(t instanceof Node) || !el.contains(t)) return;
2765
+ // Candidate — do NOT claim yet (a plain click must still work). We only
2766
+ // become a drag once the pointer crosses the threshold in onMove.
2767
+ drag = {
2768
+ pointerId: e.pointerId,
2769
+ startX: e.clientX,
2770
+ startY: e.clientY,
2771
+ lastX: e.clientX,
2772
+ lastY: e.clientY,
2773
+ grabDX: 0,
2774
+ grabDY: 0,
2775
+ el,
2776
+ cdId,
2777
+ zoom: 1,
2778
+ prevStyle: null,
2779
+ dragging: false,
2780
+ target: null,
2781
+ origin: null,
2782
+ settle: null,
2783
+ settleKey: null,
2784
+ applied: null,
2785
+ snapshot: null,
2786
+ };
2787
+ };
2788
+
2789
+ const onMove = (e: PointerEvent) => {
2790
+ const d = drag;
2791
+ if (!d || e.pointerId !== d.pointerId) return;
2792
+ if (!d.dragging) {
2793
+ if (!crossedDragThreshold(d.startX, d.startY, e.clientX, e.clientY)) return;
2794
+ d.dragging = true;
2795
+ // Start floating: the element tracks the cursor via `transform`, so its
2796
+ // layout box stays reserved (origin shows empty space) and it stays in
2797
+ // the zoomed world (styling + scale intact). Divide the screen delta by
2798
+ // the element's own zoom (screen size / natural size) so it tracks 1:1.
2799
+ const rect = d.el.getBoundingClientRect();
2800
+ d.zoom = d.el.offsetWidth ? rect.width / d.el.offsetWidth : 1;
2801
+ d.grabDX = d.startX - rect.left;
2802
+ d.grabDY = d.startY - rect.top;
2803
+ d.origin = d.el.parentElement
2804
+ ? { parent: d.el.parentElement, next: d.el.nextElementSibling }
2805
+ : null;
2806
+ // Snapshot the original stamped-node order NOW — occurrence indices must
2807
+ // reflect the pre-drag layout, not the reflowed one.
2808
+ d.snapshot = [...document.querySelectorAll('[data-cd-id]')];
2809
+ d.prevStyle = d.el.getAttribute('style');
2810
+ if (getComputedStyle(d.el).position === 'static') d.el.style.position = 'relative';
2811
+ d.el.style.zIndex = '9990';
2812
+ d.el.style.pointerEvents = 'none'; // so elementFromPoint sees what's beneath
2813
+ d.el.style.opacity = '0.9';
2814
+ d.el.style.cursor = 'grabbing';
2815
+ d.el.style.boxShadow = '0 10px 28px rgba(0, 0, 0, 0.28)';
2816
+ d.el.style.willChange = 'transform';
2817
+ d.el.style.transition = 'none';
2818
+ }
2819
+ d.lastX = e.clientX;
2820
+ d.lastY = e.clientY;
2821
+ const tx = (e.clientX - d.startX) / d.zoom;
2822
+ const ty = (e.clientY - d.startY) / d.zoom;
2823
+ d.el.style.transform = `translate(${tx}px, ${ty}px)`;
2824
+ // Indicator + settle scheduling only — the DOM moves when a target stays
2825
+ // stable for SETTLE_MS (the live-reflow preview), never per-mousemove.
2826
+ const target = computeTarget(e.clientX, e.clientY, d);
2827
+ const current = target ? isCurrentPosition(target, d) : false;
2828
+ d.target = target && !current ? target : null;
2829
+ if (!d.target) {
2830
+ clearSettle(d);
2831
+ setHighlight(null);
2832
+ hideDivider();
2833
+ } else {
2834
+ const t = d.target;
2835
+ if (t.kind === 'inside') {
2836
+ hideDivider();
2837
+ setHighlight(t.container);
2838
+ } else {
2839
+ setHighlight(null);
2840
+ showDivider(t);
2841
+ }
2842
+ const key = `${t.kind}:${reorderCdId(t.kind === 'inside' ? t.container : t.el) ?? ''}`;
2843
+ if (key !== d.settleKey) {
2844
+ if (d.settle != null) clearTimeout(d.settle);
2845
+ d.settleKey = key;
2846
+ d.settle = setTimeout(() => {
2847
+ if (drag === d && d.dragging) applyPreview(d);
2848
+ }, SETTLE_MS);
2849
+ }
2850
+ }
2851
+ };
2852
+
2853
+ const onUp = (e: PointerEvent) => {
2854
+ const d = drag;
2855
+ drag = null;
2856
+ setHighlight(null);
2857
+ hideDivider();
2858
+ if (!d || e.pointerId !== d.pointerId) return;
2859
+ if (!d.dragging) return; // a plain click — leave it to native handlers
2860
+ clearSettle(d);
2861
+ // Un-float: restore the element's exact original inline style.
2862
+ if (d.prevStyle == null) d.el.removeAttribute('style');
2863
+ else d.el.setAttribute('style', d.prevStyle);
2864
+ // Commit priority: a fresh (un-settled) target under the cursor wins;
2865
+ // otherwise the last settled preview (the layout the user is LOOKING at).
2866
+ let drop = d.target ? targetToDrop(d.target) : null;
2867
+ if (drop && d.target) {
2868
+ const t = d.target;
2869
+ animateReflow(d.el, () => applyDrop(d.el, t)); // immediate result; the remount confirms it
2870
+ } else if (d.applied) {
2871
+ drop = d.applied; // DOM already sits there from the preview
2872
+ }
2873
+ if (!drop) return; // dropped on empty space, never previewed — no move
2874
+ // Remember where this node came FROM: if the shell reports the write was
2875
+ // rejected (dgn:'reorder-failed'), we put it back so a phantom move that
2876
+ // never persisted doesn't linger until the next canvas switch.
2877
+ lastCommit = d.origin ? { el: d.el, parent: d.origin.parent, next: d.origin.next } : null;
2878
+ 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
+ const refNode = d.target?.kind === 'inside' ? d.target.container : (d.target?.el ?? null);
2895
+ window.parent.postMessage(
2896
+ {
2897
+ dgn: 'reorder-request',
2898
+ id: d.cdId,
2899
+ refId: drop.refId,
2900
+ position: drop.position,
2901
+ idIndex: occ(d.el),
2902
+ refIndex: occ(refNode),
2903
+ },
2904
+ '*'
2905
+ );
2906
+ };
2907
+
2908
+ // The shell rejected the last reorder — revert the optimistic DOM move.
2909
+ const onReorderFailed = (e: MessageEvent) => {
2910
+ if (e.source !== window.parent) return;
2911
+ const m = e.data as { dgn?: string } | null;
2912
+ if (m?.dgn !== 'reorder-failed' || !lastCommit) return;
2913
+ try {
2914
+ lastCommit.parent.insertBefore(lastCommit.el, lastCommit.next);
2915
+ } catch {
2916
+ /* origin detached — a subsequent load re-syncs */
2917
+ }
2918
+ lastCommit = null;
2919
+ };
2920
+
2921
+ document.addEventListener('pointerdown', onDown, true);
2922
+ window.addEventListener('pointermove', onMove);
2923
+ window.addEventListener('pointerup', onUp);
2924
+ window.addEventListener('keydown', onKeyDown, true);
2925
+ window.addEventListener('message', onReorderFailed);
2926
+ return () => {
2927
+ if (drag) clearSettle(drag);
2928
+ setHighlight(null);
2929
+ window.removeEventListener('message', onReorderFailed);
2930
+ if (dividerEl) {
2931
+ dividerEl.remove();
2932
+ dividerEl = null;
2933
+ }
2934
+ document.removeEventListener('pointerdown', onDown, true);
2935
+ window.removeEventListener('pointermove', onMove);
2936
+ window.removeEventListener('pointerup', onUp);
2937
+ window.removeEventListener('keydown', onKeyDown, true);
2938
+ };
2939
+ }, []);
2940
+
2941
+ return null;
2942
+ }
2943
+
2272
2944
  // ─────────────────────────────────────────────────────────────────────────────
2273
2945
  // GroupBbox — dashed outline around the union of selected elements when N > 1.
2274
2946