@1agh/maude 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
  3. package/plugins/design/dev-server/annotations-layer.tsx +343 -112
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
  6. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  7. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +691 -119
  9. package/plugins/design/dev-server/canvas-shell.tsx +818 -42
  10. package/plugins/design/dev-server/client/app.jsx +173 -52
  11. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  12. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  13. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  14. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  15. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  16. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  17. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  18. package/plugins/design/dev-server/config.schema.json +31 -5
  19. package/plugins/design/dev-server/context-menu.tsx +3 -3
  20. package/plugins/design/dev-server/context.ts +37 -3
  21. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  22. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  23. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  24. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  25. package/plugins/design/dev-server/dist/styles.css +38 -2
  26. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  27. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  28. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  29. package/plugins/design/dev-server/handoff.ts +24 -0
  30. package/plugins/design/dev-server/http.ts +41 -2
  31. package/plugins/design/dev-server/input-router.tsx +74 -10
  32. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  33. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  34. package/plugins/design/dev-server/server.mjs +84 -18
  35. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  36. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  37. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  38. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  39. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  40. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  41. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  42. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  43. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  44. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  45. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  46. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  47. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  48. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  49. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  50. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  51. package/plugins/design/dev-server/undo-stack.ts +240 -0
  52. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  53. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  54. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  55. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  56. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  57. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
  58. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  59. package/plugins/design/templates/_shell.html +17 -6
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  61. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  62. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  63. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  64. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  65. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  66. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -35,10 +35,14 @@ 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';
40
+ import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
38
41
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
39
42
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
40
43
  import { useSelectionSetOptional } from './use-selection-set.tsx';
41
44
  import { useToolMode } from './use-tool-mode.tsx';
45
+ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
42
46
 
43
47
  // ─────────────────────────────────────────────────────────────────────────────
44
48
  // Types
@@ -114,7 +118,7 @@ const FILL_PALETTE = [
114
118
  '#f4f1ee', // paper
115
119
  ] as const;
116
120
 
117
- const STROKE_WIDTH_THIN = 2;
121
+ const STROKE_WIDTH_THIN = 3;
118
122
  const STROKE_WIDTH_THICK = 6;
119
123
  type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
120
124
 
