@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,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
+ });
@@ -0,0 +1,97 @@
1
+ // Per-DS tokensCssRel auto-resolution — load-bearing for DDR-048. When a
2
+ // config entry doesn't spell out `tokensCssRel`, the dev-server must derive
3
+ // it from `<entry.path>/colors_and_type.css` so the System view can read the
4
+ // scaffolded layout without forcing every config author to repeat the path.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { normalizeDesignSystems } from '../context';
8
+
9
+ const baseCfg = {
10
+ name: 'fixture',
11
+ projectLabel: null,
12
+ designRoot: '.design',
13
+ canvasGroups: [],
14
+ rootClass: 'app',
15
+ themeDefault: 'dark' as const,
16
+ tokensCssRel: 'system/colors_and_type.css',
17
+ teamAccentDefault: null,
18
+ handoffTargets: [],
19
+ newCanvasDir: 'ui',
20
+ newComponentDir: 'ui/components',
21
+ _source: '.design/config.json' as const,
22
+ };
23
+
24
+ describe('normalizeDesignSystems', () => {
25
+ test('no designSystems → passthrough (top-level tokensCssRel is the only source)', () => {
26
+ const out = normalizeDesignSystems({ ...baseCfg, designSystems: undefined });
27
+ expect(out.designSystems).toBeUndefined();
28
+ expect(out.tokensCssRel).toBe('system/colors_and_type.css');
29
+ });
30
+
31
+ test('empty designSystems array → passthrough', () => {
32
+ const out = normalizeDesignSystems({ ...baseCfg, designSystems: [] });
33
+ expect(out.designSystems).toEqual([]);
34
+ });
35
+
36
+ test('entry without tokensCssRel → derived from <path>/colors_and_type.css', () => {
37
+ const out = normalizeDesignSystems({
38
+ ...baseCfg,
39
+ designSystems: [{ name: 'studyfi', path: 'system/studyfi' }],
40
+ });
41
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/studyfi/colors_and_type.css');
42
+ });
43
+
44
+ test('entry with explicit tokensCssRel → preserved (leading slash trimmed)', () => {
45
+ const out = normalizeDesignSystems({
46
+ ...baseCfg,
47
+ designSystems: [
48
+ { name: 'studyfi', path: 'system/studyfi', tokensCssRel: '/system/studyfi/tokens.css' },
49
+ ],
50
+ });
51
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/studyfi/tokens.css');
52
+ });
53
+
54
+ test('path with leading + trailing slashes → normalized', () => {
55
+ const out = normalizeDesignSystems({
56
+ ...baseCfg,
57
+ designSystems: [{ name: 'alpha', path: '/system/alpha/' }],
58
+ });
59
+ expect(out.designSystems?.[0].path).toBe('system/alpha');
60
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/alpha/colors_and_type.css');
61
+ });
62
+
63
+ test('multiple entries → each resolved independently', () => {
64
+ const out = normalizeDesignSystems({
65
+ ...baseCfg,
66
+ designSystems: [
67
+ { name: 'alpha', path: 'system/alpha' },
68
+ { name: 'beta', path: 'system/beta', tokensCssRel: 'system/beta/custom.css' },
69
+ ],
70
+ });
71
+ expect(out.designSystems?.[0].tokensCssRel).toBe('system/alpha/colors_and_type.css');
72
+ expect(out.designSystems?.[1].tokensCssRel).toBe('system/beta/custom.css');
73
+ });
74
+
75
+ test('preserves unrelated entry fields (description, rootClass, themes)', () => {
76
+ const out = normalizeDesignSystems({
77
+ ...baseCfg,
78
+ designSystems: [
79
+ {
80
+ name: 'studyfi',
81
+ path: 'system/studyfi',
82
+ description: 'StudyFi production mirror',
83
+ rootClass: 'studyfi',
84
+ themeDefault: 'light',
85
+ themes: ['light', 'dark'],
86
+ newCanvasDir: 'ui/studyfi',
87
+ },
88
+ ],
89
+ });
90
+ const entry = out.designSystems?.[0];
91
+ expect(entry?.description).toBe('StudyFi production mirror');
92
+ expect(entry?.rootClass).toBe('studyfi');
93
+ expect(entry?.themeDefault).toBe('light');
94
+ expect(entry?.themes).toEqual(['light', 'dark']);
95
+ expect(entry?.newCanvasDir).toBe('ui/studyfi');
96
+ });
97
+ });
@@ -0,0 +1,93 @@
1
+ // equal-spacing-detector — T27 (Wave 3). Pure detector fixtures.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { detectEqualSpacing } from '../equal-spacing-detector.ts';
6
+ import type { Rect } from '../use-snap-guides.tsx';
7
+
8
+ const r = (x: number, y: number, w = 100, h = 60): Rect => ({ x, y, w, h });
9
+
10
+ describe('detectEqualSpacing / preconditions', () => {
11
+ test('fewer than 3 rects → null', () => {
12
+ expect(detectEqualSpacing([], 'x')).toBeNull();
13
+ expect(detectEqualSpacing([r(0, 0)], 'x')).toBeNull();
14
+ expect(detectEqualSpacing([r(0, 0), r(200, 0)], 'x')).toBeNull();
15
+ });
16
+
17
+ test('overlapping rects → null (gap < 0)', () => {
18
+ // r1 spans 0..100, r2 starts at 90 (overlap by 10)
19
+ expect(detectEqualSpacing([r(0, 0), r(90, 0), r(220, 0)], 'x')).toBeNull();
20
+ });
21
+ });
22
+
23
+ describe('detectEqualSpacing / horizontal axis', () => {
24
+ test('3 rects, equal gap 20 → detects with gap=20 + 2 midpoints', () => {
25
+ // 0..100, 120..220, 240..340 — gap = 20
26
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(240, 0)], 'x');
27
+ expect(out).not.toBeNull();
28
+ expect(out?.axis).toBe('x');
29
+ expect(out?.gapPx).toBe(20);
30
+ expect(out?.midpoints).toHaveLength(2);
31
+ expect(out?.midpoints[0]).toEqual({ x: 110, y: 30 });
32
+ expect(out?.midpoints[1]).toEqual({ x: 230, y: 30 });
33
+ });
34
+
35
+ test('uneven gaps outside tolerance → null', () => {
36
+ // gaps: 20, 30
37
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(250, 0)], 'x');
38
+ expect(out).toBeNull();
39
+ });
40
+
41
+ test('within 1 px tolerance is accepted', () => {
42
+ // gaps: 20, 21 — within default tolerance of 1
43
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(241, 0)], 'x');
44
+ expect(out).not.toBeNull();
45
+ expect(out?.gapPx).toBeGreaterThanOrEqual(20);
46
+ expect(out?.gapPx).toBeLessThanOrEqual(21);
47
+ });
48
+
49
+ test('explicit tolerance allows wider band', () => {
50
+ // gaps: 20, 25
51
+ const tightFail = detectEqualSpacing([r(0, 0), r(120, 0), r(245, 0)], 'x');
52
+ expect(tightFail).toBeNull();
53
+ const loosePass = detectEqualSpacing([r(0, 0), r(120, 0), r(245, 0)], 'x', {
54
+ tolerancePx: 5,
55
+ });
56
+ expect(loosePass).not.toBeNull();
57
+ });
58
+
59
+ test('rects passed in random order → sorted internally', () => {
60
+ const out = detectEqualSpacing([r(240, 0), r(0, 0), r(120, 0)], 'x');
61
+ expect(out).not.toBeNull();
62
+ expect(out?.gapPx).toBe(20);
63
+ });
64
+ });
65
+
66
+ describe('detectEqualSpacing / vertical axis', () => {
67
+ test('3 rects stacked, equal gap 30 → detects', () => {
68
+ // y: 0..60, 90..150, 180..240 — gap 30
69
+ const out = detectEqualSpacing([r(0, 0), r(0, 90), r(0, 180)], 'y');
70
+ expect(out).not.toBeNull();
71
+ expect(out?.axis).toBe('y');
72
+ expect(out?.gapPx).toBe(30);
73
+ expect(out?.midpoints).toHaveLength(2);
74
+ expect(out?.midpoints[0]?.y).toBe(75); // 60 + 30/2
75
+ expect(out?.midpoints[1]?.y).toBe(165);
76
+ });
77
+ });
78
+
79
+ describe('detectEqualSpacing / 4+ rects', () => {
80
+ test('4 rects equally distributed → 3 midpoints', () => {
81
+ // gaps 20, 20, 20
82
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(240, 0), r(360, 0)], 'x');
83
+ expect(out).not.toBeNull();
84
+ expect(out?.midpoints).toHaveLength(3);
85
+ expect(out?.gapPx).toBe(20);
86
+ });
87
+
88
+ test('one bad gap in a 4-rect set → null', () => {
89
+ // gaps 20, 50, 20
90
+ const out = detectEqualSpacing([r(0, 0), r(120, 0), r(270, 0), r(390, 0)], 'x');
91
+ expect(out).toBeNull();
92
+ });
93
+ });
@@ -2,7 +2,14 @@
2
2
 
