@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
@@ -35,11 +35,15 @@ import { createPortal } from 'react-dom';
35
35
 
36
36
  import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
37
37
  import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
38
+ import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
39
+ import { crossedDragThreshold } from './input-router.tsx';
38
40
  import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
39
41
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
40
42
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
43
+ import { useCollab } from './use-collab.tsx';
41
44
  import { useSelectionSetOptional } from './use-selection-set.tsx';
42
45
  import { useToolMode } from './use-tool-mode.tsx';
46
+ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
43
47
 
44
48
  // ─────────────────────────────────────────────────────────────────────────────
45
49
  // Types
@@ -115,7 +119,7 @@ const FILL_PALETTE = [
115
119
  '#f4f1ee', // paper
116
120
  ] as const;
117
121
 
118
- const STROKE_WIDTH_THIN = 2;
122
+ const STROKE_WIDTH_THIN = 3;
119
123
  const STROKE_WIDTH_THICK = 6;
120
124
  type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
121
125
 
@@ -480,11 +484,11 @@ const ANNOT_CSS = `
480
484
  appearance: none;
481
485
  }
482
486
  .dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
483
- box-shadow: 0 0 0 2px var(--accent, #d63b1f);
487
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
484
488
  border-color: transparent;
485
489
  }
486
490
  .dc-annot-chrome .dc-annot-sw:focus-visible {
487
- outline: 2px solid var(--accent, #d63b1f);
491
+ outline: 2px solid var(--maude-hud-accent, #d63b1f);
488
492
  outline-offset: 2px;
489
493
  }
490
494
  .dc-annot-chrome .dc-annot-sep {
@@ -513,7 +517,7 @@ const ANNOT_CSS = `
513
517
  linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
514
518
  }
515
519
  .dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
516
- box-shadow: 0 0 0 2px var(--accent, #d63b1f);
520
+ box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
517
521
  border-color: transparent;
518
522
  }
