@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
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @file marquee-overlay.tsx — T26 (Wave 3)
3
+ * @scope plugins/design/dev-server/marquee-overlay.tsx
4
+ * @purpose Element-level marquee selection. Drag from empty padding inside
5
+ * an artboard body (NOT on top of a `[data-cd-id]` element) to
6
+ * lasso multiple stamped elements. Aseprite modifier vocabulary:
7
+ * bare = replace, Shift = add, Alt = subtract, Shift+Alt =
8
+ * intersect. Modifier state re-read at pointerup so the user can
9
+ * flip mid-drag.
10
+ *
11
+ * Co-exists with `artboard-marquee.tsx` — that overlay triggers
12
+ * when pointerdown lands OUTSIDE any artboard (empty world);
13
+ * this overlay triggers when pointerdown lands INSIDE an artboard
14
+ * body on padding (no cd-id under cursor). The two are mutually
15
+ * exclusive by start position.
16
+ *
17
+ * Renders the marquee rect per DDR-046 rev 2:
18
+ * 1 px solid accent + 8 % accent fill
19
+ * (same idiom as artboard marquee — active gesture is solid +
20
+ * tinted; dashed reserved for persistent group bbox.)
21
+ */
22
+
23
+ import { useEffect, useRef } from 'react';
24
+
25
+ import { DRAG_THRESHOLD_PX } from './input-router.tsx';
26
+ import { type Selection, useSelectionSet } from './use-selection-set.tsx';
27
+ import { useToolMode } from './use-tool-mode.tsx';
28
+
29
+ const ELEM_MARQUEE_CSS = `
30
+ .dc-cv-elem-marquee {
31
+ position: fixed;
32
+ pointer-events: none;
33
+ z-index: 5;
34
+ border: 1px solid var(--accent, #0d99ff);
35
+ background: color-mix(in oklab, var(--accent, #0d99ff) 8%, transparent);
36
+ display: none;
37
+ }
38
+ `.trim();
39
+
40
+ function ensureMarqueeStyles(): void {
41
+ if (typeof document === 'undefined') return;
42
+ if (document.getElementById('dc-cv-elem-marquee-css')) return;
43
+ const s = document.createElement('style');
44
+ s.id = 'dc-cv-elem-marquee-css';
45
+ s.textContent = ELEM_MARQUEE_CSS;
46
+ document.head.appendChild(s);
47
+ }
48
+
49
+ // Skip the same chrome / overlay surfaces as artboard-marquee — the user is
50
+ // interacting with floating UI, not the world.
51
+ function isChromeTarget(t: EventTarget | null): boolean {
52
+ if (!t || !(t as Element).closest) return true;
53
+ const el = t as Element;
54
+ if (
55
+ el.closest(
56
+ '.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'
57
+ )
58
+ ) {
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Walk from `hit` upward and decide if the pointerdown landed on "empty body
66
+ * padding" (no `[data-cd-id]` encountered before reaching `.dc-artboard-body`).
67
+ * Returns the body element when yes; null when the pointerdown should not
68
+ * start an element marquee.
69
+ */
70
+ function resolveBodyOrNull(hit: Element): Element | null {
71
+ let cur: Element | null = hit;
72
+ while (cur) {
73
+ if (cur.hasAttribute?.('data-cd-id')) return null; // on user content
74
+ if ((cur as HTMLElement).classList?.contains('dc-artboard-body')) return cur;
75
+ cur = cur.parentElement;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Mode resolved from the current modifier state. Re-read at pointerup so a
82
+ * user can flip mid-drag — e.g. start without modifiers, press Shift before
83
+ * releasing, the gesture commits as `add` instead of `replace`.
84
+ */
85
+ type Mode = 'replace' | 'add' | 'subtract' | 'intersect';
86
+
87
+ function modeOf(e: { shiftKey: boolean; altKey: boolean }): Mode {
88
+ if (e.shiftKey && e.altKey) return 'intersect';
89
+ if (e.shiftKey) return 'add';
90
+ if (e.altKey) return 'subtract';
91
+ return 'replace';
92
+ }
93
+
94
+ /**
95
+ * Build a minimal `Selection` from a `[data-cd-id]` element — just the fields
96
+ * downstream consumers actually read for bulk marquee hits. The rich shape
97
+ * (html, dom_path, text) is only enriched when a single click sets focus
98
+ * through `hoverTargetToSelection`.
99
+ */
100
+ function elementToSelection(el: Element, bodyAncestor: Element): Selection | null {
101
+ const cdId = el.getAttribute('data-cd-id');
102
+ if (!cdId) return null;
103
+ const artboardEl = bodyAncestor.closest('[data-dc-screen]');
104
+ const artboardId = artboardEl?.getAttribute('data-dc-screen') ?? null;
105
+ const rect = (el as HTMLElement).getBoundingClientRect();
106
+ return {
107
+ id: cdId,
108
+ selector: `[data-cd-id="${cdId}"]`,
109
+ artboardId,
110
+ tag: el.tagName.toLowerCase(),
111
+ classes: (el.getAttribute('class') ?? '')
112
+ .trim()
113
+ .split(/\s+/)
114
+ .filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
115
+ .join(' '),
116
+ bounds: {
117
+ x: Math.round(rect.left),
118
+ y: Math.round(rect.top),
119
+ w: Math.round(rect.width),
120
+ h: Math.round(rect.height),
121
+ },
122
+ };
123
+ }
124
+
125
+ function rectsIntersect(
126
+ a: { left: number; top: number; right: number; bottom: number },
127
+ b: { left: number; top: number; right: number; bottom: number }
128
+ ): boolean {
129
+ if (a.right < b.left || a.left > b.right) return false;
130
+ if (a.bottom < b.top || a.top > b.bottom) return false;
131
+ return true;
132
+ }
133
+
134
+ export function ElementMarqueeOverlay() {
135
+ ensureMarqueeStyles();
136
+ const selSet = useSelectionSet();
137
+ const { tool } = useToolMode();
138
+ const overlayRef = useRef<HTMLDivElement | null>(null);
139
+ const stateRef = useRef<{
140
+ pointerId: number;
141
+ startX: number;
142
+ startY: number;
143
+ crossed: boolean;
144
+ bodyEl: Element;
145
+ /** Snapshot of `selSet.selected` at gesture start — needed for subtract /
146
+ * intersect / add to compute deltas against the original set. */
147
+ startSet: Selection[];
148
+ } | null>(null);
149
+
150
+ useEffect(() => {
151
+ if (tool !== 'move') return;
152
+ if (typeof document === 'undefined') return;
153
+
154
+ const onDown = (e: PointerEvent) => {
155
+ if (e.button !== 0) return;
156
+ // Cmd / Ctrl reserved for single-element select (Cmd-click). Never start
157
+ // an element marquee when those are held — that gesture means "select
158
+ // the single element under cursor."
159
+ if (e.metaKey || e.ctrlKey) return;
160
+ const target = e.target as Element | null;
161
+ if (!target || isChromeTarget(target)) return;
162
+ const bodyEl = resolveBodyOrNull(target);
163
+ if (!bodyEl) return; // either over user content or outside any artboard
164
+ stateRef.current = {
165
+ pointerId: e.pointerId,
166
+ startX: e.clientX,
167
+ startY: e.clientY,
168
+ crossed: false,
169
+ bodyEl,
170
+ startSet: selSet.selected.slice(),
171
+ };
172
+ };
173
+
174
+ const onMove = (e: PointerEvent) => {
175
+ const s = stateRef.current;
176
+ if (!s || e.pointerId !== s.pointerId) return;
177
+ const dx = e.clientX - s.startX;
178
+ const dy = e.clientY - s.startY;
179
+ if (!s.crossed) {
180
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
181
+ s.crossed = true;
182
+ }
183
+ const div = overlayRef.current;
184
+ if (!div) return;
185
+ const left = Math.min(s.startX, e.clientX);
186
+ const top = Math.min(s.startY, e.clientY);
187
+ div.style.display = 'block';
188
+ div.style.left = `${left}px`;
189
+ div.style.top = `${top}px`;
190
+ div.style.width = `${Math.abs(dx)}px`;
191
+ div.style.height = `${Math.abs(dy)}px`;
192
+ };
193
+
194
+ const finish = (e: PointerEvent) => {
195
+ const s = stateRef.current;
196
+ if (!s || e.pointerId !== s.pointerId) return;
197
+ stateRef.current = null;
198
+ const div = overlayRef.current;
199
+ if (div) div.style.display = 'none';
200
+ if (!s.crossed) return;
201
+
202
+ const left = Math.min(s.startX, e.clientX);
203
+ const top = Math.min(s.startY, e.clientY);
204
+ const right = Math.max(s.startX, e.clientX);
205
+ const bottom = Math.max(s.startY, e.clientY);
206
+ const marqueeBox = { left, top, right, bottom };
207
+
208
+ // Intersect against every stamped element inside the originating
209
+ // artboard body. Scoping to the start body is intentional — drags
210
+ // that extend across artboards still only capture from the artboard
211
+ // the gesture started in. Cross-artboard marquee is an unimplemented
212
+ // affordance (no peer tool ships it; users rely on multi-artboard
213
+ // distribute + per-artboard marquees).
214
+ const stamped = s.bodyEl.querySelectorAll('[data-cd-id]');
215
+ const hits: Selection[] = [];
216
+ for (const el of stamped) {
217
+ const b = (el as HTMLElement).getBoundingClientRect();
218
+ if (b.width === 0 && b.height === 0) continue;
219
+ if (!rectsIntersect(marqueeBox, b)) continue;
220
+ const sel = elementToSelection(el, s.bodyEl);
221
+ if (sel) hits.push(sel);
222
+ }
223
+
224
+ const mode = modeOf(e);
225
+ applyMarqueeMode(selSet, mode, s.startSet, hits);
226
+ };
227
+
228
+ document.addEventListener('pointerdown', onDown, true);
229
+ document.addEventListener('pointermove', onMove);
230
+ document.addEventListener('pointerup', finish);
231
+ document.addEventListener('pointercancel', finish);
232
+ return () => {
233
+ document.removeEventListener('pointerdown', onDown, true);
234
+ document.removeEventListener('pointermove', onMove);
235
+ document.removeEventListener('pointerup', finish);
236
+ document.removeEventListener('pointercancel', finish);
237
+ };
238
+ }, [tool, selSet]);
239
+
240
+ return <div ref={overlayRef} className="dc-cv-elem-marquee" aria-hidden="true" />;
241
+ }
242
+
243
+ /**
244
+ * Apply marquee hits to `selSet` per Aseprite modifier semantics. Pulled out
245
+ * of the hook for unit-test reachability.
246
+ */
247
+ export function applyMarqueeMode(
248
+ selSet: {
249
+ replace: (s: Selection | Selection[]) => void;
250
+ add: (s: Selection | Selection[]) => void;
251
+ },
252
+ mode: Mode,
253
+ startSet: Selection[],
254
+ hits: Selection[]
255
+ ): void {
256
+ if (mode === 'replace') {
257
+ if (hits.length === 0) return; // empty marquee preserves selection
258
+ selSet.replace(hits);
259
+ return;
260
+ }
261
+ if (mode === 'add') {
262
+ if (hits.length === 0) return;
263
+ selSet.add(hits);
264
+ return;
265
+ }
266
+ if (mode === 'subtract') {
267
+ const keyOf = (x: Selection) => (x.id ? `id:${x.id}` : `sel:${x.selector}`);
268
+ const removeKeys = new Set(hits.map(keyOf));
269
+ const next = startSet.filter((x) => !removeKeys.has(keyOf(x)));
270
+ selSet.replace(next);
271
+ return;
272
+ }
273
+ // intersect
274
+ const keyOf = (x: Selection) => (x.id ? `id:${x.id}` : `sel:${x.selector}`);
275
+ const hitKeys = new Set(hits.map(keyOf));
276
+ const next = startSet.filter((x) => hitKeys.has(keyOf(x)));
277
+ selSet.replace(next);
278
+ }
279
+
280
+ // Re-export for tests.
281
+ export { modeOf };
282
+ export type { Mode };
@@ -53,6 +53,13 @@ export const RUNTIME_PACKAGES = [
53
53
  // (DDR-024 deferred path) and any high-end designer-tool overlays a canvas
54
54
  // wants to draw via WebGL.
55
55
  'pixi.js',
56
+ // Phase 3.7 / DDR-049 — Motion One (motion/react) is the canonical motion
57
+ // library for the canvas-lib + handoff pipeline. Externalised here so the
58
+ // canvas-lib motion helpers (<MotionDemo>, etc.) resolve through the same
59
+ // importmap path; consumers of /design:handoff still see a "motion" peer
60
+ // dep declaration on the registry-item.json output.
61
+ 'motion',
62
+ 'motion/react',
56
63
  ] as const;
57
64
 
58
65
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
@@ -0,0 +1,149 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+
3
+ import type { PenStroke, RectStroke, Stroke } from '../annotations-layer.tsx';
4
+ import { createAnnotationStrokesCommand } from '../commands/annotation-strokes-command.ts';
5
+
6
+ const pen1: PenStroke = {
7
+ id: 'pen-1',
8
+ tool: 'pen',
9
+ color: '#000',
10
+ width: 2,
11
+ points: [
12
+ [0, 0],
13
+ [10, 10],
14
+ ],
15
+ };
16
+
17
+ const pen2: PenStroke = {
18
+ id: 'pen-2',
19
+ tool: 'pen',
20
+ color: '#f00',
21
+ width: 4,
22
+ points: [[20, 20]],
23
+ };
24
+
25
+ const rect1: RectStroke = {
26
+ id: 'rect-1',
27
+ tool: 'rect',
28
+ color: '#000',
29
+ width: 2,
30
+ x: 0,
31
+ y: 0,
32
+ w: 50,
33
+ h: 30,
34
+ fill: null,
35
+ };
36
+
37
+ describe('createAnnotationStrokesCommand', () => {
38
+ test('do() PUTs the AFTER set', async () => {
39
+ const putFn = mock(() => Promise.resolve());
40
+ const cmd = createAnnotationStrokesCommand({
41
+ before: [],
42
+ after: [pen1],
43
+ putFn,
44
+ });
45
+ await cmd.do();
46
+ expect(putFn).toHaveBeenCalledTimes(1);
47
+ expect(putFn.mock.calls[0]?.[0]).toEqual([pen1]);
48
+ });
49
+
50
+ test('undo() PUTs the BEFORE set', async () => {
51
+ const putFn = mock(() => Promise.resolve());
52
+ const cmd = createAnnotationStrokesCommand({
53
+ before: [pen1],
54
+ after: [pen1, pen2],
55
+ putFn,
56
+ });
57
+ await cmd.undo();
58
+ expect(putFn).toHaveBeenCalledTimes(1);
59
+ expect(putFn.mock.calls[0]?.[0]).toEqual([pen1]);
60
+ });
61
+
62
+ test('round-trip — add stroke then undo restores empty', async () => {
63
+ const calls: Stroke[][] = [];
64
+ const putFn: (next: readonly Stroke[]) => Promise<void> = (next) => {
65
+ calls.push([...next]);
66
+ return Promise.resolve();
67
+ };
68
+ const cmd = createAnnotationStrokesCommand({
69
+ before: [],
70
+ after: [pen1],
71
+ putFn,
72
+ });
73
+ await cmd.do();
74
+ await cmd.undo();
75
+ await cmd.do(); // redo
76
+ expect(calls).toEqual([[pen1], [], [pen1]]);
77
+ });
78
+
79
+ test('eraser path — before has stroke, after is empty', async () => {
80
+ const putFn = mock(() => Promise.resolve());
81
+ const cmd = createAnnotationStrokesCommand({
82
+ before: [pen1],
83
+ after: [],
84
+ putFn,
85
+ });
86
+ expect(cmd.label).toBe('erase 1 stroke');
87
+ await cmd.do();
88
+ expect(putFn.mock.calls[0]?.[0]).toEqual([]);
89
+ await cmd.undo();
90
+ expect(putFn.mock.calls[1]?.[0]).toEqual([pen1]);
91
+ });
92
+
93
+ test('default label — add 2 strokes', () => {
94
+ const cmd = createAnnotationStrokesCommand({
95
+ before: [pen1],
96
+ after: [pen1, pen2, rect1],
97
+ putFn: () => {},
98
+ });
99
+ expect(cmd.label).toBe('add 2 strokes');
100
+ });
101
+
102
+ test('default label — same-count edit', () => {
103
+ const cmd = createAnnotationStrokesCommand({
104
+ before: [pen1],
105
+ after: [{ ...pen1, color: '#f00' }],
106
+ putFn: () => {},
107
+ });
108
+ expect(cmd.label).toBe('edit 1 stroke');
109
+ });
110
+
111
+ test('snapshots are deep-cloned — mutating source after construction does not poison', async () => {
112
+ // Use locally-owned strokes so the test's mutation only affects the
113
+ // source arrays, never the module-scope `pen1`/`pen2` shared with other
114
+ // tests.
115
+ const localPen: PenStroke = {
116
+ id: 'local',
117
+ tool: 'pen',
118
+ color: '#000',
119
+ width: 1,
120
+ points: [
121
+ [1, 1],
122
+ [2, 2],
123
+ ],
124
+ };
125
+ const localPenAfter: PenStroke = {
126
+ id: 'local-after',
127
+ tool: 'pen',
128
+ color: '#111',
129
+ width: 1,
130
+ points: [[3, 3]],
131
+ };
132
+ const sourceBefore: Stroke[] = [localPen];
133
+ const sourceAfter: Stroke[] = [localPen, localPenAfter];
134
+ const putFn = mock(() => Promise.resolve());
135
+ const cmd = createAnnotationStrokesCommand({
136
+ before: sourceBefore,
137
+ after: sourceAfter,
138
+ putFn,
139
+ });
140
+ // Mutate source arrays after construction — replace entries entirely so
141
+ // we don't reach through into shared sub-arrays.
142
+ sourceAfter[0] = { ...localPen, color: '#POISONED' };
143
+ sourceBefore.length = 0;
144
+ await cmd.do();
145
+ expect(putFn.mock.calls[0]?.[0]).toEqual([localPen, localPenAfter]);
146
+ await cmd.undo();
147
+ expect(putFn.mock.calls[1]?.[0]).toEqual([localPen]);
148
+ });
149
+ });
@@ -20,6 +20,7 @@ import {
20
20
  penPathD,
21
21
  rid,
22
22
  strokeHitTest,
23
+ strokesShallowEqual,
23
24
  strokesToSvg,
24
25
  } from '../annotations-layer.tsx';
25
26
 
@@ -417,3 +418,46 @@ describe('annotations-layer / strokes round-trip is stable for arrays', () => {
417
418
  expect(strokesToSvg(sample)).toBe(strokesToSvg(sample));
418
419
  });
419
420
  });
421
+
422
+ describe('annotations-layer / strokesShallowEqual (drag no-op gate)', () => {
423
+ const pen: PenStroke = {
424
+ id: 'p',
425
+ tool: 'pen',
426
+ color: '#000',
427
+ width: 2,
428
+ points: [[0, 0]],
429
+ };
430
+ const rect: RectStroke = {
431
+ id: 'r',
432
+ tool: 'rect',
433
+ color: '#000',
434
+ width: 2,
435
+ x: 0,
436
+ y: 0,
437
+ w: 10,
438
+ h: 10,
439
+ fill: null,
440
+ };
441
+
442
+ test('same reference → true', () => {
443
+ const arr = [pen, rect];
444
+ expect(strokesShallowEqual(arr, arr)).toBe(true);
445
+ });
446
+
447
+ test('different references but same entries (same order) → true', () => {
448
+ expect(strokesShallowEqual([pen, rect], [pen, rect])).toBe(true);
449
+ });
450
+
451
+ test('different entry reference at any slot → false', () => {
452
+ const penClone: PenStroke = { ...pen };
453
+ expect(strokesShallowEqual([pen, rect], [penClone, rect])).toBe(false);
454
+ });
455
+
456
+ test('different length → false', () => {
457
+ expect(strokesShallowEqual([pen], [pen, rect])).toBe(false);
458
+ });
459
+
460
+ test('empty arrays → true', () => {
461
+ expect(strokesShallowEqual([], [])).toBe(true);
462
+ });
463
+ });
@@ -0,0 +1,131 @@
1
+ // canvas-lib-motion — Phase 3.7 Task 14 / DDR-049.
2
+ //
3
+ // Pure-logic + AST tests for the canvas-lib motion helpers. The full DOM
4
+ // path (MotionDemo render, useMotionTokens MutationObserver, ReducedMotionToggle
5
+ // click) belongs to the agent-browser smoke step — bun:test runs in a
6
+ // non-DOM environment, so we exercise (a) the role config table shape, (b)
7
+ // easingFromToken's pure mapping, (c) the canvas-lib export surface (the
8
+ // motion helpers must be exported by name so handoff inlining can resolve
9
+ // them), (d) the canvas-lib-inline pass A walk picking up motion helpers
10
+ // transitively.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+
14
+ import { buildLibMap, inlineUsedExports } from '../canvas-lib-inline.ts';
15
+ import { canvasLibPath } from '../canvas-lib-resolver.ts';
16
+
17
+ const PROJECT_DESIGN_ROOT = `${process.cwd()}/.design`;
18
+
19
+ describe('canvas-lib motion exports', () => {
20
+ test('motion helpers exported by name', async () => {
21
+ const libPath = canvasLibPath(PROJECT_DESIGN_ROOT);
22
+ const libSource = await Bun.file(libPath).text();
23
+ const map = buildLibMap(libPath, libSource);
24
+ for (const name of [
25
+ 'MotionDemo',
26
+ 'MotionTrack',
27
+ 'TokenPlayback',
28
+ 'ReducedMotionToggle',
29
+ 'useMotionTokens',
30
+ 'easingFromToken',
31
+ ]) {
32
+ expect(map.has(name), `expected '${name}' to be a top-level export`).toBe(true);
33
+ }
34
+ });
35
+
36
+ test('motion helpers are re-exported under canonical names', async () => {
37
+ // The lib aliases motion/react primitives to _motionImpl / _useReducedMotion /
38
+ // _MotionAnimatePresence but re-exports them as `motion`, `useReducedMotion`,
39
+ // `AnimatePresence`. Handoff inlining relies on the canonical names being in
40
+ // the libMap (so a canvas importing `useReducedMotion` from @maude/canvas-lib
41
+ // resolves correctly).
42
+ const libPath = canvasLibPath(PROJECT_DESIGN_ROOT);
43
+ const libSource = await Bun.file(libPath).text();
44
+ expect(libSource).toContain("from 'motion/react'");
45
+ expect(libSource).toMatch(/_motionImpl\s+as\s+motion/);
46
+ expect(libSource).toMatch(/_useReducedMotion\s+as\s+useReducedMotion/);
47
+ expect(libSource).toMatch(/_MotionAnimatePresence\s+as\s+AnimatePresence/);
48
+ });
49
+ });
50
+
51
+ describe('canvas-lib motion inline (handoff path)', () => {
52
+ test('inlining MotionDemo carries transitive deps', async () => {
53
+ const libPath = canvasLibPath(PROJECT_DESIGN_ROOT);
54
+ const libSource = await Bun.file(libPath).text();
55
+ const map = buildLibMap(libPath, libSource);
56
+
57
+ // Synthetic canvas that imports just MotionDemo from canvas-lib.
58
+ const canvas = [
59
+ 'import { MotionDemo } from "@maude/canvas-lib";',
60
+ '',
61
+ 'export default function Probe() {',
62
+ ' return <MotionDemo role="flip" />;',
63
+ '}',
64
+ '',
65
+ ].join('\n');
66
+
67
+ const inlined = inlineUsedExports(canvas, map);
68
+ expect(inlined.droppedImport).toBe(true);
69
+
70
+ const inlinedNames = new Set(inlined.inlined);
71
+ // MotionDemo's body references useMotionTokens + easingFromToken + the
72
+ // aliased _motionImpl + _useReducedMotion. Transitive walk should pull
73
+ // useMotionTokens + easingFromToken (the function symbols), and the
74
+ // module-level role table constant.
75
+ expect(inlinedNames.has('MotionDemo')).toBe(true);
76
+ // The role config table.
77
+ expect(inlinedNames.has('MOTION_ROLE_DEFAULTS')).toBe(true);
78
+ // Reads-tokens hook (referenced inside MotionDemo).
79
+ expect(inlinedNames.has('useMotionTokens')).toBe(true);
80
+ expect(inlinedNames.has('easingFromToken')).toBe(true);
81
+ });
82
+
83
+ test('inlined motion code references aliased motion/react imports', async () => {
84
+ const libPath = canvasLibPath(PROJECT_DESIGN_ROOT);
85
+ const libSource = await Bun.file(libPath).text();
86
+ const map = buildLibMap(libPath, libSource);
87
+
88
+ const canvas = [
89
+ 'import { MotionDemo } from "@maude/canvas-lib";',
90
+ '',
91
+ 'export default function Probe() {',
92
+ ' return <MotionDemo role="soft" />;',
93
+ '}',
94
+ '',
95
+ ].join('\n');
96
+
97
+ const inlined = inlineUsedExports(canvas, map);
98
+ // The inlined body must reference the aliased symbols (handoff.ts then
99
+ // splices a synthetic import re-binding them to motion/react). If this
100
+ // assertion fails, the handoff path will produce a TSX with undefined
101
+ // identifiers.
102
+ expect(inlined.content).toMatch(/_motionImpl\b/);
103
+ expect(inlined.content).toMatch(/_useReducedMotion\b/);
104
+ });
105
+
106
+ test('role keys are stable (8-role vocabulary fixed per DDR-049)', async () => {
107
+ const libPath = canvasLibPath(PROJECT_DESIGN_ROOT);
108
+ const libSource = await Bun.file(libPath).text();
109
+ // The 8-role vocabulary is part of the DS contract — design-system-keeper
110
+ // + motion-critic both grep against it. Lock the role list with a
111
+ // structural assertion against the source.
112
+ for (const role of ['flip', 'panel', 'route', 'soft', 'spring', 'scroll', 'drag', 'presence']) {
113
+ expect(libSource).toContain(` ${role}: {`);
114
+ }
115
+ });
116
+ });
117
+
118
+ describe('motion peer-dep in package.json', () => {
119
+ test('motion declared in dependencies', async () => {
120
+ const pkg = await Bun.file('./package.json').json();
121
+ expect(pkg.dependencies.motion).toBeDefined();
122
+ expect(typeof pkg.dependencies.motion).toBe('string');
123
+ });
124
+
125
+ test('motion + motion/react in RUNTIME_PACKAGES', async () => {
126
+ const { RUNTIME_PACKAGES } = await import('../runtime-bundle.ts');
127
+ const list = [...RUNTIME_PACKAGES];
128
+ expect(list).toContain('motion');
129
+ expect(list).toContain('motion/react');
130
+ });
131
+ });