@@ -457,15 +461,15 @@ const ANNOT_CSS = `
457
461
  display: flex;
458
462
  align-items: center;
459
463
  gap: 8px;
460
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
464
+ background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
461
465
  border: 1px solid var(--u-fg-0, #1c1917);
462
- border-radius: 0;
466
+ border-radius: 8px;
463
467
  padding: 6px 10px;
464
468
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
465
469
  font-size: 11px;
466
470
  color: var(--u-fg-0, rgba(40,30,20,0.85));
467
471
  z-index: 6;
468
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
472
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
469
473
  user-select: none;
470
474
  }
471
475
  .dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
@@ -608,6 +612,23 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
608
612
  return s; // text inherits its host's bbox
609
613
  }
610
614
 
615
+ /**
616
+ * Reference-equal stroke comparison — true when the two arrays carry the same
617
+ * stroke object references in the same order. Used by the annotation drag
618
+ * onPointerUp to skip pushing an undo record when the gesture didn't actually
619
+ * move anything (zero movement OR snapshot mapped through a no-op translate
620
+ * back to the original references — `translateOne` short-circuits when dx=dy=0
621
+ * because new objects are still created, so we compare references defensively).
622
+ */
623
+ export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
624
+ if (a === b) return true;
625
+ if (a.length !== b.length) return false;
626
+ for (let i = 0; i < a.length; i++) {
627
+ if (a[i] !== b[i]) return false;
628
+ }
629
+ return true;
630
+ }
631
+
611
632
  // Annotations visibility now lives in use-annotations-visibility.tsx so the
612
633
  // ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
613
634
  // can read the same state. Re-exported here for back-compat.
@@ -618,7 +639,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
618
639
 
619
640
  export function AnnotationsLayer() {
620
641
  ensureAnnotStyles();
621
- const { tool } = useToolMode();
642
+ const { tool, setTool, sticky } = useToolMode();
622
643
  const controller = useViewportControllerContext();
623
644
  const vp = controller?.viewport ?? null;
624
645
  const worldRef = useWorldRefContext();
@@ -649,11 +670,22 @@ export function AnnotationsLayer() {
649
670
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
650
671
  const drawingRef = useRef<Stroke | null>(null);
651
672
  drawingRef.current = drawing;
673
+ /**
674
+ * Phase 20 — latest strokes mirror so command builders can read the
675
+ * pre-mutation snapshot synchronously (React state isn't refreshed
676
+ * between rapid taps in the same tick).
677
+ */
678
+ const strokesRef = useRef<Stroke[]>(strokes);
679
+ strokesRef.current = strokes;
652
680
 
653
681
  const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
654
682
  const isErase = tool === 'eraser';
655
683
  const isActive = isDraw || isErase;
656
- const supportsThickness = tool === 'pen' || tool === 'arrow';
684
+ // T20 rect + ellipse expose stroke weight too (FigJam ships thickness on
685
+ // every shape). The annotation toolbar reads supportsThickness to decide
686
+ // whether to render the Thin / Thick chips.
687
+ const supportsThickness =
688
+ tool === 'pen' || tool === 'arrow' || tool === 'rect' || tool === 'ellipse';
657
689
  const supportsFill = tool === 'rect' || tool === 'ellipse';
658
690
 
659
691
  // Load existing annotations on mount.
@@ -679,56 +711,100 @@ export function AnnotationsLayer() {
679
711
  };
680
712
  }, []);
681
713
 
682
- const scheduleSave = useCallback((next: readonly Stroke[]) => {
714
+ const undoStack = useUndoStackOptional();
715
+ const undoSinks = useUndoSinks();
716
+ const undoStackRef = useRef(undoStack);
717
+ undoStackRef.current = undoStack;
718
+
719
+ /**
720
+ * Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
721
+ * PUT to the server. Used as the `putFn` injected into the
722
+ * `AnnotationStrokesCommand` — both the initial push AND every undo/redo
723
+ * replay route through here, so the iframe's `strokes` state always
724
+ * tracks the server. (Without the setStrokesState here, Cmd+Z would
725
+ * silently PUT the prior SVG but the canvas would keep painting the
726
+ * post-edit strokes until the user reloaded.)
727
+ *
728
+ * The 200 ms scheduled-save debounce (legacy path) is cleared the moment
729
+ * we push a command, so the server only sees one PUT per edit instead
730
+ * of two-step racing.
731
+ */
732
+ const putStrokes = useCallback((next: readonly Stroke[]) => {
733
+ setStrokesState(next as Stroke[]);
683
734
  const file = fileRef.current;
684
- if (!file) return;
685
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
686
- saveTimerRef.current = setTimeout(() => {
687
- saveTimerRef.current = null;
688
- const svg = strokesToSvg(next);
689
- void fetch('/_api/annotations', {
690
- method: 'PUT',
691
- headers: { 'Content-Type': 'application/json' },
692
- body: JSON.stringify({ file, svg }),
693
- }).catch(() => {
694
- /* swallow — the user will see uncommitted state until the next stroke */
735
+ if (!file) return Promise.resolve();
736
+ const svg = strokesToSvg(next);
737
+ return fetch('/_api/annotations', {
738
+ method: 'PUT',
739
+ headers: { 'Content-Type': 'application/json' },
740
+ body: JSON.stringify({ file, svg }),
741
+ })
742
+ .then(() => undefined)
743
+ .catch(() => {
744
+ /* swallow — user sees uncommitted state until the next stroke */
695
745
  });
696
- }, 200);
697
746
  }, []);
698
747
 
748
+ // Register the strokes put sink with the undo provider so the rebuilt
749
+ // AnnotationStrokesCommand (after a canvas switch + return) routes through
750
+ // THIS iframe's React state, not the gone iframe's stale closures.
751
+ useEffect(() => {
752
+ undoSinks.setSink('strokesPutFn', putStrokes);
753
+ return () => undoSinks.setSink('strokesPutFn', undefined);
754
+ }, [undoSinks, putStrokes]);
755
+
756
+ /**
757
+ * Single entry point for every stroke mutation. Builds an undo record
758
+ * and pushes onto the stack — `push()` rebuilds the command via the
759
+ * registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
760
+ * which both updates local state and PUTs. Cancels any pending debounced
761
+ * save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
762
+ * would race the stack into a stale state.
763
+ */
764
+ const commitStrokes = useCallback(
765
+ (prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
766
+ if (saveTimerRef.current) {
767
+ clearTimeout(saveTimerRef.current);
768
+ saveTimerRef.current = null;
769
+ }
770
+ const record = buildAnnotationStrokesRecord({
771
+ before: prev,
772
+ after: next,
773
+ ...(label ? { label } : {}),
774
+ });
775
+ void undoStackRef.current.push(record);
776
+ },
777
+ []
778
+ );
779
+
699
780
  const setStrokes = useCallback(
700
781
  (next: Stroke[]) => {
701
- setStrokesState(next);
702
- scheduleSave(next);
782
+ const prev = strokesRef.current;
783
+ commitStrokes(prev, next);
703
784
  },
704
- [scheduleSave]
785
+ [commitStrokes]
705
786
  );
706
787
 
707
788
  const strokesStore = useMemo<StrokesStoreValue>(() => {
708
789
  const updateStroke = (id: string, patch: Partial<Stroke>): void => {
709
- setStrokesState((prev) => {
710
- const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
711
- scheduleSave(next);
712
- return next;
713
- });
790
+ const prev = strokesRef.current;
791
+ const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
792
+ commitStrokes(prev, next);
714
793
  };
715
794
  const deleteStrokes = (ids: string[]): void => {
716
795
  const set = new Set(ids);
717
- setStrokesState((prev) => {
718
- const next = prev.filter(
719
- (s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
720
- );
721
- scheduleSave(next);
722
- return next;
723
- });
796
+ const prev = strokesRef.current;
797
+ const next = prev.filter(
798
+ (s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
799
+ );
800
+ if (next.length === prev.length) return;
801
+ commitStrokes(prev, next);
724
802
  };
725
803
  const translateStrokes = (ids: string[], dx: number, dy: number): void => {
726
804
  const set = new Set(ids);
727
- setStrokesState((prev) => {
728
- const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
729
- scheduleSave(next);
730
- return next;
731
- });
805
+ const prev = strokesRef.current;
806
+ const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
807
+ commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
732
808
  };
733
809
  return {
734
810
  strokes,
@@ -737,7 +813,7 @@ export function AnnotationsLayer() {
737
813
  deleteStrokes,
738
814
  translateStrokes,
739
815
  };
740
- }, [strokes, setStrokes, scheduleSave]);
816
+ }, [strokes, setStrokes, commitStrokes]);
741
817
 
742
818
  // Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
743
819
  // dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
@@ -789,23 +865,21 @@ export function AnnotationsLayer() {
789
865
  (wx: number, wy: number) => {
790
866
  const zoom = vp?.zoom || 1;
791
867
  const tol = 8 / zoom;
792
- setStrokesState((prev) => {
793
- for (let i = prev.length - 1; i >= 0; i--) {
794
- const candidate = prev[i];
795
- if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
796
- const removedId = candidate.id;
797
- const next = prev
798
- .slice(0, i)
799
- .concat(prev.slice(i + 1))
800
- .filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
801
- scheduleSave(next);
802
- return next;
803
- }
868
+ const prev = strokesRef.current;
869
+ for (let i = prev.length - 1; i >= 0; i--) {
870
+ const candidate = prev[i];
871
+ if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
872
+ const removedId = candidate.id;
873
+ const next = prev
874
+ .slice(0, i)
875
+ .concat(prev.slice(i + 1))
876
+ .filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
877
+ commitStrokes(prev, next, 'erase 1 stroke');
878
+ return;
804
879
  }
805
- return prev;
806
- });
880
+ }
807
881
  },
808
- [vp, scheduleSave]
882
+ [vp, commitStrokes]
809
883
  );
810
884
 
811
885
  const beginStroke = useCallback(
@@ -839,7 +913,7 @@ export function AnnotationsLayer() {
839
913
  id,
840
914
  tool: 'rect',
841
915
  color,
842
- width: STROKE_WIDTH_THIN,
916
+ width,
843
917
  x: wx,
844
918
  y: wy,
845
919
  w: 0,
@@ -851,7 +925,7 @@ export function AnnotationsLayer() {
851
925
  id,
852
926
  tool: 'ellipse',
853
927
  color,
854
- width: STROKE_WIDTH_THIN,
928
+ width,
855
929
  cx: wx,
856
930
  cy: wy,
857
931
  rx: 0,
@@ -930,14 +1004,41 @@ export function AnnotationsLayer() {
930
1004
  if (final && !isStrokeMeaningful(final)) final = null;
931
1005
  if (final) {
932
1006
  const committed = final;
933
- setStrokesState((prev) => {
934
- const next = [...prev, committed];
935
- scheduleSave(next);
936
- return next;
937
- });
1007
+ const prev = strokesRef.current;
1008
+ const next = [...prev, committed];
1009
+ commitStrokes(prev, next, `draw ${committed.tool}`);
1010
+ // T18 — auto-select the freshly drawn shape so the user can immediately
1011
+ // see + adjust it. annotSel is optional (some test harnesses mount
1012
+ // AnnotationsLayer without the provider), so guard the call.
1013
+ if (annotSel) annotSel.replace(committed.id);
1014
+ }
1015
+ // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
1016
+ // mode is locked on this tool. Sticky lets the user draw many shapes in a
1017
+ // row (canonical pattern: tldraw double-click to lock). Eraser stays
1018
+ // armed by default — that tool is destructive, not constructive.
1019
+ const toolJustUsed = cur.tool;
1020
+ if (toolJustUsed !== 'eraser') {
1021
+ const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
1022
+ if (!stickyOnThis) setTool('move');
938
1023
  }
939
1024
  setDrawing(null);
940
- }, [isActive, isErase, visible, scheduleSave]);
1025
+ }, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
1026
+
1027
+ // T21 — abort a mid-stroke draw without committing. Dispatched by the
1028
+ // canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
1029
+ // nothing is being drawn — the early-return on drawingRef keeps it
1030
+ // a no-op.
1031
+ const cancelStroke = useCallback(() => {
1032
+ if (!drawingRef.current) return;
1033
+ setDrawing(null);
1034
+ }, []);
1035
+
1036
+ useEffect(() => {
1037
+ if (typeof document === 'undefined') return;
1038
+ const onCancel = () => cancelStroke();
1039
+ document.addEventListener('maude:cancel-stroke', onCancel);
1040
+ return () => document.removeEventListener('maude:cancel-stroke', onCancel);
1041
+ }, [cancelStroke]);
941
1042
 
942
1043
  const renderStrokes = useMemo(
943
1044
  () => (drawing ? [...strokes, drawing] : strokes),
@@ -966,11 +1067,19 @@ export function AnnotationsLayer() {
966
1067
  // - Cmd / Cmd+Shift falls through to element-selection (we bail).
967
1068
  // Once a stroke is selected, clicking inside its bbox starts a drag.
968
1069
 
1070
+ /**
1071
+ * Annotation drag state. Snapshot of strokes captured at pointerdown so the
1072
+ * whole gesture (pointermove × N → pointerup) commits as ONE undo record at
1073
+ * release time. Without the snapshot, each pointermove tick became its own
1074
+ * `translateStrokes` call and each call became its own undo record — Cmd+Z
1075
+ * had to be pressed dozens of times to walk back a single drag.
1076
+ */
969
1077
  const dragStateRef = useRef<{
970
1078
  pointerId: number;
971
1079
  startWX: number;
972
1080
  startWY: number;
973
1081
  movedIds: string[];
1082
+ snapshot: Stroke[];
974
1083
  } | null>(null);
975
1084
 
976
1085
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
@@ -982,6 +1091,7 @@ export function AnnotationsLayer() {
982
1091
  by: number;
983
1092
  } | null>(null);
984
1093
 
1094
+ // biome-ignore lint/correctness/useExhaustiveDependencies: commitStrokes/strokesStore are stable refs from parent context; manual dep list reflects effect-trigger semantics, not internal callbacks
985
1095
  useEffect(() => {
986
1096
  if (typeof document === 'undefined') return;
987
1097
  if (tool !== 'move') return;
@@ -1005,7 +1115,7 @@ export function AnnotationsLayer() {
1005
1115
  // the main tool palette, the in-canvas draw chrome, the minimap, and the
1006
1116
  // right-click menu. Clicks on these route to their own handlers.
1007
1117
  const CHROME_SELECTOR =
1008
- '.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover';
1118
+ '.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';
1009
1119
 
1010
1120
  const onDown = (e: PointerEvent) => {
1011
1121
  if (e.button !== 0) return;
@@ -1013,6 +1123,11 @@ export function AnnotationsLayer() {
1013
1123
  const target = e.target as Element | null;
1014
1124
  if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
1015
1125
  const strokeId = findStrokeId(target);
1126
+ // When pointerdown lands inside an artboard but not on a stroke, the
1127
+ // gesture belongs to artboard-drag / element-marquee — not the
1128
+ // annotation marquee. Bailing here keeps the annotation marquee from
1129
+ // racing the artboard drag (post-Wave-3 user grievance G5).
1130
+ if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
1016
1131
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
1017
1132
  const startClientX = e.clientX;
1018
1133
  const startClientY = e.clientY;
@@ -1034,29 +1149,51 @@ export function AnnotationsLayer() {
1034
1149
  annotSel.replace(strokeId);
1035
1150
  ids = [strokeId];
1036
1151
  }
1152
+ // Capture a snapshot of all strokes at drag start. Every pointermove
1153
+ // re-translates FROM the snapshot using the cumulative cursor delta
1154
+ // (NOT a delta-from-last-frame mutation), so dragging back to origin
1155
+ // restores positions exactly. Optimistic state-only updates during
1156
+ // the move; ONE undo record + ONE server PUT fires on pointerup.
1157
+ const dragSnapshot = strokesRef.current.slice();
1037
1158
  dragStateRef.current = {
1038
1159
  pointerId: e.pointerId,
1039
1160
  startWX: wx,
1040
1161
  startWY: wy,
1041
1162
  movedIds: ids,
1163
+ snapshot: dragSnapshot,
1042
1164
  };
1165
+ const movedSet = new Set(ids);
1043
1166
  const onMove = (mv: PointerEvent) => {
1044
1167
  const st = dragStateRef.current;
1045
1168
  if (!st || mv.pointerId !== st.pointerId) return;
1046
1169
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
1047
1170
  const dx = cwx - st.startWX;
1048
1171
  const dy = cwy - st.startWY;
1049
- if (dx === 0 && dy === 0) return;
1050
- strokesStore.translateStrokes(st.movedIds, dx, dy);
1051
- st.startWX = cwx;
1052
- st.startWY = cwy;
1172
+ // Drag-back-to-origin: restore exact references so the pointerup
1173
+ // shallow-equality check skips committing a no-op record.
1174
+ const next =
1175
+ dx === 0 && dy === 0
1176
+ ? st.snapshot
1177
+ : st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
1178
+ // Local React state only. No commitStrokes — no PUT, no undo push.
1179
+ setStrokesState(next);
1053
1180
  };
1054
1181
  const onUp = (up: PointerEvent) => {
1055
- if (up.pointerId !== dragStateRef.current?.pointerId) return;
1182
+ const st = dragStateRef.current;
1183
+ if (!st || up.pointerId !== st.pointerId) return;
1056
1184
  dragStateRef.current = null;
1057
1185
  document.removeEventListener('pointermove', onMove, true);
1058
1186
  document.removeEventListener('pointerup', onUp, true);
1059
1187
  document.removeEventListener('pointercancel', onUp, true);
1188
+ // Commit the gesture as ONE record. Skip on zero-movement
1189
+ // (click without drag past threshold or drag back to origin).
1190
+ const final = strokesRef.current;
1191
+ if (strokesShallowEqual(st.snapshot, final)) return;
1192
+ commitStrokes(
1193
+ st.snapshot,
1194
+ final,
1195
+ `move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
1196
+ );
1060
1197
  };
