@1agh/maude 0.18.2 → 0.19.1

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 (31) hide show
  1. package/cli/bin/maude.mjs +16 -0
  2. package/cli/lib/update-check.mjs +145 -0
  3. package/cli/lib/update-check.test.mjs +32 -0
  4. package/package.json +8 -8
  5. package/plugins/design/dev-server/annotations-context-toolbar.tsx +14 -6
  6. package/plugins/design/dev-server/annotations-layer.tsx +144 -22
  7. package/plugins/design/dev-server/api.ts +72 -19
  8. package/plugins/design/dev-server/artboard-marquee.tsx +170 -0
  9. package/plugins/design/dev-server/canvas-lib-resolver.ts +10 -1
  10. package/plugins/design/dev-server/canvas-lib.tsx +190 -94
  11. package/plugins/design/dev-server/canvas-shell.tsx +478 -34
  12. package/plugins/design/dev-server/client/app.jsx +177 -56
  13. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  14. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  15. package/plugins/design/dev-server/config.schema.json +31 -5
  16. package/plugins/design/dev-server/context-menu.tsx +3 -3
  17. package/plugins/design/dev-server/context.ts +37 -3
  18. package/plugins/design/dev-server/dist/client.bundle.js +219 -107
  19. package/plugins/design/dev-server/dist/styles.css +38 -2
  20. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  21. package/plugins/design/dev-server/http.ts +14 -2
  22. package/plugins/design/dev-server/input-router.tsx +22 -8
  23. package/plugins/design/dev-server/server.mjs +86 -20
  24. package/plugins/design/dev-server/server.ts +76 -30
  25. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  26. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  27. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  28. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  29. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  30. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  31. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
@@ -37,9 +37,13 @@ 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';
45
49
  import { CommentsOverlay } from './comments-overlay.tsx';
@@ -69,43 +73,100 @@ import { useToolMode } from './use-tool-mode.tsx';
69
73
  // plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
70
74
  // = invisible). No per-element class stamping is used.
71
75
 
