@1agh/maude 0.19.0 → 0.19.1

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 (26) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +14 -6
  3. package/plugins/design/dev-server/annotations-layer.tsx +144 -22
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +170 -0
  6. package/plugins/design/dev-server/canvas-lib.tsx +190 -94
  7. package/plugins/design/dev-server/canvas-shell.tsx +478 -34
  8. package/plugins/design/dev-server/client/app.jsx +173 -52
  9. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  10. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  11. package/plugins/design/dev-server/config.schema.json +31 -5
  12. package/plugins/design/dev-server/context-menu.tsx +3 -3
  13. package/plugins/design/dev-server/context.ts +37 -3
  14. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  15. package/plugins/design/dev-server/dist/styles.css +38 -2
  16. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  17. package/plugins/design/dev-server/http.ts +14 -2
  18. package/plugins/design/dev-server/input-router.tsx +22 -8
  19. package/plugins/design/dev-server/server.mjs +84 -18
  20. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  21. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  22. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  23. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  24. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  25. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  26. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
@@ -24,6 +24,9 @@ import { useToolMode } from './use-tool-mode.tsx';
24
24
  // 32 × 32 buttons with iconography. Grouped pill segments separated by 1 px
25
25
  // dividers — visual cue that nav / draw / view-controls are distinct kinds.
26
26
 
27
+ // DDR-046 — floating chrome ambient shadow + 8 px radius (NOT brutalist).
28
+ // Active-tool state: 14% tint bg + 2px accent underbar + accent fg, replacing
29
+ // the previous full-accent flood (too loud at 32 × 32 in a quiet chrome).
27
30
  const PALETTE_CSS = `
28
31
  .dc-tool-palette {
29
32
  position: absolute;
@@ -32,10 +35,10 @@ const PALETTE_CSS = `
32
35
  transform: translateX(-50%);
33
36
  display: flex;
34
37
  align-items: stretch;
