@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
@@ -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
+ }
@@ -36,14 +36,17 @@ import {
36
36
  useState,
37
37
  } from 'react';
38
38
 
39
+ import { DRAG_THRESHOLD_PX as INPUT_DRAG_THRESHOLD_PX } from './input-router.tsx';
39
40
  import type { Selection } from './use-selection-set.tsx';
40
41
  import { type Rect, type SnapResult, computeSnap } from './use-snap-guides.tsx';
41
42
 
42
43
  // ─────────────────────────────────────────────────────────────────────────────
43
44
  // Constants
44
45
 
45
- /** Screen-pixel distance the cursor must travel before pending → dragging. */
46
- export const DRAG_THRESHOLD_PX = 4;
46
+ /** Screen-pixel distance the cursor must travel before pending → dragging.
47
+ * Re-export from `input-router` so artboard-drag, marquees, and annotation
48
+ * drag-vs-tap share the same canonical value (T25). */
49
+ export const DRAG_THRESHOLD_PX = INPUT_DRAG_THRESHOLD_PX;
47
50
 
48
51
  /** Default grid + tolerance (world units). Documented in DDR-028. */
49
52
  export const DEFAULT_GRID_SIZE = 40;
@@ -136,7 +139,7 @@ export function dragReducer(state: DragState, ev: DragEvent): DragState {
136
139
  const dxClient = ev.clientX - state.startClientX;
137
140
  const dyClient = ev.clientY - state.startClientY;
138
141
  if (state.kind === 'pending') {
139
- if (Math.abs(dxClient) < DRAG_THRESHOLD_PX && Math.abs(dyClient) < DRAG_THRESHOLD_PX) {
142
+ if (Math.hypot(dxClient, dyClient) < DRAG_THRESHOLD_PX) {
140
143
  return state;
141
144
  }
142
145
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @file use-cursor-modifiers.tsx — T28 (Wave 3)
3
+ * @scope plugins/design/dev-server/use-cursor-modifiers.tsx
4
+ * @purpose Modifier-aware cursor state machine. Tracks Alt / Shift / Meta
5
+ * modifier state globally and reflects it as `data-mod-*`
6
+ * attributes on `.dc-canvas`. CSS rules consume those attrs to
7
+ * flip cursors live:
8
+ *
9
+ * • Alt-held over a `[data-cd-id]` in Move tool → cursor: copy
10
+ * (preview of "Alt-drag duplicates this element").
11
+ * • Shift-held in Move tool over empty body padding → crosshair
12
+ * (preview of "marquee add mode").
13
+ *
14
+ * Per the research finding (Excalidraw #250): cursor changes on
15
+ * HOVER, not on drag-start. We track modifier state at the
16
+ * document level so the cursor updates the moment the user
17
+ * presses/releases without waiting for a pointermove.
18
+ *
19
+ * Resize-handle cursors (nw / ne / ns / ew) live next to the
20
+ * handle DOM in `use-annotation-resize.tsx` — those don't need
21
+ * modifier coupling; they fire on hover via their own CSS rules.
22
+ */
23
+
24
+ import { type RefObject, useEffect } from 'react';
25
+
26
+ const STYLES = `
27
+ /* Alt + cd-id in move tool → copy cursor. The Alt-drag-duplicates affordance
28
+ is partial in v1 (no drag handler yet) — paint the cursor anyway so the
29
+ user discovers the gesture, and a future wave wires the actual duplication. */
30
+ .dc-canvas[data-active-tool="move"][data-mod-alt="true"] [data-cd-id],
31
+ .dc-canvas[data-active-tool="move"][data-mod-alt="true"] [data-dc-screen] {
32
+ cursor: copy !important;
33
+ }
34
+
35
+ /* Shift over empty body padding in move tool → crosshair (marquee add mode
36
+ preview). Body padding = the .dc-artboard-body direct background, not
37
+ user content. */
38
+ .dc-canvas[data-active-tool="move"][data-mod-shift="true"] .dc-artboard-body {
39
+ cursor: crosshair;
40
+ }
41
+ `.trim();
42
+
43
+ function ensureStyles(): void {
44
+ if (typeof document === 'undefined') return;
45
+ if (document.getElementById('dc-cv-cursor-modifiers-css')) return;
46
+ const s = document.createElement('style');
47
+ s.id = 'dc-cv-cursor-modifiers-css';
48
+ s.textContent = STYLES;
49
+ document.head.appendChild(s);
50
+ }
51
+
52
+ export interface ModifierState {
53
+ alt: boolean;
54
+ shift: boolean;
55
+ meta: boolean;
56
+ }
57
+
58
+ /**
59
+ * Reduce a key event into the modifier slice. Pulled out for unit tests —
60
+ * the actual hook is DOM-bound and harder to test directly.
61
+ */
62
+ export function reduceModifiers(
63
+ prev: ModifierState,
64
+ e: { altKey: boolean; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }
65
+ ): ModifierState {
66
+ // Treat Ctrl as Meta on non-mac so the cursor preview stays consistent with
67
+ // the input-router's `metaOrCtrl` gate.
68
+ const meta = e.metaKey || e.ctrlKey;
69
+ if (prev.alt === e.altKey && prev.shift === e.shiftKey && prev.meta === meta) {
70
+ return prev;
71
+ }
72
+ return { alt: e.altKey, shift: e.shiftKey, meta };
73
+ }
74
+
75
+ /**
76
+ * Attach modifier tracking to the canvas host element. Reflects the modifier
77
+ * state as `data-mod-alt` / `data-mod-shift` / `data-mod-meta` so cursor CSS
78
+ * rules can act on combinations without re-rendering React.
79
+ *
80
+ * Cleans up on unmount + on window blur (so leaving the tab with Alt held
81
+ * doesn't strand the cursor in copy mode).
82
+ */
83
+ export function useCursorModifiers(hostRef: RefObject<HTMLElement | null>): void {
84
+ useEffect(() => {
85
+ if (typeof document === 'undefined') return;
86
+ const host = hostRef.current;
87
+ if (!host) return;
88
+ ensureStyles();
89
+
90
+ let state: ModifierState = { alt: false, shift: false, meta: false };
91
+
92
+ const apply = (next: ModifierState) => {
93
+ if (next === state) return;
94
+ state = next;
95
+ host.setAttribute('data-mod-alt', next.alt ? 'true' : 'false');
96
+ host.setAttribute('data-mod-shift', next.shift ? 'true' : 'false');
97
+ host.setAttribute('data-mod-meta', next.meta ? 'true' : 'false');
98
+ };
99
+
100
+ const onKeyChange = (e: KeyboardEvent) => apply(reduceModifiers(state, e));
101
+ const onBlur = () => apply({ alt: false, shift: false, meta: false });
102
+ // pointermove carries the modifier state too — handles the case where
103
+ // the user presses a modifier outside the focused window and then mouses
104
+ // back in (keydown/keyup never fires for the press).
105
+ const onPointer = (e: PointerEvent) => apply(reduceModifiers(state, e));
106
+
107
+ document.addEventListener('keydown', onKeyChange, true);
108
+ document.addEventListener('keyup', onKeyChange, true);
109
+ document.addEventListener('pointermove', onPointer, { passive: true });
110
+ window.addEventListener('blur', onBlur);
111
+
112
+ return () => {
113
+ document.removeEventListener('keydown', onKeyChange, true);
114
+ document.removeEventListener('keyup', onKeyChange, true);
115
+ document.removeEventListener('pointermove', onPointer);
116
+ window.removeEventListener('blur', onBlur);
117
+ host.removeAttribute('data-mod-alt');
118
+ host.removeAttribute('data-mod-shift');
119
+ host.removeAttribute('data-mod-meta');
120
+ };
121
+ }, [hostRef]);
122
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file use-keyboard-discipline.tsx — T29 (Wave 3)
3
+ * @scope plugins/design/dev-server/use-keyboard-discipline.tsx
4
+ * @purpose Keyboard nudge + Cmd+A select-all-in-artboard. Bail when focus
5
+ * is inside an input / textarea / contenteditable so typing
6
+ * into the dev-server's own inputs (file tree filter, etc.)
7
+ * never collides.
8
+ *
9
+ * Scope decision — arrow nudge applies to **artboards** only.
10
+ * User content inside artboards has no live-position channel
11
+ * (canvas surface is HTML; element positions are CSS-derived
12
+ * from the compiled JSX, not directly manipulable). Element
13
+ * nudge would require either an ephemeral CSS-transform overlay
14
+ * (cleaner) or a TSX rewrite channel (heavier). Out of scope
15
+ * for this wave — the artboard channel + dragBus.commitPositions
16
+ * already exists, so we leverage that and document the
17
+ * limitation here.
18
+ *
19
+ * Cmd+D duplicate is also deferred — same architectural reason
20
+ * (no duplicate channel). Esc is owned by the input-router's
21
+ * onEscape callback, so we don't duplicate it here.
22
+ *
23
+ * Distance:
24
+ * • Arrow → 1 world-unit step (DDR-028 world units, not
25
+ * screen px).
26
+ * • Shift+Arrow → 10 world-unit step.
27
+ */
28
+
29
+ import { useEffect } from 'react';
30
+
31
+ import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
32
+ import { isEditableTarget } from './input-router.tsx';
33
+ import { type Selection, useSelectionSet } from './use-selection-set.tsx';
34
+
35
+ const STEP_SMALL = 1;
36
+ const STEP_LARGE = 10;
37
+
38
+ export interface NudgeInput {
39
+ key: string;
40
+ shift: boolean;
41
+ }
42
+
43
+ /**
44
+ * Map a keyboard event into a (dx, dy) delta in world units. Returns null
45
+ * when the key isn't an arrow. Exported for unit tests.
46
+ */
47
+ export function nudgeDelta(input: NudgeInput): { dx: number; dy: number } | null {
48
+ const step = input.shift ? STEP_LARGE : STEP_SMALL;
49
+ switch (input.key) {
50
+ case 'ArrowLeft':
51
+ return { dx: -step, dy: 0 };
52
+ case 'ArrowRight':
53
+ return { dx: step, dy: 0 };
54
+ case 'ArrowUp':
55
+ return { dx: 0, dy: -step };
56
+ case 'ArrowDown':
57
+ return { dx: 0, dy: step };
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function useKeyboardDiscipline(): void {
64
+ const selSet = useSelectionSet();
65
+ const artboardsCtx = useArtboardsContext();
66
+ const dragBus = useDragStateContext();
67
+
68
+ useEffect(() => {
69
+ if (typeof document === 'undefined') return;
70
+
71
+ const onKeyDown = (e: KeyboardEvent) => {
72
+ if (isEditableTarget(e.target)) return;
73
+
74
+ // Cmd+A → select all stamped elements in the active artboard.
75
+ const isMeta = e.metaKey || e.ctrlKey;
76
+ if (isMeta && e.key.toLowerCase() === 'a') {
77
+ if (!artboardsCtx) return;
78
+ const id = artboardsCtx.activeArtboardId;
79
+ if (!id) return;
80
+ const root = document.querySelector(`[data-dc-screen="${id}"] .dc-artboard-body`);
81
+ if (!root) return;
82
+ const stamped = root.querySelectorAll('[data-cd-id]');
83
+ if (stamped.length === 0) return;
84
+ const hits: Selection[] = [];
85
+ for (const el of stamped) {
86
+ const cdId = el.getAttribute('data-cd-id');
87
+ if (!cdId) continue;
88
+ hits.push({
89
+ id: cdId,
90
+ selector: `[data-cd-id="${cdId}"]`,
91
+ artboardId: id,
92
+ tag: el.tagName.toLowerCase(),
93
+ });
94
+ }
95
+ if (hits.length === 0) return;
96
+ e.preventDefault();
97
+ selSet.replace(hits);
98
+ return;
99
+ }
100
+
101
+ // Arrow nudge — artboards only (see file header for the why).
102
+ const delta = nudgeDelta({ key: e.key, shift: e.shiftKey });
103
+ if (!delta) return;
104
+ if (isMeta || e.altKey) return; // modifier combos reserved for future
105
+ if (!artboardsCtx || !dragBus) return;
106
+
107
+ const artboardSelections = selSet.selected.filter((s) => !!s.artboardId && !s.id);
108
+ if (artboardSelections.length === 0) return;
109
+ const ids = new Set(artboardSelections.map((s) => s.artboardId as string));
110
+ const targets = artboardsCtx.artboards.filter((r) => ids.has(r.id));
111
+ if (targets.length === 0) return;
112
+
113
+ e.preventDefault();
114
+ const moved = targets.map((r) => ({
115
+ id: r.id,
116
+ x: Math.round(r.x + delta.dx),
117
+ y: Math.round(r.y + delta.dy),
118
+ }));
119
+ dragBus.commitPositions(moved);
120
+ };
121
+
122
+ document.addEventListener('keydown', onKeyDown, true);
123
+ return () => document.removeEventListener('keydown', onKeyDown, true);
124
+ }, [selSet, artboardsCtx, dragBus]);
125
+ }
@@ -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
  }