@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
@@ -35,11 +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';
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';
41
43
  import { useSelectionSetOptional } from './use-selection-set.tsx';
42
44
  import { useToolMode } from './use-tool-mode.tsx';
45
+ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
43
46
 
44
47
  // ─────────────────────────────────────────────────────────────────────────────
45
48
  // Types
@@ -115,7 +118,7 @@ const FILL_PALETTE = [
115
118
  '#f4f1ee', // paper
116
119
  ] as const;
117
120
 
118
- const STROKE_WIDTH_THIN = 2;
121
+ const STROKE_WIDTH_THIN = 3;
119
122
  const STROKE_WIDTH_THICK = 6;
120
123
  type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
121
124
 
@@ -609,6 +612,23 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
609
612
  return s; // text inherits its host's bbox
610
613
  }
611
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
+
612
632
  // Annotations visibility now lives in use-annotations-visibility.tsx so the
613
633
  // ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
614
634
  // can read the same state. Re-exported here for back-compat.
@@ -650,6 +670,13 @@ export function AnnotationsLayer() {
650
670
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
651
671
  const drawingRef = useRef<Stroke | null>(null);
652
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;
653
680
 
654
681
  const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
655
682
  const isErase = tool === 'eraser';
@@ -684,56 +711,100 @@ export function AnnotationsLayer() {
684
711
  };
685
712
  }, []);
686
713
 
687
- 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[]);
688
734
  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 */
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 */
700
745
  });
701
- }, 200);
702
746
  }, []);
703
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
+
704
780
  const setStrokes = useCallback(
705
781
  (next: Stroke[]) => {
706
- setStrokesState(next);
707
- scheduleSave(next);
782
+ const prev = strokesRef.current;
783
+ commitStrokes(prev, next);
708
784
  },
709
- [scheduleSave]
785
+ [commitStrokes]
710
786
  );
711
787
 
712
788
  const strokesStore = useMemo<StrokesStoreValue>(() => {
713
789
  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
- });
790
+ const prev = strokesRef.current;
791
+ const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
792
+ commitStrokes(prev, next);
719
793
  };
720
794
  const deleteStrokes = (ids: string[]): void => {
721
795
  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
- });
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);
729
802
  };
730
803
  const translateStrokes = (ids: string[], dx: number, dy: number): void => {
731
804
  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
- });
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'}`);
737
808
  };
738
809
  return {
739
810
  strokes,
@@ -742,7 +813,7 @@ export function AnnotationsLayer() {
742
813
  deleteStrokes,
743
814
  translateStrokes,
744
815
  };
745
- }, [strokes, setStrokes, scheduleSave]);
816
+ }, [strokes, setStrokes, commitStrokes]);
746
817
 
747
818
  // Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
748
819
  // dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
@@ -794,23 +865,21 @@ export function AnnotationsLayer() {
794
865
  (wx: number, wy: number) => {
795
866
  const zoom = vp?.zoom || 1;
796
867
  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
- }
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;
809
879
  }
810
- return prev;
811
- });
880
+ }
812
881
  },
813
- [vp, scheduleSave]
882
+ [vp, commitStrokes]
814
883
  );
815
884
 
816
885
  const beginStroke = useCallback(
@@ -935,11 +1004,9 @@ export function AnnotationsLayer() {
935
1004
  if (final && !isStrokeMeaningful(final)) final = null;
936
1005
  if (final) {
937
1006
  const committed = final;
938
- setStrokesState((prev) => {
939
- const next = [...prev, committed];
940
- scheduleSave(next);
941
- return next;
942
- });
1007
+ const prev = strokesRef.current;
1008
+ const next = [...prev, committed];
1009
+ commitStrokes(prev, next, `draw ${committed.tool}`);
943
1010
  // T18 — auto-select the freshly drawn shape so the user can immediately
944
1011
  // see + adjust it. annotSel is optional (some test harnesses mount
945
1012
  // AnnotationsLayer without the provider), so guard the call.
@@ -955,7 +1022,7 @@ export function AnnotationsLayer() {
955
1022
  if (!stickyOnThis) setTool('move');
956
1023
  }
957
1024
  setDrawing(null);
958
- }, [isActive, isErase, visible, scheduleSave, annotSel, setTool, sticky]);
1025
+ }, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
959
1026
 
960
1027
  // T21 — abort a mid-stroke draw without committing. Dispatched by the
961
1028
  // canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
