@1agh/maude 0.19.0 → 0.20.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 (66) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
  3. package/plugins/design/dev-server/annotations-layer.tsx +343 -112
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
  6. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  7. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +691 -119
  9. package/plugins/design/dev-server/canvas-shell.tsx +818 -42
  10. package/plugins/design/dev-server/client/app.jsx +173 -52
  11. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  12. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  13. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  14. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  15. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  16. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  17. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  18. package/plugins/design/dev-server/config.schema.json +31 -5
  19. package/plugins/design/dev-server/context-menu.tsx +3 -3
  20. package/plugins/design/dev-server/context.ts +37 -3
  21. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  22. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  23. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  24. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  25. package/plugins/design/dev-server/dist/styles.css +38 -2
  26. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  27. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  28. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  29. package/plugins/design/dev-server/handoff.ts +24 -0
  30. package/plugins/design/dev-server/http.ts +41 -2
  31. package/plugins/design/dev-server/input-router.tsx +74 -10
  32. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  33. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  34. package/plugins/design/dev-server/server.mjs +84 -18
  35. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  36. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  37. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  38. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  39. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  40. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  41. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  42. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  43. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  44. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  45. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  46. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  47. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  48. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  49. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  50. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  51. package/plugins/design/dev-server/undo-stack.ts +240 -0
  52. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  53. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  54. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  55. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  56. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  57. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
  58. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  59. package/plugins/design/templates/_shell.html +17 -6
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  61. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  62. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  63. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  64. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  65. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  66. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -37,11 +37,16 @@ import {
37
37
  } from 'react';
38
38
 
39
39
  import { AnnotationsLayer } from './annotations-layer.tsx';
40
+ import { ArtboardMarqueeOverlay } from './artboard-marquee.tsx';
40
41
  import {
42
+ type ArtboardRect,
41
43
  SnapGuideOverlay,
42
44
  type ViewportControllerHandle,
45
+ useArtboardsContext,
46
+ useDragStateContext,
43
47
  useViewportControllerContext,
44
48
  } from './canvas-lib.tsx';
49
+ import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
45
50
  import { CommentsOverlay } from './comments-overlay.tsx';