1061
1198
  document.addEventListener('pointermove', onMove, true);
1062
1199
  document.addEventListener('pointerup', onUp, true);
@@ -1064,13 +1201,15 @@ export function AnnotationsLayer() {
1064
1201
  return;
1065
1202
  }
1066
1203
 
1067
- // Empty world — start a drag-select gesture. A small movement falls
1068
- // back to a "click on empty world → clear selection" (Figma-style).
1204
+ // Empty world — start a drag-select gesture. A bare click without
1205
+ // moving clears annotation selection (post-Wave-3 feedback: click-to-
1206
+ // deselect is back; Esc also still works).
1069
1207
  const addToSelection = e.shiftKey;
1070
1208
  let moved = false;
1071
1209
  const onMove = (mv: PointerEvent) => {
1072
- const distSq = (mv.clientX - startClientX) ** 2 + (mv.clientY - startClientY) ** 2;
1073
- if (!moved && distSq < 16) return; // 4 px threshold
1210
+ if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
1211
+ return;
1212
+ }
1074
1213
  moved = true;
1075
1214
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
1076
1215
  setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
@@ -1080,8 +1219,10 @@ export function AnnotationsLayer() {
1080
1219
  document.removeEventListener('pointerup', onUp, true);
1081
1220
  document.removeEventListener('pointercancel', onUp, true);
1082
1221
  if (!moved) {
1083
- // True click on empty world → clear (unless modifier add-mode).
1084
- if (!addToSelection && annotSel.selectedIds.length > 0) annotSel.clear();
1222
+ // Click without movement on empty world → clear annotation
1223
+ // selection (post-Wave-3 user feedback). Shift-click preserves
1224
+ // existing selection for additive-mode workflows.
1225
+ if (!addToSelection) annotSel.clear();
1085
1226
  return;
1086
1227
  }
1087
1228
  const final = marqueeRef.current;
@@ -1100,13 +1241,10 @@ export function AnnotationsLayer() {
1100
1241
  hits.push(s.id);
1101
1242
  }
1102
1243
  }
