@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,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
- async function buildSystemData() {
966
- const sysAbs = path.join(DESIGN_ROOT, SYSTEM_DIR_REL);
967
- const sysRel = path.posix.join(DESIGN_REL, SYSTEM_DIR_REL);
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, CFG.tokensCssRel);
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
- const subs = await fs.readdir(sysAbs, { withFileTypes: true });
1009
- for (const s of subs) {
1010
- if (!s.isDirectory()) continue;
1011
- const candidate = path.join(sysAbs, s.name, folderName);
1012
- try {
1013
- const stat = await fs.stat(candidate);
1014
- if (stat.isDirectory()) matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
1015
- } catch {}
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
- const data = await buildSystemData();
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
+ });