@1agh/maude 0.19.1 → 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 (49) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +112 -38
  3. package/plugins/design/dev-server/annotations-layer.tsx +204 -95
  4. package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
  5. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  6. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  7. package/plugins/design/dev-server/canvas-lib.tsx +506 -30
  8. package/plugins/design/dev-server/canvas-shell.tsx +352 -20
  9. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  10. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  11. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  12. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  13. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  14. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +3 -3
  16. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  17. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  18. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  19. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  20. package/plugins/design/dev-server/handoff.ts +24 -0
  21. package/plugins/design/dev-server/http.ts +27 -0
  22. package/plugins/design/dev-server/input-router.tsx +52 -2
  23. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  24. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  25. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  26. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  27. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  28. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  29. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  30. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  31. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  32. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  33. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  34. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  35. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  36. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  37. package/plugins/design/dev-server/undo-stack.ts +240 -0
  38. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  39. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  40. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  41. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  42. package/plugins/design/templates/_shell.html +17 -6
  43. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  44. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  45. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  46. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  47. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  48. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  49. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -46,6 +46,7 @@ import {
46
46
  useDragStateContext,
47
47
  useViewportControllerContext,
48
48
  } from './canvas-lib.tsx';
49
+ import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
49
50
  import { CommentsOverlay } from './comments-overlay.tsx';
