@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,124 @@
1
+ // annotations-bindings — FigJam v3 connector binding (pure, DOM-free).
2
+ // Semantics: Excalidraw proximity bind + FigJam side/center magnets; bound
3
+ // endpoints derive from the host; deleting the host unbinds + freezes.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ anchorPoint,
9
+ BIND_THRESHOLD_PX,
10
+ bindCandidate,
11
+ facingAnchor,
12
+ isBindable,
13
+ recomputeBoundArrows,
14
+ } from '../annotations-bindings.ts';
15
+ import type { ArrowStroke, RectStroke, Stroke, TextStroke } from '../annotations-model.ts';
16
+
17
+ function rect(id: string, x = 0, y = 0, w = 100, h = 80): RectStroke {
18
+ return { id, tool: 'rect', color: '#1f1f1f', width: 3, x, y, w, h };
19
+ }
20
+ function arrow(id: string, extra: Partial<ArrowStroke> = {}): ArrowStroke {
21
+ return { id, tool: 'arrow', color: '#1f1f1f', width: 3, x1: 0, y1: 0, x2: 50, y2: 0, ...extra };
22
+ }
23
+
24
+ describe('bindCandidate', () => {
25
+ test('binds within the threshold and snaps to the nearest magnet', () => {
26
+ const strokes: Stroke[] = [rect('host', 100, 100, 100, 100)];
27
+ // Just right of the host's right edge, vertically centered → (1, 0.5).
28
+ const bind = bindCandidate(205, 150, strokes, BIND_THRESHOLD_PX);
29
+ expect(bind).toEqual({ hostId: 'host', nx: 1, ny: 0.5 });
30
+ // Top-left corner region → (0, 0).
31
+ expect(bindCandidate(98, 102, strokes, BIND_THRESHOLD_PX)).toEqual({
32
+ hostId: 'host',
33
+ nx: 0,
34
+ ny: 0,
35
+ });
36
+ });
37
+
38
+ test('misses outside the threshold; topmost host wins; exclusions respected', () => {
39
+ const below = rect('below', 0, 0, 100, 100);
40
+ const above = rect('above', 50, 50, 100, 100);
41
+ const strokes: Stroke[] = [below, above];
42
+ expect(bindCandidate(500, 500, strokes, BIND_THRESHOLD_PX)).toBeNull();
43
+ expect(bindCandidate(75, 75, strokes, BIND_THRESHOLD_PX)?.hostId).toBe('above');
44
+ expect(bindCandidate(75, 75, strokes, BIND_THRESHOLD_PX, new Set(['above']))?.hostId).toBe(
45
+ 'below'
46
+ );
47
+ });
48
+
49
+ test('only bindable tools host (no text/pen/arrow/link)', () => {
50
+ const label: TextStroke = {
51
+ id: 't',
52
+ tool: 'text',
53
+ color: '#1a1a1a',
54
+ fontSize: 14,
55
+ text: 'x',
56
+ x: 0,
57
+ y: 0,
58
+ };
59
+ expect(isBindable(label)).toBe(false);
60
+ expect(bindCandidate(0, 0, [label], BIND_THRESHOLD_PX)).toBeNull();
61
+ expect(isBindable(rect('r'))).toBe(true);
62
+ });
63
+ });
64
+
65
+ describe('anchorPoint / recomputeBoundArrows', () => {
66
+ test('bound endpoints follow the host on move (translate → recompute)', () => {
67
+ const host = rect('host', 0, 0, 100, 100);
68
+ const ar = arrow('ar', {
69
+ x1: 100,
70
+ y1: 50,
71
+ x2: 300,
72
+ y2: 50,
73
+ startBind: { hostId: 'host', nx: 1, ny: 0.5 },
74
+ });
75
+ const moved: Stroke[] = [{ ...host, x: 50, y: 20 }, ar];
76
+ const out = recomputeBoundArrows(moved);
77
+ const a = out.find((s) => s.id === 'ar') as ArrowStroke;
78
+ expect([a.x1, a.y1]).toEqual([150, 70]);
79
+ expect([a.x2, a.y2]).toEqual([300, 50]); // free end untouched
80
+ });
81
+
82
+ test('anchor stays proportional through a host resize', () => {
83
+ const host = rect('host', 0, 0, 200, 100);
84
+ expect(anchorPoint(host, 0.5, 1)).toEqual([100, 100]);
85
+ expect(anchorPoint({ ...host, w: 400 }, 0.5, 1)).toEqual([200, 100]);
86
+ });
87
+
88
+ test('missing host → bind stripped, endpoint frozen (arrow survives)', () => {
89
+ const ar = arrow('ar', {
90
+ x1: 100,
91
+ y1: 50,
92
+ x2: 300,
93
+ y2: 50,
94
+ startBind: { hostId: 'gone', nx: 1, ny: 0.5 },
95
+ });
96
+ const out = recomputeBoundArrows([ar]);
97
+ const a = out.find((s) => s.id === 'ar') as ArrowStroke;
98
+ expect(a.startBind).toBeUndefined();
99
+ expect([a.x1, a.y1]).toEqual([100, 50]);
100
+ });
101
+
102
+ test('referential no-op when every endpoint already sits at its anchor', () => {
103
+ const host = rect('host', 0, 0, 100, 100);
104
+ const ar = arrow('ar', {
105
+ x1: 100,
106
+ y1: 50,
107
+ x2: 300,
108
+ y2: 50,
109
+ startBind: { hostId: 'host', nx: 1, ny: 0.5 },
110
+ });
111
+ const strokes: Stroke[] = [host, ar];
112
+ expect(recomputeBoundArrows(strokes)).toBe(strokes);
113
+ });
114
+ });
115
+
116
+ describe('facingAnchor (AI annotate / quick-create side pick)', () => {
117
+ test('picks the side magnet facing the target', () => {
118
+ const host = rect('h', 0, 0, 100, 100);
119
+ expect(facingAnchor(host, 500, 50)).toEqual({ hostId: 'h', nx: 1, ny: 0.5 });
120
+ expect(facingAnchor(host, -400, 50)).toEqual({ hostId: 'h', nx: 0, ny: 0.5 });
121
+ expect(facingAnchor(host, 50, 900)).toEqual({ hostId: 'h', nx: 0.5, ny: 1 });
122
+ expect(facingAnchor(host, 50, -900)).toEqual({ hostId: 'h', nx: 0.5, ny: 0 });
123
+ });
124
+ });
@@ -0,0 +1,231 @@
1
+ // annotations-groups — FigJam v3 grouping / duplicate / z-order (pure, DOM-free).
2
+ // Group model: flat `groupIds` tag-array, deepest→shallowest, outermost = last.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import {
7
+ duplicateStrokes,
8
+ expandIdsToGroups,
9
+ groupStrokes,
10
+ normalizeGroups,
11
+ outermostGroupOf,
12
+ reorderStrokes,
13
+ ungroupStrokes,
14
+ } from '../annotations-groups.ts';
15
+ import type {
16
+ ArrowStroke,
17
+ PenStroke,
18
+ RectStroke,
19
+ Stroke,
20
+ TextStroke,
21
+ } from '../annotations-model.ts';
22
+
23
+ function rect(id: string, x = 0, extra: Partial<RectStroke> = {}): RectStroke {
24
+ return { id, tool: 'rect', color: '#1f1f1f', width: 3, x, y: 0, w: 100, h: 80, ...extra };
25
+ }
26
+ function pen(id: string, extra: Partial<PenStroke> = {}): PenStroke {
27
+ return {
28
+ id,
29
+ tool: 'pen',
30
+ color: '#e5484d',
31
+ width: 3,
32
+ points: [
33
+ [0, 0],
34
+ [10, 10],
35
+ ],
36
+ ...extra,
37
+ };
38
+ }
39
+ function arrow(id: string, extra: Partial<ArrowStroke> = {}): ArrowStroke {
40
+ return { id, tool: 'arrow', color: '#1f1f1f', width: 3, x1: 0, y1: 0, x2: 50, y2: 0, ...extra };
41
+ }
42
+
43
+ describe('outermostGroupOf / expandIdsToGroups', () => {
44
+ test('outermost group is the LAST groupIds element (deepest→shallowest)', () => {
45
+ expect(outermostGroupOf(rect('a', 0, { groupIds: ['inner', 'outer'] }))).toBe('outer');
46
+ expect(outermostGroupOf(rect('a'))).toBeNull();
47
+ });
48
+
49
+ test('expanding one member id selects the whole outermost group, in z order', () => {
50
+ const strokes: Stroke[] = [
51
+ rect('a', 0, { groupIds: ['g1'] }),
52
+ pen('loose'),
53
+ rect('b', 200, { groupIds: ['g1'] }),
54
+ ];
55
+ expect(expandIdsToGroups(['b'], strokes)).toEqual(['a', 'b']);
56
+ });
57
+
58
+ test('ungrouped ids pass through; mixed selection keeps both', () => {
59
+ const strokes: Stroke[] = [
60
+ rect('a', 0, { groupIds: ['g1'] }),
61
+ pen('loose'),
62
+ rect('b', 200, { groupIds: ['g1'] }),
63
+ ];
64
+ expect(expandIdsToGroups(['loose', 'a'], strokes)).toEqual(['a', 'loose', 'b']);
65
+ });
66
+ });
67
+
68
+ describe('groupStrokes', () => {
69
+ test('groups ≥2 strokes under a fresh outermost id, contiguous at the topmost member', () => {
70
+ const strokes: Stroke[] = [rect('a'), pen('mid'), rect('b', 200)];
71
+ const res = groupStrokes(strokes, ['a', 'b'], 'gNew');
72
+ expect(res).not.toBeNull();
73
+ if (!res) return;
74
+ // Block lands where the topmost member ('b') sat — after 'mid'.
75
+ expect(res.strokes.map((s) => s.id)).toEqual(['mid', 'a', 'b']);
76
+ expect(
77
+ res.strokes.filter((s) => s.id !== 'mid').every((s) => outermostGroupOf(s) === 'gNew')
78
+ ).toBe(true);
79
+ expect(res.memberIds).toEqual(['a', 'b']);
80
+ });
81
+
82
+ test('grouping a group with a loose stroke NESTS (old id stays deeper)', () => {
83
+ const strokes: Stroke[] = [
84
+ rect('a', 0, { groupIds: ['g1'] }),
85
+ rect('b', 100, { groupIds: ['g1'] }),
86
+ pen('c'),
87
+ ];
88
+ const res = groupStrokes(strokes, ['c', 'a'], 'g2');
89
+ expect(res).not.toBeNull();
90
+ if (!res) return;
91
+ const a = res.strokes.find((s) => s.id === 'a');
92
+ expect(a?.groupIds).toEqual(['g1', 'g2']);
93
+ const c = res.strokes.find((s) => s.id === 'c');
94
+ expect(c?.groupIds).toEqual(['g2']);
95
+ });
96
+
97
+ test('fewer than two resolved members → null', () => {
98
+ expect(groupStrokes([rect('a')], ['a'])).toBeNull();
99
+ expect(groupStrokes([rect('a')], ['a', 'ghost'])).toBeNull();
100
+ });
101
+ });
102
+
103
+ describe('ungroupStrokes / normalizeGroups', () => {
104
+ test('ungroup strips ONE level (the outermost) and drops the key when empty', () => {
105
+ const strokes: Stroke[] = [
106
+ rect('a', 0, { groupIds: ['g1', 'g2'] }),
107
+ rect('b', 100, { groupIds: ['g1', 'g2'] }),
108
+ ];
109
+ const out = ungroupStrokes(strokes, ['a']);
110
+ expect(out.map((s) => s.groupIds)).toEqual([['g1'], ['g1']]);
111
+ const out2 = ungroupStrokes(out, ['b']);
112
+ for (const s of out2) expect('groupIds' in s).toBe(false);
113
+ });
114
+
115
+ test('normalizeGroups dissolves singleton groups after a delete', () => {
116
+ const strokes: Stroke[] = [rect('a', 0, { groupIds: ['g1'] }), pen('loose')];
117
+ const out = normalizeGroups(strokes);
118
+ expect('groupIds' in (out[0] as Stroke)).toBe(false);
119
+ // Referential no-op when nothing changes.
120
+ const stable: Stroke[] = [rect('x', 0, { groupIds: ['g'] }), rect('y', 1, { groupIds: ['g'] })];
121
+ expect(normalizeGroups(stable)).toBe(stable);
122
+ });
123
+ });
124
+
125
+ describe('duplicateStrokes', () => {
126
+ test('clones with fresh ids, remapped group ids, offset applied', () => {
127
+ let n = 0;
128
+ const makeId = () => `dup${++n}`;
129
+ const strokes: Stroke[] = [
130
+ rect('a', 0, { groupIds: ['g1'] }),
131
+ rect('b', 100, { groupIds: ['g1'] }),
132
+ ];
133
+ const res = duplicateStrokes(strokes, ['a'], 16, 16, makeId, () => 'gDup');
134
+ expect(res.strokes).toHaveLength(4);
135
+ expect(res.newIds).toEqual(['dup1', 'dup2']);
136
+ const dup1 = res.strokes.find((s) => s.id === 'dup1') as RectStroke;
137
+ expect(dup1.x).toBe(16);
138
+ expect(dup1.groupIds).toEqual(['gDup']);
139
+ // Originals untouched.
140
+ expect((res.strokes.find((s) => s.id === 'a') as RectStroke).x).toBe(0);
141
+ });
142
+
143
+ test('anchored text travels with its duplicated host and is NOT selectable', () => {
144
+ const host = rect('host');
145
+ const label: TextStroke = {
146
+ id: 'label',
147
+ tool: 'text',
148
+ color: '#1a1a1a',
149
+ fontSize: 14,
150
+ text: 'hi',
151
+ anchorId: 'host',
152
+ };
153
+ let n = 0;
154
+ const res = duplicateStrokes([host, label], ['host'], 10, 0, () => `d${++n}`);
155
+ expect(res.strokes).toHaveLength(4);
156
+ const dupLabel = res.strokes.find((s) => s.tool === 'text' && s.id !== 'label') as TextStroke;
157
+ expect(dupLabel.anchorId).toBe('d1'); // remapped to the cloned host
158
+ expect(res.newIds).toEqual(['d1']); // anchored-text clone excluded
159
+ });
160
+
161
+ test('arrow binds remap to co-duplicated hosts; binds to outside hosts are kept', () => {
162
+ const a = rect('a');
163
+ const b = rect('b', 300);
164
+ const ar = arrow('ar', {
165
+ startBind: { hostId: 'a', nx: 1, ny: 0.5 },
166
+ endBind: { hostId: 'b', nx: 0, ny: 0.5 },
167
+ });
168
+ let n = 0;
169
+ const res = duplicateStrokes([a, b, ar], ['a', 'ar'], 0, 0, () => `d${++n}`);
170
+ const dupArrow = res.strokes.find((s) => s.tool === 'arrow' && s.id !== 'ar') as ArrowStroke;
171
+ expect(dupArrow.startBind?.hostId).toBe('d1'); // co-duplicated → remapped
172
+ expect(dupArrow.endBind?.hostId).toBe('b'); // outside host → kept
173
+ });
174
+ });
175
+
176
+ describe('reorderStrokes (z-order, group-contiguous units)', () => {
177
+ const base = (): Stroke[] => [
178
+ rect('a'),
179
+ rect('g1a', 100, { groupIds: ['g1'] }),
180
+ rect('g1b', 200, { groupIds: ['g1'] }),
181
+ pen('top'),
182
+ ];
183
+
184
+ test('bring to front / send to back move the unit as one block', () => {
185
+ expect(reorderStrokes(base(), ['a'], 'front').map((s) => s.id)).toEqual([
186
+ 'g1a',
187
+ 'g1b',
188
+ 'top',
189
+ 'a',
190
+ ]);
191
+ expect(reorderStrokes(base(), ['top'], 'back').map((s) => s.id)).toEqual([
192
+ 'top',
193
+ 'a',
194
+ 'g1a',
195
+ 'g1b',
196
+ ]);
197
+ });
198
+
199
+ test('forward/backward hop over a WHOLE neighbouring group', () => {
200
+ expect(reorderStrokes(base(), ['a'], 'forward').map((s) => s.id)).toEqual([
201
+ 'g1a',
202
+ 'g1b',
203
+ 'a',
204
+ 'top',
205
+ ]);
206
+ expect(reorderStrokes(base(), ['top'], 'backward').map((s) => s.id)).toEqual([
207
+ 'a',
208
+ 'top',
209
+ 'g1a',
210
+ 'g1b',
211
+ ]);
212
+ });
213
+
214
+ test('selecting one group member moves the whole group', () => {
215
+ expect(reorderStrokes(base(), ['g1b'], 'front').map((s) => s.id)).toEqual([
216
+ 'a',
217
+ 'top',
218
+ 'g1a',
219
+ 'g1b',
220
+ ]);
221
+ });
222
+
223
+ test('already at the boundary is a no-op', () => {
224
+ expect(reorderStrokes(base(), ['top'], 'forward').map((s) => s.id)).toEqual([
225
+ 'a',
226
+ 'g1a',
227
+ 'g1b',
228
+ 'top',
229
+ ]);
230
+ });
231
+ });
@@ -0,0 +1,79 @@
1
+ // annotations-snap — FigJam v3 drag snapping + smart guides (pure, DOM-free).
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import {
6
+ computeSnap,
7
+ GRID_PITCH_PX,
8
+ SNAP_THRESHOLD_PX,
9
+ type SnapBox,
10
+ } from '../annotations-snap.ts';
11
+
12
+ const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({ x, y, w, h });
13
+
14
+ describe('computeSnap', () => {
15
+ test('snaps left edge to a candidate left edge within the threshold', () => {
16
+ const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX);
17
+ expect(res.dx).toBe(-4);
18
+ expect(res.dy).toBe(0);
19
+ expect(res.guides).toHaveLength(1);
20
+ const guide = res.guides[0];
21
+ expect(guide?.axis).toBe('x');
22
+ expect(guide?.at).toBe(100);
23
+ // The guide spans from the candidate to the (snapped) moving box.
24
+ expect(guide?.from).toBe(0);
25
+ expect(guide?.to).toBe(400);
26
+ });
27
+
28
+ test('centers participate — center-to-center snap', () => {
29
+ // moving center x = 153, candidate center x = 150 → dx -3.
30
+ const res = computeSnap(box(103, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
31
+ expect(res.dx).toBe(-3);
32
+ });
33
+
34
+ test('axes resolve independently against different candidates', () => {
35
+ const res = computeSnap(box(104, 203), [box(100, 500), box(500, 200)], SNAP_THRESHOLD_PX);
36
+ expect(res.dx).toBe(-4); // left edge → first candidate
37
+ expect(res.dy).toBe(-3); // top edge → second candidate
38
+ expect(res.guides).toHaveLength(2);
39
+ });
40
+
41
+ test('nearest line wins; outside the threshold = no snap', () => {
42
+ const res = computeSnap(box(104, 0), [box(100, 0), box(105, 0)], SNAP_THRESHOLD_PX);
43
+ expect(res.dx).toBe(1); // 105 is 1 away vs 100 being 4 away
44
+ const none = computeSnap(box(120, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
45
+ expect(none.dx).toBe(0);
46
+ expect(none.guides).toHaveLength(0);
47
+ });
48
+
49
+ test('empty candidates / zero threshold → no snap', () => {
50
+ expect(computeSnap(box(0, 0), [], SNAP_THRESHOLD_PX).guides).toHaveLength(0);
51
+ expect(computeSnap(box(101, 0), [box(100, 0)], 0).dx).toBe(0);
52
+ });
53
+
54
+ test('grid fallback — leading edge snaps to the 24px lattice with no candidates', () => {
55
+ // x=98 → nearest grid line 96 (4·24), 2 away; y=50 → nearest 48, 2 away.
56
+ const res = computeSnap(box(98, 50), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
57
+ expect(res.dx).toBe(-2);
58
+ expect(res.dy).toBe(-2);
59
+ // Grid snap is silent — the dots themselves are the visual, no guide lines.
60
+ expect(res.guides).toHaveLength(0);
61
+ });
62
+
63
+ test('grid fallback is per-axis — a smart-guide axis keeps its candidate snap', () => {
64
+ // x snaps to the candidate edge (dx -4 → x=100, NOT the 96 grid line);
65
+ // y has no candidate within reach → grid line 48 wins (dy -2).
66
+ const res = computeSnap(box(104, 50), [box(100, 500)], SNAP_THRESHOLD_PX, {
67
+ grid: GRID_PITCH_PX,
68
+ });
69
+ expect(res.dx).toBe(-4);
70
+ expect(res.dy).toBe(-2);
71
+ });
72
+
73
+ test('grid outside the threshold → no correction', () => {
74
+ // x=84 sits 12 from both 72 and 96 — beyond the 6px threshold.
75
+ const res = computeSnap(box(84, 84), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
76
+ expect(res.dx).toBe(0);
77
+ expect(res.dy).toBe(0);
78
+ });
79
+ });
@@ -122,6 +122,71 @@ describe('/_api/canvas — POST round-trip', () => {
122
122
  }
123
123
  });
124
124
 
125
+ test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
126
+ const { root } = makeSandbox();
127
+ const port = nextPort();
128
+ const proc = await bootServer(root, port);
129
+ let socket: WebSocket | null = null;
130
+ try {
131
+ const messages: Array<{ type: string; payload?: { action?: string; slug?: string } }> = [];
132
+ const sock = new WebSocket(`ws://localhost:${port}/_ws`);
133
+ socket = sock;
134
+ await new Promise<void>((res, rej) => {
135
+ sock.addEventListener('open', () => res());
136
+ sock.addEventListener('error', () => rej(new Error('ws error')));
137
+ setTimeout(() => rej(new Error('ws open timeout')), 2000);
138
+ });
139
+ sock.addEventListener('message', (ev) => {
140
+ try {
141
+ messages.push(JSON.parse(String(ev.data)));
142
+ } catch {
143
+ /* ignore non-JSON */
144
+ }
145
+ });
146
+
147
+ const waitFor = async (pred: (m: (typeof messages)[number]) => boolean) => {
148
+ const deadline = Date.now() + 2000;
149
+ while (Date.now() < deadline) {
150
+ const hit = messages.find(pred);
151
+ if (hit) return hit;
152
+ await Bun.sleep(25);
153
+ }
154
+ return undefined;
155
+ };
156
+
157
+ const created = await fetch(`http://localhost:${port}/_api/canvas`, {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({ name: 'Live Add', kind: 'brief-board' }),
161
+ });
162
+ expect(created.status).toBe(201);
163
+ const cj = (await created.json()) as { rel: string };
164
+
165
+ const added = await waitFor(
166
+ (m) => m.type === 'canvas-list-update' && m.payload?.action === 'added'
167
+ );
168
+ expect(added?.payload?.slug).toBe('ui-live_add');
169
+
170
+ const del = await fetch(
171
+ `http://localhost:${port}/_api/canvas?file=${encodeURIComponent(cj.rel)}`,
172
+ { method: 'DELETE' }
173
+ );
174
+ expect(del.status).toBe(200);
175
+
176
+ const removed = await waitFor(
177
+ (m) => m.type === 'canvas-list-update' && m.payload?.action === 'removed'
178
+ );
179
+ expect(removed?.payload?.slug).toBe('ui-live_add');
180
+ } finally {
181
+ try {
182
+ socket?.close();
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+ await killProc(proc);
187
+ }
188
+ }, 10_000);
189
+
125
190
  test('defaults kind to brief-board when omitted', async () => {
126
191
  const { root } = makeSandbox();
127
192
  const port = nextPort();
@@ -284,6 +349,10 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
284
349
  join(designRoot, `${created.slug}.annotations.svg`),
285
350
  '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>'
286
351
  );
352
+ // Seed the per-machine camera view file (DDR-115) to prove it's swept too.
353
+ mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
354
+ const viewAbs = join(designRoot, '_canvas-state', `${created.slug}.view.json`);
355
+ writeFileSync(viewAbs, JSON.stringify({ viewport: { x: 1, y: 2, zoom: 1 } }));
287
356
  const tsx = join(designRoot, 'ui', 'Trash Me.tsx');
288
357
  expect(existsSync(tsx)).toBe(true);
289
358
 
@@ -293,14 +362,17 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
293
362
  expect(j.ok).toBe(true);
294
363
  expect(j.trashed.some((t) => t.endsWith('Trash Me.tsx'))).toBe(true);
295
364
  expect(j.trashed.some((t) => t.endsWith('.annotations.svg'))).toBe(true);
365
+ expect(j.trashed.some((t) => t.endsWith('.view.json'))).toBe(true);
296
366
 
297
367
  // Gone from the group; present (recoverable) under _trash/ with a manifest.
298
368
  expect(existsSync(tsx)).toBe(false);
299
369
  expect(existsSync(join(designRoot, `${created.slug}.annotations.svg`))).toBe(false);
370
+ expect(existsSync(viewAbs)).toBe(false);
300
371
  const trashDirs = readdirSync(join(designRoot, '_trash'));
301
372
  expect(trashDirs.length).toBe(1);
302
373
  const td = join(designRoot, '_trash', trashDirs[0] as string);
303
374
  expect(existsSync(join(td, 'Trash Me.tsx'))).toBe(true);
375
+ expect(existsSync(join(td, `_canvas-state__${created.slug}.view.json`))).toBe(true);
304
376
  expect(existsSync(join(td, '_trash-manifest.json'))).toBe(true);
305
377
  } finally {
306
378
  await killProc(proc);