35
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
38
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
36
39
  border: 1px solid var(--u-fg-0, #1c1917);
37
- border-radius: 0;
38
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
40
+ border-radius: 8px;
41
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
39
42
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
40
43
  font-size: 12px;
41
44
  color: var(--u-fg-0, var(--fg-0, #1a1a1a));
@@ -43,8 +46,7 @@ const PALETTE_CSS = `
43
46
  user-select: none;
44
47
  /* Intentionally NO overflow:hidden — the zoom popover (.dc-tp-popover) is
45
48
  a position:absolute child anchored above the toolbar; clipping the
46
- parent would hide it. Inner buttons have their own border-radius so the
47
- rounded outer corners are preserved by content shape, not overflow. */
49
+ parent would hide it. */
48
50
  }
49
51
  .dc-tool-palette .dc-tp-group {
50
52
  display: flex;
@@ -61,7 +63,7 @@ const PALETTE_CSS = `
61
63
  appearance: none;
62
64
  background: transparent;
63
65
  border: 0;
64
- border-radius: 0;
66
+ border-radius: 6px;
65
67
  padding: 0;
66
68
  width: 32px;
67
69
  height: 32px;
@@ -70,16 +72,61 @@ const PALETTE_CSS = `
70
72
  justify-content: center;
71
73
  color: var(--fg-1, rgba(40,30,20,0.75));
72
74
  cursor: pointer;
75
+ position: relative;
73
76
  transition: background-color 80ms linear, color 80ms linear;
74
77
  }
75
- .dc-tool-palette button:hover { background: rgba(0,0,0,0.04); color: var(--fg-0, #1a1a1a); }
78
+ .dc-tool-palette button:hover {
79
+ background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
80
+ color: var(--fg-0, #1a1a1a);
81
+ }
76
82
  .dc-tool-palette button:focus-visible {
77
83
  outline: 2px solid var(--accent, #d63b1f);
78
84
  outline-offset: -2px;
79
85
  }
86
+ /* DDR-046 — Active tool: tinted background + accent underbar + accent text.
87
+ The underbar is rendered via ::after so the visual stays inside the 6 px
88
+ radius without leaking past the button edge. */
80
89
  .dc-tool-palette button[aria-pressed="true"] {
90
+ background: color-mix(in oklab, var(--accent, #d63b1f) 14%, transparent);
91
+ color: var(--accent, #d63b1f);
92
+ }
93
+ .dc-tool-palette button[aria-pressed="true"]::after {
94
+ content: "";
95
+ position: absolute;
96
+ left: 6px;
97
+ right: 6px;
98
+ bottom: 2px;
99
+ height: 2px;
81
100
  background: var(--accent, #d63b1f);
82
- color: var(--accent-fg, #fff);
101
+ border-radius: 1px;
102
+ }
103
+ /* T19 — sticky-tool lock badge. Tiny accent square in the top-right corner
104
+ of the active tool button, fades in 50 ms after a double-click. The badge
105
+ reads "armed across draws" so the user understands the tool will stay
106
+ selected after each shape commit. */
107
+ .dc-tool-palette button[data-sticky="true"] {
108
+ /* Keep the tinted active background; add a hairline ring so the lock state
109
+ is readable even when the button is also aria-pressed. */
110
+ box-shadow: inset 0 0 0 1px var(--accent, #d63b1f);
111
+ }
112
+ .dc-tool-palette button .dc-tp-sticky-badge {
113
+ position: absolute;
114
+ top: 3px;
115
+ right: 3px;
116
+ width: 6px;
117
+ height: 6px;
118
+ background: var(--accent, #d63b1f);
119
+ border-radius: 1px;
120
+ box-shadow: 0 0 0 1px var(--bg-0, #ffffff);
121
+ opacity: 0;
122
+ transition: opacity 50ms linear 50ms;
123
+ pointer-events: none;
124
+ }
125
+ .dc-tool-palette button[data-sticky="true"] .dc-tp-sticky-badge {
126
+ opacity: 1;
127
+ }
128
+ @media (prefers-reduced-motion: reduce) {
129
+ .dc-tool-palette button .dc-tp-sticky-badge { transition: none; }
83
130
  }
84
131
  .dc-tool-palette .dc-tp-zoom {
85
132
  min-width: 56px;
@@ -88,7 +135,7 @@ const PALETTE_CSS = `
88
135
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
89
136
  font-size: 11px;
90
137
  letter-spacing: 0.04em;
91
- border-radius: 0;
138
+ border-radius: 6px;
92
139
  display: inline-flex;
93
140
  align-items: center;
94
141
  gap: 4px;
@@ -98,10 +145,10 @@ const PALETTE_CSS = `
98
145
  position: absolute;
99
146
  right: 4px;
100
147
  bottom: 44px;
101
- background: var(--u-bg-2, var(--bg-1, rgba(255,255,255,0.98)));
148
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
102
149
  border: 1px solid var(--u-fg-0, #1c1917);
103
- border-radius: 0;
104
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
150
+ border-radius: 8px;
151
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
105
152
  display: flex;
106
153
  flex-direction: column;
107
154
  padding: 4px;
@@ -149,7 +196,7 @@ const DRAW_TOOLS = ['pen', 'rect', 'ellipse', 'arrow', 'eraser'] as const;
149
196
 
150
197
  export function ToolPalette() {
151
198
  ensurePaletteStyles();
152
- const { tool, setTool, tools } = useToolMode();
199
+ const { tool, setTool, tools, sticky, toggleSticky } = useToolMode();
153
200
  const controller = useViewportControllerContext();
154
201
  const visibilityCtx = useAnnotationsVisibility();
155
202
  const [mounted, setMounted] = useState(false);
@@ -176,16 +223,25 @@ export function ToolPalette() {
176
223
 
177
224
  const renderToolButton = (id: string, label: string, shortcut: string) => {
178
225
  const Icon = TOOL_ICONS[id];
226
+ const isDraw = (DRAW_TOOLS as readonly string[]).includes(id);
227
+ const isSticky = sticky.locked && sticky.tool === (id as never);
228
+ const stickyHint = isDraw ? ' · double-click to lock' : '';
179
229
  return (
180
230
  <button
181
231
  key={id}
182
232
  type="button"
183
- aria-label={`${label} (${shortcut})`}
233
+ aria-label={`${label} (${shortcut})${isSticky ? ' — locked' : stickyHint}`}
184
234
  aria-pressed={tool === id}
185
- title={`${label} (${shortcut})`}
235
+ data-sticky={isSticky ? 'true' : undefined}
236
+ title={`${label} (${shortcut})${stickyHint}`}
186
237
  onClick={() => setTool(id as never)}
238
+ onDoubleClick={() => {
239
+ if (!isDraw) return;
240
+ toggleSticky(id as never);
241
+ }}
187
242
  >
188
243
  {Icon ? <Icon /> : <span style={{ fontSize: 11 }}>{shortcut}</span>}
244
+ {isSticky ? <span className="dc-tp-sticky-badge" aria-hidden="true" /> : null}
189
245
  </button>
190
246
  );
191
247
  };
@@ -0,0 +1,295 @@
1
+ /**
2
+ * @file use-annotation-resize.tsx — Task 23 (Wave 2, G4)
3
+ * @scope plugins/design/dev-server/use-annotation-resize.tsx
4
+ * @purpose Screen-space corner / endpoint handles for the selected
5
+ * annotation. Per-tool resize math; modifier semantics (Shift
6
+ * aspect-lock, Alt scale-from-center) are deferred to a follow-up.
7
+ *
8
+ * Handles are `position: fixed` DOM siblings of the canvas (the
9
+ * same pattern element selection uses) — they stay 8 × 8 CSS px
10
+ * at any zoom and never get caught in the SVG `vector-effect`
11
+ * gymnastics.
12
+ *
13
+ * Mounts the overlay element exactly once via `AnnotationResizeOverlay`.
14
+ * The hook does the math + persistence; the overlay component
15
+ * owns the rAF loop that follows pan/zoom + pointer drags.
16
+ */
17
+
18
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
19
+
20
+ import {
21
+ type ArrowStroke,
22
+ type EllipseStroke,
23
+ type PenStroke,
24
+ type RectStroke,
25
+ type Stroke,
26
+ type StrokesStoreValue,
27
+ strokeBBox,
28
+ } from './annotations-layer.tsx';
29
+ import { useViewportControllerContext } from './canvas-lib.tsx';
30
+ import { useAnnotationSelection } from './use-annotation-selection.tsx';
31
+
32
+ const RESIZE_CSS = `
33
+ .dc-annot-resize-handle {
34
+ position: fixed;
35
+ width: 8px;
36
+ height: 8px;
37
+ background: var(--accent, #d63b1f);
38
+ border: 1px solid var(--bg-0, #ffffff);
39
+ border-radius: 1px;
40
+ box-shadow: 0 0 0 0.5px color-mix(in oklab, var(--fg-0, #1c1917) 30%, transparent);
41
+ z-index: 6;
42
+ pointer-events: auto;
43
+ touch-action: none;
44
+ }
45
+ .dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize; }
46
+ .dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize; }
47
+ .dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move; }
48
+ `.trim();
49
+
50
+ function ensureResizeStyles(): void {
51
+ if (typeof document === 'undefined') return;
52
+ if (document.getElementById('dc-annot-resize-css')) return;
53
+ const s = document.createElement('style');
54
+ s.id = 'dc-annot-resize-css';
55
+ s.textContent = RESIZE_CSS;
56
+ document.head.appendChild(s);
57
+ }
58
+
59
+ type Corner = 'nw' | 'ne' | 'sw' | 'se' | 'ep1' | 'ep2';
60
+
61
+ /** Stroke types that expose resize handles in v1. Text inherits anchor bbox. */
62
+ function isResizable(s: Stroke): s is RectStroke | EllipseStroke | ArrowStroke | PenStroke {
63
+ return s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'arrow' || s.tool === 'pen';
64
+ }
65
+
66
+ /**
67
+ * Per-tool resize math. Given a stroke + the moved corner + the new world
68
+ * coords for that corner, returns a patched stroke. `start` is the stroke at
69
+ * the moment the drag began (used as the source-of-truth for scaling math —
70
+ * avoids drift from rounding successive deltas).
71
+ */
72
+ function resizeStroke(
73
+ start: Stroke,
74
+ corner: Corner,
75
+ wx: number,
76
+ wy: number
77
+ ): Partial<Stroke> | null {
78
+ if (start.tool === 'rect') {
79
+ const bbox = { x: start.x, y: start.y, w: start.w, h: start.h };
80
+ const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
81
+ const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
82
+ const top = corner === 'nw' || corner === 'ne' ? wy : bbox.y;
83
+ const bottom = corner === 'sw' || corner === 'se' ? wy : bbox.y + bbox.h;
84
+ return {
85
+ x: Math.min(left, right),
86
+ y: Math.min(top, bottom),
87
+ w: Math.abs(right - left),
88
+ h: Math.abs(bottom - top),
89
+ } as Partial<RectStroke>;
90
+ }
91
+ if (start.tool === 'ellipse') {
92
+ // Treat the four corners as the bbox of the ellipse. Drag any corner →
93
+ // recompute the AABB and derive cx/cy/rx/ry from the diagonal anchor.
94
+ const bbox = {
95
+ x: start.cx - start.rx,
96
+ y: start.cy - start.ry,
97
+ w: start.rx * 2,
98
+ h: start.ry * 2,
99
+ };
100
+ const left = corner === 'nw' || corner === 'sw' ? wx : bbox.x;
101
+ const right = corner === 'ne' || corner === 'se' ? wx : bbox.x + bbox.w;
102
+ const top = corner === 'nw' || corner === 'ne' ? wy : bbox.y;
103
+ const bottom = corner === 'sw' || corner === 'se' ? wy : bbox.y + bbox.h;
104
+ const nx = Math.min(left, right);
105
+ const ny = Math.min(top, bottom);
106
+ const nw = Math.abs(right - left);
107
+ const nh = Math.abs(bottom - top);
108
+ return {
109
+ cx: nx + nw / 2,
110
+ cy: ny + nh / 2,
111
+ rx: Math.max(1, nw / 2),
112
+ ry: Math.max(1, nh / 2),
113
+ } as Partial<EllipseStroke>;
114
+ }
115
+ if (start.tool === 'arrow') {
116
+ if (corner === 'ep1') return { x1: wx, y1: wy } as Partial<ArrowStroke>;
117
+ if (corner === 'ep2') return { x2: wx, y2: wy } as Partial<ArrowStroke>;
118
+ return null;
119
+ }
120
+ if (start.tool === 'pen') {
121
+ // Scale all points by (newW / oldW, newH / oldH) around the opposite
122
+ // corner anchor. When the drag-start bbox has 0 width/height on an axis
123
+ // (single-point pen stroke) we skip that axis to avoid div-by-zero.
124
+ const bb = strokeBBox(start);
125
+ if (!bb) return null;
126
+ const anchorX = corner === 'nw' || corner === 'sw' ? bb.x + bb.w : bb.x;
127
+ const anchorY = corner === 'nw' || corner === 'ne' ? bb.y + bb.h : bb.y;
128
+ const newLeft = corner === 'nw' || corner === 'sw' ? wx : bb.x;
129
+ const newTop = corner === 'nw' || corner === 'ne' ? wy : bb.y;
130
+ const newRight = corner === 'ne' || corner === 'se' ? wx : bb.x + bb.w;
131
+ const newBottom = corner === 'sw' || corner === 'se' ? wy : bb.y + bb.h;
132
+ const sx = bb.w === 0 ? 1 : (newRight - newLeft) / bb.w;
133
+ const sy = bb.h === 0 ? 1 : (newBottom - newTop) / bb.h;
134
+ const scaled = start.points.map(
135
+ ([px, py]) =>
136
+ [anchorX + (px - anchorX) * sx, anchorY + (py - anchorY) * sy] as [number, number]
137
+ );
138
+ return { points: scaled } as Partial<PenStroke>;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Returns the four corners (or two endpoints, for arrow) of the selected
145
+ * stroke in world coordinates. Used by the overlay component to position
146
+ * the screen-space handle divs each rAF tick.
147
+ */
148
+ function handlePositions(s: Stroke): Array<{ corner: Corner; x: number; y: number }> {
149
+ if (s.tool === 'arrow') {
150
+ return [
151
+ { corner: 'ep1', x: s.x1, y: s.y1 },
152
+ { corner: 'ep2', x: s.x2, y: s.y2 },
153
+ ];
154
+ }
155
+ const bb = strokeBBox(s);
156
+ if (!bb) return [];
157
+ return [
158
+ { corner: 'nw', x: bb.x, y: bb.y },
159
+ { corner: 'ne', x: bb.x + bb.w, y: bb.y },
160
+ { corner: 'sw', x: bb.x, y: bb.y + bb.h },
161
+ { corner: 'se', x: bb.x + bb.w, y: bb.y + bb.h },
162
+ ];
163
+ }
164
+
165
+ export function AnnotationResizeOverlay({
166
+ store,
167
+ }: {
168
+ store: StrokesStoreValue | null;
169
+ }): ReactNode {
170
+ ensureResizeStyles();
171
+ const annotSel = useAnnotationSelection();
172
+ const controller = useViewportControllerContext();
173
+ const vp = controller?.viewport ?? null;
174
+ const containerRef = useRef<HTMLDivElement | null>(null);
175
+ const rafRef = useRef<number | null>(null);
176
+ const dragRef = useRef<{
177
+ pointerId: number;
178
+ startStroke: Stroke;
179
+ corner: Corner;
180
+ } | null>(null);
181
+
182
+ const selectedId = annotSel.selectedIds.length === 1 ? (annotSel.selectedIds[0] ?? null) : null;
183
+ const selectedStroke: Stroke | null = useMemo(() => {
184
+ if (!selectedId || !store) return null;
185
+ return store.strokes.find((s) => s.id === selectedId) ?? null;
186
+ }, [selectedId, store]);
187
+
188
+ const screenToWorld = useCallback(
189
+ (cx: number, cy: number): [number, number] => {
190
+ const v = vp ?? { x: 0, y: 0, zoom: 1 };
191
+ const z = v.zoom || 1;
192
+ return [(cx - v.x) / z, (cy - v.y) / z];
193
+ },
194
+ [vp]
195
+ );
196
+
197
+ // rAF loop — repositions handles on every frame while a single resizable
198
+ // stroke is selected. Cheaper than wiring pan/zoom observers because the
199
+ // halo overlays already follow the same pattern.
200
+ useEffect(() => {
201
+ if (!selectedStroke || !isResizable(selectedStroke)) {
202
+ const c = containerRef.current;
203
+ if (c) {
204
+ for (const child of Array.from(c.children)) {
205
+ (child as HTMLElement).style.display = 'none';
206
+ }
207
+ }
208
+ return;
209
+ }
210
+ const tick = () => {
211
+ rafRef.current = null;
212
+ const c = containerRef.current;
213
+ if (!c) return;
214
+ const positions = handlePositions(selectedStroke);
215
+ const v = vp ?? { x: 0, y: 0, zoom: 1 };
216
+ const z = v.zoom || 1;
217
+ // Ensure enough handle children exist (each corner = one absolutely-
218
+ // positioned div). 4 for rect/ellipse/pen; 2 for arrow.
219
+ while (c.children.length < positions.length) {
220
+ const handle = document.createElement('div');
221
+ handle.className = 'dc-annot-resize-handle';
222
+ c.appendChild(handle);
223
+ }
224
+ while (c.children.length > positions.length) {
225
+ c.lastChild && c.removeChild(c.lastChild);
226
+ }
227
+ for (let i = 0; i < positions.length; i++) {
228
+ const pos = positions[i];
229
+ const handle = c.children[i] as HTMLElement | undefined;
230
+ if (!pos || !handle) continue;
231
+ const sx = pos.x * z + v.x;
232
+ const sy = pos.y * z + v.y;
233
+ handle.style.display = 'block';
234
+ handle.style.left = `${Math.round(sx - 4)}px`;
235
+ handle.style.top = `${Math.round(sy - 4)}px`;
236
+ handle.dataset.corner = pos.corner;
237
+ }
238
+ rafRef.current = requestAnimationFrame(tick);
239
+ };
240
+ rafRef.current = requestAnimationFrame(tick);
241
+ return () => {
242
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
243
+ };
244
+ }, [selectedStroke, vp]);
245
+
246
+ // Pointer handling — pointerdown on a handle starts a drag; pointermove
247
+ // patches the stroke via `store.updateStroke`; pointerup commits.
248
+ useEffect(() => {
249
+ const c = containerRef.current;
250
+ if (!c) return;
251
+ const onDown = (e: PointerEvent) => {
252
+ const t = e.target as HTMLElement | null;
253
+ if (!t?.classList.contains('dc-annot-resize-handle')) return;
254
+ if (!selectedStroke || !store) return;
255
+ const corner = t.dataset.corner as Corner | undefined;
256
+ if (!corner) return;
257
+ e.preventDefault();
258
+ e.stopPropagation();
259
+ dragRef.current = { pointerId: e.pointerId, startStroke: selectedStroke, corner };
260
+ try {
261
+ t.setPointerCapture(e.pointerId);
262
+ } catch {
263
+ /* some browsers reject capture on synthetic events */
264
+ }
265
+ };
266
+ const onMove = (e: PointerEvent) => {
267
+ const d = dragRef.current;
268
+ if (!d || e.pointerId !== d.pointerId) return;
269
+ if (!store) return;
270
+ const [wx, wy] = screenToWorld(e.clientX, e.clientY);
271
+ const patch = resizeStroke(d.startStroke, d.corner, wx, wy);
272
+ if (patch) store.updateStroke(d.startStroke.id, patch);
273
+ };
274
+ const onUp = (e: PointerEvent) => {
275
+ const d = dragRef.current;
276
+ if (!d || e.pointerId !== d.pointerId) return;
277
+ dragRef.current = null;
278
+ };
279
+ c.addEventListener('pointerdown', onDown);
280
+ document.addEventListener('pointermove', onMove);
281
+ document.addEventListener('pointerup', onUp);
282
+ document.addEventListener('pointercancel', onUp);
283
+ return () => {
284
+ c.removeEventListener('pointerdown', onDown);
285
+ document.removeEventListener('pointermove', onMove);
286
+ document.removeEventListener('pointerup', onUp);
287
+ document.removeEventListener('pointercancel', onUp);
288
+ };
289
+ }, [selectedStroke, store, screenToWorld]);
290
+
291
+ // Only render when there's exactly one resizable stroke selected. Multi
292
+ // resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
293
+ if (!selectedStroke || !isResizable(selectedStroke)) return null;
294
+ return <div ref={containerRef} aria-hidden="true" />;
295
+ }
@@ -40,6 +40,13 @@ export interface Rect {
40
40
 
41
41
  export type SnapAxis = 'x' | 'y';
42
42
 
43
+ /** DDR-046 — `kind` lets SnapGuideOverlay route grid vs sibling guides to
44
+ * different visual treatments (grid = lighter gray fallback; sibling = full
45
+ * magenta confidence). `delta` is the signed correction the snap applied;
46
+ * the overlay renders a `Δ{Math.round(delta)}` pill mid-span when |delta| > 0
47
+ * and the guide span > 60 px. */
48
+ export type SnapKind = 'grid' | 'sibling';
49
+
43
50
  export interface SnapGuide {
44
51
  /** `"x"` → vertical line (snapping X coord). Line sits at `pos` on X,
45
52
  * spans `from..to` on Y. `"y"` is the dual: horizontal line at `pos` on Y,
@@ -48,6 +55,12 @@ export interface SnapGuide {
48
55
  pos: number;
49
56
  from: number;
50
57
  to: number;
58
+ /** Signed pixel delta the snap corrected (additive: `proposed + delta`).
59
+ * Optional for back-compat with pre-DDR-046 readers. */
60
+ delta?: number;
61
+ /** Whether the winning candidate came from the grid pass or a sibling edge.
62
+ * Optional for back-compat. */
63
+ kind?: SnapKind;
51
64
  }
52
65
 
53
66
  export interface SnapResult {
@@ -76,6 +89,8 @@ interface AxisCandidate {
76
89
  /** Perpendicular extent — `from..to` of the would-be guide. */
77
90
  from: number;
78
91
  to: number;
92
+ /** Whether this candidate came from the grid pass or the sibling pass. */
93
+ kind: SnapKind;
79
94
  }
80
95
 
81
96
  function nearestGridDelta(coord: number, gridSize: number, tolerance: number): number | null {
@@ -108,12 +123,17 @@ function pickClosest(cands: AxisCandidate[]): AxisCandidate | null {
108
123
  function mergeAtPos(axis: SnapAxis, winner: AxisCandidate, cands: AxisCandidate[]): SnapGuide {
109
124
  let from = winner.from;
110
125
  let to = winner.to;
126
+ let worstDelta = winner.delta;
111
127
  for (const c of cands) {
112
128
  if (Math.abs(c.pos - winner.pos) > 0.001) continue;
113
129
  if (c.from < from) from = c.from;
114
130
  if (c.to > to) to = c.to;
131
+ // Distance pill shows the worst-case correction among merged candidates,
132
+ // not the average (per DDR-046 — "render the pixels the user actually
133
+ // had to be corrected by").
134
+ if (Math.abs(c.delta) > Math.abs(worstDelta)) worstDelta = c.delta;
115
135
  }
116
- return { axis, pos: winner.pos, from, to };
136
+ return { axis, pos: winner.pos, from, to, delta: worstDelta, kind: winner.kind };
117
137
  }
118
138
 
119
139
  export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions): SnapResult {
@@ -140,6 +160,7 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
140
160
  pos: propLeft + gridX,
141
161
  from: propTop,
142
162
  to: propBottom,
163
+ kind: 'grid',
143
164
  });
144
165
  }
145
166
  const gridY = nearestGridDelta(propTop, gridSize, tolerance);
@@ -149,6 +170,7 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
149
170
  pos: propTop + gridY,
150
171
  from: propLeft,
151
172
  to: propRight,
173
+ kind: 'grid',
152
174
  });
153
175
  }
154
176
 
@@ -177,6 +199,7 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
177
199
  pos: otherCoord,
178
200
  from: Math.min(propTop, oTop),
179
201
  to: Math.max(propBottom, oBottom),
202
+ kind: 'sibling',
180
203
  });
181
204
  }
182
205
 
@@ -196,6 +219,7 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
196
219
  pos: otherCoord,
197
220
  from: Math.min(propLeft, oLeft),
198
221
  to: Math.max(propRight, oRight),
222
+ kind: 'sibling',
199
223
  });
200
224
  }
201
225
  }
@@ -54,6 +54,13 @@ interface ToolContextValue {
54
54
  tool: Tool;
55
55
  setTool: (t: Tool) => void;
56
56
  tools: readonly ToolDescriptor[];
57
+ /** T19 — sticky-tool double-click lock. When `sticky.locked === true` AND
58
+ * `sticky.tool === tool`, draw tools stay armed after each shape commit
59
+ * (T18 auto-flip is suppressed). Single-click on any other tool clears
60
+ * sticky; Esc clears + flips to Move. */
61
+ sticky: { tool: Tool | null; locked: boolean };
62
+ toggleSticky: (t: Tool) => void;
63
+ clearSticky: () => void;
57
64
  }
58
65
 
59
66
  const ToolContext = createContext<ToolContextValue | null>(null);
@@ -71,7 +78,26 @@ export function ToolProvider({
71
78
  initial?: Tool;
72
79
  }) {
73
80
  const [tool, setToolState] = useState<Tool>(initial);
74
- const setTool = useCallback((t: Tool) => setToolState(t), []);
81
+ const [sticky, setSticky] = useState<{ tool: Tool | null; locked: boolean }>(() => ({
82
+ tool: null,
83
+ locked: false,
84
+ }));
85
+ const setTool = useCallback((t: Tool) => {
86
+ setToolState(t);
87
+ // Single-click on a different tool clears any sticky lock — sticky is
88
+ // a per-tool flag, not global.
89
+ setSticky((prev) => (prev.locked && prev.tool === t ? prev : { tool: null, locked: false }));
90
+ }, []);
91
+ const toggleSticky = useCallback((t: Tool) => {
92
+ setSticky((prev) => {
93
+ if (prev.locked && prev.tool === t) return { tool: null, locked: false };
94
+ return { tool: t, locked: true };
95
+ });
96
+ setToolState(t);
97
+ }, []);
98
+ const clearSticky = useCallback(() => {
99
+ setSticky({ tool: null, locked: false });
100
+ }, []);
75
101
 
76
102
  // Body cursor sync — applied to the canvas iframe's body (this hook runs
77
103
  // inside the iframe context). The viewport-controller still owns the
@@ -88,7 +114,10 @@ export function ToolProvider({
88
114
  };
89
115
  }, [tool, tools]);
90
116
 
91
- const value = useMemo<ToolContextValue>(() => ({ tool, setTool, tools }), [tool, setTool, tools]);
117
+ const value = useMemo<ToolContextValue>(
118
+ () => ({ tool, setTool, tools, sticky, toggleSticky, clearSticky }),
119
+ [tool, setTool, tools, sticky, toggleSticky, clearSticky]
120
+ );
92
121
 
93
122
  return <ToolContext.Provider value={value}>{children}</ToolContext.Provider>;
94
123
  }