@1agh/maude 0.19.1 → 0.21.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 (105) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +17 -9
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -36,6 +36,7 @@ import {
36
36
  useState,
37
37
  } from 'react';
38
38
 
39
+ import { AiBanner } from './ai-banner.tsx';
39
40
  import { AnnotationsLayer } from './annotations-layer.tsx';
40
41
  import { ArtboardMarqueeOverlay } from './artboard-marquee.tsx';
41
42
  import {
@@ -46,6 +47,7 @@ import {
46
47
  useDragStateContext,
47
48
  useViewportControllerContext,
48
49
  } from './canvas-lib.tsx';
50
+ import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
49
51
  import { CommentsOverlay } from './comments-overlay.tsx';
50
52
  import {
51
53
  ContextMenuProvider,
@@ -55,16 +57,27 @@ import {
55
57
  type MenuItem,
56
58
  useContextMenu,
57
59
  } from './context-menu.tsx';
60
+ import { ContextualToolbar } from './contextual-toolbar.tsx';
61
+ import { CursorsOverlay } from './cursors-overlay.tsx';
62
+ import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
58
63
  import { ExportDialogProvider } from './export-dialog.tsx';
59
64
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
65
+ import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
66
+ import { ParticipantsChrome } from './participants-chrome.tsx';
60
67
  import { ToolPalette } from './tool-palette.tsx';
68
+ import { UndoHud } from './undo-hud.tsx';
61
69
  import {
62
70
  AnnotationSelectionProvider,
63
71
  useAnnotationSelection,
72
+ useAnnotationSelectionOptional,
64
73
  } from './use-annotation-selection.tsx';
65
74
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
75
+ import { useCollab } from './use-collab.tsx';
76
+ import { useCursorModifiers } from './use-cursor-modifiers.tsx';
77
+ import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
66
78
  import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
67
79
  import { useToolMode } from './use-tool-mode.tsx';
80
+ import { useUndoStack } from './use-undo-stack.tsx';
68
81
 
69
82
  // ─────────────────────────────────────────────────────────────────────────────
70
83
  // Styles — halos render as `position: fixed` siblings of the canvas. Reading
@@ -73,6 +86,28 @@ import { useToolMode } from './use-tool-mode.tsx';
73
86
  // plane would otherwise scale a 2 px outline to 0.84 px at 42 % zoom (subpixel
74
87
  // = invisible). No per-element class stamping is used.
75
88
 
89
+ // HUD-namespace token block. System-review 2026-05-27 (D-4) flagged that the
90
+ // dev-server chrome (toolbar + minimap + halos + marquee + AI banner) used
91
+ // `var(--accent, …)` which inherited the canvas DS palette — a violet StudyFi
92
+ // canvas turned the floating cursor toolbar violet. The HUD owns its own
93
+ // `--maude-hud-*` token family, set on `:root` of the canvas iframe document
94
+ // here. Canvas DSs do NOT define `--maude-hud-*`, so HUD CSS resolves against
95
+ // this block regardless of what the imported `:root { --accent: … }` looks like.
96
+ //
97
+ // Defaults match the existing inline fallback color (`#d63b1f`, Maude brand
98
+ // orange-rust) so no visual change to the default theme. Users who want to
99
+ // re-theme the HUD can set `--maude-hud-accent` etc. via a `<style>` block
100
+ // AFTER this one (CSS cascade — later wins).
101
+ const HUD_TOKENS_CSS = `
102
+ :root {
103
+ --maude-hud-accent: #d63b1f;
104
+ --maude-hud-accent-hover: #b8331b;
105
+ --maude-hud-accent-active: #962a16;
106
+ --maude-hud-accent-fg: #ffffff;
107
+ --maude-hud-accent-tint: color-mix(in oklab, #d63b1f 14%, transparent);
108
+ }
109
+ `;
110
+
76
111
  // DDR-046 — Three-state halo language. Each state has its own border weight,
77
112
  // color treatment, and geometric idiom so 8+ semantic states (hover / selected
78
113
  // / member-of-multi / group / snap-sibling / snap-grid / marquee / annotation
@@ -90,20 +125,20 @@ const HALO_CSS = `
90
125
  /* Hover — lighter 1.5px tinted line + white inner ring for contrast on dark
91
126
  elements. NO ring, NO ticks. Synchronous paint (no debounce). */
92
127
  .dc-cv-halo--hover {
93
- border: 1.5px solid color-mix(in oklab, var(--accent, #0d99ff) 60%, transparent);
128
+ border: 1.5px solid color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 60%, transparent);
94
129
  box-shadow: inset 0 0 0 1px var(--bg-0, #ffffff);
95
130
  }
96
131
  /* Selected (single) — 2px solid + 18% ring halo + 4 filled corner ticks.
97
132
  Ticks are <i class="tick tick-*"> children at inset:-3px, 8x8, accent fill. */
98
133
  .dc-cv-halo--selected {
99
- border: 2px solid var(--accent, #0d99ff);
100
- box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent, #0d99ff) 18%, transparent);
134
+ border: 2px solid var(--maude-hud-accent, #0d99ff);
135
+ box-shadow: 0 0 0 4px color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 18%, transparent);
101
136
  }
102
137
  .dc-cv-halo--selected .tick {
103
138
  position: absolute;
104
139
  width: 8px;
105
140
  height: 8px;
106
- background: var(--accent, #0d99ff);
141
+ background: var(--maude-hud-accent, #0d99ff);
107
142
  border-radius: 1px;
108
143
  box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
109
144
  }
@@ -117,7 +152,7 @@ const HALO_CSS = `
117
152
  as "draft / placeholder" and members would melt away inside artboards once
118
153
  the artboard border itself is 22%-tinted (T15). */
119
154
  .dc-cv-halo--selected-member {
120
- border: 1.5px solid var(--accent, #0d99ff);
155
+ border: 1.5px solid var(--maude-hud-accent, #0d99ff);
121
156
  }
122
157
  /* Group bbox — 1 px DASHED full accent + four 6 × 6 square corner handles.
123
158
  T16 / DDR-046 rev 2 — dashed is the canonical group-container affordance
@@ -130,14 +165,14 @@ const HALO_CSS = `
130
165
  position: fixed;
131
166
  pointer-events: none;
132
167
  z-index: 5;
133
- border: 1px dashed var(--accent, #0d99ff);
168
+ border: 1px dashed var(--maude-hud-accent, #0d99ff);
134
169
  border-radius: 2px;
135
170
  }
136
171
  .dc-cv-group-bbox .tick {
137
172
  position: absolute;
138
173
  width: 6px;
139
174
  height: 6px;
140
- background: var(--accent, #0d99ff);
175
+ background: var(--maude-hud-accent, #0d99ff);
141
176
  border-radius: 1px;
142
177
  box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
143
178
  }
@@ -157,7 +192,7 @@ const HALO_CSS = `
157
192
  (3 px ring + hard 6×6×0 offset) was readable but visually expensive once
158
193
  the frame itself lost its brutalist treatment. A 2 px ring on a 22 %
159
194
  tinted hairline reads unambiguous without claiming subject-ness. */
160
- box-shadow: 0 0 0 2px var(--accent, #0d99ff);
195
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #0d99ff);
161
196
  transition: box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1);
162
197
  }
163
198
  /* Respect prefers-reduced-motion across all chrome transitions. */
@@ -215,10 +250,39 @@ const HALO_CSS = `
215
250
  .dc-canvas[data-active-tool="eraser"] * {
216
251
  cursor: var(--cursor-eraser), cell !important;
217
252
  }
253
+
254
+ /* T31 — Level of detail. Below 0.35 zoom we hide pre-attentive chrome that
255
+ becomes visual noise (corner ticks, distance pills, active-artboard ring,
256
+ snap pills). Above 4.0 we coax the browser into crisper text rendering.
257
+ The "normal" band 0.35..4.0 carries the full chrome. */
258
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-cv-halo .tick,
259
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-cv-group-bbox .tick {
260
+ display: none;
261
+ }
262
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-snap-pill,
263
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-cv-eq-pill {
264
+ display: none;
265
+ }
266
+ .dc-canvas[data-cv-zoom-lod="low"] .dc-artboard[aria-current="true"] {
267
+ box-shadow: none;
268
+ }
269
+ .dc-canvas[data-cv-zoom-lod="crisp"] {
270
+ -webkit-font-smoothing: subpixel-antialiased;
271
+ font-smooth: always;
272
+ }
218
273
  `.trim();
219
274
 
220
275
  function ensureHaloStyles(): void {
221
276
  if (typeof document === 'undefined') return;
277
+ // HUD tokens MUST be injected before HALO_CSS so the cascade resolves
278
+ // `var(--maude-hud-accent, …)` against the dev-server's brand defaults
279
+ // even when the canvas DS's tokens.css later sets `:root { --accent: … }`.
280
+ if (!document.getElementById('dc-cv-hud-tokens-css')) {
281
+ const t = document.createElement('style');
282
+ t.id = 'dc-cv-hud-tokens-css';
283
+ t.textContent = HUD_TOKENS_CSS;
284
+ document.head.appendChild(t);
285
+ }
222
286
  if (document.getElementById('dc-cv-halo-css')) return;
223
287
  const s = document.createElement('style');
224
288
  s.id = 'dc-cv-halo-css';
@@ -279,9 +343,142 @@ function CanvasCore({
279
343
  };
280
344
  }, [hostRef, tool]);
281
345
 
346
+ // T28 — modifier-aware cursor (Alt → copy on cd-id, Shift → crosshair on
347
+ // body padding). CSS-driven once data-mod-* is reflected on the host.
348
+ useCursorModifiers(hostRef);
349
+ // T29 — arrow nudge (artboards) + Cmd+A select-all (active artboard).
350
+ // Cmd+D duplicate deferred; no live duplicate channel for either artboards
351
+ // or stamped elements yet.
352
+ useKeyboardDiscipline();
353
+
282
354
  const artboardsCtx = useArtboardsContext();
283
355
  const dragBus = useDragStateContext();
284
356
 
357
+ // T33 — programmatic-zoom easing via double-click on empty world only.
358
+ // Per post-Wave-3 user feedback, dblclick-on-artboard auto-zoom was
359
+ // surprising (interfered with native dblclick text-select inside chrome
360
+ // and felt magnetic). We keep the dblclick-empty → `fit()` path because
361
+ // it's a discoverable "back to overview" gesture; artboard zoom is still
362
+ // reachable via Cmd+1 and the zoom HUD.
363
+ useEffect(() => {
364
+ if (!controller) return;
365
+ const host = hostRef.current;
366
+ if (!host) return;
367
+ const onDbl = (e: MouseEvent) => {
368
+ const t = e.target as Element | null;
369
+ if (!t || !t.closest) return;
370
+ // Floating chrome / overlays / drawn user content / any artboard
371
+ // surface → leave alone. Only dblclick that lands on the canvas
372
+ // background outside every artboard triggers `fit()`.
373
+ if (
374
+ t.closest(
375
+ '.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'
376
+ )
377
+ ) {
378
+ return;
379
+ }
380
+ if (t.closest('[data-dc-screen]')) return; // any part of an artboard
381
+ e.preventDefault();
382
+ controller.fit();
383
+ };
384
+ host.addEventListener('dblclick', onDbl);
385
+ return () => host.removeEventListener('dblclick', onDbl);
386
+ }, [controller, hostRef]);
387
+
388
+ // T31 — level-of-detail attribute on `.dc-canvas`. CSS rules hide
389
+ // pre-attentive chrome (ticks, distance pills, accent ring) below 0.35
390
+ // zoom and sharpen text above 4.0. tldraw's textShadowLod = 0.35 is the
391
+ // canonical threshold. Reads the published viewport (settle-cadence) so
392
+ // the LOD doesn't flicker between bands mid-zoom — chrome should settle
393
+ // once per gesture, not on every frame.
394
+ const publishedZoom = artboardsCtx?.viewport?.zoom ?? 1;
395
+ useEffect(() => {
396
+ const host = hostRef.current;
397
+ if (!host) return;
398
+ const lod = publishedZoom < 0.35 ? 'low' : publishedZoom > 4 ? 'crisp' : 'normal';
399
+ host.setAttribute('data-cv-zoom-lod', lod);
400
+ return () => host.removeAttribute('data-cv-zoom-lod');
401
+ }, [hostRef, publishedZoom]);
402
+
403
+ // Phase 8 — publish local cursor (world coords) + viewport to Awareness
404
+ // so foreign peers can render our cursor on their CursorsOverlay. The
405
+ // collab.publishAwareness call is already throttled to ~30 Hz internally
406
+ // (use-collab.tsx) — we just need to compute screen → world per move.
407
+ const collab = useCollab();
408
+ useEffect(() => {
409
+ const host = hostRef.current;
410
+ if (!host || !collab || !controller) return;
411
+ const onMove = (e: MouseEvent) => {
412
+ const v = controller.viewport;
413
+ // screen → world: world = (screen - viewport.{x,y}) / zoom.
414
+ const worldX = (e.clientX - v.x) / Math.max(v.zoom, 0.0001);
415
+ const worldY = (e.clientY - v.y) / Math.max(v.zoom, 0.0001);
416
+ collab.publishAwareness({ cursor: { x: worldX, y: worldY } });
417
+ };
418
+ const onLeave = () => {
419
+ collab.publishAwareness({ cursor: null });
420
+ };
421
+ host.addEventListener('mousemove', onMove);
422
+ host.addEventListener('mouseleave', onLeave);
423
+ return () => {
424
+ host.removeEventListener('mousemove', onMove);
425
+ host.removeEventListener('mouseleave', onLeave);
426
+ };
427
+ }, [hostRef, collab, controller]);
428
+
429
+ // Phase 8 — publish viewport when it settles. The CursorsOverlay only
430
+ // needs the LOCAL viewport (to transform foreign world coords back to
431
+ // screen), but exposing ours over Awareness sets up Task 6's follow-mode.
432
+ // biome-ignore lint/correctness/useExhaustiveDependencies: deliberate settle-cadence — re-publish only when x/y/zoom change, not on every viewport field.
433
+ useEffect(() => {
434
+ if (!collab || !controller) return;
435
+ collab.publishAwareness({ viewport: controller.viewport });
436
+ }, [
437
+ collab,
438
+ controller,
439
+ controller?.viewport.x,
440
+ controller?.viewport.y,
441
+ controller?.viewport.zoom,
442
+ ]);
443
+
444
+ // Phase 8 — publish local selection so foreign PeerSelection halos can
445
+ // render around what *I* selected. Convert the first entry's selector
446
+ // (CSS path the peer resolves in their own DOM) + current bounds. The
447
+ // bounds are screen-px from publish time — peers re-resolve cssPath
448
+ // when possible and fall back to bounds otherwise.
449
+ useEffect(() => {
450
+ if (!collab) return;
451
+ const first = selSet.selected[0];
452
+ if (!first || !first.selector) {
453
+ collab.publishAwareness({ selection: null });
454
+ return;
455
+ }
456
+ const b = first.bounds;
457
+ const bounds =
458
+ b && typeof b === 'object'
459
+ ? {
460
+ x: Number(b.x) || 0,
461
+ y: Number(b.y) || 0,
462
+ w: Number(b.w) || 0,
463
+ h: Number(b.h) || 0,
464
+ }
465
+ : { x: 0, y: 0, w: 0, h: 0 };
466
+ collab.publishAwareness({ selection: { cssPath: first.selector, bounds } });
467
+ }, [collab, selSet.selected]);
468
+
469
+ // Phase 8 — publish annotation selection (Phase 5 strokes). Separate from
470
+ // selSet because annotations have their own selection registry. Peers
471
+ // render halos by querying `[data-id="<id>"]` so the same halo follows
472
+ // resize / move (SVG re-emits with the same data-id). `Optional` flavor
473
+ // because CanvasCore is mounted INSIDE the AnnotationSelectionProvider
474
+ // tree but TypeScript / a defensive boot path can't always prove it.
475
+ const annotSelForPublish = useAnnotationSelectionOptional();
476
+ const annotSelectedIds = annotSelForPublish?.selectedIds;
477
+ useEffect(() => {
478
+ if (!collab) return;
479
+ collab.publishAwareness({ annotationSelection: annotSelectedIds ?? [] });
480
+ }, [collab, annotSelectedIds]);
481
+
285
482
  /**
286
483
  * T24 — distribute the currently-selected artboards evenly on the given
287
484
  * axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
@@ -323,11 +520,98 @@ function CanvasCore({
323
520
  }
324
521
  }
325
522
  if (moved.length === 0) return;
326
- dragBus.commitPositions(moved);
523
+ dragBus.commitPositions(moved, { label: equalSpacingLabel(sorted.length) });
327
524
  },
328
525
  [artboardsCtx, dragBus, selSet.selected]
329
526
  );
330
527
 
528
+ /**
529
+ * G7 — align selected artboards to a common edge / midline. Requires ≥ 2
530
+ * selected artboards. Six modes:
531
+ * - 'left' → all artboards share the minimum x of the set
532
+ * - 'right' → all artboards share the maximum (x + w) edge
533
+ * - 'center-x' → all artboards share the midpoint of the set's x extent
534
+ * - 'top' → all artboards share the minimum y
535
+ * - 'bottom' → all artboards share the maximum (y + h) edge
536
+ * - 'center-y' → all artboards share the midpoint of the set's y extent
537
+ *
538
+ * Holds the reference edge constant; only the perpendicular axis stays as
539
+ * each artboard already was (alignment doesn't relocate on the orthogonal
540
+ * axis). This matches Figma / Sketch / FigJam align semantics.
541
+ */
542
+ const alignArtboards = useCallback(
543
+ (mode: AlignMode) => {
544
+ if (!artboardsCtx || !dragBus) return;
545
+ const ids = new Set(
546
+ selSet.selected.filter((s) => !!s.artboardId).map((s) => s.artboardId as string)
547
+ );
548
+ if (ids.size < 2) return;
549
+ const targets: ArtboardRect[] = artboardsCtx.artboards.filter((r) => ids.has(r.id));
550
+ if (targets.length < 2) return;
551
+
552
+ // Union bbox for center modes.
553
+ let xMin = Number.POSITIVE_INFINITY;
554
+ let yMin = Number.POSITIVE_INFINITY;
555
+ let xMax = Number.NEGATIVE_INFINITY;
556
+ let yMax = Number.NEGATIVE_INFINITY;
557
+ for (const r of targets) {
558
+ if (r.x < xMin) xMin = r.x;
559
+ if (r.y < yMin) yMin = r.y;
560
+ if (r.x + r.w > xMax) xMax = r.x + r.w;
561
+ if (r.y + r.h > yMax) yMax = r.y + r.h;
562
+ }
563
+ const cx = (xMin + xMax) / 2;
564
+ const cy = (yMin + yMax) / 2;
565
+
566
+ const moved: { id: string; x: number; y: number }[] = [];
567
+ for (const r of targets) {
568
+ let nx = r.x;
569
+ let ny = r.y;
570
+ switch (mode) {
571
+ case 'left':
572
+ nx = xMin;
573
+ break;
574
+ case 'right':
575
+ nx = xMax - r.w;
576
+ break;
577
+ case 'center-x':
578
+ nx = cx - r.w / 2;
579
+ break;
580
+ case 'top':
581
+ ny = yMin;
582
+ break;
583
+ case 'bottom':
584
+ ny = yMax - r.h;
585
+ break;
586
+ case 'center-y':
587
+ ny = cy - r.h / 2;
588
+ break;
589
+ }
590
+ if (Math.round(nx) === r.x && Math.round(ny) === r.y) continue;
591
+ moved.push({ id: r.id, x: Math.round(nx), y: Math.round(ny) });
592
+ }
593
+ if (moved.length === 0) return;
594
+ dragBus.commitPositions(moved, { label: alignLabel(mode, targets.length) });
595
+ },
596
+ [artboardsCtx, dragBus, selSet.selected]
597
+ );
598
+
599
+ /**
600
+ * G2v2 — "Fit just this artboard" context-menu entry previously bridged
601
+ * via a synthetic click on the label button (which used to call
602
+ * controller.jumpTo). Now that the label is purely a11y, the menu entry
603
+ * needs a direct path: look the rect up from the live artboards list and
604
+ * call jumpTo straight on the controller.
605
+ */
606
+ const focusArtboard = useCallback(
607
+ (artboardId: string) => {
608
+ if (!controller || !artboardsCtx) return;
609
+ const rect = artboardsCtx.artboards.find((r) => r.id === artboardId);
610
+ if (rect) controller.jumpTo(rect);
611
+ },
612
+ [controller, artboardsCtx]
613
+ );
614
+
331
615
  const registry = useMemo<ContextRegistry>(
332
616
  () =>
333
617
  buildRegistry({
@@ -335,8 +619,10 @@ function CanvasCore({
335
619
  clearSelection: selSet.clear,
336
620
  selSet,
337
621
  distributeArtboards,
622
+ alignArtboards,
623
+ focusArtboard,
338
624
  }),
339
- [controller, selSet, distributeArtboards]
625
+ [controller, selSet, distributeArtboards, alignArtboards, focusArtboard]
340
626
  );
341
627
 
342
628
  // Distribute is reached via the MultiArtboardToolbar (floating chrome
@@ -346,7 +632,11 @@ function CanvasCore({
346
632
  return (
347
633
  <ExportDialogProvider>
348
634
  <ContextMenuProvider registry={registry}>
349
- <CanvasRouter hostRef={hostRef} distributeArtboards={distributeArtboards}>
635
+ <CanvasRouter
636
+ hostRef={hostRef}
637
+ distributeArtboards={distributeArtboards}
638
+ alignArtboards={alignArtboards}
639
+ >
350
640
  {children}
351
641
  </CanvasRouter>
352
642
  </ContextMenuProvider>
@@ -362,14 +652,19 @@ function buildRegistry(deps: {
362
652
  clearSelection: () => void;
363
653
  selSet: { selected: Selection[] };
364
654
  distributeArtboards: (axis: 'x' | 'y') => void;
655
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
656
+ focusArtboard: (artboardId: string) => void;
365
657
  }): ContextRegistry {
366
- const { controller, clearSelection, selSet, distributeArtboards } = deps;
658
+ const { controller, clearSelection, selSet, distributeArtboards, alignArtboards, focusArtboard } =
659
+ deps;
367
660
 
368
661
  // T24 — distribute commands are only enabled when ≥ 3 artboards are
369
662
  // selected. Below that, the menu items render as `disabled` so the user
370
663
  // sees the affordance but understands the precondition.
664
+ // G7 — align commands are enabled at ≥ 2 (alignment is well-defined with 2).
371
665
  const selectedArtboardCount = selSet.selected.filter((s) => !!s.artboardId).length;
372
666
  const distributeEnabled = selectedArtboardCount >= 3;
667
+ const alignEnabled = selectedArtboardCount >= 2;
373
668
 
374
669
  const copy = (text: string): void => {
375
670
  if (typeof navigator === 'undefined' || !navigator.clipboard) return;
@@ -492,19 +787,54 @@ function buildRegistry(deps: {
492
787
  id: 'fit-one',
493
788
  label: 'Fit just this artboard',
494
789
  onSelect: (target) => {
495
- if (!controller || !target.artboardId) return;
496
- // controller exposes jumpTo(rect) — DCArtboard.onFocus uses the
497
- // same pattern. The artboard label button already wires to
498
- // onFocus; dispatch a synthetic click as the simplest bridge.
499
- const btn = (target.el ?? document)
500
- .closest?.('[data-dc-screen]')
501
- ?.querySelector('button.dc-artboard-label');
502
- (btn as HTMLButtonElement | null)?.click();
790
+ if (!target.artboardId) return;
791
+ focusArtboard(target.artboardId);
503
792
  },
504
793
  },
505
794
  fitItem,
506
795
  resetItem,
507
796
  ],
797
+ [
798
+ // G7 — align commands. Six modes; gated on ≥ 2 selected artboards
799
+ // (alignment is well-defined with 2). Primary surface is the
800
+ // MultiArtboardToolbar; menu entries are discoverability backup.
801
+ {
802
+ id: 'align-left',
803
+ label: 'Align left',
804
+ disabled: !alignEnabled,
805
+ onSelect: () => alignArtboards('left'),
806
+ },
807
+ {
808
+ id: 'align-center-x',
809
+ label: 'Align center (horizontal)',
810
+ disabled: !alignEnabled,
811
+ onSelect: () => alignArtboards('center-x'),
812
+ },
813
+ {
814
+ id: 'align-right',
815
+ label: 'Align right',
816
+ disabled: !alignEnabled,
817
+ onSelect: () => alignArtboards('right'),
818
+ },
819
+ {
820
+ id: 'align-top',
821
+ label: 'Align top',
822
+ disabled: !alignEnabled,
823
+ onSelect: () => alignArtboards('top'),
824
+ },
825
+ {
826
+ id: 'align-center-y',
827
+ label: 'Align center (vertical)',
828
+ disabled: !alignEnabled,
829
+ onSelect: () => alignArtboards('center-y'),
830
+ },
831
+ {
832
+ id: 'align-bottom',
833
+ label: 'Align bottom',
834
+ disabled: !alignEnabled,
835
+ onSelect: () => alignArtboards('bottom'),
836
+ },
837
+ ],
508
838
  [
509
839
  // T24 — distribute commands. Primary surface is the floating
510
840
  // MultiArtboardToolbar above the group bbox; the menu entries are
@@ -553,15 +883,18 @@ function CanvasRouter({
553
883
  hostRef,
554
884
  children,
555
885
  distributeArtboards,
886
+ alignArtboards,
556
887
  }: {
557
888
  hostRef: RefObject<HTMLDivElement | null>;
558
889
  children: ReactNode;
559
890
  distributeArtboards: (axis: 'x' | 'y') => void;
891
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
560
892
  }) {
561
893
  const { tool, setTool, clearSticky } = useToolMode();
562
894
  const selSet = useSelectionSet();
563
895
  const annotSel = useAnnotationSelection();
564
896
  const ctxMenu = useContextMenu();
897
+ const undoStack = useUndoStack();
565
898
 
566
899
  // Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
567
900
  // itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
@@ -663,6 +996,12 @@ function CanvasRouter({
663
996
  ctxMenu.open(ctxTarget);
664
997
  },
665
998
  onTool: ({ tool: t }) => setTool(t),
999
+ onUndo: () => {
1000
+ void undoStack.undo();
1001
+ },
1002
+ onRedo: () => {
1003
+ void undoStack.redo();
1004
+ },
666
1005
  onEscape: () => {
667
1006
  // T21 — abort any mid-stroke draw FIRST. The annotations layer
668
1007
  // listens for `maude:cancel-stroke` and drops the in-progress
@@ -781,11 +1120,21 @@ function CanvasRouter({
781
1120
  <AnnotationsLayer />
782
1121
  <ToolPalette />
783
1122
  <ArtboardMarqueeOverlay />
1123
+ <ElementMarqueeOverlay />
784
1124
  <HoverHalo el={hoverEl} />
785
1125
  <SelectionHalos />
786
1126
  <GroupBbox />
787
- <MultiArtboardToolbar distributeArtboards={distributeArtboards} />
1127
+ <EqualSpacingHandles />
1128
+ <ContextualToolbar />
1129
+ <MultiArtboardToolbar
1130
+ distributeArtboards={distributeArtboards}
1131
+ alignArtboards={alignArtboards}
1132
+ />
788
1133
  <SnapGuideOverlay />
1134
+ <UndoHud />
1135
+ <CursorsOverlay />
1136
+ <AiBanner />
1137
+ <ParticipantsChrome />
789
1138
  </>
790
1139
  );
791
1140
  }
@@ -831,7 +1180,7 @@ const MULTI_TOOLBAR_CSS = `
831
1180
  transition: background-color 80ms linear;
832
1181
  }
833
1182
  .dc-multi-artboard-tb button:hover:not(:disabled) {
834
- background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
1183
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
835
1184
  }
836
1185
  .dc-multi-artboard-tb button:disabled {
837
1186
  cursor: default;
@@ -849,6 +1198,12 @@ const MULTI_TOOLBAR_CSS = `
849
1198
  align-items: center;
850
1199
  justify-content: center;
851
1200
  }
1201
+ .dc-multi-artboard-tb .dc-mab-divider {
1202
+ width: 1px;
1203
+ align-self: stretch;
1204
+ background: var(--u-border-subtle, rgba(0,0,0,0.10));
1205
+ margin: 0 4px;
1206
+ }
852
1207
  @media (prefers-reduced-motion: reduce) {
853
1208
  .dc-multi-artboard-tb button { transition: none; }
854
1209
  }
@@ -865,8 +1220,10 @@ function ensureMultiToolbarStyles(): void {
865
1220
 
866
1221
  function MultiArtboardToolbar({
867
1222
  distributeArtboards,
1223
+ alignArtboards,
868
1224
  }: {
869
1225
  distributeArtboards: (axis: 'x' | 'y') => void;
1226
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
870
1227
  }) {
871
1228
  ensureMultiToolbarStyles();
872
1229
  const { selected } = useSelectionSet();
@@ -875,7 +1232,8 @@ function MultiArtboardToolbar({
875
1232
 
876
1233
  const artboardSelections = useMemo(() => selected.filter((s) => !!s.artboardId), [selected]);
877
1234
  const artboardCount = artboardSelections.length;
878
- const enabled = artboardCount >= 3;
1235
+ const distributeOk = artboardCount >= 3;
1236
+ const alignOk = artboardCount >= 2;
879
1237
 
880
1238
  useEffect(() => {
881
1239
  const div = ref.current;
@@ -943,11 +1301,105 @@ function MultiArtboardToolbar({
943
1301
  aria-label="Multi-artboard actions"
944
1302
  >
945
1303
  <span className="dc-mab-count">{artboardCount} artboards</span>
1304
+ {/* G7 — align cluster. Icon-only to keep the toolbar narrow; full
1305
+ label lives in the tooltip + the right-click menu. Enabled at ≥ 2
1306
+ artboards (alignment is well-defined with 2). */}
1307
+ <button
1308
+ type="button"
1309
+ disabled={!alignOk}
1310
+ title={alignOk ? 'Align left' : 'Select at least 2 artboards to align'}
1311
+ aria-label="Align left"
1312
+ onClick={() => alignArtboards('left')}
1313
+ >
1314
+ <span className="dc-mab-icon" aria-hidden="true">
1315
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1316
+ <line x1="0.5" y1="0.5" x2="0.5" y2="13.5" stroke="currentColor" />
1317
+ <rect x="1" y="2" width="7" height="3" fill="currentColor" />
1318
+ <rect x="1" y="9" width="11" height="3" fill="currentColor" />
1319
+ </svg>
1320
+ </span>
1321
+ </button>
1322
+ <button
1323
+ type="button"
1324
+ disabled={!alignOk}
1325
+ title={alignOk ? 'Align center (horizontal)' : 'Select at least 2 artboards to align'}
1326
+ aria-label="Align center horizontally"
1327
+ onClick={() => alignArtboards('center-x')}
1328
+ >
1329
+ <span className="dc-mab-icon" aria-hidden="true">
1330
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1331
+ <line x1="7" y1="0.5" x2="7" y2="13.5" stroke="currentColor" />
1332
+ <rect x="3.5" y="2" width="7" height="3" fill="currentColor" />
1333
+ <rect x="1.5" y="9" width="11" height="3" fill="currentColor" />
1334
+ </svg>
1335
+ </span>
1336
+ </button>
1337
+ <button
1338
+ type="button"
1339
+ disabled={!alignOk}
1340
+ title={alignOk ? 'Align right' : 'Select at least 2 artboards to align'}
1341
+ aria-label="Align right"
1342
+ onClick={() => alignArtboards('right')}
1343
+ >
1344
+ <span className="dc-mab-icon" aria-hidden="true">
1345
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1346
+ <line x1="13.5" y1="0.5" x2="13.5" y2="13.5" stroke="currentColor" />
1347
+ <rect x="6" y="2" width="7" height="3" fill="currentColor" />
1348
+ <rect x="2" y="9" width="11" height="3" fill="currentColor" />
1349
+ </svg>
1350
+ </span>
1351
+ </button>
1352
+ <button
1353
+ type="button"
1354
+ disabled={!alignOk}
1355
+ title={alignOk ? 'Align top' : 'Select at least 2 artboards to align'}
1356
+ aria-label="Align top"
1357
+ onClick={() => alignArtboards('top')}
1358
+ >
1359
+ <span className="dc-mab-icon" aria-hidden="true">
1360
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1361
+ <line x1="0.5" y1="0.5" x2="13.5" y2="0.5" stroke="currentColor" />
1362
+ <rect x="2" y="1" width="3" height="7" fill="currentColor" />
1363
+ <rect x="9" y="1" width="3" height="11" fill="currentColor" />
1364
+ </svg>
1365
+ </span>
1366
+ </button>
1367
+ <button
1368
+ type="button"
1369
+ disabled={!alignOk}
1370
+ title={alignOk ? 'Align center (vertical)' : 'Select at least 2 artboards to align'}
1371
+ aria-label="Align center vertically"
1372
+ onClick={() => alignArtboards('center-y')}
1373
+ >
1374
+ <span className="dc-mab-icon" aria-hidden="true">
1375
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1376
+ <line x1="0.5" y1="7" x2="13.5" y2="7" stroke="currentColor" />
1377
+ <rect x="2" y="3.5" width="3" height="7" fill="currentColor" />
1378
+ <rect x="9" y="1.5" width="3" height="11" fill="currentColor" />
1379
+ </svg>
1380
+ </span>
1381
+ </button>
1382
+ <button
1383
+ type="button"
1384
+ disabled={!alignOk}
1385
+ title={alignOk ? 'Align bottom' : 'Select at least 2 artboards to align'}
1386
+ aria-label="Align bottom"
1387
+ onClick={() => alignArtboards('bottom')}
1388
+ >
1389
+ <span className="dc-mab-icon" aria-hidden="true">
1390
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1391
+ <line x1="0.5" y1="13.5" x2="13.5" y2="13.5" stroke="currentColor" />
1392
+ <rect x="2" y="6" width="3" height="7" fill="currentColor" />
1393
+ <rect x="9" y="2" width="3" height="11" fill="currentColor" />
1394
+ </svg>
1395
+ </span>
1396
+ </button>
1397
+ <span className="dc-mab-divider" aria-hidden="true" />
946
1398
  <button
947
1399
  type="button"
948
- disabled={!enabled}
1400
+ disabled={!distributeOk}
949
1401
  title={
950
- enabled
1402
+ distributeOk
951
1403
  ? 'Distribute horizontally — equal gaps between artboards'
952
1404
  : 'Select at least 3 artboards to distribute'
953
1405
  }
@@ -961,13 +1413,12 @@ function MultiArtboardToolbar({
961
1413
  <rect x="10.5" y="3" width="3" height="8" fill="currentColor" />
962
1414
  </svg>
963
1415
  </span>
964
- Distribute H
965
1416
  </button>
966
1417
  <button
967
1418
  type="button"
968
- disabled={!enabled}
1419
+ disabled={!distributeOk}
969
1420
  title={
970
- enabled
1421
+ distributeOk
971
1422
  ? 'Distribute vertically — equal gaps between artboards'
972
1423
  : 'Select at least 3 artboards to distribute'
973
1424
  }
@@ -981,7 +1432,6 @@ function MultiArtboardToolbar({
981
1432
  <rect x="3" y="10.5" width="8" height="3" fill="currentColor" />
982
1433
  </svg>
983
1434
  </span>
984
- Distribute V
985
1435
  </button>
986
1436
  </div>
987
1437
  );