46
51
  import {
47
52
  ContextMenuProvider,
@@ -51,16 +56,23 @@ import {
51
56
  type MenuItem,
52
57
  useContextMenu,
53
58
  } from './context-menu.tsx';
59
+ import { ContextualToolbar } from './contextual-toolbar.tsx';
60
+ import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
54
61
  import { ExportDialogProvider } from './export-dialog.tsx';
55
62
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
63
+ import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
56
64
  import { ToolPalette } from './tool-palette.tsx';
65
+ import { UndoHud } from './undo-hud.tsx';
57
66
  import {
58
67
  AnnotationSelectionProvider,
59
68
  useAnnotationSelection,
60
69
  } from './use-annotation-selection.tsx';
61
70
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
71
+ import { useCursorModifiers } from './use-cursor-modifiers.tsx';
72
+ import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
62
73
  import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
63
74
  import { useToolMode } from './use-tool-mode.tsx';
75
+ import { useUndoStack } from './use-undo-stack.tsx';
64
76
 
65
77
  // ─────────────────────────────────────────────────────────────────────────────
66
78
  // Styles — halos render as `position: fixed` siblings of the canvas. Reading
@@ -69,43 +81,100 @@ import { useToolMode } from './use-tool-mode.tsx';
69
81
  // plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
70
82
  // = invisible). No per-element class stamping is used.
71
83
 
84
+ // DDR-046 — Three-state halo language. Each state has its own border weight,
85
+ // color treatment, and geometric idiom so 8+ semantic states (hover / selected
86
+ // / member-of-multi / group / snap-sibling / snap-grid / marquee / annotation
87
+ // / active-artboard) stay visually distinct. Painting one with another's
88
+ // idiom is a regression.
72
89
  const HALO_CSS = `
73
90
  .dc-cv-halo {
74
91
  position: fixed;
75
92
  pointer-events: none;
76
93
  z-index: 5;
77
- border: 2px solid var(--accent, #d63b1f);
78
94
  box-sizing: border-box;
79
95
  border-radius: 2px;
80
96
  transition: opacity 60ms linear;
81
97
  }
98
+ /* Hover — lighter 1.5px tinted line + white inner ring for contrast on dark
99
+ elements. NO ring, NO ticks. Synchronous paint (no debounce). */
82
100
  .dc-cv-halo--hover {
83
- border-width: 2px;
84
- opacity: 0.85;
101
+ border: 1.5px solid color-mix(in oklab, var(--accent, #0d99ff) 60%, transparent);
102
+ box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
85
103
  }
104
+ /* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
105
+ Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
86
106
  .dc-cv-halo--selected {
87
- border-width: 2px;
88
- box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #d63b1f) 18%, transparent);
107
+ border: 2px solid var(--accent, #0d99ff);
108
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #0d99ff) 18%, transparent);
89
109
  }
110
+ .dc-cv-halo--selected .tick {
111
+ position: absolute;
112
+ width: 8px;
113
+ height: 8px;
114
+ background: var(--accent, #0d99ff);
115
+ border-radius: 1px;
116
+ box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
117
+ }
118
+ .dc-cv-halo--selected .tick-tl { top: -3px; left: -3px; }
119
+ .dc-cv-halo--selected .tick-tr { top: -3px; right: -3px; }
120
+ .dc-cv-halo--selected .tick-bl { bottom: -3px; left: -3px; }
121
+ .dc-cv-halo--selected .tick-br { bottom: -3px; right: -3px; }
122
+ /* Selected (member of multi-selection) — 1.5 px solid full-accent outline.
123
+ No ring, no ticks (the group bbox above carries the container signal).
124
+ T16 / DDR-046 rev 2 — full opacity instead of 50%-tinted: tinted lines read
125
+ as "draft / placeholder" and members would melt away inside artboards once
126
+ the artboard border itself is 22%-tinted (T15). */
127
+ .dc-cv-halo--selected-member {
128
+ border: 1.5px solid var(--accent, #0d99ff);
129
+ }
130
+ /* Group bbox — 1 px DASHED full accent + four 6 × 6 square corner handles.
131
+ T16 / DDR-046 rev 2 — dashed is the canonical group-container affordance
132
+ (Figma group bbox, FigJam Section drag-state, Photoshop marching ants).
133
+ Dashed reads "ambient binding" without claiming subject-ness; the loud
134
+ solid outlines on each MEMBER carry the active-selection signal. Corner
135
+ handles are 6 × 6 (vs single-select's 8 × 8 ticks) so the group idiom
136
+ reads as "thinner authority" than single-select. */
90
137
  .dc-cv-group-bbox {
91
138
  position: fixed;
92
139
  pointer-events: none;
93
140
  z-index: 5;
94
- border: 1px dashed var(--accent, #d63b1f);
141
+ border: 1px dashed var(--accent, #0d99ff);
95
142
  border-radius: 2px;
96
- opacity: 0.85;
97
143
  }
144
+ .dc-cv-group-bbox .tick {
145
+ position: absolute;
146
+ width: 6px;
147
+ height: 6px;
148
+ background: var(--accent, #0d99ff);
149
+ border-radius: 1px;
150
+ box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
151
+ }
152
+ .dc-cv-group-bbox .tick-tl { top: -3px; left: -3px; }
153
+ .dc-cv-group-bbox .tick-tr { top: -3px; right: -3px; }
154
+ .dc-cv-group-bbox .tick-bl { bottom: -3px; left: -3px; }
155
+ .dc-cv-group-bbox .tick-br { bottom: -3px; right: -3px; }
98
156
  /*
99
157
  * Active-artboard indicator — the artboard whose center sits closest to the
100
158
  * viewport midpoint after pan settles is "active" (DesignCanvas tracks this
101
- * for keyboard jumps + the /design:edit context anchor). Phase 4 shipped a
102
- * 2 px accent ring there; that's too loud next to selection halos. Keep it
103
- * subtle: 1 px outline at low opacity. The drop-shadow stays untouched.
159
+ * for keyboard jumps + the /design:edit context anchor). DDR-046 ring sits
160
+ * OUTSIDE the hard drop-shadow so it's visible at any pan distance / zoom.
161
+ * 120 ms ease-out so activation is felt, not invisible.
104
162
  */
105
163
  .dc-canvas .dc-artboard[aria-current="true"] {
106
- box-shadow:
107
- 6px 6px 0 var(--fg-0, #2a2520),
108
- 0 0 0 1px color-mix(in oklab, var(--accent, #d63b1f) 40%, transparent);
164
+ /* T15 — quiet frame, single 2 px accent ring. The previous double-shadow
165
+ (3 px ring + hard 6×6×0 offset) was readable but visually expensive once
166
+ the frame itself lost its brutalist treatment. A 2 px ring on a 22 %
167
+ tinted hairline reads unambiguous without claiming subject-ness. */
168
+ box-shadow: 0 0 0 2px var(--accent, #0d99ff);
169
+ transition: box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1);
170
+ }
171
+ /* Respect prefers-reduced-motion across all chrome transitions. */
172
+ @media (prefers-reduced-motion: reduce) {
173
+ .dc-cv-halo,
174
+ .dc-cv-group-bbox,
175
+ .dc-canvas .dc-artboard[aria-current="true"] {
176
+ transition: none !important;
177
+ }
109
178
  }
110
179
  /*
111
180
  * Force tool cursor across the canvas tree in comment / hand modes. Without
@@ -114,17 +183,37 @@ const HALO_CSS = `
114
183
  * the user hovers an interactive element — wrong signal when native
115
184
  * interactions are suppressed by the router anyway.
116
185
  */
186
+ /* T22 — per-tool SVG cursors. Each cursor is a 16 × 16 SVG data-URI with a
187
+ declared hotspot. The crosshair / cell / grab fallbacks remain in the
188
+ chain so older browsers still get a recognisable affordance. SVGs are
189
+ utf-8 encoded inline (Chromium / Safari 16+ / Firefox 117+ all accept). */
190
+ .dc-canvas {
191
+ --cursor-pen: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M14 1.5 L9 6.5 L2.5 13.5 L1.5 14.5 L2 13 L8.5 6 L13.5 1.5 Z' fill='%23111' stroke='white' stroke-width='0.7' stroke-linejoin='round'/></svg>") 2 14;
192
+ --cursor-rect: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><rect x='10.5' y='10.5' width='4' height='3' fill='white' stroke='%23111' stroke-width='1'/></svg>") 8 8;
193
+ --cursor-ellipse: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><ellipse cx='12.5' cy='12' rx='2.5' ry='1.5' fill='white' stroke='%23111' stroke-width='1'/></svg>") 8 8;
194
+ --cursor-arrow: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><line x1='0.5' y1='8' x2='15.5' y2='8' stroke='%23111' stroke-width='1'/><line x1='8' y1='0.5' x2='8' y2='15.5' stroke='%23111' stroke-width='1'/><path d='M10 13 L15 13 M12.5 11 L15 13 L12.5 15' fill='none' stroke='%23111' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/></svg>") 8 8;
195
+ --cursor-eraser: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><rect x='3' y='9' width='8' height='5' rx='1' fill='%23ffb3da' stroke='%23111' stroke-width='1' transform='rotate(-20 7 11.5)'/><rect x='3' y='9' width='8' height='2' fill='%23d63b6e' transform='rotate(-20 7 10)' opacity='0.6'/></svg>") 8 14;
196
+ --cursor-comment: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M3 3 L14 3 L14 11 L8 11 L4 14 L4 11 L3 11 Z' fill='white' stroke='%23111' stroke-width='1' stroke-linejoin='round'/></svg>") 4 4;
197
+ }
117
198
  .dc-canvas[data-active-tool="comment"],
118
- .dc-canvas[data-active-tool="comment"] *,
199
+ .dc-canvas[data-active-tool="comment"] * {
200
+ cursor: var(--cursor-comment), crosshair !important;
201
+ }
119
202
  .dc-canvas[data-active-tool="pen"],
120
- .dc-canvas[data-active-tool="pen"] *,
203
+ .dc-canvas[data-active-tool="pen"] * {
204
+ cursor: var(--cursor-pen), crosshair !important;
205
+ }
121
206
  .dc-canvas[data-active-tool="rect"],
122
- .dc-canvas[data-active-tool="rect"] *,
207
+ .dc-canvas[data-active-tool="rect"] * {
208
+ cursor: var(--cursor-rect), crosshair !important;
209
+ }
123
210
  .dc-canvas[data-active-tool="ellipse"],
124
- .dc-canvas[data-active-tool="ellipse"] *,
211
+ .dc-canvas[data-active-tool="ellipse"] * {
212
+ cursor: var(--cursor-ellipse), crosshair !important;
213
+ }
125
214
  .dc-canvas[data-active-tool="arrow"],
126
215
  .dc-canvas[data-active-tool="arrow"] * {
127
- cursor: crosshair !important;
216
+ cursor: var(--cursor-arrow), crosshair !important;
128
217
  }
129
218
  .dc-canvas[data-active-tool="hand"],
130
219
  .dc-canvas[data-active-tool="hand"] * {
@@ -132,7 +221,27 @@ const HALO_CSS = `
132
221
  }
133
222
  .dc-canvas[data-active-tool="eraser"],
134
223
  .dc-canvas[data-active-tool="eraser"] * {
135
- cursor: cell !important;
224
+ cursor: var(--cursor-eraser), cell !important;
225
+ }
226
+
227
+ /* T31 — Level of detail. Below 0.35 zoom we hide pre-attentive chrome that
228
+ becomes visual noise (corner ticks, distance pills, active-artboard ring,
229
+ snap pills). Above 4.0 we coax the browser into crisper text rendering.
230
+ The "normal" band 0.35..4.0 carries the full chrome. */
231
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-cv-halo .tick,
232
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-cv-group-bbox .tick {
233
+ display: none;
234
+ }
235
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-snap-pill,
236
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-cv-eq-pill {
237
+ display: none;
238
+ }
239
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-artboard[aria-current="true"] {
240
+ box-shadow: none;
241
+ }
242
+ .dc-canvas[data-cv-zoom-lod="crisp"] {
243
+ -webkit-font-smoothing: subpixel-antialiased;
244
+ font-smooth: always;
136
245
  }
137
246
  `.trim();
138
247
 
@@ -198,15 +307,223 @@ function CanvasCore({
198
307
  };
199
308
  }, [hostRef, tool]);
200
309
 
310
+ // T28 — modifier-aware cursor (Alt → copy on cd-id, Shift → crosshair on
311
+ // body padding). CSS-driven once data-mod-* is reflected on the host.
312
+ useCursorModifiers(hostRef);
313
+ // T29 — arrow nudge (artboards) + Cmd+A select-all (active artboard).
314
+ // Cmd+D duplicate deferred; no live duplicate channel for either artboards
315
+ // or stamped elements yet.
316
+ useKeyboardDiscipline();
317
+
318
+ const artboardsCtx = useArtboardsContext();
319
+ const dragBus = useDragStateContext();
320
+
321
+ // T33 — programmatic-zoom easing via double-click on empty world only.
322
+ // Per post-Wave-3 user feedback, dblclick-on-artboard auto-zoom was
323
+ // surprising (interfered with native dblclick text-select inside chrome
324
+ // and felt magnetic). We keep the dblclick-empty → `fit()` path because
325
+ // it's a discoverable "back to overview" gesture; artboard zoom is still
326
+ // reachable via Cmd+1 and the zoom HUD.
327
+ useEffect(() => {
328
+ if (!controller) return;
329
+ const host = hostRef.current;
330
+ if (!host) return;
331
+ const onDbl = (e: MouseEvent) => {
332
+ const t = e.target as Element | null;
333
+ if (!t || !t.closest) return;
334
+ // Floating chrome / overlays / drawn user content / any artboard
335
+ // surface → leave alone. Only dblclick that lands on the canvas
336
+ // background outside every artboard triggers `fit()`.
337
+ if (
338
+ t.closest(
339
+ '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-annot-svg, .dc-annot-ctx, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin'
340
+ )
341
+ ) {
342
+ return;
343
+ }
344
+ if (t.closest('[data-dc-screen]')) return; // any part of an artboard
345
+ e.preventDefault();
346
+ controller.fit();
347
+ };
348
+ host.addEventListener('dblclick', onDbl);
349
+ return () => host.removeEventListener('dblclick', onDbl);
350
+ }, [controller, hostRef]);
351
+
352
+ // T31 — level-of-detail attribute on `.dc-canvas`. CSS rules hide
353
+ // pre-attentive chrome (ticks, distance pills, accent ring) below 0.35
354
+ // zoom and sharpen text above 4.0. tldraw's textShadowLod = 0.35 is the
355
+ // canonical threshold. Reads the published viewport (settle-cadence) so
356
+ // the LOD doesn't flicker between bands mid-zoom — chrome should settle
357
+ // once per gesture, not on every frame.
358
+ const publishedZoom = artboardsCtx?.viewport?.zoom ?? 1;
359
+ useEffect(() => {
360
+ const host = hostRef.current;
361
+ if (!host) return;
362
+ const lod = publishedZoom < 0.35 ? 'low' : publishedZoom > 4 ? 'crisp' : 'normal';
363
+ host.setAttribute('data-cv-zoom-lod', lod);
364
+ return () => host.removeAttribute('data-cv-zoom-lod');
365
+ }, [hostRef, publishedZoom]);
366
+
367
+ /**
368
+ * T24 — distribute the currently-selected artboards evenly on the given
369
+ * axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
370
+ * first + last in place, and reposition the middle artboards so the gaps
371
+ * between trailing edge → next leading edge are equal.
372
+ */
373
+ const distributeArtboards = useCallback(
374
+ (axis: 'x' | 'y') => {
375
+ if (!artboardsCtx || !dragBus) return;
376
+ const ids = new Set(
377
+ selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
378
+ );
379
+ if (ids.size < 3) return;
380
+ const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
381
+ if (targets.length < 3) return;
382
+ const sorted = [...targets].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
383
+ const first = sorted[0];
384
+ const last = sorted[sorted.length - 1];
385
+ if (!first || !last) return;
386
+ // Total content extent (sum of side-lengths on the axis) + the span
387
+ // between first leading edge and last trailing edge gives us the gap
388
+ // budget to divide equally between (n-1) inter-artboard slots.
389
+ const sideLen = (r: ArtboardRect) => (axis === 'x' ? r.w : r.h);
390
+ const totalSides = sorted.reduce((acc, r) => acc + sideLen(r), 0);
391
+ const span =
392
+ (axis === 'x' ? last.x + last.w - first.x : last.y + last.h - first.y) - totalSides;
393
+ const gap = span / (sorted.length - 1);
394
+ const moved: { id: string; x: number; y: number }[] = [];
395
+ let cursor = axis === 'x' ? first.x + first.w + gap : first.y + first.h + gap;
396
+ for (let i = 1; i < sorted.length - 1; i++) {
397
+ const r = sorted[i];
398
+ if (!r) continue;
399
+ if (axis === 'x') {
400
+ moved.push({ id: r.id, x: Math.round(cursor), y: r.y });
401
+ cursor += r.w + gap;
402
+ } else {
403
+ moved.push({ id: r.id, x: r.x, y: Math.round(cursor) });
404
+ cursor += r.h + gap;
405
+ }
406
+ }
407
+ if (moved.length === 0) return;
408
+ dragBus.commitPositions(moved, { label: equalSpacingLabel(sorted.length) });
409
+ },
410
+ [artboardsCtx, dragBus, selSet.selected]
411
+ );
412
+
413
+ /**
414
+ * G7 — align selected artboards to a common edge / midline. Requires ≥ 2
415
+ * selected artboards. Six modes:
416
+ * - 'left' → all artboards share the minimum x of the set
417
+ * - 'right' → all artboards share the maximum (x + w) edge
418
+ * - 'center-x' → all artboards share the midpoint of the set's x extent
419
+ * - 'top' → all artboards share the minimum y
420
+ * - 'bottom' → all artboards share the maximum (y + h) edge
421
+ * - 'center-y' → all artboards share the midpoint of the set's y extent
422
+ *
423
+ * Holds the reference edge constant; only the perpendicular axis stays as
424
+ * each artboard already was (alignment doesn't relocate on the orthogonal
425
+ * axis). This matches Figma / Sketch / FigJam align semantics.
426
+ */
427
+ const alignArtboards = useCallback(
428
+ (mode: AlignMode) => {
429
+ if (!artboardsCtx || !dragBus) return;
430
+ const ids = new Set(
431
+ selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
432
+ );
433
+ if (ids.size < 2) return;
434
+ const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
435
+ if (targets.length < 2) return;
436
+
437
+ // Union bbox for center modes.
438
+ let xMin = Number.POSITIVE_INFINITY;
439
+ let yMin = Number.POSITIVE_INFINITY;
440
+ let xMax = Number.NEGATIVE_INFINITY;
441
+ let yMax = Number.NEGATIVE_INFINITY;
442
+ for (const r of targets) {
443
+ if (r.x < xMin) xMin = r.x;
444
+ if (r.y < yMin) yMin = r.y;
445
+ if (r.x + r.w > xMax) xMax = r.x + r.w;
446
+ if (r.y + r.h > yMax) yMax = r.y + r.h;
447
+ }
448
+ const cx = (xMin + xMax) / 2;
449
+ const cy = (yMin + yMax) / 2;
450
+
451
+ const moved: { id: string; x: number; y: number }[] = [];
452
+ for (const r of targets) {
453
+ let nx = r.x;
454
+ let ny = r.y;
455
+ switch (mode) {
456
+ case 'left':
457
+ nx = xMin;
458
+ break;
459
+ case 'right':
460
+ nx = xMax - r.w;
461
+ break;
462
+ case 'center-x':
463
+ nx = cx - r.w / 2;
464
+ break;
465
+ case 'top':
466
+ ny = yMin;
467
+ break;
468
+ case 'bottom':
469
+ ny = yMax - r.h;
470
+ break;
471
+ case 'center-y':
472
+ ny = cy - r.h / 2;
473
+ break;
474
+ }
475
+ if (Math.round(nx) === r.x && Math.round(ny) === r.y) continue;
476
+ moved.push({ id: r.id, x: Math.round(nx), y: Math.round(ny) });
477
+ }
478
+ if (moved.length === 0) return;
479
+ dragBus.commitPositions(moved, { label: alignLabel(mode, targets.length) });
480
+ },
481
+ [artboardsCtx, dragBus, selSet.selected]
482
+ );
483
+
484
+ /**
485
+ * G2v2 — "Fit just this artboard" context-menu entry previously bridged
486
+ * via a synthetic click on the label button (which used to call
487
+ * controller.jumpTo). Now that the label is purely a11y, the menu entry
488
+ * needs a direct path: look the rect up from the live artboards list and
489
+ * call jumpTo straight on the controller.
490
+ */
491
+ const focusArtboard = useCallback(
492
+ (artboardId: string) => {
493
+ if (!controller || !artboardsCtx) return;
494
+ const rect = artboardsCtx.artboards.find((r) => r.id === artboardId);
495
+ if (rect) controller.jumpTo(rect);
496
+ },
497
+ [controller, artboardsCtx]
498
+ );
499
+
201
500
  const registry = useMemo<ContextRegistry>(
202
- () => buildRegistry({ controller, clearSelection: selSet.clear }),
203
- [controller, selSet.clear]
501
+ () =>
502
+ buildRegistry({
503
+ controller,
504
+ clearSelection: selSet.clear,
505
+ selSet,
506
+ distributeArtboards,
507
+ alignArtboards,
508
+ focusArtboard,
509
+ }),
510
+ [controller, selSet, distributeArtboards, alignArtboards, focusArtboard]
204
511
  );
205
512
 
513
+ // Distribute is reached via the MultiArtboardToolbar (floating chrome
514
+ // anchored above the group bbox) + context menu. No keyboard shortcut —
515
+ // user feedback (post-Wave-2) preferred the toolbar over global hotkeys.
516
+
206
517
  return (
207
518
  <ExportDialogProvider>
208
519
  <ContextMenuProvider registry={registry}>
209
- <CanvasRouter hostRef={hostRef}>{children}</CanvasRouter>
520
+ <CanvasRouter
521
+ hostRef={hostRef}
522
+ distributeArtboards={distributeArtboards}
523
+ alignArtboards={alignArtboards}
524
+ >
525
+ {children}
526
+ </CanvasRouter>
210
527
  </ContextMenuProvider>
211
528
  </ExportDialogProvider>
212
529
  );
@@ -218,8 +535,21 @@ function CanvasCore({
218
535
  function buildRegistry(deps: {
219
536
  controller: ViewportControllerHandle | null;
220
537
  clearSelection: () => void;
538
+ selSet: { selected: Selection[] };
539
+ distributeArtboards: (axis: 'x' | 'y') => void;
540
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
541
+ focusArtboard: (artboardId: string) => void;
221
542
  }): ContextRegistry {
222
- const { controller, clearSelection } = deps;
543
+ const { controller, clearSelection, selSet, distributeArtboards, alignArtboards, focusArtboard } =
544
+ deps;
545
+
546
+ // T24 — distribute commands are only enabled when ≥ 3 artboards are
547
+ // selected. Below that, the menu items render as `disabled` so the user
548
+ // sees the affordance but understands the precondition.
549
+ // G7 — align commands are enabled at ≥ 2 (alignment is well-defined with 2).
550
+ const selectedArtboardCount = selSet.selected.filter((s) => !!s.artboardId).length;
551
+ const distributeEnabled = selectedArtboardCount >= 3;
552
+ const alignEnabled = selectedArtboardCount >= 2;
223
553
 
224
554
  const copy = (text: string): void => {
225
555
  if (typeof navigator === 'undefined' || !navigator.clipboard) return;
@@ -342,19 +672,72 @@ function buildRegistry(deps: {
342
672
  id: 'fit-one',
343
673
  label: 'Fit just this artboard',
344
674
  onSelect: (target) => {
345
- if (!controller || !target.artboardId) return;
346
- // controller exposes jumpTo(rect) — DCArtboard.onFocus uses the
347
- // same pattern. The artboard label button already wires to
348
- // onFocus; dispatch a synthetic click as the simplest bridge.
349
- const btn = (target.el ?? document)
350
- .closest?.('[data-dc-screen]')
351
- ?.querySelector('button.dc-artboard-label');
352
- (btn as HTMLButtonElement | null)?.click();
675
+ if (!target.artboardId) return;
676
+ focusArtboard(target.artboardId);
353
677
  },
354
678
  },
355
679
  fitItem,
356
680
  resetItem,
357
681
  ],
682
+ [
683
+ // G7 — align commands. Six modes; gated on ≥ 2 selected artboards
684
+ // (alignment is well-defined with 2). Primary surface is the
685
+ // MultiArtboardToolbar; menu entries are discoverability backup.
686
+ {
687
+ id: 'align-left',
688
+ label: 'Align left',
689
+ disabled: !alignEnabled,
690
+ onSelect: () => alignArtboards('left'),
691
+ },
692
+ {
693
+ id: 'align-center-x',
694
+ label: 'Align center (horizontal)',
695
+ disabled: !alignEnabled,
696
+ onSelect: () => alignArtboards('center-x'),
697
+ },
698
+ {
699
+ id: 'align-right',
700
+ label: 'Align right',
701
+ disabled: !alignEnabled,
702
+ onSelect: () => alignArtboards('right'),
703
+ },
704
+ {
705
+ id: 'align-top',
706
+ label: 'Align top',
707
+ disabled: !alignEnabled,
708
+ onSelect: () => alignArtboards('top'),
709
+ },
710
+ {
711
+ id: 'align-center-y',
712
+ label: 'Align center (vertical)',
713
+ disabled: !alignEnabled,
714
+ onSelect: () => alignArtboards('center-y'),
715
+ },
716
+ {
717
+ id: 'align-bottom',
718
+ label: 'Align bottom',
719
+ disabled: !alignEnabled,
720
+ onSelect: () => alignArtboards('bottom'),
721
+ },
722
+ ],
723
+ [
724
+ // T24 — distribute commands. Primary surface is the floating
725
+ // MultiArtboardToolbar above the group bbox; the menu entries are
726
+ // a discoverability backup. Disabled when fewer than 3 artboards
727
+ // are selected.
728
+ {
729
+ id: 'distribute-h',
730
+ label: 'Distribute horizontally',
731
+ disabled: !distributeEnabled,
732
+ onSelect: () => distributeArtboards('x'),
733
+ },
734
+ {
735
+ id: 'distribute-v',
736
+ label: 'Distribute vertically',
737
+ disabled: !distributeEnabled,
738
+ onSelect: () => distributeArtboards('y'),
739
+ },
740
+ ],
358
741
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
359
742
  ],
360
743
  world: [
@@ -384,14 +767,19 @@ function boundsOf(el: HTMLElement) {
384
767
  function CanvasRouter({
385
768
  hostRef,
386
769
  children,
770
+ distributeArtboards,
771
+ alignArtboards,
387
772
  }: {
388
773
  hostRef: RefObject<HTMLDivElement | null>;
389
774
  children: ReactNode;
775
+ distributeArtboards: (axis: 'x' | 'y') => void;
776
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
390
777
  }) {
391
- const { tool, setTool } = useToolMode();
778
+ const { tool, setTool, clearSticky } = useToolMode();
392
779
  const selSet = useSelectionSet();
393
780
  const annotSel = useAnnotationSelection();
394
781
  const ctxMenu = useContextMenu();
782
+ const undoStack = useUndoStack();
395
783
 
396
784
  // Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
397
785
  // itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
@@ -468,9 +856,11 @@ function CanvasRouter({
468
856
  onSelect: ({ mode, deep, clientX, clientY }) => {
469
857
  const target = resolveHoverTarget(document, clientX, clientY, { deep });
470
858
  if (!target) {
471
- // Cmd-click on dead space (canvas chrome, label strip, empty world):
472
- // clear for `replace`, leave the set alone for `add`.
473
- if (mode === 'replace') selSet.clear();
859
+ // No-target click (canvas chrome / dead space) DO NOT auto-clear
860
+ // the selection. Esc is the canonical deselect gesture; click-to-
861
+ // clear loses the user's selection on accidental misses. The empty
862
+ // marquee path (artboard-marquee.tsx) likewise never clears unless
863
+ // it actually captured something.
474
864
  return;
475
865
  }
476
866
  const sel = hoverTargetToSelection(target);
@@ -491,7 +881,25 @@ function CanvasRouter({
491
881
  ctxMenu.open(ctxTarget);
492
882
  },
493
883
  onTool: ({ tool: t }) => setTool(t),
884
+ onUndo: () => {
885
+ void undoStack.undo();
886
+ },
887
+ onRedo: () => {
888
+ void undoStack.redo();
889
+ },
494
890
  onEscape: () => {
891
+ // T21 — abort any mid-stroke draw FIRST. The annotations layer
892
+ // listens for `maude:cancel-stroke` and drops the in-progress
893
+ // shape without committing.
894
+ try {
895
+ document.dispatchEvent(new CustomEvent('maude:cancel-stroke'));
896
+ } catch {
897
+ /* non-DOM env */
898
+ }
899
+ // T19 — clear sticky-tool lock + flip back to Move. Esc is the
900
+ // canonical "back to default state" gesture across canvas apps.
901
+ clearSticky();
902
+ if (tool !== 'move') setTool('move');
495
903
  ctxMenu.close();
496
904
  selSet.clear();
497
905
  annotSel.clear();
@@ -596,14 +1004,321 @@ function CanvasRouter({
596
1004
  <CommentsOverlay />
597
1005
  <AnnotationsLayer />
598
1006
  <ToolPalette />
1007
+ <ArtboardMarqueeOverlay />
1008
+ <ElementMarqueeOverlay />
599
1009
  <HoverHalo el={hoverEl} />
600
1010
  <SelectionHalos />
601
1011
  <GroupBbox />
1012
+ <EqualSpacingHandles />
1013
+ <ContextualToolbar />
1014
+ <MultiArtboardToolbar
1015
+ distributeArtboards={distributeArtboards}
1016
+ alignArtboards={alignArtboards}
1017
+ />
602
1018
  <SnapGuideOverlay />
1019
+ <UndoHud />
603
1020
  </>
604
1021
  );
605
1022
  }
606
1023
 
1024
+ // ─────────────────────────────────────────────────────────────────────────────
1025
+ // MultiArtboardToolbar — floating chrome anchored above the group bbox when
1026
+ // ≥ 2 artboards are multi-selected. Primary surface for the distribute
1027
+ // commands (post-Wave-2 feedback preferred this over global ⌘⌥H / ⌘⌥V
1028
+ // hotkeys). Disabled state when fewer than 3 artboards are selected — the
1029
+ // math is undefined for 2.
1030
+
1031
+ const MULTI_TOOLBAR_CSS = `
1032
+ .dc-multi-artboard-tb {
1033
+ position: fixed;
1034
+ pointer-events: auto;
1035
+ z-index: 6;
1036
+ display: none;
1037
+ align-items: stretch;
1038
+ gap: 2px;
1039
+ padding: 4px;
1040
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
1041
+ border: 1px solid var(--u-fg-0, #1c1917);
1042
+ border-radius: 8px;
1043
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
1044
+ font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1045
+ font-size: 11px;
1046
+ letter-spacing: 0.02em;
1047
+ color: var(--u-fg-0, #1a1a1a);
1048
+ user-select: none;
1049
+ }
1050
+ .dc-multi-artboard-tb button {
1051
+ appearance: none;
1052
+ background: transparent;
1053
+ border: 0;
1054
+ border-radius: 6px;
1055
+ padding: 4px 10px;
1056
+ font: inherit;
1057
+ cursor: pointer;
1058
+ color: inherit;
1059
+ display: inline-flex;
1060
+ align-items: center;
1061
+ gap: 6px;
1062
+ transition: background-color 80ms linear;
1063
+ }
1064
+ .dc-multi-artboard-tb button:hover:not(:disabled) {
1065
+ background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
1066
+ }
1067
+ .dc-multi-artboard-tb button:disabled {
1068
+ cursor: default;
1069
+ opacity: 0.4;
1070
+ }
1071
+ .dc-multi-artboard-tb .dc-mab-count {
1072
+ padding: 4px 8px 4px 10px;
1073
+ color: var(--fg-1, rgba(40,30,20,0.7));
1074
+ border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
1075
+ margin-right: 2px;
1076
+ font-variant-numeric: tabular-nums;
1077
+ }
1078
+ .dc-multi-artboard-tb .dc-mab-icon {
1079
+ display: inline-flex;
1080
+ align-items: center;
1081
+ justify-content: center;
1082
+ }
1083
+ .dc-multi-artboard-tb .dc-mab-divider {
1084
+ width: 1px;
1085
+ align-self: stretch;
1086
+ background: var(--u-border-subtle, rgba(0,0,0,0.10));
1087
+ margin: 0 4px;
1088
+ }
1089
+ @media (prefers-reduced-motion: reduce) {
1090
+ .dc-multi-artboard-tb button { transition: none; }
1091
+ }
1092
+ `.trim();
1093
+
1094
+ function ensureMultiToolbarStyles(): void {
1095
+ if (typeof document === 'undefined') return;
1096
+ if (document.getElementById('dc-multi-artboard-tb-css')) return;
1097
+ const s = document.createElement('style');
1098
+ s.id = 'dc-multi-artboard-tb-css';
1099
+ s.textContent = MULTI_TOOLBAR_CSS;
1100
+ document.head.appendChild(s);
1101
+ }
1102
+
1103
+ function MultiArtboardToolbar({
1104
+ distributeArtboards,
1105
+ alignArtboards,
1106
+ }: {
1107
+ distributeArtboards: (axis: 'x' | 'y') => void;
1108
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
1109
+ }) {
1110
+ ensureMultiToolbarStyles();
1111
+ const { selected } = useSelectionSet();
1112
+ const ref = useRef<HTMLDivElement | null>(null);
1113
+ const rafRef = useRef<number | null>(null);
1114
+
1115
+ const artboardSelections = useMemo(() => selected.filter((s) => !!s.artboardId), [selected]);
1116
+ const artboardCount = artboardSelections.length;
1117
+ const distributeOk = artboardCount >= 3;
1118
+ const alignOk = artboardCount >= 2;
1119
+
1120
+ useEffect(() => {
1121
+ const div = ref.current;
1122
+ if (!div) return;
1123
+ if (artboardCount < 2) {
1124
+ div.style.display = 'none';
1125
+ return;
1126
+ }
1127
+ // Track the screen-coord union bbox of all selected artboards each
1128
+ // frame. Anchor toolbar centered horizontally above the bbox top
1129
+ // edge with a 14 px gap; flip BELOW if the top edge is < 60 px from
1130
+ // the viewport top.
1131
+ const tick = () => {
1132
+ rafRef.current = null;
1133
+ let xMin = Number.POSITIVE_INFINITY;
1134
+ let yMin = Number.POSITIVE_INFINITY;
1135
+ let xMax = Number.NEGATIVE_INFINITY;
1136
+ let yMax = Number.NEGATIVE_INFINITY;
1137
+ let any = false;
1138
+ for (const sel of artboardSelections) {
1139
+ const el = document.querySelector(`[data-dc-screen="${sel.artboardId}"]`);
1140
+ if (!el) continue;
1141
+ const r = (el as HTMLElement).getBoundingClientRect();
1142
+ if (r.width === 0 && r.height === 0) continue;
1143
+ any = true;
1144
+ if (r.left < xMin) xMin = r.left;
1145
+ if (r.top < yMin) yMin = r.top;
1146
+ if (r.right > xMax) xMax = r.right;
1147
+ if (r.bottom > yMax) yMax = r.bottom;
1148
+ }
1149
+ if (!any) {
1150
+ div.style.display = 'none';
1151
+ rafRef.current = requestAnimationFrame(tick);
1152
+ return;
1153
+ }
1154
+ // Make the toolbar visible OFFSCREEN first to measure its own
1155
+ // width, then move it to the anchor. (Cheap: only re-anchors on
1156
+ // every frame after the first.)
1157
+ div.style.display = 'flex';
1158
+ const tw = div.offsetWidth || 0;
1159
+ const centerX = (xMin + xMax) / 2;
1160
+ const top = yMin;
1161
+ const gap = 14;
1162
+ let anchorY = top - div.offsetHeight - gap;
1163
+ if (anchorY < 60) anchorY = yMax + gap; // flip below
1164
+ div.style.left = `${Math.round(centerX - tw / 2)}px`;
1165
+ div.style.top = `${Math.round(anchorY)}px`;
1166
+ rafRef.current = requestAnimationFrame(tick);
1167
+ };
1168
+ rafRef.current = requestAnimationFrame(tick);
1169
+ return () => {
1170
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
1171
+ };
1172
+ }, [artboardCount, artboardSelections]);
1173
+
1174
+ if (artboardCount < 2) {
1175
+ return <div ref={ref} className="dc-multi-artboard-tb" aria-hidden="true" />;
1176
+ }
1177
+
1178
+ return (
1179
+ <div
1180
+ ref={ref}
1181
+ className="dc-multi-artboard-tb"
1182
+ role="toolbar"
1183
+ aria-label="Multi-artboard actions"
1184
+ >
1185
+ <span className="dc-mab-count">{artboardCount} artboards</span>
1186
+ {/* G7 — align cluster. Icon-only to keep the toolbar narrow; full
1187
+ label lives in the tooltip + the right-click menu. Enabled at ≥ 2
1188
+ artboards (alignment is well-defined with 2). */}
1189
+ <button
1190
+ type="button"
1191
+ disabled={!alignOk}
1192
+ title={alignOk ? 'Align left' : 'Select at least 2 artboards to align'}
1193
+ aria-label="Align left"
1194
+ onClick={() => alignArtboards('left')}
1195
+ >
1196
+ <span className="dc-mab-icon" aria-hidden="true">
1197
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1198
+ <line x1="0.5" y1="0.5" x2="0.5" y2="13.5" stroke="currentColor" />
1199
+ <rect x="1" y="2" width="7" height="3" fill="currentColor" />
1200
+ <rect x="1" y="9" width="11" height="3" fill="currentColor" />
1201
+ </svg>
1202
+ </span>
1203
+ </button>
1204
+ <button
1205
+ type="button"
1206
+ disabled={!alignOk}
1207
+ title={alignOk ? 'Align center (horizontal)' : 'Select at least 2 artboards to align'}
1208
+ aria-label="Align center horizontally"
1209
+ onClick={() => alignArtboards('center-x')}
1210
+ >
1211
+ <span className="dc-mab-icon" aria-hidden="true">
1212
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1213
+ <line x1="7" y1="0.5" x2="7" y2="13.5" stroke="currentColor" />
1214
+ <rect x="3.5" y="2" width="7" height="3" fill="currentColor" />
1215
+ <rect x="1.5" y="9" width="11" height="3" fill="currentColor" />
1216
+ </svg>
1217
+ </span>
1218
+ </button>
1219
+ <button
1220
+ type="button"
1221
+ disabled={!alignOk}
1222
+ title={alignOk ? 'Align right' : 'Select at least 2 artboards to align'}
1223
+ aria-label="Align right"
1224
+ onClick={() => alignArtboards('right')}
1225
+ >
1226
+ <span className="dc-mab-icon" aria-hidden="true">
1227
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1228
+ <line x1="13.5" y1="0.5" x2="13.5" y2="13.5" stroke="currentColor" />
1229
+ <rect x="6" y="2" width="7" height="3" fill="currentColor" />
1230
+ <rect x="2" y="9" width="11" height="3" fill="currentColor" />
1231
+ </svg>
1232
+ </span>
1233
+ </button>
1234
+ <button
1235
+ type="button"
1236
+ disabled={!alignOk}
1237
+ title={alignOk ? 'Align top' : 'Select at least 2 artboards to align'}
1238
+ aria-label="Align top"
1239
+ onClick={() => alignArtboards('top')}
1240
+ >
1241
+ <span className="dc-mab-icon" aria-hidden="true">
1242
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1243
+ <line x1="0.5" y1="0.5" x2="13.5" y2="0.5" stroke="currentColor" />
1244
+ <rect x="2" y="1" width="3" height="7" fill="currentColor" />
1245
+ <rect x="9" y="1" width="3" height="11" fill="currentColor" />
1246
+ </svg>
1247
+ </span>
1248
+ </button>
1249
+ <button
1250
+ type="button"
1251
+ disabled={!alignOk}
1252
+ title={alignOk ? 'Align center (vertical)' : 'Select at least 2 artboards to align'}
1253
+ aria-label="Align center vertically"
1254
+ onClick={() => alignArtboards('center-y')}
1255
+ >
1256
+ <span className="dc-mab-icon" aria-hidden="true">
1257
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1258
+ <line x1="0.5" y1="7" x2="13.5" y2="7" stroke="currentColor" />
1259
+ <rect x="2" y="3.5" width="3" height="7" fill="currentColor" />
1260
+ <rect x="9" y="1.5" width="3" height="11" fill="currentColor" />
1261
+ </svg>
1262
+ </span>
1263
+ </button>
1264
+ <button
1265
+ type="button"
1266
+ disabled={!alignOk}
1267
+ title={alignOk ? 'Align bottom' : 'Select at least 2 artboards to align'}
1268
+ aria-label="Align bottom"
1269
+ onClick={() => alignArtboards('bottom')}
1270
+ >
1271
+ <span className="dc-mab-icon" aria-hidden="true">
1272
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1273
+ <line x1="0.5" y1="13.5" x2="13.5" y2="13.5" stroke="currentColor" />
1274
+ <rect x="2" y="6" width="3" height="7" fill="currentColor" />
1275
+ <rect x="9" y="2" width="3" height="11" fill="currentColor" />
1276
+ </svg>
1277
+ </span>
1278
+ </button>
1279
+ <span className="dc-mab-divider" aria-hidden="true" />
1280
+ <button
1281
+ type="button"
1282
+ disabled={!distributeOk}
1283
+ title={
1284
+ distributeOk
1285
+ ? 'Distribute horizontally — equal gaps between artboards'
1286
+ : 'Select at least 3 artboards to distribute'
1287
+ }
1288
+ aria-label="Distribute horizontally"
1289
+ onClick={() => distributeArtboards('x')}
1290
+ >
1291
+ <span className="dc-mab-icon" aria-hidden="true">
1292
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1293
+ <rect x="0.5" y="3" width="3" height="8" fill="currentColor" />
1294
+ <rect x="5.5" y="3" width="3" height="8" fill="currentColor" />
1295
+ <rect x="10.5" y="3" width="3" height="8" fill="currentColor" />
1296
+ </svg>
1297
+ </span>
1298
+ </button>
1299
+ <button
1300
+ type="button"
1301
+ disabled={!distributeOk}
1302
+ title={
1303
+ distributeOk
1304
+ ? 'Distribute vertically — equal gaps between artboards'
1305
+ : 'Select at least 3 artboards to distribute'
1306
+ }
1307
+ aria-label="Distribute vertically"
1308
+ onClick={() => distributeArtboards('y')}
1309
+ >
1310
+ <span className="dc-mab-icon" aria-hidden="true">
1311
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1312
+ <rect x="3" y="0.5" width="8" height="3" fill="currentColor" />
1313
+ <rect x="3" y="5.5" width="8" height="3" fill="currentColor" />
1314
+ <rect x="3" y="10.5" width="8" height="3" fill="currentColor" />
1315
+ </svg>
1316
+ </span>
1317
+ </button>
1318
+ </div>
1319
+ );
1320
+ }
1321
+
607
1322
  // ─────────────────────────────────────────────────────────────────────────────
608
1323
  // HoverHalo — single floating overlay tracking the hovered element's screen
609
1324
  // bounds. Updates on every animation frame while mounted. Position: fixed so
@@ -654,6 +1369,27 @@ function HoverHalo({ el }: { el: Element | null }) {
654
1369
  // SelectionHalos — N floating overlays, one per selected element. Resolves
655
1370
  // elements by `data-cd-id` when present, falling back to the selector path.
656
1371
 
1372
+ // DDR-046 — single-select halo carries 4 corner ticks; multi-select members get
1373
+ // the lighter 1px tinted outline (no ticks). The GroupBbox renders the loud
1374
+ // signal when selected.length > 1.
1375
+ const TICK_CLASSES = ['tick-tl', 'tick-tr', 'tick-bl', 'tick-br'] as const;
1376
+
1377
+ function makeSelectedNode(withTicks: boolean): HTMLDivElement {
1378
+ const child = document.createElement('div');
1379
+ child.className = withTicks
1380
+ ? 'dc-cv-halo dc-cv-halo--selected'
1381
+ : 'dc-cv-halo dc-cv-halo--selected-member';
1382
+ child.setAttribute('aria-hidden', 'true');
1383
+ if (withTicks) {
1384
+ for (const cls of TICK_CLASSES) {
1385
+ const tick = document.createElement('i');
1386
+ tick.className = `tick ${cls}`;
1387
+ child.appendChild(tick);
1388
+ }
1389
+ }
1390
+ return child;
1391
+ }
1392
+
657
1393
  function SelectionHalos() {
658
1394
  const { selected } = useSelectionSet();
659
1395
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -669,16 +1405,34 @@ function SelectionHalos() {
669
1405
  rafRef.current = null;
670
1406
  const c = containerRef.current;
671
1407
  if (!c) return;
672
- // Match the rendered halo count to selected.length; reuse DOM nodes.
1408
+ const wantsTicks = selected.length === 1;
1409
+ const wantClass = wantsTicks
1410
+ ? 'dc-cv-halo dc-cv-halo--selected'
1411
+ : 'dc-cv-halo dc-cv-halo--selected-member';
1412
+ // Match rendered halo count to selected.length; reuse DOM nodes.
673
1413
  while (c.children.length < selected.length) {
674
- const child = document.createElement('div');
675
- child.className = 'dc-cv-halo dc-cv-halo--selected';
676
- child.setAttribute('aria-hidden', 'true');
677
- c.appendChild(child);
1414
+ c.appendChild(makeSelectedNode(wantsTicks));
678
1415
  }
679
1416
  while (c.children.length > selected.length) {
680
1417
  c.removeChild(c.lastChild as Node);
681
1418
  }
1419
+ // When selection size crosses 1↔N+, swap class + tick children on each
1420
+ // existing node so reused nodes adopt the new visual idiom.
1421
+ for (let i = 0; i < c.children.length; i++) {
1422
+ const child = c.children[i] as HTMLDivElement;
1423
+ if (child.className !== wantClass) {
1424
+ child.className = wantClass;
1425
+ // Strip any existing ticks, then re-append if needed.
1426
+ while (child.firstChild) child.removeChild(child.firstChild);
1427
+ if (wantsTicks) {
1428
+ for (const cls of TICK_CLASSES) {
1429
+ const t = document.createElement('i');
1430
+ t.className = `tick ${cls}`;
1431
+ child.appendChild(t);
1432
+ }
1433
+ }
1434
+ }
1435
+ }
682
1436
  for (let i = 0; i < selected.length; i++) {
683
1437
  const sel = selected[i];
684
1438
  const child = c.children[i] as HTMLDivElement;
@@ -691,6 +1445,9 @@ function SelectionHalos() {
691
1445
  child.style.display = 'none';
692
1446
  continue;
693
1447
  }
1448
+ // Post-Wave-2: artboard drag is now direct (article updates its own
1449
+ // `left/top` in real-time), so the halo can follow via
1450
+ // getBoundingClientRect — no special drag suppression needed.
694
1451
  const r = (el as HTMLElement).getBoundingClientRect();
695
1452
  if (r.width === 0 && r.height === 0) {
696
1453
  child.style.display = 'none';
@@ -740,6 +1497,9 @@ function GroupBbox() {
740
1497
  ? document.querySelector(`[data-cd-id="${cssEscape(sel.id)}"]`)
741
1498
  : safeQuery(sel.selector);
742
1499
  if (!el) continue;
1500
+ // Post-Wave-2: direct artboard drag — the article updates its own
1501
+ // `left/top` during drag, so the group bbox just follows via
1502
+ // getBoundingClientRect with no special-casing.
743
1503
  const r = (el as HTMLElement).getBoundingClientRect();
744
1504
  if (r.width === 0 && r.height === 0) continue;
745
1505
  anyHit = true;
@@ -767,7 +1527,13 @@ function GroupBbox() {
767
1527
  }, [selected]);
768
1528
 
769
1529
  if (selected.length < 2) return null;
770
- return <div ref={ref} className="dc-cv-group-bbox" aria-hidden="true" />;
1530
+ return (
1531
+ <div ref={ref} className="dc-cv-group-bbox" aria-hidden="true">
1532
+ {TICK_CLASSES.map((cls) => (
1533
+ <i key={cls} className={`tick ${cls}`} />
1534
+ ))}
1535
+ </div>
1536
+ );
771
1537
  }
772
1538
 
773
1539
  // ─────────────────────────────────────────────────────────────────────────────
@@ -795,10 +1561,20 @@ function hoverTargetToSelection(target: HoverTarget): Selection {
795
1561
  // climbs to an ancestor. Falls back to cssPath of the hit when no stable
796
1562
  // anchor exists.
797
1563
  const cdId = target.cdId;
1564
+ // Selector resolution order:
1565
+ // 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
1566
+ // 2. data-dc-screen — chrome click promoted to whole-artboard select
1567
+ // (T24.5 G8 multi-artboard gesture).
1568
+ // 3. cssPath of the hit — last-resort path string.
1569
+ const selector = cdId
1570
+ ? `[data-cd-id="${cdId}"]`
1571
+ : !cdId && target.artboardId
1572
+ ? `[data-dc-screen="${target.artboardId}"]`
1573
+ : cssPath(el);
798
1574
  return {
799
1575
  file: typeof window !== 'undefined' ? deriveFile() : undefined,
800
1576
  id: cdId ?? undefined,
801
- selector: cdId ? `[data-cd-id="${cdId}"]` : cssPath(el),
1577
+ selector,
802
1578
  artboardId: target.artboardId,
803
1579
  tag: el?.tagName.toLowerCase() ?? '',
804
1580
  classes: realClasses(el),