1103
- if (addToSelection) {
1104
- annotSel.add(hits);
1105
- } else if (hits.length === 0) {
1106
- annotSel.clear();
1107
- } else {
1108
- annotSel.replace(hits);
1109
- }
1244
+ // Marquee that captured no strokes — preserve existing selection.
1245
+ if (hits.length === 0) return;
1246
+ if (addToSelection) annotSel.add(hits);
1247
+ else annotSel.replace(hits);
1110
1248
  };
1111
1249
  document.addEventListener('pointermove', onMove, true);
1112
1250
  document.addEventListener('pointerup', onUp, true);
@@ -1146,33 +1284,36 @@ export function AnnotationsLayer() {
1146
1284
  const commitText = useCallback(
1147
1285
  (anchorId: string, text: string) => {
1148
1286
  const trimmed = text.trim();
1149
- setStrokesState((prev) => {
1150
- const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
1151
- | TextStroke
1152
- | undefined;
1153
- let next: Stroke[];
1154
- if (trimmed.length === 0) {
1155
- next = existing ? prev.filter((s) => s.id !== existing.id) : prev;
1156
- } else if (existing) {
1157
- next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
1158
- } else {
1159
- next = [
1160
- ...prev,
1161
- {
1162
- id: rid(),
1163
- tool: 'text',
1164
- color: '#1a1a1a',
1165
- fontSize: DEFAULT_FONT_SIZE,
1166
- text: trimmed,
1167
- anchorId,
1168
- } as TextStroke,
1169
- ];
1170
- }
1171
- scheduleSave(next);
1172
- return next;
1173
- });
1287
+ const prev = strokesRef.current;
1288
+ const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
1289
+ | TextStroke
1290
+ | undefined;
1291
+ let next: Stroke[];
1292
+ let label = 'edit text';
1293
+ if (trimmed.length === 0) {
1294
+ if (!existing) return; // nothing to do
1295
+ next = prev.filter((s) => s.id !== existing.id);
1296
+ label = 'delete text';
1297
+ } else if (existing) {
1298
+ if (existing.text === trimmed) return; // identity edit
1299
+ next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
1300
+ } else {
1301
+ next = [
1302
+ ...prev,
1303
+ {
1304
+ id: rid(),
1305
+ tool: 'text',
1306
+ color: '#1a1a1a',
1307
+ fontSize: DEFAULT_FONT_SIZE,
1308
+ text: trimmed,
1309
+ anchorId,
1310
+ } as TextStroke,
1311
+ ];
1312
+ label = 'add text';
1313
+ }
1314
+ commitStrokes(prev, next, label);
1174
1315
  },
1175
- [scheduleSave]
1316
+ [commitStrokes]
1176
1317
  );
1177
1318
 
1178
1319
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
@@ -1257,6 +1398,7 @@ export function AnnotationsLayer() {
1257
1398
  />
1258
1399
  ) : null}