519
523
  .dc-annot-chrome .dc-annot-btn {
@@ -529,13 +533,13 @@ const ANNOT_CSS = `
529
533
  text-transform: uppercase;
530
534
  }
531
535
  .dc-annot-chrome .dc-annot-btn[aria-pressed="true"] {
532
- background: var(--accent, #d63b1f);
533
- color: var(--accent-fg, #fff);
536
+ background: var(--maude-hud-accent, #d63b1f);
537
+ color: var(--maude-hud-accent-fg, #fff);
534
538
  border-color: transparent;
535
539
  }
536
540
  .dc-annot-chrome .dc-annot-btn:hover { background: rgba(0,0,0,0.04); }
537
541
  .dc-annot-chrome .dc-annot-btn:focus-visible {
538
- outline: 2px solid var(--accent, #d63b1f);
542
+ outline: 2px solid var(--maude-hud-accent, #d63b1f);
539
543
  outline-offset: 2px;
540
544
  }
541
545
  .dc-annot-input {
@@ -564,8 +568,8 @@ const ANNOT_CSS = `
564
568
  /* Drag-select marquee — rendered while user is dragging to select strokes. */
565
569
  .dc-annot-marquee {
566
570
  pointer-events: none;
567
- fill: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
568
- stroke: var(--accent, #d63b1f);
571
+ fill: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
572
+ stroke: var(--maude-hud-accent, #d63b1f);
569
573
  stroke-width: 1;
570
574
  stroke-dasharray: 4 3;
571
575
  }
@@ -609,6 +613,23 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
609
613
  return s; // text inherits its host's bbox
610
614
  }
611
615
 
616
+ /**
617
+ * Reference-equal stroke comparison — true when the two arrays carry the same
618
+ * stroke object references in the same order. Used by the annotation drag
619
+ * onPointerUp to skip pushing an undo record when the gesture didn't actually
620
+ * move anything (zero movement OR snapshot mapped through a no-op translate
621
+ * back to the original references — `translateOne` short-circuits when dx=dy=0
622
+ * because new objects are still created, so we compare references defensively).
623
+ */
624
+ export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
625
+ if (a === b) return true;
626
+ if (a.length !== b.length) return false;
627
+ for (let i = 0; i < a.length; i++) {
628
+ if (a[i] !== b[i]) return false;
629
+ }
630
+ return true;
631
+ }
632
+
612
633
  // Annotations visibility now lives in use-annotations-visibility.tsx so the
613
634
  // ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
614
635
  // can read the same state. Re-exported here for back-compat.
@@ -650,6 +671,13 @@ export function AnnotationsLayer() {
650
671
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
651
672
  const drawingRef = useRef<Stroke | null>(null);
652
673
  drawingRef.current = drawing;
674
+ /**
675
+ * Phase 20 — latest strokes mirror so command builders can read the
676
+ * pre-mutation snapshot synchronously (React state isn't refreshed
677
+ * between rapid taps in the same tick).
678
+ */
679
+ const strokesRef = useRef<Stroke[]>(strokes);
680
+ strokesRef.current = strokes;
653
681
 
654
682
  const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
655
683
  const isErase = tool === 'eraser';
@@ -662,6 +690,10 @@ export function AnnotationsLayer() {
662
690
  const supportsFill = tool === 'rect' || tool === 'ellipse';
663
691
 
664
692
  // Load existing annotations on mount.
693
+ // Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
694
+ // collab connects shortly after this fetch lands) doesn't re-apply the
695
+ // same content we just hydrated from REST.
696
+ const lastAppliedSvgRef = useRef<string>('');
665
697
  useEffect(() => {
666
698
  const file = deriveFile();
667
699
  fileRef.current = file;
@@ -674,7 +706,10 @@ export function AnnotationsLayer() {
674
706
  .then((text) => {
675
707
  if (cancelled) return;
676
708
  const loaded = svgToStrokes(text);
677
- if (loaded.length) setStrokesState(loaded);
709
+ if (loaded.length) {
710
+ setStrokesState(loaded);
711
+ lastAppliedSvgRef.current = text;
712
+ }
678
713
  })
679
714
  .catch(() => {
680
715
  /* network blip — start with an empty annotation set */
@@ -684,56 +719,132 @@ export function AnnotationsLayer() {
684
719
  };
685
720
  }, []);
686
721
 
687
- const scheduleSave = useCallback((next: readonly Stroke[]) => {
722
+ // Phase 8 Task 5 observe the Y.Map.annotations for live updates from
723
+ // other tabs. Bail when the incoming SVG STRING is identical to the one
724
+ // we last applied (covers the local echo round-trip without missing real
725
+ // foreign changes). The prior length+first/last-id check was wrong: a
726
+ // resize / move keeps the same id list, so all three predicates matched
727
+ // even though geometry changed — foreign edits silently disappeared.
728
+ const collab = useCollab();
729
+ useEffect(() => {
730
+ if (!collab) return;
731
+ const map = collab.doc.getMap<string>('annotations');
732
+ const apply = () => {
733
+ const svg = map.get('svg');
734
+ if (typeof svg !== 'string' || !svg) return;
735
+ if (svg === lastAppliedSvgRef.current) return;
736
+ lastAppliedSvgRef.current = svg;
737
+ setStrokesState(svgToStrokes(svg));
738
+ };
739
+ apply();
740
+ map.observe(apply);
741
+ return () => {
742
+ try {
743
+ map.unobserve(apply);
744
+ } catch {
745
+ /* doc destroyed before unmount */
746
+ }
747
+ };
748
+ }, [collab]);
749
+
750
+ const undoStack = useUndoStackOptional();
751
+ const undoSinks = useUndoSinks();
752
+ const undoStackRef = useRef(undoStack);
753
+ undoStackRef.current = undoStack;
754
+
755
+ /**
756
+ * Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
757
+ * PUT to the server. Used as the `putFn` injected into the
758
+ * `AnnotationStrokesCommand` — both the initial push AND every undo/redo
759
+ * replay route through here, so the iframe's `strokes` state always
760
+ * tracks the server. (Without the setStrokesState here, Cmd+Z would
761
+ * silently PUT the prior SVG but the canvas would keep painting the
762
+ * post-edit strokes until the user reloaded.)
763
+ *
764
+ * The 200 ms scheduled-save debounce (legacy path) is cleared the moment
765
+ * we push a command, so the server only sees one PUT per edit instead
766
+ * of two-step racing.
767
+ */
768
+ const putStrokes = useCallback((next: readonly Stroke[]) => {
769
+ setStrokesState(next as Stroke[]);
688
770
  const file = fileRef.current;
689
- if (!file) return;
690
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
691
- saveTimerRef.current = setTimeout(() => {
692
- saveTimerRef.current = null;
693
- const svg = strokesToSvg(next);
694
- void fetch('/_api/annotations', {
695
- method: 'PUT',
696
- headers: { 'Content-Type': 'application/json' },
697
- body: JSON.stringify({ file, svg }),
698
- }).catch(() => {
699
- /* swallow — the user will see uncommitted state until the next stroke */
771
+ if (!file) return Promise.resolve();
772
+ const svg = strokesToSvg(next);
773
+ // Phase 8 Task 5 — record the SVG we just authored locally so the
774
+ // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
775
+ // Y.Map.observe) doesn't trigger a redundant setStrokesState.
776
+ lastAppliedSvgRef.current = svg;
777
+ return fetch('/_api/annotations', {
778
+ method: 'PUT',
779
+ headers: { 'Content-Type': 'application/json' },
780
+ body: JSON.stringify({ file, svg }),
781
+ })
782
+ .then(() => undefined)
783
+ .catch(() => {
784
+ /* swallow — user sees uncommitted state until the next stroke */
700
785
  });
701
- }, 200);
702
786
  }, []);
703
787
 
788
+ // Register the strokes put sink with the undo provider so the rebuilt
789
+ // AnnotationStrokesCommand (after a canvas switch + return) routes through
790
+ // THIS iframe's React state, not the gone iframe's stale closures.
791
+ useEffect(() => {
792
+ undoSinks.setSink('strokesPutFn', putStrokes);
793
+ return () => undoSinks.setSink('strokesPutFn', undefined);
794
+ }, [undoSinks, putStrokes]);
795
+
796
+ /**
797
+ * Single entry point for every stroke mutation. Builds an undo record
798
+ * and pushes onto the stack — `push()` rebuilds the command via the
799
+ * registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
800
+ * which both updates local state and PUTs. Cancels any pending debounced
801
+ * save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
802
+ * would race the stack into a stale state.
803
+ */
804
+ const commitStrokes = useCallback(
805
+ (prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
806
+ if (saveTimerRef.current) {
807
+ clearTimeout(saveTimerRef.current);
808
+ saveTimerRef.current = null;
809
+ }
810
+ const record = buildAnnotationStrokesRecord({
811
+ before: prev,
812
+ after: next,
813
+ ...(label ? { label } : {}),
814
+ });
815
+ void undoStackRef.current.push(record);
816
+ },
817
+ []
818
+ );
819
+
704
820
  const setStrokes = useCallback(
705
821
  (next: Stroke[]) => {
706
- setStrokesState(next);
707
- scheduleSave(next);
822
+ const prev = strokesRef.current;
823
+ commitStrokes(prev, next);
708
824
  },
709
- [scheduleSave]
825
+ [commitStrokes]
710
826
  );
711
827
 
712
828
  const strokesStore = useMemo<StrokesStoreValue>(() => {
713
829
  const updateStroke = (id: string, patch: Partial<Stroke>): void => {
714
- setStrokesState((prev) => {
715
- const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
716
- scheduleSave(next);
717
- return next;
718
- });
830
+ const prev = strokesRef.current;
831
+ const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
832
+ commitStrokes(prev, next);
719
833
  };
720
834
  const deleteStrokes = (ids: string[]): void => {
721
835
  const set = new Set(ids);
722
- setStrokesState((prev) => {
723
- const next = prev.filter(
724
- (s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
725
- );
726
- scheduleSave(next);
727
- return next;
728
- });
836
+ const prev = strokesRef.current;
837
+ const next = prev.filter(
838
+ (s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
839
+ );
840
+ if (next.length === prev.length) return;
841
+ commitStrokes(prev, next);
729
842
  };
730
843
  const translateStrokes = (ids: string[], dx: number, dy: number): void => {
731
844
  const set = new Set(ids);
732
- setStrokesState((prev) => {
733
- const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
734
- scheduleSave(next);
735
- return next;
736
- });
845
+ const prev = strokesRef.current;
846
+ const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
847
+ commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
737
848
  };
738
849
  return {
739
850
  strokes,
@@ -742,7 +853,7 @@ export function AnnotationsLayer() {
742
853
  deleteStrokes,
743
854
  translateStrokes,
744
855
  };
745
- }, [strokes, setStrokes, scheduleSave]);
856
+ }, [strokes, setStrokes, commitStrokes]);
746
857
 
747
858
  // Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
748
859
  // dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
@@ -794,23 +905,21 @@ export function AnnotationsLayer() {
794
905
  (wx: number, wy: number) => {
795
906
  const zoom = vp?.zoom || 1;
796
907
  const tol = 8 / zoom;
797
- setStrokesState((prev) => {
798
- for (let i = prev.length - 1; i >= 0; i--) {
799
- const candidate = prev[i];
800
- if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
801
- const removedId = candidate.id;
802
- const next = prev
803
- .slice(0, i)
804
- .concat(prev.slice(i + 1))
805
- .filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
806
- scheduleSave(next);
807
- return next;
808
- }
908
+ const prev = strokesRef.current;
909
+ for (let i = prev.length - 1; i >= 0; i--) {
910
+ const candidate = prev[i];
911
+ if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
912
+ const removedId = candidate.id;
913
+ const next = prev
914
+ .slice(0, i)
915
+ .concat(prev.slice(i + 1))
916
+ .filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
917
+ commitStrokes(prev, next, 'erase 1 stroke');
918
+ return;
809
919
  }
810
- return prev;
811
- });
920
+ }
812
921
  },
813
- [vp, scheduleSave]
922
+ [vp, commitStrokes]
814
923
  );
815
924
 
816
925
  const beginStroke = useCallback(
@@ -935,11 +1044,9 @@ export function AnnotationsLayer() {
935
1044
  if (final && !isStrokeMeaningful(final)) final = null;
936
1045
  if (final) {
937
1046
  const committed = final;
938
- setStrokesState((prev) => {
939
- const next = [...prev, committed];
940
- scheduleSave(next);
941
- return next;
942
- });
1047
+ const prev = strokesRef.current;
1048
+ const next = [...prev, committed];
1049
+ commitStrokes(prev, next, `draw ${committed.tool}`);
943
1050
  // T18 — auto-select the freshly drawn shape so the user can immediately
944
1051
  // see + adjust it. annotSel is optional (some test harnesses mount
945
1052
  // AnnotationsLayer without the provider), so guard the call.
@@ -955,7 +1062,7 @@ export function AnnotationsLayer() {
955
1062
  if (!stickyOnThis) setTool('move');
956
1063
  }
957
1064
  setDrawing(null);
958
- }, [isActive, isErase, visible, scheduleSave, annotSel, setTool, sticky]);
1065
+ }, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
959
1066
 
960
1067
  // T21 — abort a mid-stroke draw without committing. Dispatched by the
961
1068
  // canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
@@ -1000,11 +1107,19 @@ export function AnnotationsLayer() {
1000
1107
  // - Cmd / Cmd+Shift falls through to element-selection (we bail).
1001
1108
  // Once a stroke is selected, clicking inside its bbox starts a drag.
1002
1109
 
1110
+ /**
1111
+ * Annotation drag state. Snapshot of strokes captured at pointerdown so the
1112
+ * whole gesture (pointermove × N → pointerup) commits as ONE undo record at
1113
+ * release time. Without the snapshot, each pointermove tick became its own
1114
+ * `translateStrokes` call and each call became its own undo record — Cmd+Z
1115
+ * had to be pressed dozens of times to walk back a single drag.
1116
+ */
1003
1117
  const dragStateRef = useRef<{
1004
1118
  pointerId: number;
1005
1119
  startWX: number;
1006
1120
  startWY: number;
1007
1121
  movedIds: string[];
1122
+ snapshot: Stroke[];
1008
1123
  } | null>(null);
1009
1124
 
1010
1125
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
@@ -1016,6 +1131,7 @@ export function AnnotationsLayer() {
1016
1131
  by: number;
1017
1132
  } | null>(null);
1018
1133
 
1134
+ // biome-ignore lint/correctness/useExhaustiveDependencies: commitStrokes/strokesStore are stable refs from parent context; manual dep list reflects effect-trigger semantics, not internal callbacks
1019
1135
  useEffect(() => {
1020
1136
  if (typeof document === 'undefined') return;
1021
1137
  if (tool !== 'move') return;
@@ -1039,7 +1155,7 @@ export function AnnotationsLayer() {
1039
1155
  // the main tool palette, the in-canvas draw chrome, the minimap, and the
1040
1156
  // right-click menu. Clicks on these route to their own handlers.
1041
1157
  const CHROME_SELECTOR =
1042
- '.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover';
1158
+ '.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle';
1043
1159
 
1044
1160
  const onDown = (e: PointerEvent) => {
1045
1161
  if (e.button !== 0) return;
@@ -1047,6 +1163,11 @@ export function AnnotationsLayer() {
1047
1163
  const target = e.target as Element | null;
1048
1164
  if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
1049
1165
  const strokeId = findStrokeId(target);
1166
+ // When pointerdown lands inside an artboard but not on a stroke, the
1167
+ // gesture belongs to artboard-drag / element-marquee — not the
1168
+ // annotation marquee. Bailing here keeps the annotation marquee from
1169
+ // racing the artboard drag (post-Wave-3 user grievance G5).
1170
+ if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
1050
1171
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
1051
1172
  const startClientX = e.clientX;
1052
1173
  const startClientY = e.clientY;
@@ -1068,29 +1189,51 @@ export function AnnotationsLayer() {
1068
1189
  annotSel.replace(strokeId);
1069
1190
  ids = [strokeId];
1070
1191
  }
1192
+ // Capture a snapshot of all strokes at drag start. Every pointermove
1193
+ // re-translates FROM the snapshot using the cumulative cursor delta
1194
+ // (NOT a delta-from-last-frame mutation), so dragging back to origin
1195
+ // restores positions exactly. Optimistic state-only updates during
1196
+ // the move; ONE undo record + ONE server PUT fires on pointerup.
1197
+ const dragSnapshot = strokesRef.current.slice();
1071
1198
  dragStateRef.current = {
1072
1199
  pointerId: e.pointerId,
1073
1200
  startWX: wx,
1074
1201
  startWY: wy,
1075
1202
  movedIds: ids,
1203
+ snapshot: dragSnapshot,
1076
1204
  };
1205
+ const movedSet = new Set(ids);
1077
1206
  const onMove = (mv: PointerEvent) => {
1078
1207
  const st = dragStateRef.current;
1079
1208
  if (!st || mv.pointerId !== st.pointerId) return;
1080
1209
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
1081
1210
  const dx = cwx - st.startWX;
1082
1211
  const dy = cwy - st.startWY;
1083
- if (dx === 0 && dy === 0) return;
1084
- strokesStore.translateStrokes(st.movedIds, dx, dy);
1085
- st.startWX = cwx;
1086
- st.startWY = cwy;
1212
+ // Drag-back-to-origin: restore exact references so the pointerup
1213
+ // shallow-equality check skips committing a no-op record.
1214
+ const next =
1215
+ dx === 0 && dy === 0
1216
+ ? st.snapshot
1217
+ : st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
1218
+ // Local React state only. No commitStrokes — no PUT, no undo push.
1219
+ setStrokesState(next);
1087
1220
  };
1088
1221
  const onUp = (up: PointerEvent) => {
1089
- if (up.pointerId !== dragStateRef.current?.pointerId) return;
1222
+ const st = dragStateRef.current;
1223
+ if (!st || up.pointerId !== st.pointerId) return;
1090
1224
  dragStateRef.current = null;
1091
1225
  document.removeEventListener('pointermove', onMove, true);
1092
1226
  document.removeEventListener('pointerup', onUp, true);
1093
1227
  document.removeEventListener('pointercancel', onUp, true);
1228
+ // Commit the gesture as ONE record. Skip on zero-movement
1229
+ // (click without drag past threshold or drag back to origin).
1230
+ const final = strokesRef.current;
1231
+ if (strokesShallowEqual(st.snapshot, final)) return;
1232
+ commitStrokes(
1233
+ st.snapshot,
1234
+ final,
1235
+ `move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
1236
+ );
1094
1237
  };
1095
1238
  document.addEventListener('pointermove', onMove, true);
1096
1239
  document.addEventListener('pointerup', onUp, true);
@@ -1099,13 +1242,14 @@ export function AnnotationsLayer() {
1099
1242
  }
1100
1243
 
1101
1244
  // Empty world — start a drag-select gesture. A bare click without
1102
- // moving is a no-op (post-Wave-2 feedback: click-on-empty-space
1103
- // does NOT clear selection; Esc is the canonical deselect).
1245
+ // moving clears annotation selection (post-Wave-3 feedback: click-to-
1246
+ // deselect is back; Esc also still works).
1104
1247
  const addToSelection = e.shiftKey;
1105
1248
  let moved = false;
1106
1249
  const onMove = (mv: PointerEvent) => {
1107
- const distSq = (mv.clientX - startClientX) ** 2 + (mv.clientY - startClientY) ** 2;
1108
- if (!moved && distSq < 16) return; // 4 px threshold
1250
+ if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
1251
+ return;
1252
+ }
1109
1253
  moved = true;
1110
1254
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
1111
1255
  setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
@@ -1115,8 +1259,10 @@ export function AnnotationsLayer() {
1115
1259
  document.removeEventListener('pointerup', onUp, true);
1116
1260
  document.removeEventListener('pointercancel', onUp, true);
1117
1261
  if (!moved) {
1118
- // Click without movement on empty world → no-op. Selection
1119
- // survives accidental misses; Esc is how the user deselects.
1262
+ // Click without movement on empty world → clear annotation
1263
+ // selection (post-Wave-3 user feedback). Shift-click preserves
1264
+ // existing selection for additive-mode workflows.
1265
+ if (!addToSelection) annotSel.clear();
1120
1266
  return;
1121
1267
  }
1122
1268
  const final = marqueeRef.current;
@@ -1178,33 +1324,36 @@ export function AnnotationsLayer() {
1178
1324
  const commitText = useCallback(
1179
1325
  (anchorId: string, text: string) => {
1180
1326
  const trimmed = text.trim();
1181
- setStrokesState((prev) => {
1182
- const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
1183
- | TextStroke
1184
- | undefined;
1185
- let next: Stroke[];
1186
- if (trimmed.length === 0) {
1187
- next = existing ? prev.filter((s) => s.id !== existing.id) : prev;
1188
- } else if (existing) {
1189
- next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
1190
- } else {
1191
- next = [
1192
- ...prev,
1193
- {
1194
- id: rid(),
1195
- tool: 'text',
1196
- color: '#1a1a1a',
1197
- fontSize: DEFAULT_FONT_SIZE,
1198
- text: trimmed,
1199
- anchorId,
1200
- } as TextStroke,
1201
- ];
1202
- }
1203
- scheduleSave(next);
1204
- return next;
1205
- });
1327
+ const prev = strokesRef.current;
1328
+ const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
1329
+ | TextStroke
1330
+ | undefined;
1331
+ let next: Stroke[];
1332
+ let label = 'edit text';
1333
+ if (trimmed.length === 0) {
1334
+ if (!existing) return; // nothing to do
1335
+ next = prev.filter((s) => s.id !== existing.id);
1336
+ label = 'delete text';
1337
+ } else if (existing) {
1338
+ if (existing.text === trimmed) return; // identity edit
1339
+ next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
1340
+ } else {
1341
+ next = [
1342
+ ...prev,
1343
+ {
1344
+ id: rid(),
1345
+ tool: 'text',
1346
+ color: '#1a1a1a',
1347
+ fontSize: DEFAULT_FONT_SIZE,
1348
+ text: trimmed,
1349
+ anchorId,
1350
+ } as TextStroke,
1351
+ ];
1352
+ label = 'add text';
1353
+ }
1354
+ commitStrokes(prev, next, label);
1206
1355
  },
1207
- [scheduleSave]
1356
+ [commitStrokes]
1208
1357
  );
1209
1358
 
1210
1359
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
@@ -1574,7 +1723,7 @@ function SelectionHalo({
1574
1723
  width={bbox.w + pad * 2}
1575
1724
  height={bbox.h + pad * 2}
1576
1725
  fill="none"
1577
- stroke="var(--accent, #d63b1f)"
1726
+ stroke="var(--maude-hud-accent, #d63b1f)"
1578
1727
  strokeWidth={multi ? 1.5 : 2}
1579
1728
  vectorEffect="non-scaling-stroke"
1580
1729
  pointerEvents="none"
@@ -1629,7 +1778,7 @@ function AnnotGroupBbox({
1629
1778
  width={w}
1630
1779
  height={h}
1631
1780
  fill="none"
1632
- stroke="var(--accent, #d63b1f)"
1781
+ stroke="var(--maude-hud-accent, #d63b1f)"
1633
1782
  strokeWidth={1}
1634
1783
  strokeDasharray="4 3"
1635
1784
  vectorEffect="non-scaling-stroke"
@@ -1642,7 +1791,7 @@ function AnnotGroupBbox({
1642
1791
  y={c.y}
1643
1792
  width={handle}
1644
1793
  height={handle}
1645
- fill="var(--accent, #d63b1f)"
1794
+ fill="var(--maude-hud-accent, #d63b1f)"
1646
1795
  stroke="var(--bg-0, #ffffff)"
1647
1796
  strokeWidth={1}
1648
1797
  vectorEffect="non-scaling-stroke"
@@ -1816,7 +1965,7 @@ function AnnotationsChrome({
1816
1965
  type="button"
1817
1966
  className="dc-annot-btn"
1818
1967
  aria-pressed={thickness === STROKE_WIDTH_THIN}
1819
- title="Thin (2px)"
1968
+ title="Thin (3px)"
1820
1969
  onClick={() => setThickness(STROKE_WIDTH_THIN)}
1821
1970
  >
1822
1971
  Thin
@@ -140,6 +140,12 @@ export interface Api {
140
140
  commentsDelete(id: string): Promise<boolean>;
141
141
  commentsAddReply(id: string, payload: { body: string; author?: string }): Promise<Comment | null>;
142
142
  gitCommitters(): Promise<GitCommitter[]>;
143
+ /**
144
+ * Phase 8 — local `git config user.name`, cached for the process lifetime.
145
+ * Used by the collab client to derive a stable color hash per peer.
146
+ * Empty string when git is unset; the client falls back to `anonymous-<pid>`.
147
+ */
148
+ gitCurrentUser(): Promise<string>;
143
149
  parseMentions(text: string): string[];
144
150
  // Canvas state
145
151
  loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
@@ -161,7 +167,15 @@ export interface Api {
161
167
  appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
162
168
  }
163
169
 
164
- export function createApi(ctx: Context, onCommentsChanged: (file: string) => void): Api {
170
+ export interface ApiHooks {
171
+ onCommentsChanged: (file: string) => void;
172
+ /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
173
+ onAnnotationsChanged?: (file: string, svg: string) => void;
174
+ }
175
+
176
+ export function createApi(ctx: Context, hooks: ApiHooks): Api {
177
+ const onCommentsChanged = hooks.onCommentsChanged;
178
+ const onAnnotationsChanged = hooks.onAnnotationsChanged;
165
179
  const { paths, cfg } = ctx;
166
180
 
167
181
  function fileSlug(file: string): string {
@@ -576,6 +590,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
576
590
  // content fully, so we don't try to sanitize beyond a tag check.
577
591
  if (!/^\s*<svg[\s>]/i.test(svg)) return false;
578
592
  await Bun.write(annotationsPath(file), svg);
593
+ onAnnotationsChanged?.(file, svg);
579
594
  return true;
580
595
  }
581
596
 
@@ -936,6 +951,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
936
951
  commentsDelete,
937
952
  commentsAddReply,
938
953
  gitCommitters,
954
+ gitCurrentUser,
939
955
  parseMentions,
940
956
  loadCanvasState,
941
957
  saveCanvasState,