76
+ // DDR-046 — Three-state halo language. Each state has its own border weight,
77
+ // color treatment, and geometric idiom so 8+ semantic states (hover / selected
78
+ // / member-of-multi / group / snap-sibling / snap-grid / marquee / annotation
79
+ // / active-artboard) stay visually distinct. Painting one with another's
80
+ // idiom is a regression.
72
81
  const HALO_CSS = `
73
82
  .dc-cv-halo {
74
83
  position: fixed;
75
84
  pointer-events: none;
76
85
  z-index: 5;
77
- border: 2px solid var(--accent, #d63b1f);
78
86
  box-sizing: border-box;
79
87
  border-radius: 2px;
80
88
  transition: opacity 60ms linear;
81
89
  }
90
+ /* Hover — lighter 1.5px tinted line + white inner ring for contrast on dark
91
+ elements. NO ring, NO ticks. Synchronous paint (no debounce). */
82
92
  .dc-cv-halo--hover {
83
- border-width: 2px;
84
- opacity: 0.85;
93
+ border: 1.5px solid color-mix(in oklab, var(--accent, #0d99ff) 60%, transparent);
94
+ box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
85
95
  }
96
+ /* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
97
+ Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
86
98
  .dc-cv-halo--selected {
87
- border-width: 2px;
88
- box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #d63b1f) 18%, transparent);
99
+ border: 2px solid var(--accent, #0d99ff);
100
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #0d99ff) 18%, transparent);
89
101
  }
102
+ .dc-cv-halo--selected .tick {
103
+ position: absolute;
104
+ width: 8px;
105
+ height: 8px;
106
+ background: var(--accent, #0d99ff);
107
+ border-radius: 1px;
108
+ box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
109
+ }
110
+ .dc-cv-halo--selected .tick-tl { top: -3px; left: -3px; }
111
+ .dc-cv-halo--selected .tick-tr { top: -3px; right: -3px; }
112
+ .dc-cv-halo--selected .tick-bl { bottom: -3px; left: -3px; }
113
+ .dc-cv-halo--selected .tick-br { bottom: -3px; right: -3px; }
114
+ /* Selected (member of multi-selection) — 1.5 px solid full-accent outline.
115
+ No ring, no ticks (the group bbox above carries the container signal).
116
+ T16 / DDR-046 rev 2 — full opacity instead of 50%-tinted: tinted lines read
117
+ as "draft / placeholder" and members would melt away inside artboards once
118
+ the artboard border itself is 22%-tinted (T15). */
119
+ .dc-cv-halo--selected-member {
120
+ border: 1.5px solid var(--accent, #0d99ff);
121
+ }
122
+ /* Group bbox — 1 px DASHED full accent + four 6 × 6 square corner handles.
123
+ T16 / DDR-046 rev 2 — dashed is the canonical group-container affordance
124
+ (Figma group bbox, FigJam Section drag-state, Photoshop marching ants).
125
+ Dashed reads "ambient binding" without claiming subject-ness; the loud
126
+ solid outlines on each MEMBER carry the active-selection signal. Corner
127
+ handles are 6 × 6 (vs single-select's 8 × 8 ticks) so the group idiom
128
+ reads as "thinner authority" than single-select. */
90
129
  .dc-cv-group-bbox {
91
130
  position: fixed;
92
131
  pointer-events: none;
93
132
  z-index: 5;
94
- border: 1px dashed var(--accent, #d63b1f);
133
+ border: 1px dashed var(--accent, #0d99ff);
95
134
  border-radius: 2px;
96
- opacity: 0.85;
97
135
  }
136
+ .dc-cv-group-bbox .tick {
137
+ position: absolute;
138
+ width: 6px;
139
+ height: 6px;
140
+ background: var(--accent, #0d99ff);
141
+ border-radius: 1px;
142
+ box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
143
+ }
144
+ .dc-cv-group-bbox .tick-tl { top: -3px; left: -3px; }
145
+ .dc-cv-group-bbox .tick-tr { top: -3px; right: -3px; }
146
+ .dc-cv-group-bbox .tick-bl { bottom: -3px; left: -3px; }
147
+ .dc-cv-group-bbox .tick-br { bottom: -3px; right: -3px; }
98
148
  /*
99
149
  * Active-artboard indicator — the artboard whose center sits closest to the
100
150
  * 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.
151
+ * for keyboard jumps + the /design:edit context anchor). DDR-046 ring sits
152
+ * OUTSIDE the hard drop-shadow so it's visible at any pan distance / zoom.
153
+ * 120 ms ease-out so activation is felt, not invisible.
104
154
  */
105
155
  .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);
156
+ /* T15 — quiet frame, single 2 px accent ring. The previous double-shadow
157
+ (3 px ring + hard 6×6×0 offset) was readable but visually expensive once
158
+ the frame itself lost its brutalist treatment. A 2 px ring on a 22 %
159
+ tinted hairline reads unambiguous without claiming subject-ness. */
160
+ box-shadow: 0 0 0 2px var(--accent, #0d99ff);
161
+ transition: box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1);
162
+ }
163
+ /* Respect prefers-reduced-motion across all chrome transitions. */
164
+ @media (prefers-reduced-motion: reduce) {
165
+ .dc-cv-halo,
166
+ .dc-cv-group-bbox,
167
+ .dc-canvas .dc-artboard[aria-current="true"] {
168
+ transition: none !important;
169
+ }
109
170
  }
110
171
  /*
111
172
  * Force tool cursor across the canvas tree in comment / hand modes. Without
@@ -114,17 +175,37 @@ const HALO_CSS = `
114
175
  * the user hovers an interactive element — wrong signal when native
115
176
  * interactions are suppressed by the router anyway.
116
177
  */
178
+ /* T22 — per-tool SVG cursors. Each cursor is a 16 × 16 SVG data-URI with a
179
+ declared hotspot. The crosshair / cell / grab fallbacks remain in the
180
+ chain so older browsers still get a recognisable affordance. SVGs are
181
+ utf-8 encoded inline (Chromium / Safari 16+ / Firefox 117+ all accept). */
182
+ .dc-canvas {
183
+ --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;
184
+ --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;
185
+ --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;
186
+ --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;
187
+ --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;
188
+ --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;
189
+ }
117
190
  .dc-canvas[data-active-tool="comment"],