50
51
  import {
51
52
  ContextMenuProvider,
@@ -55,16 +56,23 @@ import {
55
56
  type MenuItem,
56
57
  useContextMenu,
57
58
  } from './context-menu.tsx';
59
+ import { ContextualToolbar } from './contextual-toolbar.tsx';
60
+ import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
58
61
  import { ExportDialogProvider } from './export-dialog.tsx';
59
62
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
63
+ import { ElementMarqueeOverlay } from './marquee-overlay.tsx';
60
64
  import { ToolPalette } from './tool-palette.tsx';
65
+ import { UndoHud } from './undo-hud.tsx';
61
66
  import {
62
67
  AnnotationSelectionProvider,
63
68
  useAnnotationSelection,
64
69
  } from './use-annotation-selection.tsx';
65
70
  import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
71
+ import { useCursorModifiers } from './use-cursor-modifiers.tsx';
72
+ import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
66
73
  import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
67
74
  import { useToolMode } from './use-tool-mode.tsx';
75
+ import { useUndoStack } from './use-undo-stack.tsx';
68
76
 
69
77
  // ─────────────────────────────────────────────────────────────────────────────
70
78
  // Styles — halos render as `position: fixed` siblings of the canvas. Reading
@@ -215,6 +223,26 @@ const HALO_CSS = `
215
223
  .dc-canvas[data-active-tool="eraser"] * {
216
224
  cursor: var(--cursor-eraser), cell !important;
217
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;
245
+ }
218
246
  `.trim();
219
247
 
220
248
  function ensureHaloStyles(): void {
@@ -279,9 +307,63 @@ function CanvasCore({
279
307
  };
280
308
  }, [hostRef, tool]);
281
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
+
282
318
  const artboardsCtx = useArtboardsContext();
283
319
  const dragBus = useDragStateContext();
284
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
+
285
367
  /**
286
368
  * T24 — distribute the currently-selected artboards evenly on the given
287
369
  * axis. Requires ≥ 3 selected artboards. Sort by leading edge, hold the
@@ -323,11 +405,98 @@ function CanvasCore({
323
405
  }
324
406
  }
325
407
  if (moved.length === 0) return;
326
- dragBus.commitPositions(moved);
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) });
327
480
  },
328
481
  [artboardsCtx, dragBus, selSet.selected]
329
482
  );
330
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
+
331
500
  const registry = useMemo<ContextRegistry>(
332
501
  () =>
333
502
  buildRegistry({
@@ -335,8 +504,10 @@ function CanvasCore({
335
504
  clearSelection: selSet.clear,
336
505
  selSet,
337
506
  distributeArtboards,
507
+ alignArtboards,
508
+ focusArtboard,
338
509
  }),
339
- [controller, selSet, distributeArtboards]
510
+ [controller, selSet, distributeArtboards, alignArtboards, focusArtboard]
340
511
  );
341
512
 
342
513
  // Distribute is reached via the MultiArtboardToolbar (floating chrome
@@ -346,7 +517,11 @@ function CanvasCore({
346
517
  return (
347
518
  <ExportDialogProvider>
348
519
  <ContextMenuProvider registry={registry}>
349
- <CanvasRouter hostRef={hostRef} distributeArtboards={distributeArtboards}>
520
+ <CanvasRouter
521
+ hostRef={hostRef}
522
+ distributeArtboards={distributeArtboards}
523
+ alignArtboards={alignArtboards}
524
+ >
350
525
  {children}
351
526
  </CanvasRouter>
352
527
  </ContextMenuProvider>
@@ -362,14 +537,19 @@ function buildRegistry(deps: {
362
537
  clearSelection: () => void;
363
538
  selSet: { selected: Selection[] };
364
539
  distributeArtboards: (axis: 'x' | 'y') => void;
540
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
541
+ focusArtboard: (artboardId: string) => void;
365
542
  }): ContextRegistry {
366
- const { controller, clearSelection, selSet, distributeArtboards } = deps;
543
+ const { controller, clearSelection, selSet, distributeArtboards, alignArtboards, focusArtboard } =
544
+ deps;
367
545
 
368
546
  // T24 — distribute commands are only enabled when ≥ 3 artboards are
369
547
  // selected. Below that, the menu items render as `disabled` so the user
370
548
  // sees the affordance but understands the precondition.
549
+ // G7 — align commands are enabled at ≥ 2 (alignment is well-defined with 2).
371
550
  const selectedArtboardCount = selSet.selected.filter((s) => !!s.artboardId).length;
372
551
  const distributeEnabled = selectedArtboardCount >= 3;
552
+ const alignEnabled = selectedArtboardCount >= 2;
373
553
 
374
554
  const copy = (text: string): void => {
375
555
  if (typeof navigator === 'undefined' || !navigator.clipboard) return;
@@ -492,19 +672,54 @@ function buildRegistry(deps: {
492
672
  id: 'fit-one',
493
673
  label: 'Fit just this artboard',
494
674
  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();
675
+ if (!target.artboardId) return;
676
+ focusArtboard(target.artboardId);
503
677
  },
504
678
  },
505
679
  fitItem,
506
680
  resetItem,
507
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
+ ],
508
723
  [
509
724
  // T24 — distribute commands. Primary surface is the floating
510
725
  // MultiArtboardToolbar above the group bbox; the menu entries are
@@ -553,15 +768,18 @@ function CanvasRouter({
553
768
  hostRef,
554
769
  children,
555
770
  distributeArtboards,
771
+ alignArtboards,
556
772
  }: {
557
773
  hostRef: RefObject<HTMLDivElement | null>;
558
774
  children: ReactNode;
559
775
  distributeArtboards: (axis: 'x' | 'y') => void;
776
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
560
777
  }) {
561
778
  const { tool, setTool, clearSticky } = useToolMode();
562
779
  const selSet = useSelectionSet();
563
780
  const annotSel = useAnnotationSelection();
564
781
  const ctxMenu = useContextMenu();
782
+ const undoStack = useUndoStack();
565
783
 
566
784
  // Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
567
785
  // itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
@@ -663,6 +881,12 @@ function CanvasRouter({
663
881
  ctxMenu.open(ctxTarget);
664
882
  },
665
883
  onTool: ({ tool: t }) => setTool(t),
884
+ onUndo: () => {
885
+ void undoStack.undo();
886
+ },
887
+ onRedo: () => {
888
+ void undoStack.redo();
889
+ },
666
890
  onEscape: () => {
667
891
  // T21 — abort any mid-stroke draw FIRST. The annotations layer
668
892
  // listens for `maude:cancel-stroke` and drops the in-progress
@@ -781,11 +1005,18 @@ function CanvasRouter({
781
1005
  <AnnotationsLayer />
782
1006
  <ToolPalette />
783
1007
  <ArtboardMarqueeOverlay />
1008
+ <ElementMarqueeOverlay />
784
1009
  <HoverHalo el={hoverEl} />
785
1010
  <SelectionHalos />
786
1011
  <GroupBbox />
787
- <MultiArtboardToolbar distributeArtboards={distributeArtboards} />
1012
+ <EqualSpacingHandles />
1013
+ <ContextualToolbar />
1014
+ <MultiArtboardToolbar
1015
+ distributeArtboards={distributeArtboards}
1016
+ alignArtboards={alignArtboards}
1017
+ />
788
1018
  <SnapGuideOverlay />
1019
+ <UndoHud />
789
1020
  </>
790
1021
  );
791
1022
  }
@@ -849,6 +1080,12 @@ const MULTI_TOOLBAR_CSS = `
849
1080
  align-items: center;
850
1081
  justify-content: center;
851
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
+ }
852
1089
  @media (prefers-reduced-motion: reduce) {
853
1090
  .dc-multi-artboard-tb button { transition: none; }
854
1091
  }
@@ -865,8 +1102,10 @@ function ensureMultiToolbarStyles(): void {
865
1102
 
866
1103
  function MultiArtboardToolbar({
867
1104
  distributeArtboards,
1105
+ alignArtboards,
868
1106
  }: {
869
1107
  distributeArtboards: (axis: 'x' | 'y') => void;
1108
+ alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
870
1109
  }) {
871
1110
  ensureMultiToolbarStyles();
872
1111
  const { selected } = useSelectionSet();
@@ -875,7 +1114,8 @@ function MultiArtboardToolbar({
875
1114
 
876
1115
  const artboardSelections = useMemo(() => selected.filter((s) => !!s.artboardId), [selected]);
877
1116
  const artboardCount = artboardSelections.length;
878
- const enabled = artboardCount >= 3;
1117
+ const distributeOk = artboardCount >= 3;
1118
+ const alignOk = artboardCount >= 2;
879
1119
 
880
1120
  useEffect(() => {
881
1121
  const div = ref.current;
@@ -943,11 +1183,105 @@ function MultiArtboardToolbar({
943
1183
  aria-label="Multi-artboard actions"
944
1184
  >
945
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" />
946
1280
  <button
947
1281
  type="button"
948
- disabled={!enabled}
1282
+ disabled={!distributeOk}
949
1283
  title={
950
- enabled
1284
+ distributeOk
951
1285
  ? 'Distribute horizontally — equal gaps between artboards'
952
1286
  : 'Select at least 3 artboards to distribute'
953
1287
  }
@@ -961,13 +1295,12 @@ function MultiArtboardToolbar({
961
1295
  <rect x="10.5" y="3" width="3" height="8" fill="currentColor" />
962
1296
  </svg>
963
1297
  </span>
964
- Distribute H
965
1298
  </button>
966
1299
  <button
967
1300
  type="button"
968
- disabled={!enabled}
1301
+ disabled={!distributeOk}
969
1302
  title={
970
- enabled
1303
+ distributeOk
971
1304
  ? 'Distribute vertically — equal gaps between artboards'
972
1305
  : 'Select at least 3 artboards to distribute'
973
1306
  }
@@ -981,7 +1314,6 @@ function MultiArtboardToolbar({
981
1314
  <rect x="3" y="10.5" width="8" height="3" fill="currentColor" />
982
1315
  </svg>
983
1316
  </span>
984
- Distribute V
985
1317
  </button>
986
1318
  </div>
987
1319
  );
@@ -65,6 +65,34 @@ function handle(evt) {
65
65
  }
66
66
  }
67
67
  }
68
+ return;
69
+ }
70
+ if (evt.type === 'fs:json' && evt.file) {
71
+ // Phase 20 (DDR-049) — external edits to canvas `.meta.json` or
72
+ // `.annotations.svg` invalidate the iframe's in-memory undo stack
73
+ // (stale before/after snapshots would otherwise restore wrong state).
74
+ // The iframe's UndoStackProvider self-echo-dampens our own PATCH-es
75
+ // via window.__maude_last_meta_self_write_at, so we forward
76
+ // indiscriminately here.
77
+ const file = evt.file;
78
+ const isMeta = file.endsWith('.meta.json');
79
+ if (!isMeta) return;
80
+ const iframes = document.querySelectorAll('iframe[data-canvas-path]');
81
+ for (const f of iframes) {
82
+ if (!f.dataset.canvasPath) continue;
83
+ // Match by the canvas filename root (foo.tsx ↔ foo.meta.json).
84
+ const root = f.dataset.canvasPath.replace(/\.(tsx|jsx|ts|js|html)$/i, '');
85
+ if (!file.includes(`${root}.meta.json`)) continue;
86
+ try {
87
+ f.contentWindow?.dispatchEvent(
88
+ new CustomEvent('maude:invalidate-undo', {
89
+ detail: { reason: 'Edit history reset (external change)' },
90
+ })
91
+ );
92
+ } catch {
93
+ /* cross-origin / detached — ignore */
94
+ }
95
+ }
68
96
  }
69
97
  }
70
98