@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.
- package/package.json +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +112 -38
- package/plugins/design/dev-server/annotations-layer.tsx +204 -95
- package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +506 -30
- package/plugins/design/dev-server/canvas-shell.tsx +352 -20
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +12 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +27 -0
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/runtime-bundle.ts +7 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/templates/_shell.html +17 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- /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
|
+
});
|