118
- .dc-canvas[data-active-tool="comment"] *,
191
+ .dc-canvas[data-active-tool="comment"] * {
192
+ cursor: var(--cursor-comment), crosshair !important;
193
+ }
119
194
  .dc-canvas[data-active-tool="pen"],
120
- .dc-canvas[data-active-tool="pen"] *,
195
+ .dc-canvas[data-active-tool="pen"] * {
196
+ cursor: var(--cursor-pen), crosshair !important;
197
+ }
121
198
  .dc-canvas[data-active-tool="rect"],
122
- .dc-canvas[data-active-tool="rect"] *,
199
+ .dc-canvas[data-active-tool="rect"] * {
200
+ cursor: var(--cursor-rect), crosshair !important;
201
+ }
123
202
  .dc-canvas[data-active-tool="ellipse"],
124
- .dc-canvas[data-active-tool="ellipse"] *,
203
+ .dc-canvas[data-active-tool="ellipse"] * {
204
+ cursor: var(--cursor-ellipse), crosshair !important;
205
+ }
125
206
  .dc-canvas[data-active-tool="arrow"],
126
207
  .dc-canvas[data-active-tool="arrow"] * {
127
- cursor: crosshair !important;
208
+ cursor: var(--cursor-arrow), crosshair !important;
128
209
  }
129
210
  .dc-canvas[data-active-tool="hand"],
130
211
  .dc-canvas[data-active-tool="hand"] * {
@@ -132,7 +213,7 @@ const HALO_CSS = `
132
213
  }
133
214
  .dc-canvas[data-active-tool="eraser"],
134
215
  .dc-canvas[data-active-tool="eraser"] * {
135
- cursor: cell !important;
216
+ cursor: var(--cursor-eraser), cell !important;
136
217
  }
137
218
  `.trim();
138
219
 
@@ -198,15 +279,76 @@ function CanvasCore({
198
279
  };
199
280
  }, [hostRef, tool]);
200
281
 
282
+ const artboardsCtx = useArtboardsContext();
283
+ const dragBus = useDragStateContext();
284
+
285
+ /**
286
+ * T24 — distribute the currently-selected artboards evenly on the given
287
+ * axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
288
+ * first + last in place, and reposition the middle artboards so the gaps
289
+ * between trailing edge → next leading edge are equal.
290
+ */
291
+ const distributeArtboards = useCallback(
292
+ (axis: 'x' | 'y') => {
293
+ if (!artboardsCtx || !dragBus) return;
294
+ const ids = new Set(
295
+ selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
296
+ );
297
+ if (ids.size < 3) return;
298
+ const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
299
+ if (targets.length < 3) return;
300
+ const sorted = [...targets].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
301
+ const first = sorted[0];
302
+ const last = sorted[sorted.length - 1];
303
+ if (!first || !last) return;
304
+ // Total content extent (sum of side-lengths on the axis) + the span
305
+ // between first leading edge and last trailing edge gives us the gap
306
+ // budget to divide equally between (n-1) inter-artboard slots.
307
+ const sideLen = (r: ArtboardRect) => (axis === 'x' ? r.w : r.h);
308
+ const totalSides = sorted.reduce((acc, r) => acc + sideLen(r), 0);
309
+ const span =
310
+ (axis === 'x' ? last.x + last.w - first.x : last.y + last.h - first.y) - totalSides;
311
+ const gap = span / (sorted.length - 1);
312
+ const moved: { id: string; x: number; y: number }[] = [];
313
+ let cursor = axis === 'x' ? first.x + first.w + gap : first.y + first.h + gap;
314
+ for (let i = 1; i < sorted.length - 1; i++) {
315
+ const r = sorted[i];
316
+ if (!r) continue;
317
+ if (axis === 'x') {
318
+ moved.push({ id: r.id, x: Math.round(cursor), y: r.y });
319
+ cursor += r.w + gap;
320
+ } else {
321
+ moved.push({ id: r.id, x: r.x, y: Math.round(cursor) });
322
+ cursor += r.h + gap;
323
+ }
324
+ }
325
+ if (moved.length === 0) return;
326
+ dragBus.commitPositions(moved);
327
+ },
328
+ [artboardsCtx, dragBus, selSet.selected]
329
+ );
330
+
201
331
  const registry = useMemo<ContextRegistry>(
202
- () => buildRegistry({ controller, clearSelection: selSet.clear }),
203
- [controller, selSet.clear]
332
+ () =>
333
+ buildRegistry({
334
+ controller,
335
+ clearSelection: selSet.clear,
336
+ selSet,
337
+ distributeArtboards,
338
+ }),
339
+ [controller, selSet, distributeArtboards]
204
340
  );