3
3
  import { describe, expect, test } from 'bun:test';
4
4
 
5
- import { type ClassifyInput, type Tool, classify, isEditableTarget } from '../input-router.tsx';
5
+ import {
6
+ type ClassifyInput,
7
+ DRAG_THRESHOLD_PX,
8
+ type Tool,
9
+ classify,
10
+ crossedDragThreshold,
11
+ isEditableTarget,
12
+ } from '../input-router.tsx';
6
13
 
7
14
  const base = (over: Partial<ClassifyInput>): ClassifyInput => ({
8
15
  type: 'pointermove',
@@ -314,3 +321,82 @@ describe('input-router / isEditableTarget', () => {
314
321
  expect(isEditableTarget(el)).toBe(true);
315
322
  });
316
323
  });
324
+
325
+ // T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
326
+ // (artboard drag, artboard marquee, element marquee, annotation pen/rect/etc.)
327
+ // reads the same value. Microsoft Win32 SM_CXDRAG default + d3-drag convention.
328
+ describe('input-router / crossedDragThreshold', () => {
329
+ test('constant is 4', () => {
330
+ expect(DRAG_THRESHOLD_PX).toBe(4);
331
+ });
332
+
333
+ test('no movement → false', () => {
334
+ expect(crossedDragThreshold(100, 200, 100, 200)).toBe(false);
335
+ });
336
+
337
+ test('3 px move (sub-threshold) → false', () => {
338
+ expect(crossedDragThreshold(0, 0, 3, 0)).toBe(false);
339
+ expect(crossedDragThreshold(0, 0, 2, 2)).toBe(false); // hypot ≈ 2.83
340
+ });
341
+
342
+ test('exactly 4 px → true (boundary inclusive)', () => {
343
+ expect(crossedDragThreshold(0, 0, 4, 0)).toBe(true);
344
+ expect(crossedDragThreshold(0, 0, 0, 4)).toBe(true);
345
+ });
346
+
347
+ test('5 px diagonal → true', () => {
348
+ // 3-4-5 right triangle: hypot = 5
349
+ expect(crossedDragThreshold(10, 20, 13, 24)).toBe(true);
350
+ });
351
+
352
+ test('negative deltas — hypot is direction-agnostic', () => {
353
+ expect(crossedDragThreshold(50, 50, 47, 46)).toBe(true); // hypot = 5
354
+ expect(crossedDragThreshold(50, 50, 48, 49)).toBe(false); // hypot ≈ 2.24
355
+ });
356
+ });
357
+
358
+ describe('input-router / keydown — undo + redo (Phase 20)', () => {
359
+ test('Cmd+Z → undo', () => {
360
+ expect(classify(base({ type: 'keydown', key: 'z', metaKey: true })).kind).toBe('undo');
361
+ });
362
+
363
+ test('Cmd+Shift+Z → redo', () => {
364
+ expect(classify(base({ type: 'keydown', key: 'z', metaKey: true, shiftKey: true })).kind).toBe(
365
+ 'redo'
366
+ );
367
+ });
368
+
369
+ test('Ctrl+Z → undo (Windows / Linux mac-less)', () => {
370
+ expect(classify(base({ type: 'keydown', key: 'z', ctrlKey: true })).kind).toBe('undo');
371
+ });
372
+
373
+ test('Ctrl+Y → redo (Windows convention)', () => {
374
+ expect(classify(base({ type: 'keydown', key: 'y', ctrlKey: true })).kind).toBe('redo');
375
+ });
376
+
377
+ test('Cmd+Y → redo (mac users with Windows muscle-memory)', () => {
378
+ expect(classify(base({ type: 'keydown', key: 'y', metaKey: true })).kind).toBe('redo');
379
+ });
380
+
381
+ test('Cmd+Z inside editable → no-op (browser native text undo wins)', () => {
382
+ expect(
383
+ classify(base({ type: 'keydown', key: 'z', metaKey: true, isEditable: true })).kind
384
+ ).toBe('no-op');
385
+ });
386
+
387
+ test('Cmd+Opt+Z → no-op (Alt is reserved for browser text gestures)', () => {
388
+ expect(classify(base({ type: 'keydown', key: 'z', metaKey: true, altKey: true })).kind).toBe(
389
+ 'no-op'
390
+ );
391
+ });
392
+
393
+ test('bare Z → no-op (Z is not a tool letter; needs Cmd)', () => {
394
+ expect(classify(base({ type: 'keydown', key: 'z' })).kind).toBe('no-op');
395
+ });
396
+
397
+ test('Cmd+Shift+Y → no-op (only Cmd+Y; we do not over-claim)', () => {
398
+ expect(classify(base({ type: 'keydown', key: 'y', metaKey: true, shiftKey: true })).kind).toBe(
399
+ 'no-op'
400
+ );
401
+ });
402
+ });
@@ -0,0 +1,94 @@
1
+ // marquee-overlay — T26 (Wave 3). Aseprite-vocabulary modifier semantics
2
+ // table tests + applyMarqueeMode set-algebra tests. Pure functions, no DOM.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { applyMarqueeMode, modeOf } from '../marquee-overlay.tsx';
7
+ import type { Selection } from '../use-selection-set.tsx';
8
+
9
+ describe('marquee-overlay / modeOf', () => {
10
+ test('no modifiers → replace', () => {
11
+ expect(modeOf({ shiftKey: false, altKey: false })).toBe('replace');
12
+ });
13
+ test('shift → add', () => {
14
+ expect(modeOf({ shiftKey: true, altKey: false })).toBe('add');
15
+ });
16
+ test('alt → subtract', () => {
17
+ expect(modeOf({ shiftKey: false, altKey: true })).toBe('subtract');
18
+ });
19
+ test('shift+alt → intersect', () => {
20
+ expect(modeOf({ shiftKey: true, altKey: true })).toBe('intersect');
21
+ });
22
+ });
23
+
24
+ describe('marquee-overlay / applyMarqueeMode', () => {
25
+ // Stub `selSet` that records the last call so the test can assert outcome.
26
+ type Recorded = { method: 'replace' | 'add'; payload: Selection[] };
27
+ const makeStub = () => {
28
+ const log: Recorded[] = [];
29
+ const stub = {
30
+ replace: (s: Selection | Selection[]) => {
31
+ log.push({ method: 'replace', payload: Array.isArray(s) ? s : [s] });
32
+ },
33
+ add: (s: Selection | Selection[]) => {
34
+ log.push({ method: 'add', payload: Array.isArray(s) ? s : [s] });
35
+ },
36
+ };
37
+ return { stub, log };
38
+ };
39
+
40
+ const sel = (id: string): Selection => ({ id, selector: `[data-cd-id="${id}"]` });
41
+
42
+ test('replace with empty hits → no-op (selection preserved)', () => {
43
+ const { stub, log } = makeStub();
44
+ applyMarqueeMode(stub, 'replace', [sel('a')], []);
45
+ expect(log).toHaveLength(0);
46
+ });
47
+
48
+ test('replace with hits → swaps to hits', () => {
49
+ const { stub, log } = makeStub();
50
+ applyMarqueeMode(stub, 'replace', [sel('a')], [sel('b'), sel('c')]);
51
+ expect(log).toEqual([{ method: 'replace', payload: [sel('b'), sel('c')] }]);
52
+ });
53
+
54
+ test('add with empty hits → no-op', () => {
55
+ const { stub, log } = makeStub();
56
+ applyMarqueeMode(stub, 'add', [sel('a')], []);
57
+ expect(log).toHaveLength(0);
58
+ });
59
+
60
+ test('add with hits → adds to existing', () => {
61
+ const { stub, log } = makeStub();
62
+ applyMarqueeMode(stub, 'add', [sel('a')], [sel('b')]);
63
+ expect(log).toEqual([{ method: 'add', payload: [sel('b')] }]);
64
+ });
65
+
66
+ test('subtract removes the hit ids from startSet', () => {
67
+ const { stub, log } = makeStub();
68
+ applyMarqueeMode(stub, 'subtract', [sel('a'), sel('b'), sel('c')], [sel('b')]);
69
+ expect(log).toEqual([{ method: 'replace', payload: [sel('a'), sel('c')] }]);
70
+ });
71
+
72
+ test('subtract with no overlap → identity (replace with startSet)', () => {
73
+ const { stub, log } = makeStub();
74
+ applyMarqueeMode(stub, 'subtract', [sel('a'), sel('b')], [sel('z')]);
75
+ expect(log).toEqual([{ method: 'replace', payload: [sel('a'), sel('b')] }]);
76
+ });
77
+
78
+ test('intersect keeps only ids present in both sets', () => {
79
+ const { stub, log } = makeStub();
80
+ applyMarqueeMode(
81
+ stub,
82
+ 'intersect',
83
+ [sel('a'), sel('b'), sel('c')],
84
+ [sel('b'), sel('c'), sel('d')]
85
+ );
86
+ expect(log).toEqual([{ method: 'replace', payload: [sel('b'), sel('c')] }]);
87
+ });
88
+
89
+ test('intersect with empty hits → clears (empty replace)', () => {
90
+ const { stub, log } = makeStub();
91
+ applyMarqueeMode(stub, 'intersect', [sel('a'), sel('b')], []);
92
+ expect(log).toEqual([{ method: 'replace', payload: [] }]);
93
+ });
94
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+
3
+ import {
4
+ type ArtboardLayoutEntry,
5
+ createMoveArtboardsCommand,
6
+ diffLayoutPositions,
7
+ } from '../commands/move-artboards-command.ts';
8
+
9
+ const A: ArtboardLayoutEntry = { id: 'a', x: 0, y: 0, w: 100, h: 80 };
10
+ const B: ArtboardLayoutEntry = { id: 'b', x: 200, y: 0, w: 100, h: 80 };
11
+ const A2: ArtboardLayoutEntry = { id: 'a', x: 50, y: 25, w: 100, h: 80 };
12
+ const B2: ArtboardLayoutEntry = { id: 'b', x: 260, y: 0, w: 100, h: 80 };
13
+
14
+ describe('createMoveArtboardsCommand', () => {
15
+ test('do() patches with the AFTER snapshot', async () => {
16
+ const patchFn = mock(() => {});
17
+ const cmd = createMoveArtboardsCommand({ before: [A, B], after: [A2, B], patchFn });
18
+ await cmd.do();
19
+ expect(patchFn).toHaveBeenCalledTimes(1);
20
+ const arg = patchFn.mock.calls[0]?.[0];
21
+ expect(arg).toEqual([A2, B]);
22
+ });
23
+
24
+ test('undo() patches with the BEFORE snapshot', async () => {
25
+ const patchFn = mock(() => {});
26
+ const cmd = createMoveArtboardsCommand({ before: [A, B], after: [A2, B], patchFn });
27
+ await cmd.undo();
28
+ expect(patchFn).toHaveBeenCalledTimes(1);
29
+ const arg = patchFn.mock.calls[0]?.[0];
30
+ expect(arg).toEqual([A, B]);
31
+ });
32
+
33
+ test('redo idempotence — do() twice yields the same patch payload', async () => {
34
+ const patchFn = mock(() => {});
35
+ const cmd = createMoveArtboardsCommand({ before: [A, B], after: [A2, B2], patchFn });
36
+ await cmd.do();
37
+ await cmd.do();
38
+ expect(patchFn).toHaveBeenCalledTimes(2);
39
+ expect(patchFn.mock.calls[0]?.[0]).toEqual([A2, B2]);
40
+ expect(patchFn.mock.calls[1]?.[0]).toEqual([A2, B2]);
41
+ });
42
+
43
+ test('snapshots are deep-cloned — mutating the source after construction has no effect', async () => {
44
+ const before: ArtboardLayoutEntry[] = [{ ...A }, { ...B }];
45
+ const after: ArtboardLayoutEntry[] = [{ ...A2 }, { ...B }];
46
+ const patchFn = mock(() => {});
47
+ const cmd = createMoveArtboardsCommand({ before, after, patchFn });
48
+ // Poison the source arrays in place.
49
+ // biome-ignore lint/style/noNonNullAssertion: test invariant — arrays literally constructed two lines above
50
+ after[0]!.x = 9999;
51
+ // biome-ignore lint/style/noNonNullAssertion: test invariant — arrays literally constructed two lines above
52
+ before[1]!.y = -777;
53
+ await cmd.do();
54
+ expect(patchFn.mock.calls[0]?.[0]).toEqual([A2, B]);
55
+ await cmd.undo();
56
+ expect(patchFn.mock.calls[1]?.[0]).toEqual([A, B]);
57
+ });
58
+
59
+ test('label defaults to "move N artboards" with correct count', () => {
60
+ const cmd = createMoveArtboardsCommand({
61
+ before: [A, B],
62
+ after: [A2, B],
63
+ patchFn: () => {},
64
+ });
65
+ expect(cmd.label).toBe('move 1 artboard');
66
+
67
+ const cmd2 = createMoveArtboardsCommand({
68
+ before: [A, B],
69
+ after: [A2, B2],
70
+ patchFn: () => {},
71
+ });
72
+ expect(cmd2.label).toBe('move 2 artboards');
73
+ });
74
+
75
+ test('label override (equal-spacing wraps with its own copy)', () => {
76
+ const cmd = createMoveArtboardsCommand({
77
+ before: [A, B],
78
+ after: [A2, B2],
79
+ patchFn: () => {},
80
+ label: 'equal-space 2 artboards',
81
+ });
82
+ expect(cmd.label).toBe('equal-space 2 artboards');
83
+ });
84
+ });
85
+
86
+ describe('diffLayoutPositions', () => {
87
+ test('identical → null (skip push)', () => {
88
+ expect(diffLayoutPositions([A, B], [A, B])).toBeNull();
89
+ expect(diffLayoutPositions([A, B], [{ ...A }, { ...B }])).toBeNull();
90
+ });
91
+
92
+ test('1 moved → { changed: 1 }', () => {
93
+ expect(diffLayoutPositions([A, B], [A2, B])).toEqual({ changed: 1 });
94
+ });
95
+
96
+ test('both moved → { changed: 2 }', () => {
97
+ expect(diffLayoutPositions([A, B], [A2, B2])).toEqual({ changed: 2 });
98
+ });
99
+
100
+ test('different array lengths → reports max (defensive — real flows align ids)', () => {
101
+ expect(diffLayoutPositions([A], [A, B])).toEqual({ changed: 2 });
102
+ });
103
+
104
+ test('size-only diff (no position change) → null', () => {
105
+ const Aresized: ArtboardLayoutEntry = { id: 'a', x: 0, y: 0, w: 200, h: 160 };
106
+ expect(diffLayoutPositions([A], [Aresized])).toBeNull();
107
+ });
108
+ });