@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,98 @@
1
+ /**
2
+ * @file equal-spacing-detector.ts — T27 (Wave 3)
3
+ * @scope plugins/design/dev-server/equal-spacing-detector.ts
4
+ * @purpose Pure detector for Figma "Smart Selection" pink-dot affordance
5
+ * (Rasmus Andersson 2018). Given 3+ rects on a single axis,
6
+ * returns the equal gap + the screen-coord midpoints between
7
+ * adjacent pairs IF all pairwise gaps are within `tolerancePx`
8
+ * of each other. Returns `null` when the rects are not equally
9
+ * distributed.
10
+ *
11
+ * Pure / DOM-free / framework-free — same convention as
12
+ * `computeSnap` in `use-snap-guides.tsx`. The overlay layer
13
+ * consumes the result and paints the pink dots.
14
+ *
15
+ * Coordinate space is the caller's choice — pass screen-space
16
+ * rects for live overlay rendering, or world-space rects for
17
+ * distribute-command verification. The math is uniform either
18
+ * way; only the unit of `tolerancePx` differs.
19
+ */
20
+
21
+ import type { Rect } from './use-snap-guides.tsx';
22
+
23
+ export type SpacingAxis = 'x' | 'y';
24
+
25
+ export interface EqualSpacingResult {
26
+ axis: SpacingAxis;
27
+ /** The gap measured between adjacent rects (post-sort by leading edge).
28
+ * Caller renders this in the distance pill above each pink dot. */
29
+ gapPx: number;
30
+ /** Midpoint coords between consecutive rects. Length = rects.length - 1.
31
+ * Each entry is in caller's coord space; renderers anchor pink dots here. */
32
+ midpoints: Array<{ x: number; y: number }>;
33
+ }
34
+
35
+ interface DetectOptions {
36
+ /** How close gaps must be to count as "equal", in caller's unit (default 1). */
37
+ tolerancePx?: number;
38
+ }
39
+
40
+ /**
41
+ * Detect equal spacing along one axis. Returns `null` when:
42
+ * - fewer than 3 rects (2 rects trivially have "equal" spacing — undefined).
43
+ * - any pairwise gap differs from the median by more than `tolerancePx`.
44
+ * - rects overlap on the spacing axis (gap < 0 anywhere).
45
+ *
46
+ * The midpoint y (for axis='x') or x (for axis='y') is the average of the
47
+ * adjacent rects' center on the perpendicular axis — this places the pink
48
+ * dot vertically centered between the two siblings, which is where the
49
+ * distance pill anchors above.
50
+ */
51
+ export function detectEqualSpacing(
52
+ rects: Rect[],
53
+ axis: SpacingAxis,
54
+ opts: DetectOptions = {}
55
+ ): EqualSpacingResult | null {
56
+ if (rects.length < 3) return null;
57
+ const tol = opts.tolerancePx ?? 1;
58
+
59
+ // Sort by leading edge on the spacing axis.
60
+ const sorted = [...rects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
61
+
62
+ // Compute pairwise gaps + midpoints.
63
+ const gaps: number[] = [];
64
+ const midpoints: Array<{ x: number; y: number }> = [];
65
+ for (let i = 1; i < sorted.length; i++) {
66
+ const prev = sorted[i - 1];
67
+ const cur = sorted[i];
68
+ if (!prev || !cur) return null;
69
+ if (axis === 'x') {
70
+ const gap = cur.x - (prev.x + prev.w);
71
+ if (gap < 0) return null; // overlap; not a distributed set
72
+ gaps.push(gap);
73
+ midpoints.push({
74
+ x: prev.x + prev.w + gap / 2,
75
+ y: (prev.y + prev.h / 2 + (cur.y + cur.h / 2)) / 2,
76
+ });
77
+ } else {
78
+ const gap = cur.y - (prev.y + prev.h);
79
+ if (gap < 0) return null;
80
+ gaps.push(gap);
81
+ midpoints.push({
82
+ x: (prev.x + prev.w / 2 + (cur.x + cur.w / 2)) / 2,
83
+ y: prev.y + prev.h + gap / 2,
84
+ });
85
+ }
86
+ }
87
+
88
+ // All gaps must be within tolerance of each other. Use the median as anchor
89
+ // to be robust against a single outlier — though if any gap is outside the
90
+ // band we return null, so median vs mean is academic here.
91
+ const sortedGaps = [...gaps].sort((a, b) => a - b);
92
+ const median = sortedGaps[Math.floor(sortedGaps.length / 2)] ?? 0;
93
+ for (const g of gaps) {
94
+ if (Math.abs(g - median) > tol) return null;
95
+ }
96
+
97
+ return { axis, gapPx: median, midpoints };
98
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * @file equal-spacing-handles.tsx — T27 (Wave 3)
3
+ * @scope plugins/design/dev-server/equal-spacing-handles.tsx
4
+ * @purpose Figma Smart Selection pink-dot affordance. When 3+ elements are
5
+ * selected AND their bounding rects are equally distributed on an
6
+ * axis, render small pink dots at the midpoints between adjacent
7
+ * pairs, each with a gap-distance pill above. Hover-gated:
8
+ * dots are visible only while the cursor is within the union
9
+ * bbox + 40 px padding — discoverable but not noisy.
10
+ *
11
+ * Drag-to-adjust the gap is a Wave 3.x follow-up; for v1 we
12
+ * paint the affordance only.
13
+ */
14
+
15
+ import { useEffect, useRef, useState } from 'react';
16
+
17
+ import {
18
+ type EqualSpacingResult,
19
+ type SpacingAxis,
20
+ detectEqualSpacing,
21
+ } from './equal-spacing-detector.ts';
22
+ import { useSelectionSet } from './use-selection-set.tsx';
23
+
24
+ const HOVER_PADDING_PX = 40;
25
+ /** Equal-spacing tolerance in screen pixels — distribute leaves 0–1 px
26
+ * rounding error; 2 px is comfortably above noise without flagging
27
+ * intentionally-uneven sets. */
28
+ const EQUAL_TOLERANCE_PX = 2;
29
+
30
+ const STYLES = `
31
+ .dc-cv-eq-spacing-layer {
32
+ position: fixed;
33
+ inset: 0;
34
+ pointer-events: none;
35
+ z-index: 5;
36
+ }
37
+ .dc-cv-eq-dot {
38
+ position: absolute;
39
+ width: 6px;
40
+ height: 6px;
41
+ border-radius: 50%;
42
+ background: #FF24BD;
43
+ transform: translate(-50%, -50%);
44
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85);
45
+ opacity: 0;
46
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
47
+ }
48
+ .dc-cv-eq-pill {
49
+ position: absolute;
50
+ transform: translate(-50%, calc(-100% - 8px));
51
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
52
+ font-size: 10px;
53
+ padding: 2px 5px;
54
+ background: #FF24BD;
55
+ color: #ffffff;
56
+ border-radius: 2px;
57
+ letter-spacing: 0.02em;
58
+ white-space: nowrap;
59
+ opacity: 0;
60
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
61
+ }
62
+ .dc-cv-eq-spacing-layer[data-visible="true"] .dc-cv-eq-dot,
63
+ .dc-cv-eq-spacing-layer[data-visible="true"] .dc-cv-eq-pill {
64
+ opacity: 1;
65
+ }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .dc-cv-eq-dot, .dc-cv-eq-pill { transition-duration: 1ms; }
68
+ }
69
+ `.trim();
70
+
71
+ function ensureStyles(): void {
72
+ if (typeof document === 'undefined') return;
73
+ if (document.getElementById('dc-cv-eq-spacing-css')) return;
74
+ const s = document.createElement('style');
75
+ s.id = 'dc-cv-eq-spacing-css';
76
+ s.textContent = STYLES;
77
+ document.head.appendChild(s);
78
+ }
79
+
80
+ function cssEscape(s: string): string {
81
+ // CSS.escape is available everywhere we care; manual fallback handles the
82
+ // jsdom test path where it may be missing.
83
+ // biome-ignore lint/suspicious/noExplicitAny: feature detect
84
+ const ce = (typeof CSS !== 'undefined' ? (CSS as any).escape : null) as
85
+ | ((v: string) => string)
86
+ | null;
87
+ if (ce) return ce(s);
88
+ return s.replace(/(["\\\]])/g, '\\$1');
89
+ }
90
+
91
+ function safeQuery(selector: string): Element | null {
92
+ try {
93
+ return document.querySelector(selector);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ interface ScreenRect {
100
+ x: number;
101
+ y: number;
102
+ w: number;
103
+ h: number;
104
+ }
105
+
106
+ function resolveSelectionRects(sels: Array<{ id?: string; selector: string }>): ScreenRect[] {
107
+ const rects: ScreenRect[] = [];
108
+ for (const s of sels) {
109
+ const el = s.id
110
+ ? document.querySelector(`[data-cd-id="${cssEscape(s.id)}"]`)
111
+ : safeQuery(s.selector);
112
+ if (!el) continue;
113
+ const b = (el as HTMLElement).getBoundingClientRect();
114
+ if (b.width === 0 && b.height === 0) continue;
115
+ rects.push({ x: b.left, y: b.top, w: b.width, h: b.height });
116
+ }
117
+ return rects;
118
+ }
119
+
120
+ function unionBbox(rects: ScreenRect[]): ScreenRect | null {
121
+ if (rects.length === 0) return null;
122
+ let left = Number.POSITIVE_INFINITY;
123
+ let top = Number.POSITIVE_INFINITY;
124
+ let right = Number.NEGATIVE_INFINITY;
125
+ let bottom = Number.NEGATIVE_INFINITY;
126
+ for (const r of rects) {
127
+ if (r.x < left) left = r.x;
128
+ if (r.y < top) top = r.y;
129
+ if (r.x + r.w > right) right = r.x + r.w;
130
+ if (r.y + r.h > bottom) bottom = r.y + r.h;
131
+ }
132
+ return { x: left, y: top, w: right - left, h: bottom - top };
133
+ }
134
+
135
+ function pointInPaddedBox(px: number, py: number, b: ScreenRect, pad: number): boolean {
136
+ return px >= b.x - pad && px <= b.x + b.w + pad && py >= b.y - pad && py <= b.y + b.h + pad;
137
+ }
138
+
139
+ export function EqualSpacingHandles() {
140
+ ensureStyles();
141
+ const { selected } = useSelectionSet();
142
+ const layerRef = useRef<HTMLDivElement | null>(null);
143
+ const rafRef = useRef<number | null>(null);
144
+ const [hovered, setHovered] = useState(false);
145
+
146
+ // Track cursor position to gate dot visibility. We hide handles whenever
147
+ // the cursor leaves the (union bbox + padding) zone — discoverable on
148
+ // approach, invisible while the user is doing unrelated work elsewhere.
149
+ useEffect(() => {
150
+ if (typeof document === 'undefined') return;
151
+ const onMove = (e: PointerEvent) => {
152
+ const layer = layerRef.current;
153
+ if (!layer) return;
154
+ const dotsZone = layer.getBoundingClientRect();
155
+ // Layer is inset:0 in screen-fixed coords. We compare against the
156
+ // currently painted union bbox stored on a data attr each rAF tick.
157
+ const ubStr = layer.getAttribute('data-union-bbox');
158
+ if (!ubStr) {
159
+ setHovered(false);
160
+ return;
161
+ }
162
+ // Avoid TS-touchy split — manual parse.
163
+ const parts = ubStr.split(',');
164
+ const x = Number(parts[0]);
165
+ const y = Number(parts[1]);
166
+ const w = Number(parts[2]);
167
+ const h = Number(parts[3]);
168
+ if (Number.isNaN(x) || Number.isNaN(y)) return;
169
+ const inZone = pointInPaddedBox(e.clientX, e.clientY, { x, y, w, h }, HOVER_PADDING_PX);
170
+ if (inZone !== hovered) setHovered(inZone);
171
+ // Touch `dotsZone` to keep the ref-read in the dependency graph.
172
+ void dotsZone;
173
+ };
174
+ document.addEventListener('pointermove', onMove, { passive: true });
175
+ return () => document.removeEventListener('pointermove', onMove);
176
+ }, [hovered]);
177
+
178
+ // Resolve current selection → DOM rects → equal-spacing detector → paint.
179
+ // rAF loop so the affordance follows zoom + pan smoothly without React
180
+ // re-renders per frame.
181
+ useEffect(() => {
182
+ if (selected.length < 3) {
183
+ // Clear layer when selection drops below the affordance threshold.
184
+ const layer = layerRef.current;
185
+ if (layer) {
186
+ while (layer.firstChild) layer.removeChild(layer.firstChild);
187
+ layer.removeAttribute('data-union-bbox');
188
+ }
189
+ return;
190
+ }
191
+ let alive = true;
192
+
193
+ const tick = () => {
194
+ if (!alive) return;
195
+ rafRef.current = null;
196
+ const layer = layerRef.current;
197
+ if (!layer) return;
198
+ const rects = resolveSelectionRects(selected);
199
+ if (rects.length < 3) {
200
+ while (layer.firstChild) layer.removeChild(layer.firstChild);
201
+ layer.removeAttribute('data-union-bbox');
202
+ return;
203
+ }
204
+ // Detect on both axes; prefer the axis with the smaller relative spread
205
+ // (the "more confidently distributed" axis). Ties = horizontal wins.
206
+ const detX = detectEqualSpacing(rects, 'x', { tolerancePx: EQUAL_TOLERANCE_PX });
207
+ const detY = detectEqualSpacing(rects, 'y', { tolerancePx: EQUAL_TOLERANCE_PX });
208
+ const result = pickDetection(detX, detY);
209
+ if (!result) {
210
+ while (layer.firstChild) layer.removeChild(layer.firstChild);
211
+ layer.removeAttribute('data-union-bbox');
212
+ return;
213
+ }
214
+
215
+ const ub = unionBbox(rects);
216
+ if (ub) layer.setAttribute('data-union-bbox', `${ub.x},${ub.y},${ub.w},${ub.h}`);
217
+
218
+ paintHandles(layer, result);
219
+ };
220
+
221
+ const onFrame = () => {
222
+ if (rafRef.current != null) return;
223
+ rafRef.current = requestAnimationFrame(tick);
224
+ };
225
+
226
+ // Initial paint + continuous follow on pan/zoom/scroll.
227
+ onFrame();
228
+ const recheck = () => onFrame();
229
+ window.addEventListener('scroll', recheck, { passive: true, capture: true });
230
+ window.addEventListener('resize', recheck, { passive: true });
231
+ const observer = new MutationObserver(() => onFrame());
232
+ observer.observe(document.body, { attributes: true, subtree: true, childList: true });
233
+
234
+ return () => {
235
+ alive = false;
236
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
237
+ window.removeEventListener('scroll', recheck, { capture: true } as EventListenerOptions);
238
+ window.removeEventListener('resize', recheck);
239
+ observer.disconnect();
240
+ };
241
+ }, [selected]);
242
+
243
+ return (
244
+ <div
245
+ ref={layerRef}
246
+ className="dc-cv-eq-spacing-layer"
247
+ data-visible={hovered ? 'true' : 'false'}
248
+ aria-hidden="true"
249
+ />
250
+ );
251
+ }
252
+
253
+ function pickDetection(
254
+ detX: EqualSpacingResult | null,
255
+ detY: EqualSpacingResult | null
256
+ ): EqualSpacingResult | null {
257
+ if (detX && detY) return detX.midpoints.length >= detY.midpoints.length ? detX : detY;
258
+ return detX ?? detY;
259
+ }
260
+
261
+ function paintHandles(layer: HTMLDivElement, result: EqualSpacingResult): void {
262
+ // Match child count to 2× midpoints (each midpoint = one dot + one pill).
263
+ const want = result.midpoints.length * 2;
264
+ while (layer.children.length < want) {
265
+ const isDot = layer.children.length % 2 === 0;
266
+ const node = document.createElement('div');
267
+ node.className = isDot ? 'dc-cv-eq-dot' : 'dc-cv-eq-pill';
268
+ if (!isDot) node.textContent = '';
269
+ layer.appendChild(node);
270
+ }
271
+ while (layer.children.length > want) {
272
+ layer.removeChild(layer.lastChild as Node);
273
+ }
274
+ const label = `${Math.round(result.gapPx)}`;
275
+ for (let i = 0; i < result.midpoints.length; i++) {
276
+ const m = result.midpoints[i];
277
+ if (!m) continue;
278
+ const dot = layer.children[i * 2] as HTMLDivElement;
279
+ const pill = layer.children[i * 2 + 1] as HTMLDivElement;
280
+ dot.style.left = `${m.x}px`;
281
+ dot.style.top = `${m.y}px`;
282
+ pill.style.left = `${m.x}px`;
283
+ pill.style.top = `${m.y}px`;
284
+ pill.textContent = label;
285
+ }
286
+ }
287
+
288
+ // Re-export the picker for tests.
289
+ export { pickDetection };
@@ -115,9 +115,9 @@ const DIALOG_CSS = `
115
115
  .dc-export-dialog {
116
116
  border: 1px solid var(--u-fg-0, #1c1917);
117
117
  padding: 0;
118
- border-radius: 0;
119
- background: var(--u-bg-2, var(--bg-1, #fff));
120
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
118
+ border-radius: 8px;
119
+ background: var(--u-bg-0, var(--bg-0, #fff));
120
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
121
121
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
122
122
  color: var(--u-fg-0, var(--fg-0, #1a1a1a));
123
123
  width: min(640px, 100vw - 48px);
@@ -554,6 +554,16 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
554
554
  // The trick: replace `DesignCanvas`, `DCArtboard`, `DCSection` in the
555
555
  // libMap with their static-frame variants before BFS. The static variants
556
556
  // have empty deps, so the transitive walk never reaches the engine code.
557
+ //
558
+ // Phase 3.7 / DDR-049 — motion helpers (MotionDemo, MotionTrack,
559
+ // TokenPlayback, ReducedMotionToggle, useMotionTokens, easingFromToken)
560
+ // depend on aliased imports from motion/react (_motionImpl,
561
+ // _useReducedMotion, _MotionAnimatePresence). When any of those land in the
562
+ // inlined output, splice the matching motion/react import line at the file
563
+ // head AND force-add "motion" to the registry-item's dependencies. The
564
+ // consumer's npm install + Next.js bundler resolves motion → bunx shadcn
565
+ // add lands an animated component with zero manual wiring.
566
+ let motionUsed = false;
557
567
  if (opts.designRoot) {
558
568
  const libPath = canvasLibPath(opts.designRoot);
559
569
  const libFile = Bun.file(libPath);
@@ -563,6 +573,19 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
563
573
  applyHandoffStaticOverrides(libMap);
564
574
  const inlined = inlineUsedExports(tsx, libMap);
565
575
  tsx = inlined.content;
576
+ // Detect motion-helper usage from the inlined surface (the body refs
577
+ // _motionImpl / _useReducedMotion / _MotionAnimatePresence). We probe
578
+ // the post-inline source so we don't false-positive on a canvas that
579
+ // imports a non-motion helper sharing a name prefix.
580
+ motionUsed =
581
+ /\b_motionImpl\b/.test(tsx) ||
582
+ /\b_useReducedMotion\b/.test(tsx) ||
583
+ /\b_MotionAnimatePresence\b/.test(tsx);
584
+ if (motionUsed) {
585
+ const motionImport =
586
+ "import { motion as _motionImpl, useReducedMotion as _useReducedMotion, AnimatePresence as _MotionAnimatePresence } from 'motion/react';\n";
587
+ tsx = `${motionImport}${tsx}`;
588
+ }
566
589
  }
567
590
  }
568
591
 
@@ -578,6 +601,7 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
578
601
  const depSet = new Set(depsFiltered);
579
602
  depSet.add('react');
580
603
  depSet.add('react-dom');
604
+ if (motionUsed) depSet.add('motion');
581
605
  const finalDeps = [...depSet].sort();
582
606
 
583
607
  // Compute slug for `name` field — kebab-case of the file stem.
@@ -192,6 +192,33 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
192
192
  }
193
193
  void libWatcher;
194
194
 
195
+ // G7v2 — canvas-lib.tsx transitively imports many dev-server siblings
196
+ // (canvas-shell, contextual-toolbar, equal-spacing-handles, ...). Editing
197
+ // any of them invalidates the bundled canvas output. Without watching them
198
+ // the mtime-keyed `canvasCache` keeps serving the stale bundle and the
199
+ // user sees pre-edit behaviour even after a hard iframe reload.
200
+ //
201
+ // Recursive watch over DEV_SERVER_ROOT, filtered to .tsx — server-only .ts
202
+ // (api / http / context / etc.) doesn't reach the canvas. Test files
203
+ // (`test/`) and built output (`dist/`, `client/`) also skipped.
204
+ let devSrcWatcher: ReturnType<typeof watch> | null = null;
205
+ try {
206
+ devSrcWatcher = watch(DEV_SERVER_ROOT, { recursive: true }, (_evt, filename) => {
207
+ if (!filename) return;
208
+ if (!filename.endsWith('.tsx')) return;
209
+ if (filename.startsWith('test/') || filename.startsWith('test\\')) return;
210
+ if (filename.startsWith('dist/') || filename.startsWith('client/')) return;
211
+ canvasCache.clear();
212
+ ctx.bus.emit('fs:any', `_lib/${filename}`);
213
+ });
214
+ } catch (err) {
215
+ console.warn(
216
+ '[dev-server-src] failed to watch source tree:',
217
+ err instanceof Error ? err.message : err
218
+ );
219
+ }
220
+ void devSrcWatcher;
221
+
195
222
  async function readJson<T = unknown>(req: Request, max = 256 * 1024): Promise<T | null> {
196
223
  try {
197
224
  const text = await req.text();
@@ -218,8 +245,20 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
218
245
  '/_index-data': async () =>
219
246
  Response.json(await api.buildIndexData(), { headers: { 'Cache-Control': 'no-store' } }),
220
247
 
221
- '/_system-data': async () =>
222
- Response.json(await api.buildSystemData(), { headers: { 'Cache-Control': 'no-store' } }),
248
+ '/_system-data': async (req: Request) => {
249
+ // DDR-048 `?ds=<name>` scopes to one design system (per-DS tokens,
250
+ // per-DS preview gallery). Omitted = legacy top-level scan for
251
+ // backwards compat with single-DS projects.
252
+ const dsName = new URL(req.url).searchParams.get('ds');
253
+ const data = await api.buildSystemData(dsName);
254
+ if (data === null) {
255
+ return Response.json(
256
+ { error: 'unknown design system', ds: dsName },
257
+ { status: 404, headers: { 'Cache-Control': 'no-store' } }
258
+ );
259
+ }
260
+ return Response.json(data, { headers: { 'Cache-Control': 'no-store' } });
261
+ },
223
262
 
224
263
  '/_comments-all': async () =>
225
264
  Response.json(await api.loadAllComments(), { headers: { 'Cache-Control': 'no-store' } }),
@@ -29,6 +29,30 @@
29
29
 
30
30
  import { type RefObject, useEffect } from 'react';
31
31
 
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Drag-vs-click threshold (T25)
34
+ //
35
+ // 4 px screen-pixel hypot separates "click" from "drag" — Microsoft Win32
36
+ // canonical (`SM_CXDRAG`/`SM_CYDRAG` default), also d3-drag and tldraw default.
37
+ // Owned here so artboard-drag, artboard-marquee, element-marquee, annotation-
38
+ // drag-vs-tap, and any future drag-class gesture all read the same constant.
39
+ // Wheel + pinch-zoom are EXEMPT — threshold is for `pointerdown → pointermove`
40
+ // drag classification only.
41
+
42
+ export const DRAG_THRESHOLD_PX = 4;
43
+
44
+ /** True once the pointer has moved ≥ DRAG_THRESHOLD_PX from its start. */
45
+ export function crossedDragThreshold(
46
+ startX: number,
47
+ startY: number,
48
+ curX: number,
49
+ curY: number
50
+ ): boolean {
51
+ const dx = curX - startX;
52
+ const dy = curY - startY;
53
+ return Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX;
54
+ }
55
+
32
56
  // ─────────────────────────────────────────────────────────────────────────────
33
57
  // Types
34
58
 
@@ -68,7 +92,9 @@ export type RouterAction =
68
92
  | { kind: 'drop-comment'; clientX: number; clientY: number }
69
93
  | { kind: 'context-menu'; clientX: number; clientY: number }
70
94
  | { kind: 'tool'; tool: Tool }
71
- | { kind: 'escape' };
95
+ | { kind: 'escape' }
96
+ | { kind: 'undo' }
97
+ | { kind: 'redo' };
72
98
 
73
99
  export interface ClassifyInput {
74
100
  type: 'pointermove' | 'pointerdown' | 'contextmenu' | 'keydown';
@@ -101,6 +127,15 @@ export function classify(input: ClassifyInput): RouterAction {
101
127
  if (input.metaKey || input.ctrlKey || input.altKey) {
102
128
  // Esc with modifiers still dismisses.
103
129
  if (input.key === 'Escape') return { kind: 'escape' };
130
+ // Undo / redo (Phase 20). Alt is reserved — Cmd+Opt+Z is a browser
131
+ // text-input gesture we don't claim. `metaKey || ctrlKey` covers both
132
+ // mac and Windows / Linux without a platform sniff.
133
+ const k = (input.key || '').toLowerCase();
134
+ if (!input.altKey && (input.metaKey || input.ctrlKey)) {
135
+ if (k === 'z' && input.shiftKey) return { kind: 'redo' };
136
+ if (k === 'z') return { kind: 'undo' };
137
+ if (k === 'y' && !input.shiftKey) return { kind: 'redo' };
138
+ }
104
139
  return { kind: 'no-op' };
105
140
  }
106
141
  const k = (input.key || '').toLowerCase();
@@ -218,6 +253,10 @@ export interface RouterCallbacks {
218
253
  onContextMenu?: (a: Extract<RouterAction, { kind: 'context-menu' }>) => void;
219
254
  onTool?: (a: Extract<RouterAction, { kind: 'tool' }>) => void;
220
255
  onEscape?: () => void;
256
+ /** Phase 20 — Cmd+Z / Ctrl+Z. */
257
+ onUndo?: () => void;
258
+ /** Phase 20 — Cmd+Shift+Z / Ctrl+Y / Cmd+Y. */
259
+ onRedo?: () => void;
221
260
  }
222
261
 
223
262
  export interface UseInputRouterOptions {
@@ -283,6 +322,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
283
322
  case 'escape':
284
323
  callbacks.onEscape?.();
285
324
  break;
325
+ case 'undo':
326
+ callbacks.onUndo?.();
327
+ break;
328
+ case 'redo':
329
+ callbacks.onRedo?.();
330
+ break;
286
331
  case 'no-op':
287
332
  break;
288
333
  }
@@ -404,7 +449,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
404
449
  isEditable: isEditableTarget(e.target),
405
450
  activeTool: getActiveTool(),
406
451
  });
407
- if (action.kind === 'tool' || action.kind === 'escape') {
452
+ if (
453
+ action.kind === 'tool' ||
454
+ action.kind === 'escape' ||
455
+ action.kind === 'undo' ||
456
+ action.kind === 'redo'
457
+ ) {
408
458
  e.preventDefault();
409
459
  }
410
460
  dispatch(action);
@@ -469,16 +519,30 @@ export function resolveHoverTarget(
469
519
  const artboardEl = hit.closest?.('[data-dc-screen]') ?? null;
470
520
  const artboardId = artboardEl?.getAttribute('data-dc-screen') ?? null;
471
521
 
472
- // Hover-target hard ceiling = `.dc-artboard-body`. The artboard chrome
473
- // (article root + label button + body wrapper) is never a selection
474
- // candidate — those carry `data-cd-id` from the pipeline pass but visually
475
- // selecting them makes the WHOLE artboard outline, which looks like the
476
- // canvas viewport is selected. If the user landed on chrome (the label
477
- // button or empty body padding), bail.
522
+ // Hover-target hard ceiling = `.dc-artboard-body`. Inner DOM content lives
523
+ // there; chrome lives outside (label, header, article root). The two paths
524
+ // diverge from here:
525
+ // * hit body resolve to the deepest stamped element (existing
526
+ // deep/top logic below).
527
+ // * hit chrome (label/header/article-root) → the user wants to select
528
+ // the WHOLE artboard. Return the article element itself with no cdId;
529
+ // consumers (hoverTargetToSelection) fall back to a
530
+ // `[data-dc-screen="…"]` selector that wraps the whole frame. This is
531
+ // what enables Cmd+Shift+Click multi-select of artboards (T24 / G8).
478
532
  const bodyEl = hit.closest?.('.dc-artboard-body') ?? null;
479
- if (!bodyEl) return null;
533
+ if (!bodyEl) {
534
+ if (artboardEl && artboardId) {
535
+ return { el: artboardEl, cdId: null, artboardId };
536
+ }
537
+ return null;
538
+ }
480
539
  if (hit === bodyEl) {
481
- // Clicked exactly on the body wrapper, no inner element under cursor.
540
+ // Clicked the body wrapper itself (empty padding inside an artboard, no
541
+ // user content under the cursor). Promote to "select whole artboard" so
542
+ // the gesture stays consistent with chrome clicks above.
543
+ if (artboardEl && artboardId) {
544
+ return { el: artboardEl, cdId: null, artboardId };
545
+ }
482
546
  return null;
483
547
  }
484
548