205
341
 
342
+ // Distribute is reached via the MultiArtboardToolbar (floating chrome
343
+ // anchored above the group bbox) + context menu. No keyboard shortcut —
344
+ // user feedback (post-Wave-2) preferred the toolbar over global hotkeys.
345
+
206
346
  return (
207
347
  <ExportDialogProvider>
208
348
  <ContextMenuProvider registry={registry}>
209
- <CanvasRouter hostRef={hostRef}>{children}</CanvasRouter>
349
+ <CanvasRouter hostRef={hostRef} distributeArtboards={distributeArtboards}>
350
+ {children}
351
+ </CanvasRouter>
210
352
  </ContextMenuProvider>
211
353
  </ExportDialogProvider>
212
354
  );
@@ -218,8 +360,16 @@ function CanvasCore({
218
360
  function buildRegistry(deps: {
219
361
  controller: ViewportControllerHandle | null;
220
362
  clearSelection: () => void;
363
+ selSet: { selected: Selection[] };
364
+ distributeArtboards: (axis: 'x' | 'y') => void;
221
365
  }): ContextRegistry {
222
- const { controller, clearSelection } = deps;
366
+ const { controller, clearSelection, selSet, distributeArtboards } = deps;
367
+
368
+ // T24 — distribute commands are only enabled when ≥ 3 artboards are
369
+ // selected. Below that, the menu items render as `disabled` so the user
370
+ // sees the affordance but understands the precondition.
371
+ const selectedArtboardCount = selSet.selected.filter((s) => !!s.artboardId).length;
372
+ const distributeEnabled = selectedArtboardCount >= 3;
223
373
 
224
374
  const copy = (text: string): void => {
225
375
  if (typeof navigator === 'undefined' || !navigator.clipboard) return;
@@ -355,6 +505,24 @@ function buildRegistry(deps: {
355
505
  fitItem,
356
506
  resetItem,
357
507
  ],
508
+ [
509
+ // T24 — distribute commands. Primary surface is the floating
510
+ // MultiArtboardToolbar above the group bbox; the menu entries are
511
+ // a discoverability backup. Disabled when fewer than 3 artboards
512
+ // are selected.
513
+ {
514
+ id: 'distribute-h',
515
+ label: 'Distribute horizontally',
516
+ disabled: !distributeEnabled,
517
+ onSelect: () => distributeArtboards('x'),
518
+ },
519
+ {
520
+ id: 'distribute-v',
521
+ label: 'Distribute vertically',
522
+ disabled: !distributeEnabled,
523
+ onSelect: () => distributeArtboards('y'),
524
+ },
525
+ ],
358
526
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
359
527
  ],
360
528
  world: [
@@ -384,11 +552,13 @@ function boundsOf(el: HTMLElement) {
384
552
  function CanvasRouter({
385
553
  hostRef,
386
554
  children,
555
+ distributeArtboards,
387
556
  }: {
388
557
  hostRef: RefObject<HTMLDivElement | null>;
389
558
  children: ReactNode;
559
+ distributeArtboards: (axis: 'x' | 'y') => void;
390
560
  }) {
391
- const { tool, setTool } = useToolMode();
561
+ const { tool, setTool, clearSticky } = useToolMode();
392
562
  const selSet = useSelectionSet();
393
563
  const annotSel = useAnnotationSelection();
394
564
  const ctxMenu = useContextMenu();
@@ -468,9 +638,11 @@ function CanvasRouter({
468
638
  onSelect: ({ mode, deep, clientX, clientY }) => {
469
639
  const target = resolveHoverTarget(document, clientX, clientY, { deep });
470
640
  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();
641
+ // No-target click (canvas chrome / dead space) DO NOT auto-clear
642
+ // the selection. Esc is the canonical deselect gesture; click-to-
643
+ // clear loses the user's selection on accidental misses. The empty
644
+ // marquee path (artboard-marquee.tsx) likewise never clears unless
645
+ // it actually captured something.
474
646
  return;
475
647
  }
476
648
  const sel = hoverTargetToSelection(target);
@@ -492,6 +664,18 @@ function CanvasRouter({
492
664
  },
493
665
  onTool: ({ tool: t }) => setTool(t),
494
666
  onEscape: () => {
667
+ // T21 — abort any mid-stroke draw FIRST. The annotations layer
668
+ // listens for `maude:cancel-stroke` and drops the in-progress
669
+ // shape without committing.
670
+ try {
671
+ document.dispatchEvent(new CustomEvent('maude:cancel-stroke'));
672
+ } catch {
673
+ /* non-DOM env */
674
+ }
675
+ // T19 — clear sticky-tool lock + flip back to Move. Esc is the
676
+ // canonical "back to default state" gesture across canvas apps.
677
+ clearSticky();
678
+ if (tool !== 'move') setTool('move');
495
679
  ctxMenu.close();
496
680
  selSet.clear();
497
681
  annotSel.clear();
@@ -596,14 +780,213 @@ function CanvasRouter({
596
780
  <CommentsOverlay />
597
781
  <AnnotationsLayer />
598
782
  <ToolPalette />
783
+ <ArtboardMarqueeOverlay />
599
784
  <HoverHalo el={hoverEl} />
600
785
  <SelectionHalos />
601
786
  <GroupBbox />
787
+ <MultiArtboardToolbar distributeArtboards={distributeArtboards} />
602
788
  <SnapGuideOverlay />
603
789
  </>
604
790
  );
605
791
  }
606
792
 
793
+ // ─────────────────────────────────────────────────────────────────────────────
794
+ // MultiArtboardToolbar — floating chrome anchored above the group bbox when
795
+ // ≥ 2 artboards are multi-selected. Primary surface for the distribute
796
+ // commands (post-Wave-2 feedback preferred this over global ⌘⌥H / ⌘⌥V
797
+ // hotkeys). Disabled state when fewer than 3 artboards are selected — the
798
+ // math is undefined for 2.
799
+
800
+ const MULTI_TOOLBAR_CSS = `
801
+ .dc-multi-artboard-tb {
802
+ position: fixed;
803
+ pointer-events: auto;
804
+ z-index: 6;
805
+ display: none;
806
+ align-items: stretch;
807
+ gap: 2px;
808
+ padding: 4px;
809
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
810
+ border: 1px solid var(--u-fg-0, #1c1917);
811
+ border-radius: 8px;
812
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
813
+ font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
814
+ font-size: 11px;
815
+ letter-spacing: 0.02em;
816
+ color: var(--u-fg-0, #1a1a1a);
817
+ user-select: none;
818
+ }
819
+ .dc-multi-artboard-tb button {
820
+ appearance: none;
821
+ background: transparent;
822
+ border: 0;
823
+ border-radius: 6px;
824
+ padding: 4px 10px;
825
+ font: inherit;
826
+ cursor: pointer;
827
+ color: inherit;
828
+ display: inline-flex;
829
+ align-items: center;
830
+ gap: 6px;
831
+ transition: background-color 80ms linear;
832
+ }
833
+ .dc-multi-artboard-tb button:hover:not(:disabled) {
834
+ background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
835
+ }
836
+ .dc-multi-artboard-tb button:disabled {
837
+ cursor: default;
838
+ opacity: 0.4;
839
+ }
840
+ .dc-multi-artboard-tb .dc-mab-count {
841
+ padding: 4px 8px 4px 10px;
842
+ color: var(--fg-1, rgba(40,30,20,0.7));
843
+ border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
844
+ margin-right: 2px;
845
+ font-variant-numeric: tabular-nums;
846
+ }
847
+ .dc-multi-artboard-tb .dc-mab-icon {
848
+ display: inline-flex;
849
+ align-items: center;
850
+ justify-content: center;
851
+ }
852
+ @media (prefers-reduced-motion: reduce) {
853
+ .dc-multi-artboard-tb button { transition: none; }
854
+ }
855
+ `.trim();
856
+
857
+ function ensureMultiToolbarStyles(): void {
858
+ if (typeof document === 'undefined') return;
859
+ if (document.getElementById('dc-multi-artboard-tb-css')) return;
860
+ const s = document.createElement('style');
861
+ s.id = 'dc-multi-artboard-tb-css';
862
+ s.textContent = MULTI_TOOLBAR_CSS;
863
+ document.head.appendChild(s);
864
+ }
865
+
866
+ function MultiArtboardToolbar({
867
+ distributeArtboards,
868
+ }: {
869
+ distributeArtboards: (axis: 'x' | 'y') => void;
870
+ }) {
871
+ ensureMultiToolbarStyles();
872
+ const { selected } = useSelectionSet();
873
+ const ref = useRef<HTMLDivElement | null>(null);
874
+ const rafRef = useRef<number | null>(null);
875
+
876
+ const artboardSelections = useMemo(() => selected.filter((s) => !!s.artboardId), [selected]);
877
+ const artboardCount = artboardSelections.length;
878
+ const enabled = artboardCount >= 3;
879
+
880
+ useEffect(() => {
881
+ const div = ref.current;
882
+ if (!div) return;
883
+ if (artboardCount < 2) {
884
+ div.style.display = 'none';
885
+ return;
886
+ }
887
+ // Track the screen-coord union bbox of all selected artboards each
888
+ // frame. Anchor toolbar centered horizontally above the bbox top
889
+ // edge with a 14 px gap; flip BELOW if the top edge is < 60 px from
890
+ // the viewport top.
891
+ const tick = () => {
892
+ rafRef.current = null;
893
+ let xMin = Number.POSITIVE_INFINITY;
894
+ let yMin = Number.POSITIVE_INFINITY;
895
+ let xMax = Number.NEGATIVE_INFINITY;
896
+ let yMax = Number.NEGATIVE_INFINITY;
897
+ let any = false;
898
+ for (const sel of artboardSelections) {
899
+ const el = document.querySelector(`[data-dc-screen="${sel.artboardId}"]`);
900
+ if (!el) continue;
901
+ const r = (el as HTMLElement).getBoundingClientRect();
902
+ if (r.width === 0 && r.height === 0) continue;
903
+ any = true;
904
+ if (r.left < xMin) xMin = r.left;
905
+ if (r.top < yMin) yMin = r.top;
906
+ if (r.right > xMax) xMax = r.right;
907
+ if (r.bottom > yMax) yMax = r.bottom;
908
+ }
909
+ if (!any) {
910
+ div.style.display = 'none';
911
+ rafRef.current = requestAnimationFrame(tick);
912
+ return;
913
+ }
914
+ // Make the toolbar visible OFFSCREEN first to measure its own
915
+ // width, then move it to the anchor. (Cheap: only re-anchors on
916
+ // every frame after the first.)
917
+ div.style.display = 'flex';
918
+ const tw = div.offsetWidth || 0;
919
+ const centerX = (xMin + xMax) / 2;
920
+ const top = yMin;
921
+ const gap = 14;
922
+ let anchorY = top - div.offsetHeight - gap;
923
+ if (anchorY < 60) anchorY = yMax + gap; // flip below
924
+ div.style.left = `${Math.round(centerX - tw / 2)}px`;
925
+ div.style.top = `${Math.round(anchorY)}px`;
926
+ rafRef.current = requestAnimationFrame(tick);
927
+ };
928
+ rafRef.current = requestAnimationFrame(tick);
929
+ return () => {
930
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
931
+ };
932
+ }, [artboardCount, artboardSelections]);
933
+
934
+ if (artboardCount < 2) {
935
+ return <div ref={ref} className="dc-multi-artboard-tb" aria-hidden="true" />;
936
+ }
937
+
938
+ return (
939
+ <div
940
+ ref={ref}
941
+ className="dc-multi-artboard-tb"
942
+ role="toolbar"
943
+ aria-label="Multi-artboard actions"
944
+ >
945
+ <span className="dc-mab-count">{artboardCount} artboards</span>
946
+ <button
947
+ type="button"
948
+ disabled={!enabled}
949
+ title={
950
+ enabled
951
+ ? 'Distribute horizontally — equal gaps between artboards'
952
+ : 'Select at least 3 artboards to distribute'
953
+ }
954
+ aria-label="Distribute horizontally"
955
+ onClick={() => distributeArtboards('x')}
956
+ >
957
+ <span className="dc-mab-icon" aria-hidden="true">
958
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
959
+ <rect x="0.5" y="3" width="3" height="8" fill="currentColor" />
960
+ <rect x="5.5" y="3" width="3" height="8" fill="currentColor" />
961
+ <rect x="10.5" y="3" width="3" height="8" fill="currentColor" />
962
+ </svg>
963
+ </span>
964
+ Distribute H
965
+ </button>
966
+ <button
967
+ type="button"
968
+ disabled={!enabled}
969
+ title={
970
+ enabled
971
+ ? 'Distribute vertically — equal gaps between artboards'
972
+ : 'Select at least 3 artboards to distribute'
973
+ }
974
+ aria-label="Distribute vertically"
975
+ onClick={() => distributeArtboards('y')}
976
+ >
977
+ <span className="dc-mab-icon" aria-hidden="true">
978
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
979
+ <rect x="3" y="0.5" width="8" height="3" fill="currentColor" />
980
+ <rect x="3" y="5.5" width="8" height="3" fill="currentColor" />
981
+ <rect x="3" y="10.5" width="8" height="3" fill="currentColor" />
982
+ </svg>
983
+ </span>
984
+ Distribute V
985
+ </button>
986
+ </div>
987
+ );
988
+ }
989
+
607
990
  // ─────────────────────────────────────────────────────────────────────────────
608
991
  // HoverHalo — single floating overlay tracking the hovered element's screen
609
992
  // bounds. Updates on every animation frame while mounted. Position: fixed so
@@ -654,6 +1037,27 @@ function HoverHalo({ el }: { el: Element | null }) {
654
1037
  // SelectionHalos — N floating overlays, one per selected element. Resolves
655
1038
  // elements by `data-cd-id` when present, falling back to the selector path.
656
1039
 
1040
+ // DDR-046 — single-select halo carries 4 corner ticks; multi-select members get
1041
+ // the lighter 1px tinted outline (no ticks). The GroupBbox renders the loud
1042
+ // signal when selected.length > 1.
1043
+ const TICK_CLASSES = ['tick-tl', 'tick-tr', 'tick-bl', 'tick-br'] as const;
1044
+
1045
+ function makeSelectedNode(withTicks: boolean): HTMLDivElement {
1046
+ const child = document.createElement('div');
1047
+ child.className = withTicks
1048
+ ? 'dc-cv-halo dc-cv-halo--selected'
1049
+ : 'dc-cv-halo dc-cv-halo--selected-member';
1050
+ child.setAttribute('aria-hidden', 'true');
1051
+ if (withTicks) {
1052
+ for (const cls of TICK_CLASSES) {
1053
+ const tick = document.createElement('i');
1054
+ tick.className = `tick ${cls}`;
1055
+ child.appendChild(tick);
1056
+ }
1057
+ }
1058
+ return child;
1059
+ }
1060
+
657
1061
  function SelectionHalos() {
658
1062
  const { selected } = useSelectionSet();
659
1063
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -669,16 +1073,34 @@ function SelectionHalos() {
669
1073
  rafRef.current = null;
670
1074
  const c = containerRef.current;
671
1075
  if (!c) return;
672
- // Match the rendered halo count to selected.length; reuse DOM nodes.
1076
+ const wantsTicks = selected.length === 1;
1077
+ const wantClass = wantsTicks
1078
+ ? 'dc-cv-halo dc-cv-halo--selected'
1079
+ : 'dc-cv-halo dc-cv-halo--selected-member';
1080
+ // Match rendered halo count to selected.length; reuse DOM nodes.
673
1081
  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);
1082
+ c.appendChild(makeSelectedNode(wantsTicks));
678
1083
  }
679
1084
  while (c.children.length > selected.length) {
680
1085
  c.removeChild(c.lastChild as Node);
681
1086
  }
1087
+ // When selection size crosses 1↔N+, swap class + tick children on each
1088
+ // existing node so reused nodes adopt the new visual idiom.
1089
+ for (let i = 0; i < c.children.length; i++) {
1090
+ const child = c.children[i] as HTMLDivElement;
1091
+ if (child.className !== wantClass) {
1092
+ child.className = wantClass;
1093
+ // Strip any existing ticks, then re-append if needed.
1094
+ while (child.firstChild) child.removeChild(child.firstChild);
1095
+ if (wantsTicks) {
1096
+ for (const cls of TICK_CLASSES) {
1097
+ const t = document.createElement('i');
1098
+ t.className = `tick ${cls}`;
1099
+ child.appendChild(t);
1100
+ }
1101
+ }
1102
+ }
1103
+ }
682
1104
  for (let i = 0; i < selected.length; i++) {
683
1105
  const sel = selected[i];
684
1106
  const child = c.children[i] as HTMLDivElement;
@@ -691,6 +1113,9 @@ function SelectionHalos() {
691
1113
  child.style.display = 'none';
692
1114
  continue;
693
1115
  }
1116
+ // Post-Wave-2: artboard drag is now direct (article updates its own
1117
+ // `left/top` in real-time), so the halo can follow via
1118
+ // getBoundingClientRect — no special drag suppression needed.
694
1119
  const r = (el as HTMLElement).getBoundingClientRect();
695
1120
  if (r.width === 0 && r.height === 0) {
696
1121
  child.style.display = 'none';
@@ -740,6 +1165,9 @@ function GroupBbox() {
740
1165
  ? document.querySelector(`[data-cd-id="${cssEscape(sel.id)}"]`)
741
1166
  : safeQuery(sel.selector);
742
1167
  if (!el) continue;
1168
+ // Post-Wave-2: direct artboard drag — the article updates its own
1169
+ // `left/top` during drag, so the group bbox just follows via
1170
+ // getBoundingClientRect with no special-casing.
743
1171
  const r = (el as HTMLElement).getBoundingClientRect();
744
1172
  if (r.width === 0 && r.height === 0) continue;
745
1173
  anyHit = true;
@@ -767,7 +1195,13 @@ function GroupBbox() {
767
1195
  }, [selected]);
768
1196
 
769
1197
  if (selected.length < 2) return null;
770
- return <div ref={ref} className="dc-cv-group-bbox" aria-hidden="true" />;
1198
+ return (
1199
+ <div ref={ref} className="dc-cv-group-bbox" aria-hidden="true">
1200
+ {TICK_CLASSES.map((cls) => (
1201
+ <i key={cls} className={`tick ${cls}`} />
1202
+ ))}
1203
+ </div>
1204
+ );
771
1205
  }
772
1206
 
773
1207
  // ─────────────────────────────────────────────────────────────────────────────
@@ -795,10 +1229,20 @@ function hoverTargetToSelection(target: HoverTarget): Selection {
795
1229
  // climbs to an ancestor. Falls back to cssPath of the hit when no stable
796
1230
  // anchor exists.
797
1231
  const cdId = target.cdId;
1232
+ // Selector resolution order:
1233
+ // 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
1234
+ // 2. data-dc-screen — chrome click promoted to whole-artboard select
1235
+ // (T24.5 G8 multi-artboard gesture).
1236
+ // 3. cssPath of the hit — last-resort path string.
1237
+ const selector = cdId
1238
+ ? `[data-cd-id="${cdId}"]`
1239
+ : !cdId && target.artboardId
1240
+ ? `[data-dc-screen="${target.artboardId}"]`
1241
+ : cssPath(el);
798
1242
  return {
799
1243
  file: typeof window !== 'undefined' ? deriveFile() : undefined,
800
1244
  id: cdId ?? undefined,
801
- selector: cdId ? `[data-cd-id="${cdId}"]` : cssPath(el),
1245
+ selector,
802
1246
  artboardId: target.artboardId,
803
1247
  tag: el?.tagName.toLowerCase() ?? '',
804
1248
  classes: realClasses(el),