@1agh/maude 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +112 -38
  3. package/plugins/design/dev-server/annotations-layer.tsx +204 -95
  4. package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
  5. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  6. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  7. package/plugins/design/dev-server/canvas-lib.tsx +506 -30
  8. package/plugins/design/dev-server/canvas-shell.tsx +352 -20
  9. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  10. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  11. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  12. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  13. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  14. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +3 -3
  16. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  17. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  18. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  19. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  20. package/plugins/design/dev-server/handoff.ts +24 -0
  21. package/plugins/design/dev-server/http.ts +27 -0
  22. package/plugins/design/dev-server/input-router.tsx +52 -2
  23. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  24. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  25. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  26. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  27. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  28. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  29. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  30. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  31. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  32. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  33. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  34. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  35. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  36. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  37. package/plugins/design/dev-server/undo-stack.ts +240 -0
  38. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  39. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  40. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  41. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  42. package/plugins/design/templates/_shell.html +17 -6
  43. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  44. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  45. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  46. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  47. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  48. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  49. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -0,0 +1,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
+ });
@@ -0,0 +1,211 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ type CommandRecord,
5
+ MAX_DEPTH,
6
+ _clearBuilderRegistry,
7
+ _clearStackStore,
8
+ canRedo,
9
+ canUndo,
10
+ createUndoStackState,
11
+ loadStackState,
12
+ peekRedo,
13
+ peekUndo,
14
+ rebuildCommand,
15
+ registerCommand,
16
+ saveStackState,
17
+ undoReducer,
18
+ } from '../undo-stack.ts';
19
+
20
+ afterEach(() => {
21
+ _clearStackStore();
22
+ });
23
+
24
+ function rec(label = 'fake', payload: unknown = null): CommandRecord {
25
+ return { kind: 'test', label, payload };
26
+ }
27
+
28
+ describe('undoReducer', () => {
29
+ test('push appends record to past and clears future', () => {
30
+ const a = rec('a');
31
+ const b = rec('b');
32
+ let s = createUndoStackState();
33
+ s = undoReducer(s, { type: 'push', record: a });
34
+ expect(s.past).toEqual([a]);
35
+ expect(s.future).toEqual([]);
36
+ s = undoReducer(s, { type: 'push', record: b });
37
+ expect(s.past).toEqual([a, b]);
38
+ expect(s.future).toEqual([]);
39
+ });
40
+
41
+ test('double push preserves order', () => {
42
+ const a = rec('a');
43
+ const b = rec('b');
44
+ const c = rec('c');
45
+ let s = createUndoStackState();
46
+ s = undoReducer(s, { type: 'push', record: a });
47
+ s = undoReducer(s, { type: 'push', record: b });
48
+ s = undoReducer(s, { type: 'push', record: c });
49
+ expect(s.past).toEqual([a, b, c]);
50
+ });
51
+
52
+ test('undo pops past top into future', () => {
53
+ const a = rec('a');
54
+ const b = rec('b');
55
+ let s = createUndoStackState();
56
+ s = undoReducer(s, { type: 'push', record: a });
57
+ s = undoReducer(s, { type: 'push', record: b });
58
+ s = undoReducer(s, { type: 'undo' });
59
+ expect(s.past).toEqual([a]);
60
+ expect(s.future).toEqual([b]);
61
+ expect(peekUndo(s)).toBe(a);
62
+ expect(peekRedo(s)).toBe(b);
63
+ });
64
+
65
+ test('undo on empty is a no-op (identity)', () => {
66
+ const s = createUndoStackState();
67
+ const next = undoReducer(s, { type: 'undo' });
68
+ expect(next).toBe(s);
69
+ expect(canUndo(next)).toBe(false);
70
+ });
71
+
72
+ test('redo pops future top back into past', () => {
73
+ const a = rec('a');
74
+ let s = createUndoStackState();
75
+ s = undoReducer(s, { type: 'push', record: a });
76
+ s = undoReducer(s, { type: 'undo' });
77
+ s = undoReducer(s, { type: 'redo' });
78
+ expect(s.past).toEqual([a]);
79
+ expect(s.future).toEqual([]);
80
+ });
81
+
82
+ test('redo on empty future is a no-op', () => {
83
+ const a = rec('a');
84
+ let s = createUndoStackState();
85
+ s = undoReducer(s, { type: 'push', record: a });
86
+ const next = undoReducer(s, { type: 'redo' });
87
+ expect(next).toBe(s);
88
+ expect(canRedo(next)).toBe(false);
89
+ });
90
+
91
+ test('push discards future branch (canonical undo-stack semantics)', () => {
92
+ const a = rec('a');
93
+ const b = rec('b');
94
+ const c = rec('c');
95
+ let s = createUndoStackState();
96
+ s = undoReducer(s, { type: 'push', record: a });
97
+ s = undoReducer(s, { type: 'push', record: b });
98
+ s = undoReducer(s, { type: 'undo' });
99
+ expect(s.future.length).toBe(1);
100
+ s = undoReducer(s, { type: 'push', record: c });
101
+ expect(s.past).toEqual([a, c]);
102
+ expect(s.future).toEqual([]);
103
+ });
104
+
105
+ test('depth cap drops oldest from past (ring buffer)', () => {
106
+ let s = createUndoStackState();
107
+ const records: CommandRecord[] = [];
108
+ for (let i = 0; i < MAX_DEPTH + 5; i++) {
109
+ const r = rec(`r-${i}`);
110
+ records.push(r);
111
+ s = undoReducer(s, { type: 'push', record: r });
112
+ }
113
+ expect(s.past.length).toBe(MAX_DEPTH);
114
+ // biome-ignore lint/style/noNonNullAssertion: records pushed sequentially above; index guaranteed in range
115
+ expect(s.past[0]).toBe(records[5]!);
116
+ // biome-ignore lint/style/noNonNullAssertion: records pushed sequentially above; index guaranteed in range
117
+ expect(s.past[s.past.length - 1]).toBe(records[records.length - 1]!);
118
+ });
119
+
120
+ test('clear resets both stacks', () => {
121
+ const a = rec('a');
122
+ let s = createUndoStackState();
123
+ s = undoReducer(s, { type: 'push', record: a });
124
+ s = undoReducer(s, { type: 'undo' });
125
+ s = undoReducer(s, { type: 'clear' });
126
+ expect(s.past).toEqual([]);
127
+ expect(s.future).toEqual([]);
128
+ expect(canUndo(s)).toBe(false);
129
+ expect(canRedo(s)).toBe(false);
130
+ });
131
+
132
+ test('hydrate replaces both stacks atomically', () => {
133
+ const a = rec('a');
134
+ const b = rec('b');
135
+ let s = createUndoStackState();
136
+ s = undoReducer(s, { type: 'hydrate', state: { past: [a, b], future: [a] } });
137
+ expect(s.past).toEqual([a, b]);
138
+ expect(s.future).toEqual([a]);
139
+ });
140
+ });
141
+
142
+ describe('command builder registry', () => {
143
+ test('registerCommand + rebuildCommand round-trip', () => {
144
+ _clearBuilderRegistry();
145
+ registerCommand('test', (record, sinks) => {
146
+ const fn = sinks.layoutPatchFn as ((layout: unknown) => void) | undefined;
147
+ if (!fn) return null;
148
+ return {
149
+ kind: record.kind,
150
+ label: record.label,
151
+ do() {
152
+ fn(record.payload);
153
+ },
154
+ undo() {
155
+ fn({ undone: true });
156
+ },
157
+ };
158
+ });
159
+ const record = rec('test-label', { foo: 'bar' });
160
+ const built = rebuildCommand(record, { layoutPatchFn: () => {} } as never);
161
+ expect(built).not.toBeNull();
162
+ expect(built?.label).toBe('test-label');
163
+ });
164
+
165
+ test('rebuildCommand returns null for unknown kind', () => {
166
+ _clearBuilderRegistry();
167
+ const out = rebuildCommand({ kind: 'unknown', label: '?', payload: null }, {});
168
+ expect(out).toBeNull();
169
+ });
170
+
171
+ test('rebuildCommand returns null when required sink is missing', () => {
172
+ _clearBuilderRegistry();
173
+ registerCommand('needs-sink', (_record, sinks) => {
174
+ if (!sinks.layoutPatchFn) return null;
175
+ return { kind: 'needs-sink', label: '', do() {}, undo() {} };
176
+ });
177
+ const out = rebuildCommand({ kind: 'needs-sink', label: '', payload: null }, {});
178
+ expect(out).toBeNull();
179
+ });
180
+ });
181
+
182
+ describe('cross-iframe persistence (loadStackState / saveStackState)', () => {
183
+ test('round-trips state under a canvas file key', () => {
184
+ const a = rec('a');
185
+ const initial = loadStackState('ui/Foo.tsx');
186
+ expect(initial).toEqual({ past: [], future: [] });
187
+ saveStackState('ui/Foo.tsx', { past: [a], future: [] });
188
+ expect(loadStackState('ui/Foo.tsx').past).toEqual([a]);
189
+ });
190
+
191
+ test('isolation across canvas files', () => {
192
+ saveStackState('ui/Foo.tsx', { past: [rec('foo')], future: [] });
193
+ saveStackState('ui/Bar.tsx', { past: [rec('bar-1'), rec('bar-2')], future: [] });
194
+ expect(loadStackState('ui/Foo.tsx').past.length).toBe(1);
195
+ expect(loadStackState('ui/Bar.tsx').past.length).toBe(2);
196
+ expect(loadStackState('ui/Baz.tsx').past).toEqual([]);
197
+ });
198
+
199
+ test('survives a "remount" simulation — load → save → reload yields same state', () => {
200
+ // Simulate: iframe A pushes 2 records, iframe A unmounts, iframe B
201
+ // mounts for the same canvas → sees both records.
202
+ saveStackState('ui/Persist.tsx', {
203
+ past: [rec('e1'), rec('e2')],
204
+ future: [rec('redo-me')],
205
+ });
206
+ // Pretend a remount happens — same window store, fresh load.
207
+ const reloaded = loadStackState('ui/Persist.tsx');
208
+ expect(reloaded.past.length).toBe(2);
209
+ expect(reloaded.future.length).toBe(1);
210
+ });
211
+ });
@@ -0,0 +1,59 @@
1
+ // use-cursor-modifiers — T28 (Wave 3). Pure reducer covers the cross-platform
2
+ // Ctrl≡Meta normalization + same-state short-circuit.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { type ModifierState, reduceModifiers } from '../use-cursor-modifiers.tsx';
7
+
8
+ const initial: ModifierState = { alt: false, shift: false, meta: false };
9
+
10
+ const k = (over: Partial<KeyboardEventInit & { ctrlKey: boolean }> = {}) => ({
11
+ altKey: false,
12
+ shiftKey: false,
13
+ metaKey: false,
14
+ ctrlKey: false,
15
+ ...over,
16
+ });
17
+
18
+ describe('reduceModifiers', () => {
19
+ test('no change → returns same reference (cheap equality)', () => {
20
+ const out = reduceModifiers(initial, k());
21
+ expect(out).toBe(initial);
22
+ });
23
+
24
+ test('alt down → alt:true', () => {
25
+ const out = reduceModifiers(initial, k({ altKey: true }));
26
+ expect(out).toEqual({ alt: true, shift: false, meta: false });
27
+ });
28
+
29
+ test('shift down → shift:true', () => {
30
+ const out = reduceModifiers(initial, k({ shiftKey: true }));
31
+ expect(out).toEqual({ alt: false, shift: true, meta: false });
32
+ });
33
+
34
+ test('meta down → meta:true', () => {
35
+ const out = reduceModifiers(initial, k({ metaKey: true }));
36
+ expect(out).toEqual({ alt: false, shift: false, meta: true });
37
+ });
38
+
39
+ test('ctrl is normalized to meta (cross-platform parity)', () => {
40
+ const out = reduceModifiers(initial, k({ ctrlKey: true }));
41
+ expect(out.meta).toBe(true);
42
+ });
43
+
44
+ test('meta + ctrl together → meta:true (not duplicated)', () => {
45
+ const out = reduceModifiers(initial, k({ metaKey: true, ctrlKey: true }));
46
+ expect(out).toEqual({ alt: false, shift: false, meta: true });
47
+ });
48
+
49
+ test('alt+shift simultaneously → both true', () => {
50
+ const out = reduceModifiers(initial, k({ altKey: true, shiftKey: true }));
51
+ expect(out).toEqual({ alt: true, shift: true, meta: false });
52
+ });
53
+
54
+ test('releasing alt while shift still held → shift only', () => {
55
+ const both: ModifierState = { alt: true, shift: true, meta: false };
56
+ const out = reduceModifiers(both, k({ shiftKey: true })); // alt released
57
+ expect(out).toEqual({ alt: false, shift: true, meta: false });
58
+ });
59
+ });