@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.
- package/package.json +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
- package/plugins/design/dev-server/annotations-layer.tsx +343 -112
- package/plugins/design/dev-server/api.ts +70 -17
- package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
- 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 +691 -119
- package/plugins/design/dev-server/canvas-shell.tsx +818 -42
- package/plugins/design/dev-server/client/app.jsx +173 -52
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
- package/plugins/design/dev-server/client/styles/4-components.css +32 -2
- 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/config.schema.json +31 -5
- package/plugins/design/dev-server/context-menu.tsx +3 -3
- package/plugins/design/dev-server/context.ts +37 -3
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +212 -103
- 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/dist/styles.css +38 -2
- 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/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +41 -2
- package/plugins/design/dev-server/input-router.tsx +74 -10
- 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/server.mjs +84 -18
- 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/context-resolve-tokens.test.ts +97 -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/snap-distance-pill.test.ts +68 -0
- package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -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/tool-palette.tsx +71 -15
- 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-annotation-resize.tsx +295 -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-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
- 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];
|
|
@@ -78,7 +78,28 @@ function loadConfig() {
|
|
|
78
78
|
console.error(` warn: ${CONFIG_PATH} is not valid JSON: ${e.message}. Using defaults.`);
|
|
79
79
|
return { ...DEFAULT_CONFIG, _source: 'defaults (config invalid)' };
|
|
80
80
|
}
|
|
81
|
-
return { ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' };
|
|
81
|
+
return normalizeDesignSystems({ ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mirror of context.ts `normalizeDesignSystems` for the legacy Node entry.
|
|
86
|
+
* Auto-derives per-DS `tokensCssRel = <entry.path>/colors_and_type.css` when
|
|
87
|
+
* missing so the System view can find tokens regardless of multi-DS layout.
|
|
88
|
+
* DDR-048. Keep in lock-step with the .ts version until DDR-009 retires this.
|
|
89
|
+
*/
|
|
90
|
+
function normalizeDesignSystems(cfg) {
|
|
91
|
+
if (!cfg.designSystems?.length) return cfg;
|
|
92
|
+
const designSystems = cfg.designSystems.map((entry) => {
|
|
93
|
+
const p = String(entry.path).replace(/^\/+|\/+$/g, '');
|
|
94
|
+
return {
|
|
95
|
+
...entry,
|
|
96
|
+
path: p,
|
|
97
|
+
tokensCssRel:
|
|
98
|
+
(entry.tokensCssRel ?? '').toString().replace(/^\/+/, '') ||
|
|
99
|
+
path.posix.join(p, 'colors_and_type.css'),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
return { ...cfg, designSystems };
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
const CFG = loadConfig();
|
|
@@ -962,9 +983,22 @@ function parseTokens(css) {
|
|
|
962
983
|
return tokens;
|
|
963
984
|
}
|
|
964
985
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
986
|
+
/**
|
|
987
|
+
* Build the System view payload. When `dsName` is set, scope to that DS entry
|
|
988
|
+
* (per-DS tokens + per-DS preview/ui_kits gallery). Returns null when the
|
|
989
|
+
* name is set but not registered in `CFG.designSystems` so the caller can
|
|
990
|
+
* 404 instead of silently falling back to shell defaults. DDR-048.
|
|
991
|
+
*/
|
|
992
|
+
async function buildSystemData(dsName) {
|
|
993
|
+
let dsEntry = null;
|
|
994
|
+
if (dsName) {
|
|
995
|
+
dsEntry = (CFG.designSystems || []).find((d) => d.name === dsName) || null;
|
|
996
|
+
if (!dsEntry) return null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const scopedSystemRel = dsEntry ? dsEntry.path : SYSTEM_DIR_REL;
|
|
1000
|
+
const sysAbs = path.join(DESIGN_ROOT, scopedSystemRel);
|
|
1001
|
+
const sysRel = path.posix.join(DESIGN_REL, scopedSystemRel);
|
|
968
1002
|
|
|
969
1003
|
// README — try canonical locations (designRoot/README.md, system/README.md, system/<project>/README.md)
|
|
970
1004
|
let readme = null, readmePath = null;
|
|
@@ -986,11 +1020,13 @@ async function buildSystemData() {
|
|
|
986
1020
|
} catch {}
|
|
987
1021
|
}
|
|
988
1022
|
|
|
989
|
-
// Tokens
|
|
1023
|
+
// Tokens — per-DS path wins; top-level CFG.tokensCssRel is the fallback
|
|
1024
|
+
// for legacy single-DS configs that don't declare `designSystems[]`.
|
|
1025
|
+
const tokensCssRel = (dsEntry && dsEntry.tokensCssRel) || CFG.tokensCssRel;
|
|
990
1026
|
let tokens = [];
|
|
991
1027
|
let tokensPath = null;
|
|
992
1028
|
try {
|
|
993
|
-
const tokensAbs = path.join(DESIGN_ROOT,
|
|
1029
|
+
const tokensAbs = path.join(DESIGN_ROOT, tokensCssRel);
|
|
994
1030
|
const css = await fs.readFile(tokensAbs, 'utf8');
|
|
995
1031
|
tokens = parseTokens(css);
|
|
996
1032
|
tokensPath = path.relative(REPO_ROOT, tokensAbs);
|
|
@@ -1005,14 +1041,19 @@ async function buildSystemData() {
|
|
|
1005
1041
|
// Look one level deep — system/<project>/<folderName>/...
|
|
1006
1042
|
const matches = [];
|
|
1007
1043
|
try {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
1044
|
+
// When scoped to one DS (sysAbs IS the DS folder), only check the
|
|
1045
|
+
// DS-relative `<folderName>` subdir. Scanning sub-dirs here would
|
|
1046
|
+
// misread the DS's own `preview/` as a sibling DS root.
|
|
1047
|
+
if (!dsEntry) {
|
|
1048
|
+
const subs = await fs.readdir(sysAbs, { withFileTypes: true });
|
|
1049
|
+
for (const s of subs) {
|
|
1050
|
+
if (!s.isDirectory()) continue;
|
|
1051
|
+
const candidate = path.join(sysAbs, s.name, folderName);
|
|
1052
|
+
try {
|
|
1053
|
+
const stat = await fs.stat(candidate);
|
|
1054
|
+
if (stat.isDirectory()) matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
|
|
1055
|
+
} catch {}
|
|
1056
|
+
}
|
|
1016
1057
|
}
|
|
1017
1058
|
// Also accept top-level system/<folderName>/
|
|
1018
1059
|
try {
|
|
@@ -1037,10 +1078,27 @@ async function buildSystemData() {
|
|
|
1037
1078
|
const previewGallery = await galleryFor('preview');
|
|
1038
1079
|
const uiKitsGallery = await galleryFor('ui_kits');
|
|
1039
1080
|
|
|
1081
|
+
const availableDesignSystems = (CFG.designSystems || []).map((d) => ({
|
|
1082
|
+
name: d.name,
|
|
1083
|
+
path: d.path,
|
|
1084
|
+
description: d.description || null,
|
|
1085
|
+
}));
|
|
1086
|
+
|
|
1040
1087
|
return {
|
|
1041
1088
|
project: CFG.name,
|
|
1042
1089
|
designRoot: DESIGN_REL,
|
|
1043
1090
|
systemDir: sysRel,
|
|
1091
|
+
ds: dsEntry
|
|
1092
|
+
? {
|
|
1093
|
+
name: dsEntry.name,
|
|
1094
|
+
path: dsEntry.path,
|
|
1095
|
+
description: dsEntry.description || null,
|
|
1096
|
+
rootClass: dsEntry.rootClass || null,
|
|
1097
|
+
themeDefault: dsEntry.themeDefault || null,
|
|
1098
|
+
}
|
|
1099
|
+
: null,
|
|
1100
|
+
availableDesignSystems,
|
|
1101
|
+
defaultDesignSystem: CFG.defaultDesignSystem || availableDesignSystems[0]?.name || null,
|
|
1044
1102
|
readme,
|
|
1045
1103
|
readmePath,
|
|
1046
1104
|
tokens,
|
|
@@ -1048,8 +1106,8 @@ async function buildSystemData() {
|
|
|
1048
1106
|
tokensPath,
|
|
1049
1107
|
previewGallery,
|
|
1050
1108
|
uiKitsGallery,
|
|
1051
|
-
rootClass: CFG.rootClass,
|
|
1052
|
-
themeDefault: CFG.themeDefault,
|
|
1109
|
+
rootClass: (dsEntry && dsEntry.rootClass) || CFG.rootClass,
|
|
1110
|
+
themeDefault: (dsEntry && dsEntry.themeDefault) || CFG.themeDefault,
|
|
1053
1111
|
teamAccentDefault: CFG.teamAccentDefault,
|
|
1054
1112
|
};
|
|
1055
1113
|
}
|
|
@@ -1105,8 +1163,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1105
1163
|
res.end(JSON.stringify(data));
|
|
1106
1164
|
return;
|
|
1107
1165
|
}
|
|
1108
|
-
if (reqPath === '/_system-data') {
|
|
1109
|
-
|
|
1166
|
+
if (reqPath === '/_system-data' || reqPath.startsWith('/_system-data?')) {
|
|
1167
|
+
// DDR-048 — `?ds=<name>` scopes to one DS; omitted = legacy unscoped.
|
|
1168
|
+
const sysUrl = new URL(reqPath, 'http://x');
|
|
1169
|
+
const dsName = sysUrl.searchParams.get('ds');
|
|
1170
|
+
const data = await buildSystemData(dsName);
|
|
1171
|
+
if (data === null) {
|
|
1172
|
+
res.writeHead(404, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1173
|
+
res.end(JSON.stringify({ error: 'unknown design system', ds: dsName }));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1110
1176
|
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-store' });
|
|
1111
1177
|
res.end(JSON.stringify(data));
|
|
1112
1178
|
return;
|
|
@@ -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
|
+
});
|