@@ -1000,11 +1067,19 @@ export function AnnotationsLayer() {
1000
1067
  // - Cmd / Cmd+Shift falls through to element-selection (we bail).
1001
1068
  // Once a stroke is selected, clicking inside its bbox starts a drag.
1002
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
+ */
1003
1077
  const dragStateRef = useRef<{
1004
1078
  pointerId: number;
1005
1079
  startWX: number;
1006
1080
  startWY: number;
1007
1081
  movedIds: string[];
1082
+ snapshot: Stroke[];
1008
1083
  } | null>(null);
1009
1084
 
1010
1085
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
@@ -1016,6 +1091,7 @@ export function AnnotationsLayer() {
1016
1091
  by: number;
1017
1092
  } | null>(null);
1018
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
1019
1095
  useEffect(() => {
1020
1096
  if (typeof document === 'undefined') return;
1021
1097
  if (tool !== 'move') return;
@@ -1039,7 +1115,7 @@ export function AnnotationsLayer() {
1039
1115
  // the main tool palette, the in-canvas draw chrome, the minimap, and the
1040
1116
  // right-click menu. Clicks on these route to their own handlers.
1041
1117
  const CHROME_SELECTOR =
1042
- '.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';
1043
1119
 
1044
1120
  const onDown = (e: PointerEvent) => {
1045
1121
  if (e.button !== 0) return;
@@ -1047,6 +1123,11 @@ export function AnnotationsLayer() {
1047
1123
  const target = e.target as Element | null;
1048
1124
  if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
1049
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;
1050
1131
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
1051
1132
  const startClientX = e.clientX;
1052
1133
  const startClientY = e.clientY;
@@ -1068,29 +1149,51 @@ export function AnnotationsLayer() {
1068
1149
  annotSel.replace(strokeId);
1069
1150
  ids = [strokeId];
1070
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();
1071
1158
  dragStateRef.current = {
1072
1159
  pointerId: e.pointerId,
1073
1160
  startWX: wx,
1074
1161
  startWY: wy,
1075
1162
  movedIds: ids,
1163
+ snapshot: dragSnapshot,
1076
1164
  };
1165
+ const movedSet = new Set(ids);
1077
1166
  const onMove = (mv: PointerEvent) => {
1078
1167
  const st = dragStateRef.current;
1079
1168
  if (!st || mv.pointerId !== st.pointerId) return;
1080
1169
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
1081
1170
  const dx = cwx - st.startWX;
1082
1171
  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;
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);
1087
1180
  };
1088
1181
  const onUp = (up: PointerEvent) => {
1089
- if (up.pointerId !== dragStateRef.current?.pointerId) return;
1182
+ const st = dragStateRef.current;
1183
+ if (!st || up.pointerId !== st.pointerId) return;
1090
1184
  dragStateRef.current = null;
1091
1185
  document.removeEventListener('pointermove', onMove, true);
1092
1186
  document.removeEventListener('pointerup', onUp, true);
1093
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
+ );
1094
1197
  };
1095
1198
  document.addEventListener('pointermove', onMove, true);
1096
1199
  document.addEventListener('pointerup', onUp, true);
@@ -1099,13 +1202,14 @@ export function AnnotationsLayer() {
1099
1202
  }
1100
1203
 
1101
1204
  // 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).
1205
+ // moving clears annotation selection (post-Wave-3 feedback: click-to-
1206
+ // deselect is back; Esc also still works).
1104
1207
  const addToSelection = e.shiftKey;
1105
1208
  let moved = false;
1106
1209
  const onMove = (mv: PointerEvent) => {
1107
- const distSq = (mv.clientX - startClientX) ** 2 + (mv.clientY - startClientY) ** 2;
1108
- if (!moved && distSq < 16) return; // 4 px threshold
1210
+ if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
1211
+ return;
1212
+ }
1109
1213
  moved = true;
1110
1214
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
1111
1215
  setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
@@ -1115,8 +1219,10 @@ export function AnnotationsLayer() {
1115
1219
  document.removeEventListener('pointerup', onUp, true);
1116
1220
  document.removeEventListener('pointercancel', onUp, true);
1117
1221
  if (!moved) {
1118
- // Click without movement on empty world → no-op. Selection
1119
- // survives accidental misses; Esc is how the user deselects.
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();
1120
1226
  return;
1121
1227
  }
1122
1228
  const final = marqueeRef.current;
@@ -1178,33 +1284,36 @@ export function AnnotationsLayer() {
1178
1284
  const commitText = useCallback(
1179
1285
  (anchorId: string, text: string) => {
1180
1286
  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
- });
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);
1206
1315
  },
1207
- [scheduleSave]
1316
+ [commitStrokes]
1208
1317
  );
1209
1318
 
1210
1319
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
@@ -1816,7 +1925,7 @@ function AnnotationsChrome({
1816
1925
  type="button"
1817
1926
  className="dc-annot-btn"
1818
1927
  aria-pressed={thickness === STROKE_WIDTH_THIN}
1819
- title="Thin (2px)"
1928
+ title="Thin (3px)"
1820
1929
  onClick={() => setThickness(STROKE_WIDTH_THIN)}
1821
1930
  >
1822
1931
  Thin
@@ -18,11 +18,10 @@
18
18
  import { useEffect, useRef } from 'react';
19
19
 
20
20
  import { useArtboardsContext } from './canvas-lib.tsx';
21
+ import { DRAG_THRESHOLD_PX } from './input-router.tsx';
21
22
  import { type Selection, useSelectionSet } from './use-selection-set.tsx';
22
23
  import { useToolMode } from './use-tool-mode.tsx';
23
24
 
24
- const DRAG_THRESHOLD_PX = 4;
25
-
26
25
  const MARQUEE_CSS = `
27
26
  .dc-cv-marquee {
28
27
  position: fixed;
@@ -52,7 +51,7 @@ function shouldIgnoreTarget(t: EventTarget | null): boolean {
52
51
  // Skip floating chrome and existing overlays (their click handlers run).
53
52
  if (
54
53
  el.closest(
55
- '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle'
54
+ '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
56
55
  )
57
56
  ) {
58
57
  return true;
@@ -125,7 +124,12 @@ export function ArtboardMarqueeOverlay() {
125
124
  stateRef.current = null;
126
125
  const div = overlayRef.current;
127
126
  if (div) div.style.display = 'none';
128
- if (!s.crossed) return;
127
+ if (!s.crossed) {
128
+ // Bare click on empty world without drag → clear selection
129
+ // (post-Wave-3 user feedback). Shift-click preserves it.
130
+ if (!s.shift) selSet.clear();
131
+ return;
132
+ }
129
133
  if (!artboardsCtx) return;
130
134
 
131
135
  // Intersect the marquee AABB with each artboard's screen-coord bbox.