@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
@@ -6,19 +6,16 @@
6
6
  * into the dev-server's own inputs (file tree filter, etc.)
7
7
  * never collides.
8
8
  *
9
- * Scope decision — arrow nudge applies to **artboards** only.
10
- * User content inside artboards has no live-position channel
11
- * (canvas surface is HTML; element positions are CSS-derived
12
- * from the compiled JSX, not directly manipulable). Element
13
- * nudge would require either an ephemeral CSS-transform overlay
14
- * (cleaner) or a TSX rewrite channel (heavier). Out of scope
15
- * for this wave — the artboard channel + dragBus.commitPositions
16
- * already exists, so we leverage that and document the
17
- * limitation here.
9
+ * Arrow nudge applies to artboards AND to a single OUT-OF-FLOW
10
+ * element (position:absolute/fixed) the latter added in Task L1
11
+ * once the `reposition-request` channel existed (the original
12
+ * "no live-position channel" limitation is resolved for out-of-flow
13
+ * elements: preview inline, commit the settle via reposition-request).
14
+ * In-flow elements stay a no-op (nudge is ambiguous with no left/top).
18
15
  *
19
- * Cmd+D duplicate is also deferred same architectural reason
20
- * (no duplicate channel). Esc is owned by the input-router's
21
- * onEscape callback, so we don't duplicate it here.
16
+ * Cmd+D duplicate (Task L3) + Cmd+Opt+C/V copy/paste-style (Task L4)
17
+ * are handled here too, relayed to the parent shell. Esc is owned by
18
+ * the input-router's onEscape callback, so we don't duplicate it.
22
19
  *
23
20
  * Distance:
24
21
  * • Arrow → 1 world-unit step (DDR-028 world units, not
@@ -26,10 +23,10 @@
26
23
  * • Shift+Arrow → 10 world-unit step.
27
24
  */
28
25
 
29
- import { useEffect } from 'react';
26
+ import { useEffect, useRef } from 'react';
30
27
 
31
28
  import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
32
- import { scopedCdSelector, selectorIndex } from './dom-selection.ts';
29
+ import { resolveSelectionEl, scopedCdSelector, selectorIndex } from './dom-selection.ts';
33
30
  import { isEditableTarget } from './input-router.tsx';
34
31
  import { type Selection, useSelectionSet } from './use-selection-set.tsx';
35
32
 
@@ -65,6 +62,16 @@ export function useKeyboardDiscipline(): void {
65
62
  const selSet = useSelectionSet();
66
63
  const artboardsCtx = useArtboardsContext();
67
64
  const dragBus = useDragStateContext();
65
+ // Element arrow-nudge burst (Task L1): accumulate left/top, preview inline, and
66
+ // commit once after a short pause (one undo per settle, not per keypress).
67
+ const nudgeRef = useRef<{
68
+ id: string;
69
+ beforeLeft: number;
70
+ beforeTop: number;
71
+ left: number;
72
+ top: number;
73
+ timer: ReturnType<typeof setTimeout>;
74
+ } | null>(null);
68
75
 
69
76
  useEffect(() => {
70
77
  if (typeof document === 'undefined') return;
@@ -101,10 +108,193 @@ export function useKeyboardDiscipline(): void {
101
108
  return;
102
109
  }
103
110
 
104
- // Arrow nudge artboards only (see file header for the why).
111
+ // Cmd/Ctrl+D duplicate the single selected element (Task L3). Main-origin
112
+ // write, so post to the parent shell (pinned to the active canvas + undo).
113
+ // preventDefault so the browser doesn't bookmark the page.
114
+ if (isMeta && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'd') {
115
+ const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
116
+ if (one?.id) {
117
+ e.preventDefault();
118
+ try {
119
+ window.parent.postMessage(
120
+ { dgn: 'duplicate-request', id: one.id, idIndex: one.index },
121
+ '*'
122
+ );
123
+ } catch {
124
+ /* detached / cross-origin */
125
+ }
126
+ }
127
+ return;
128
+ }
129
+
130
+ // Cmd/Ctrl+Opt+C / +V → copy-style / paste-style (Task L4). Copy captures the
131
+ // selection's authored appearance (shell-side); paste applies it to the
132
+ // currently selected element. The shell holds the clipboard + does the write.
133
+ if (isMeta && e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'v')) {
134
+ const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
135
+ if (one?.id) {
136
+ e.preventDefault();
137
+ try {
138
+ window.parent.postMessage(
139
+ e.key.toLowerCase() === 'c'
140
+ ? { dgn: 'copy-style' }
141
+ : { dgn: 'paste-style', id: one.id },
142
+ '*'
143
+ );
144
+ } catch {
145
+ /* detached / cross-origin */
146
+ }
147
+ }
148
+ return;
149
+ }
150
+
151
+ // Keyboard tree traversal (Task L2) — Enter = first child, Shift+Enter =
152
+ // parent, Tab / Shift+Tab = next / previous sibling. Mouse-free selection of
153
+ // the deeply-nested / overlapping elements the plan's predictability goal is
154
+ // about. Scoped to a single element selection + no meta modifier.
155
+ if (!isMeta && !e.altKey && (e.key === 'Enter' || e.key === 'Tab')) {
156
+ const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
157
+ if (!one?.id) return;
158
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
159
+ if (!el) return;
160
+ let next: Element | null = null;
161
+ if (e.key === 'Enter' && !e.shiftKey) {
162
+ next = el.querySelector('[data-cd-id]'); // first descendant
163
+ } else if (e.key === 'Enter' && e.shiftKey) {
164
+ next = el.parentElement?.closest('[data-cd-id]') ?? null; // parent
165
+ } else if (e.key === 'Tab') {
166
+ // Next / previous stamped sibling in document order.
167
+ const forward = !e.shiftKey;
168
+ let s: Element | null = forward ? el.nextElementSibling : el.previousElementSibling;
169
+ while (s) {
170
+ if (s.hasAttribute('data-cd-id')) {
171
+ next = s;
172
+ break;
173
+ }
174
+ const inner = s.querySelector('[data-cd-id]');
175
+ if (inner) {
176
+ next = inner;
177
+ break;
178
+ }
179
+ s = forward ? s.nextElementSibling : s.previousElementSibling;
180
+ }
181
+ }
182
+ // Don't leave the artboard body (a parent hop past the artboard root).
183
+ if (next?.closest('.dc-artboard-body') && next.getAttribute('data-cd-id')) {
184
+ e.preventDefault();
185
+ const cdId = next.getAttribute('data-cd-id') as string;
186
+ const artboardId =
187
+ next.closest('[data-dc-screen]')?.getAttribute('data-dc-screen') ?? null;
188
+ const sel = scopedCdSelector(cdId, artboardId);
189
+ selSet.replace([
190
+ {
191
+ id: cdId,
192
+ selector: sel,
193
+ artboardId,
194
+ index: selectorIndex(document, sel, next),
195
+ tag: next.tagName.toLowerCase(),
196
+ },
197
+ ]);
198
+ } else if (e.key === 'Tab') {
199
+ // Swallow Tab even with no target, so focus doesn't jump out of the canvas.
200
+ e.preventDefault();
201
+ }
202
+ return;
203
+ }
204
+
205
+ // Delete / Backspace → request a source delete of the single selected
206
+ // element (feature-element-editing-robustness Stage I). The write is
207
+ // main-origin-only (DDR-054), so we POST the request to the parent shell,
208
+ // which performs it (pinned to the active canvas) + records undo. Guarded
209
+ // against text-editing focus above; skip modifier combos + non-single /
210
+ // artboard-only selections (whole-artboard delete is out of scope here).
211
+ if ((e.key === 'Delete' || e.key === 'Backspace') && !isMeta && !e.altKey) {
212
+ const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
213
+ if (one?.id) {
214
+ e.preventDefault();
215
+ try {
216
+ // `index` is the DOM occurrence among same-cd-id nodes, which equals
217
+ // the reused-component usage index — so a shared-instance delete
218
+ // targets the right <Component/> usage (resolveUsageId, canvas-edit).
219
+ window.parent.postMessage(
220
+ { dgn: 'delete-request', id: one.id, idIndex: one.index },
221
+ '*'
222
+ );
223
+ } catch {
224
+ /* detached / cross-origin */
225
+ }
226
+ } else if (one?.artboardId && !one.id) {
227
+ // Whole-artboard delete — addressed by its `id` prop (no cd-id on the
228
+ // rendered <article>). Shell performs it main-origin-only + records undo.
229
+ e.preventDefault();
230
+ try {
231
+ window.parent.postMessage(
232
+ { dgn: 'delete-artboard-request', artboardId: one.artboardId },
233
+ '*'
234
+ );
235
+ } catch {
236
+ /* detached / cross-origin */
237
+ }
238
+ }
239
+ return;
240
+ }
241
+
105
242
  const delta = nudgeDelta({ key: e.key, shift: e.shiftKey });
106
243
  if (!delta) return;
107
244
  if (isMeta || e.altKey) return; // modifier combos reserved for future
245
+
246
+ // Arrow nudge — a single OUT-OF-FLOW element (position:absolute/fixed).
247
+ // In-flow elements are a no-op (nudging a flow element is ambiguous — it has
248
+ // no free left/top). Preview inline, commit the settle via reposition-request.
249
+ {
250
+ const one = selSet.selected.length === 1 ? selSet.selected[0] : null;
251
+ if (one?.id) {
252
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
253
+ if (!el) return;
254
+ const cs = getComputedStyle(el);
255
+ if (cs.position !== 'absolute' && cs.position !== 'fixed') return; // no-op
256
+ e.preventDefault();
257
+ const prev = nudgeRef.current?.id === one.id ? nudgeRef.current : null;
258
+ const curLeft = prev
259
+ ? prev.left
260
+ : Number.parseFloat(el.style.left) || Number.parseFloat(cs.left) || 0;
261
+ const curTop = prev
262
+ ? prev.top
263
+ : Number.parseFloat(el.style.top) || Number.parseFloat(cs.top) || 0;
264
+ const beforeLeft = prev ? prev.beforeLeft : curLeft;
265
+ const beforeTop = prev ? prev.beforeTop : curTop;
266
+ const left = curLeft + delta.dx;
267
+ const top = curTop + delta.dy;
268
+ el.style.left = `${left}px`;
269
+ el.style.top = `${top}px`;
270
+ if (prev) clearTimeout(prev.timer);
271
+ const id = one.id;
272
+ const timer = setTimeout(() => {
273
+ const s = nudgeRef.current;
274
+ nudgeRef.current = null;
275
+ if (!s) return;
276
+ try {
277
+ window.parent.postMessage(
278
+ {
279
+ dgn: 'reposition-request',
280
+ id: s.id,
281
+ left: s.left,
282
+ top: s.top,
283
+ beforeLeft: s.beforeLeft,
284
+ beforeTop: s.beforeTop,
285
+ },
286
+ '*'
287
+ );
288
+ } catch {
289
+ /* detached / cross-origin */
290
+ }
291
+ }, 350);
292
+ nudgeRef.current = { id, beforeLeft, beforeTop, left, top, timer };
293
+ return;
294
+ }
295
+ }
296
+
297
+ // Arrow nudge — artboards only (see file header for the why).
108
298
  if (!artboardsCtx || !dragBus) return;
109
299
 
110
300
  const artboardSelections = selSet.selected.filter((s) => !!s.artboardId && !s.id);
@@ -60,6 +60,12 @@ export interface Selection {
60
60
  text?: string;
61
61
  dom_path?: string[];
62
62
  bounds?: { x: number; y: number; w: number; h: number } | null;
63
+ /** Stage D4 tail — WORLD-unit size (`offsetWidth`/`offsetHeight`, unaffected by
64
+ * the `.dc-world` zoom transform, unlike `bounds` which is the SCREEN rect).
65
+ * The Inspector's artboard-resize fields need the true JSX-authored size to
66
+ * pre-fill correctly regardless of zoom. */
67
+ worldW?: number;
68
+ worldH?: number;
63
69
  html?: string;
64
70
  /** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
65
71
  authored?: Record<string, string>;
@@ -70,6 +76,14 @@ export interface Selection {
70
76
  /** Phase 12.3 — custom HTML attributes on the element (data-, aria-, role, title…),
71
77
  * so the panel reflects a custom attribute the user added via the escape hatch. */
72
78
  attrs?: Record<string, string>;
79
+ /** Stage M — the PARENT element's resolved `display` + `flex-direction`, captured
80
+ * at selection time (the shell can't reach the cross-origin iframe to read them
81
+ * later). Drives the Fixed/Hug/Fill sizing control: "Fill" is `flex-grow:1` on a
82
+ * flex child's MAIN axis, `align-self:stretch` on its cross axis, else `100%`. Also
83
+ * gates the flex-child (align-self / flex-grow…) rows so they don't show on a
84
+ * block child. Absent for a detached node / no parent. */
85
+ parentDisplay?: string;
86
+ parentFlexDirection?: string;
73
87
  }
74
88
 
75
89
  interface SelectionSetValue {
@@ -0,0 +1,388 @@
1
+ /**
2
+ * @file use-spacing-handles.tsx — Stage J (feature-element-editing-robustness)
3
+ * @scope apps/studio/use-spacing-handles.tsx
4
+ * @purpose On-canvas padding + gap drag. 4 small padding-edge handles (always,
5
+ * for the single selected element) + one dot per flex gap (only when
6
+ * the element is a flex/inline-flex container with ≥2 children).
7
+ * Live preview via direct inline-style writes (mirrors
8
+ * `use-element-resize.tsx`'s `applyPreview`); commit rides the SAME
9
+ * `resize-request` lane the element-resize overlay already uses —
10
+ * `padding-top/right/bottom/left`/`gap` were added to the shell's
11
+ * `resizeElement` allowed-prop list, so this needed no new write
12
+ * surface. A component-instance drag threads the Stage H3 occurrence
13
+ * index the same way (`globalCdOccurrence`) so it stays local.
14
+ *
15
+ * CSS-Grid gap editing is out of v1 scope — grid tracks are a
16
+ * separate follow-up plan (`feature-grid-track-editor.md`, Stage M3).
17
+ */
18
+
19
+ import { type ReactNode, useCallback, useEffect, useRef } from 'react';
20
+ import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts';
21
+ import { isElementDragActive } from './drag-state.ts';
22
+ import {
23
+ computeGapDrag,
24
+ computeGapMidpoints,
25
+ computePaddingDrag,
26
+ computePaddingLines,
27
+ flexMainAxis,
28
+ type PaddingSide,
29
+ paddingSideSet,
30
+ } from './spacing-handles.ts';
31
+ import { scaleFromMatrix } from './use-element-resize.tsx';
32
+ import { useSelectionSet } from './use-selection-set.tsx';
33
+ import { useToolMode } from './use-tool-mode.tsx';
34
+
35
+ const SPACING_CSS = `
36
+ .dc-spacing-handle {
37
+ position: fixed;
38
+ transform: translate(-50%, -50%);
39
+ /* Below the Stage-D resize handles (z-index 6) — at 0 padding a spacing
40
+ handle sits exactly on the border, where a resize mid-edge handle also
41
+ lives; the resize handle should win that hit-test tie. */
42
+ z-index: 4;
43
+ pointer-events: auto;
44
+ touch-action: none;
45
+ /* INV-2 — the idle-to-hover/dragging opacity bump (0.55/0.7 to 1, below) had
46
+ no transition, so the reduced-motion collapse further down was dead code
47
+ (nothing to collapse). Kept as a plain opacity transition, not a reveal/
48
+ unmount fade — this component still hard-toggles display to show/hide
49
+ the whole handle set (unlike the resize overlay's fade), so a handle is
50
+ grabbable the instant it appears, no half-visible window. */
51
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
52
+ }
53
+ .dc-spacing-handle--padding {
54
+ width: 10px;
55
+ height: 10px;
56
+ border-radius: 2px;
57
+ background: var(--maude-hud-accent, oklch(0.680 0.180 268));
58
+ border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
59
+ opacity: 0.55;
60
+ }
61
+ .dc-spacing-handle--padding:hover,
62
+ .dc-spacing-handle--padding[data-dragging="true"] { opacity: 1; }
63
+ .dc-spacing-handle--padding[data-side="top"],
64
+ .dc-spacing-handle--padding[data-side="bottom"] { cursor: ns-resize; }
65
+ .dc-spacing-handle--padding[data-side="left"],
66
+ .dc-spacing-handle--padding[data-side="right"] { cursor: ew-resize; }
67
+ .dc-spacing-handle--gap {
68
+ width: 8px;
69
+ height: 8px;
70
+ border-radius: 50%;
71
+ background: #FF24BD;
72
+ border: 1px solid rgba(255, 255, 255, 0.85);
73
+ opacity: 0.7;
74
+ }
75
+ .dc-spacing-handle--gap:hover,
76
+ .dc-spacing-handle--gap[data-dragging="true"] { opacity: 1; }
77
+ .dc-spacing-handle--gap[data-axis="x"] { cursor: ew-resize; }
78
+ .dc-spacing-handle--gap[data-axis="y"] { cursor: ns-resize; }
79
+ @media (prefers-reduced-motion: reduce) {
80
+ .dc-spacing-handle { transition-duration: 1ms; }
81
+ }
82
+ `.trim();
83
+
84
+ function ensureSpacingStyles(): void {
85
+ if (typeof document === 'undefined') return;
86
+ if (document.getElementById('dc-spacing-css')) return;
87
+ const s = document.createElement('style');
88
+ s.id = 'dc-spacing-css';
89
+ s.textContent = SPACING_CSS;
90
+ document.head.appendChild(s);
91
+ }
92
+
93
+ function computedPx(cs: CSSStyleDeclaration, prop: string): number {
94
+ return Number.parseFloat(cs.getPropertyValue(prop)) || 0;
95
+ }
96
+
97
+ interface SpacingDrag {
98
+ pointerId: number;
99
+ kind: 'padding' | 'gap';
100
+ side: PaddingSide | null;
101
+ axis: 'x' | 'y';
102
+ el: HTMLElement;
103
+ cdId: string;
104
+ idIndex: number;
105
+ startClientX: number;
106
+ startClientY: number;
107
+ zoom: number;
108
+ startValue: number;
109
+ /** The single CSS property this drag writes — `padding-<side>` or `gap`. */
110
+ prop: string;
111
+ /** Inline value BEFORE the drag (`el.style.getPropertyValue(prop)` or null). */
112
+ before: string | null;
113
+ /** All 4 padding sides' inline values BEFORE the drag — for the Alt (pair) /
114
+ * Alt+Shift (all) box-model modifiers (`paddingSideSet`), which can touch
115
+ * sides OTHER than the one grabbed. Undefined for a gap drag. */
116
+ paddingBefore?: Record<PaddingSide, string | null>;
117
+ }
118
+
119
+ /** One touched property's before/after — the shape `resizeElement`'s multi-prop
120
+ * `patch`/`before` objects already accept (Stage D3/L8 established this). */
121
+ interface PropEntry {
122
+ prop: string;
123
+ before: string | null;
124
+ }
125
+
126
+ export function SpacingHandlesOverlay(): ReactNode {
127
+ ensureSpacingStyles();
128
+ const { selected } = useSelectionSet();
129
+ const { tool } = useToolMode();
130
+ const containerRef = useRef<HTMLDivElement | null>(null);
131
+ const rafRef = useRef<number | null>(null);
132
+ const dragRef = useRef<SpacingDrag | null>(null);
133
+ const lastCommitRef = useRef<{ el: HTMLElement; entries: PropEntry[] } | null>(null);
134
+
135
+ const one = selected.length === 1 ? selected[0] : null;
136
+ const cdId = one && typeof one.id === 'string' ? one.id : null;
137
+ const active = tool === 'move' && !!cdId;
138
+
139
+ // rAF loop — recompute + repaint every frame (pan/zoom/layout AND a live drag,
140
+ // since a gap drag reflows sibling children — re-deriving from the DOM each
141
+ // frame is simpler and more correct than hand-propagating the effect to every
142
+ // other gap handle).
143
+ useEffect(() => {
144
+ const c = containerRef.current;
145
+ if (!c) return;
146
+ const hideAll = () => {
147
+ for (const child of Array.from(c.children)) (child as HTMLElement).style.display = 'none';
148
+ };
149
+ if (!active || !one) {
150
+ hideAll();
151
+ return;
152
+ }
153
+ const tick = () => {
154
+ rafRef.current = null;
155
+ // Dogfood 2026-07-07 — a live ReorderDrag is the canvas's most DOM-churn-
156
+ // heavy gesture; skip this overlay's own getBoundingClientRect/
157
+ // getComputedStyle work while one is in flight (its handles are
158
+ // irrelevant mid-drag anyway — the drag has its own live-preview chrome).
159
+ if (isElementDragActive()) {
160
+ hideAll();
161
+ rafRef.current = requestAnimationFrame(tick);
162
+ return;
163
+ }
164
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
165
+ if (!el) {
166
+ hideAll();
167
+ rafRef.current = requestAnimationFrame(tick);
168
+ return;
169
+ }
170
+ const r = el.getBoundingClientRect();
171
+ if (r.width <= 0 && r.height <= 0) {
172
+ hideAll();
173
+ rafRef.current = requestAnimationFrame(tick);
174
+ return;
175
+ }
176
+ const cs = getComputedStyle(el);
177
+ const world = el.closest('.dc-world') as HTMLElement | null;
178
+ const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
179
+ const padding = {
180
+ top: computedPx(cs, 'padding-top'),
181
+ right: computedPx(cs, 'padding-right'),
182
+ bottom: computedPx(cs, 'padding-bottom'),
183
+ left: computedPx(cs, 'padding-left'),
184
+ };
185
+ const lines = computePaddingLines(
186
+ { x: r.left, y: r.top, w: r.width, h: r.height },
187
+ padding,
188
+ zoom
189
+ );
190
+ const isFlex = cs.display === 'flex' || cs.display === 'inline-flex';
191
+ const axis = flexMainAxis(cs.flexDirection);
192
+ const gapPoints = isFlex
193
+ ? computeGapMidpoints(
194
+ Array.from(el.children).map((child) => {
195
+ const cr = (child as HTMLElement).getBoundingClientRect();
196
+ return { x: cr.left, y: cr.top, w: cr.width, h: cr.height };
197
+ }),
198
+ axis
199
+ )
200
+ : [];
201
+ const TOTAL = lines.length + gapPoints.length;
202
+ while (c.children.length < TOTAL) c.appendChild(document.createElement('div'));
203
+ while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild);
204
+ let i = 0;
205
+ for (const line of lines) {
206
+ const h = c.children[i] as HTMLElement;
207
+ h.className = 'dc-spacing-handle dc-spacing-handle--padding';
208
+ h.dataset.side = line.side;
209
+ h.dataset.prop = `padding-${line.side}`;
210
+ h.dataset.axis = line.axis;
211
+ h.title = `padding-${line.side} · drag to scrub · alt = symmetric pair · alt+shift = all sides`;
212
+ h.style.display = 'block';
213
+ h.style.left = `${Math.round(line.x)}px`;
214
+ h.style.top = `${Math.round(line.y)}px`;
215
+ i++;
216
+ }
217
+ for (const pt of gapPoints) {
218
+ const h = c.children[i] as HTMLElement;
219
+ h.className = 'dc-spacing-handle dc-spacing-handle--gap';
220
+ h.dataset.side = '';
221
+ h.dataset.prop = 'gap';
222
+ h.title = 'gap · drag to scrub';
223
+ h.dataset.axis = axis;
224
+ h.style.display = 'block';
225
+ h.style.left = `${Math.round(pt.x)}px`;
226
+ h.style.top = `${Math.round(pt.y)}px`;
227
+ i++;
228
+ }
229
+ rafRef.current = requestAnimationFrame(tick);
230
+ };
231
+ rafRef.current = requestAnimationFrame(tick);
232
+ return () => {
233
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
234
+ };
235
+ }, [active, one]);
236
+
237
+ /** Commit one or more touched properties in a SINGLE `resize-request` — a
238
+ * plain drag has one entry; an Alt (pair) / Alt+Shift (all sides) padding
239
+ * drag has 2 or 4, all set to the SAME value (mirrors the CSS panel's own
240
+ * `side()` scrub semantics — every touched side becomes the dragged value,
241
+ * not each grown independently). */
242
+ const commit = useCallback((d: SpacingDrag, entries: PropEntry[], value: number) => {
243
+ if (!entries.length) return;
244
+ const after = `${value}px`;
245
+ lastCommitRef.current = { el: d.el, entries };
246
+ const patch: Record<string, string> = {};
247
+ const before: Record<string, string | null> = {};
248
+ for (const e of entries) {
249
+ patch[e.prop] = after;
250
+ before[e.prop] = e.before;
251
+ }
252
+ try {
253
+ window.parent.postMessage(
254
+ { dgn: 'resize-request', id: d.cdId, patch, before, idIndex: d.idIndex },
255
+ '*'
256
+ );
257
+ } catch {
258
+ /* detached / cross-origin teardown */
259
+ }
260
+ }, []);
261
+
262
+ useEffect(() => {
263
+ const c = containerRef.current;
264
+ if (!c) return;
265
+
266
+ const onDown = (e: PointerEvent) => {
267
+ const t = e.target as HTMLElement | null;
268
+ if (!t?.classList.contains('dc-spacing-handle')) return;
269
+ if (!one || typeof one.id !== 'string') return;
270
+ const el = resolveSelectionEl(document, one) as HTMLElement | null;
271
+ if (!el) return;
272
+ e.preventDefault();
273
+ e.stopPropagation();
274
+ const prop = t.dataset.prop || '';
275
+ const isGap = prop === 'gap';
276
+ const cs = getComputedStyle(el);
277
+ const world = el.closest('.dc-world') as HTMLElement | null;
278
+ const zoom = world ? scaleFromMatrix(getComputedStyle(world).transform) : 1;
279
+ const drag: SpacingDrag = {
280
+ pointerId: e.pointerId,
281
+ kind: isGap ? 'gap' : 'padding',
282
+ side: isGap ? null : ((t.dataset.side as PaddingSide) ?? null),
283
+ axis: (t.dataset.axis as 'x' | 'y') ?? 'x',
284
+ el,
285
+ cdId: one.id,
286
+ idIndex: globalCdOccurrence(document, one.id, el),
287
+ startClientX: e.clientX,
288
+ startClientY: e.clientY,
289
+ zoom: zoom > 0 ? zoom : 1,
290
+ startValue: computedPx(cs, prop),
291
+ prop,
292
+ before: el.style.getPropertyValue(prop) || null,
293
+ // Alt (pair) / Alt+Shift (all sides) can touch sides OTHER than the one
294
+ // grabbed — capture every side's PRE-drag inline value up front so a
295
+ // late modifier press still gets the correct undo `before`.
296
+ paddingBefore: isGap
297
+ ? undefined
298
+ : {
299
+ top: el.style.getPropertyValue('padding-top') || null,
300
+ right: el.style.getPropertyValue('padding-right') || null,
301
+ bottom: el.style.getPropertyValue('padding-bottom') || null,
302
+ left: el.style.getPropertyValue('padding-left') || null,
303
+ },
304
+ };
305
+ dragRef.current = drag;
306
+ t.dataset.dragging = 'true';
307
+ try {
308
+ t.setPointerCapture(e.pointerId);
309
+ } catch {
310
+ /* synthetic events may reject capture */
311
+ }
312
+ };
313
+
314
+ // Alt (pair) / Alt+Shift (all sides) — the CSS panel's own box-model scrub
315
+ // grammar, matched exactly (`paddingSideSet`). Gap has no "opposite side"
316
+ // concept, so modifiers only ever apply to a padding drag.
317
+ const touchedEntries = (d: SpacingDrag, ev: PointerEvent): PropEntry[] => {
318
+ if (d.kind === 'gap' || !d.paddingBefore) return [{ prop: d.prop, before: d.before }];
319
+ const sides = paddingSideSet(d.side ?? 'top', ev.altKey, ev.shiftKey);
320
+ return sides.map((s) => ({ prop: `padding-${s}`, before: d.paddingBefore?.[s] ?? null }));
321
+ };
322
+
323
+ const onMove = (e: PointerEvent) => {
324
+ const d = dragRef.current;
325
+ if (!d || e.pointerId !== d.pointerId) return;
326
+ e.preventDefault();
327
+ const dx = e.clientX - d.startClientX;
328
+ const dy = e.clientY - d.startClientY;
329
+ const next =
330
+ d.kind === 'gap'
331
+ ? computeGapDrag(d.axis, d.startValue, dx, dy, d.zoom)
332
+ : computePaddingDrag(d.side ?? 'top', d.startValue, dx, dy, d.zoom);
333
+ for (const entry of touchedEntries(d, e)) d.el.style.setProperty(entry.prop, `${next}px`);
334
+ };
335
+
336
+ const onUp = (e: PointerEvent) => {
337
+ const d = dragRef.current;
338
+ if (!d || e.pointerId !== d.pointerId) return;
339
+ dragRef.current = null;
340
+ const handle = containerRef.current?.querySelector<HTMLElement>(
341
+ `.dc-spacing-handle[data-prop="${d.prop}"][data-dragging="true"]`
342
+ );
343
+ if (handle) delete handle.dataset.dragging;
344
+ const dx = e.clientX - d.startClientX;
345
+ const dy = e.clientY - d.startClientY;
346
+ const next =
347
+ d.kind === 'gap'
348
+ ? computeGapDrag(d.axis, d.startValue, dx, dy, d.zoom)
349
+ : computePaddingDrag(d.side ?? 'top', d.startValue, dx, dy, d.zoom);
350
+ const entries = touchedEntries(d, e);
351
+ const after = `${next}px`;
352
+ const changed = entries.some((entry) => after !== (entry.before ?? `${d.startValue}px`));
353
+ if (!changed) return; // no-op guard
354
+ commit(d, entries, next);
355
+ };
356
+
357
+ // Restore the pre-drag inline value(s) if the shell reports the write
358
+ // failed (shares `resize-failed` with the element-resize overlay — both
359
+ // restore from their OWN lastCommitRef, so they don't collide).
360
+ const onFail = (e: MessageEvent) => {
361
+ const m = e.data as { dgn?: string } | null;
362
+ if (m?.dgn !== 'resize-failed') return;
363
+ if (e.source !== window.parent) return; // only the parent shell (DDR-054)
364
+ const last = lastCommitRef.current;
365
+ if (!last) return;
366
+ for (const entry of last.entries) {
367
+ if (entry.before == null) last.el.style.removeProperty(entry.prop);
368
+ else last.el.style.setProperty(entry.prop, entry.before);
369
+ }
370
+ lastCommitRef.current = null;
371
+ };
372
+
373
+ c.addEventListener('pointerdown', onDown);
374
+ window.addEventListener('pointermove', onMove);
375
+ window.addEventListener('pointerup', onUp);
376
+ window.addEventListener('pointercancel', onUp);
377
+ window.addEventListener('message', onFail);
378
+ return () => {
379
+ c.removeEventListener('pointerdown', onDown);
380
+ window.removeEventListener('pointermove', onMove);
381
+ window.removeEventListener('pointerup', onUp);
382
+ window.removeEventListener('pointercancel', onUp);
383
+ window.removeEventListener('message', onFail);
384
+ };
385
+ }, [one, commit]);
386
+
387
+ return <div ref={containerRef} aria-hidden="true" />;
388
+ }