1259
1400
  <AnnotationContextToolbar />
1401
+ {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
1260
1402
  {isActive ? (
1261
1403
  <AnnotationsChrome
1262
1404
  color={color}
@@ -1382,8 +1524,14 @@ function AnnotationsSvg({
1382
1524
  <StrokeNode key={s.id} stroke={s} anchorsById={anchorsById} interactive={selectMode} />
1383
1525
  ))}
1384
1526
  {selectedStrokes.map((s) => (
1385
- <SelectionHalo key={`halo-${s.id}`} stroke={s} anchorsById={anchorsById} />
1527
+ <SelectionHalo
1528
+ key={`halo-${s.id}`}
1529
+ stroke={s}
1530
+ anchorsById={anchorsById}
1531
+ multi={selectedStrokes.length > 1}
1532
+ />
1386
1533
  ))}
1534
+ <AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
1387
1535
  {marquee ? (
1388
1536
  <rect
1389
1537
  className="dc-annot-marquee"
@@ -1507,12 +1655,26 @@ function TextEditor({
1507
1655
  function SelectionHalo({
1508
1656
  stroke,
1509
1657
  anchorsById,
1658
+ multi,
1510
1659
  }: {
1511
1660
  stroke: Stroke;
1512
1661
  anchorsById: Map<string, RectStroke | EllipseStroke>;
1662
+ multi: boolean;
1513
1663
  }) {
1514
1664
  const bbox = strokeBBox(stroke, anchorsById);
1515
1665
  if (!bbox) return null;
1666
+ // T17 + post-Wave-2 fix — annotation halo idioms:
1667
+ // * Single select → 2 px solid border, NO ring, NO corner ticks.
1668
+ // The resize overlay (T23) renders the corner handles in screen-space,
1669
+ // so painting SVG ticks here too would duplicate them. The element
1670
+ // halo uses CSS box-shadow for the 18% ring; the SVG equivalent (a
1671
+ // second outline rect) reads as "double frame" rather than a halo —
1672
+ // user feedback flagged this immediately. Solid 2 px is enough signal
1673
+ // once the resize handles claim the corners.
1674
+ // * Multi member → 1.5 px solid full accent, no ring, no ticks (group
1675
+ // bbox above carries the container affordance).
1676
+ // Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
1677
+ // ambient group-container + active-gesture idioms per DDR-046 rev 2.
1516
1678
  const pad = 4;
1517
1679
  return (
1518
1680
  <rect
@@ -1522,8 +1684,7 @@ function SelectionHalo({
1522
1684
  height={bbox.h + pad * 2}
1523
1685
  fill="none"
1524
1686
  stroke="var(--accent, #d63b1f)"
1525
- strokeWidth={1.5}
1526
- strokeDasharray="4 3"
1687
+ strokeWidth={multi ? 1.5 : 2}
1527
1688
  vectorEffect="non-scaling-stroke"
1528
1689
  pointerEvents="none"
1529
1690
  rx={2}
@@ -1531,6 +1692,76 @@ function SelectionHalo({
1531
1692
  );
1532
1693
  }
1533
1694
 
1695
+ // T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
1696
+ // the element-side GroupBbox idiom (1 px dashed accent + 6 × 6 corner handles).
1697
+ function AnnotGroupBbox({
1698
+ selectedStrokes,
1699
+ anchorsById,
1700
+ }: {
1701
+ selectedStrokes: readonly Stroke[];
1702
+ anchorsById: Map<string, RectStroke | EllipseStroke>;
1703
+ }) {
1704
+ if (selectedStrokes.length < 2) return null;
1705
+ let xMin = Number.POSITIVE_INFINITY;
1706
+ let yMin = Number.POSITIVE_INFINITY;
1707
+ let xMax = Number.NEGATIVE_INFINITY;
1708
+ let yMax = Number.NEGATIVE_INFINITY;
1709
+ let any = false;
1710
+ for (const s of selectedStrokes) {
1711
+ const b = strokeBBox(s, anchorsById);
1712
+ if (!b) continue;
1713
+ any = true;
1714
+ if (b.x < xMin) xMin = b.x;
1715
+ if (b.y < yMin) yMin = b.y;
1716
+ if (b.x + b.w > xMax) xMax = b.x + b.w;
1717
+ if (b.y + b.h > yMax) yMax = b.y + b.h;
1718
+ }
1719
+ if (!any) return null;
1720
+ const pad = 6;
1721
+ const x = xMin - pad;
1722
+ const y = yMin - pad;
1723
+ const w = xMax - xMin + pad * 2;
1724
+ const h = yMax - yMin + pad * 2;
1725
+ const handle = 6;
1726
+ const inset = 3;
1727
+ const handles = [
1728
+ { corner: 'nw', x: x - inset, y: y - inset },
1729
+ { corner: 'ne', x: x + w - handle + inset, y: y - inset },
1730
+ { corner: 'sw', x: x - inset, y: y + h - handle + inset },
1731
+ { corner: 'se', x: x + w - handle + inset, y: y + h - handle + inset },
1732
+ ];
1733
+ return (
1734
+ <g pointerEvents="none">
1735
+ <rect
1736
+ x={x}
1737
+ y={y}
1738
+ width={w}
1739
+ height={h}
1740
+ fill="none"
1741
+ stroke="var(--accent, #d63b1f)"
1742
+ strokeWidth={1}
1743
+ strokeDasharray="4 3"
1744
+ vectorEffect="non-scaling-stroke"
1745
+ rx={2}
1746
+ />
1747
+ {handles.map((c) => (
1748
+ <rect
1749
+ key={c.corner}
1750
+ x={c.x}
1751
+ y={c.y}
1752
+ width={handle}
1753
+ height={handle}
1754
+ fill="var(--accent, #d63b1f)"
1755
+ stroke="var(--bg-0, #ffffff)"
1756
+ strokeWidth={1}
1757
+ vectorEffect="non-scaling-stroke"
1758
+ rx={1}
1759
+ />
1760
+ ))}
1761
+ </g>
1762
+ );
1763
+ }
1764
+
1534
1765
  // ─────────────────────────────────────────────────────────────────────────────
1535
1766
  // Stroke renderer
1536
1767
 
@@ -1694,7 +1925,7 @@ function AnnotationsChrome({
1694
1925
  type="button"
1695
1926
  className="dc-annot-btn"
1696
1927
  aria-pressed={thickness === STROKE_WIDTH_THIN}
1697
- title="Thin (2px)"
1928
+ title="Thin (3px)"
1698
1929
  onClick={() => setThickness(STROKE_WIDTH_THIN)}
1699
1930
  >
1700
1931
  Thin