@1agh/maude 0.29.0 → 0.30.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 (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -0,0 +1,184 @@
1
+ // annotate-write — FigJam v3 AI write verb (`maude design annotate`).
2
+ // Drives bin/annotate.mjs as a subprocess against a temp design root and
3
+ // asserts the output through the CANONICAL parser: everything the verb writes
4
+ // must be sanitize-stable, byte-identical under parse → re-serialize, and
5
+ // readable back as a graph by read-annotations --graph.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
12
+
13
+ import {
14
+ type ArrowStroke,
15
+ type StickyStroke,
16
+ type Stroke,
17
+ sanitizeAnnotationSvg,
18
+ strokesToSvg,
19
+ svgToStrokes,
20
+ } from '../annotations-model.ts';
21
+
22
+ const BIN = new URL('../bin/annotate.mjs', import.meta.url).pathname;
23
+ const READER = new URL('../bin/read-annotations.mjs', import.meta.url).pathname;
24
+
25
+ let root: string;
26
+
27
+ beforeAll(() => {
28
+ GlobalRegistrator.register();
29
+ root = mkdtempSync(join(tmpdir(), 'annotate-test-'));
30
+ // A design root with no config — resolveDesignRoot defaults to .design.
31
+ writeFileSync(
32
+ join(root, 'flow.json'),
33
+ JSON.stringify({
34
+ nodes: [
35
+ { id: 'a', label: 'Start' },
36
+ { id: 'b', label: 'Middle' },
37
+ { id: 'c', label: 'End', shape: 'ellipse' },
38
+ ],
39
+ edges: [
40
+ { from: 'a', to: 'b' },
41
+ { from: 'b', to: 'c', label: 'ships' },
42
+ ],
43
+ })
44
+ );
45
+ Bun.spawnSync(['mkdir', '-p', join(root, '.design')]);
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await GlobalRegistrator.unregister();
50
+ rmSync(root, { recursive: true, force: true });
51
+ });
52
+
53
+ function runAnnotate(args: string[], stdin?: string): { code: number; out: string; err: string } {
54
+ const proc = Bun.spawnSync(['bun', BIN, ...args], {
55
+ stdin: stdin ? new TextEncoder().encode(stdin) : undefined,
56
+ });
57
+ return {
58
+ code: proc.exitCode,
59
+ out: new TextDecoder().decode(proc.stdout),
60
+ err: new TextDecoder().decode(proc.stderr),
61
+ };
62
+ }
63
+
64
+ function readSvg(): string {
65
+ return readFileSync(join(root, '.design', 'ui-flow.annotations.svg'), 'utf8');
66
+ }
67
+
68
+ describe('annotate --flow', () => {
69
+ test('writes an auto-laid-out diagram of bound connectors', () => {
70
+ const res = runAnnotate(['ui/Flow.tsx', '--flow', join(root, 'flow.json'), '--root', root]);
71
+ expect(res.code).toBe(0);
72
+ const result = JSON.parse(res.out) as { ok: boolean; refs: Record<string, string> };
73
+ expect(result.ok).toBe(true);
74
+ expect(Object.keys(result.refs)).toEqual(['@a', '@b', '@c']);
75
+
76
+ const svg = readSvg();
77
+ // Canonical-parser compatible, sanitize-stable, byte-identical round-trip.
78
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
79
+ const strokes = svgToStrokes(svg);
80
+ expect(strokesToSvg(strokes)).toBe(svg);
81
+ // 3 shapes + 3 labels (2 anchored + 1 ellipse-anchored) + 2 arrows + 1 edge label.
82
+ const arrows = strokes.filter((s): s is ArrowStroke => s.tool === 'arrow');
83
+ expect(arrows).toHaveLength(2);
84
+ for (const a of arrows) {
85
+ expect(a.startBind?.hostId).toBeDefined();
86
+ expect(a.endBind?.hostId).toBeDefined();
87
+ expect(a.author).toBe('ai');
88
+ }
89
+ // Layered layout: 'b' sits one column right of 'a'.
90
+ const aShape = strokes.find((s) => s.id === result.refs['@a']) as Stroke & { x: number };
91
+ const bShape = strokes.find((s) => s.id === result.refs['@b']) as Stroke & { x: number };
92
+ expect(bShape.x).toBeGreaterThan(aShape.x);
93
+ });
94
+
95
+ test('read-annotations --graph reads the diagram back as the same graph', () => {
96
+ const proc = Bun.spawnSync(['node', READER, 'ui/Flow.tsx', '--root', root, '--graph']);
97
+ const parsed = JSON.parse(new TextDecoder().decode(proc.stdout)) as {
98
+ graph: { nodes: Array<{ label: string | null }>; edges: unknown[] };
99
+ };
100
+ expect(parsed.graph.edges).toHaveLength(2);
101
+ expect(parsed.graph.nodes.map((n) => n.label).sort()).toEqual(['End', 'Middle', 'Start']);
102
+ });
103
+ });
104
+
105
+ describe('annotate --ops', () => {
106
+ test('create sticky + group + connect-to-existing + delete, via stdin', () => {
107
+ // Connect to a node minted by the previous --flow run.
108
+ const flowSvg = readSvg();
109
+ const existingId = (svgToStrokes(flowSvg).find((s) => s.tool === 'rect') as Stroke).id;
110
+ const ops = {
111
+ ops: [
112
+ { op: 'create', type: 'sticky', ref: '@n1', text: 'pain point', x: 100, y: 600 },
113
+ { op: 'create', type: 'sticky', ref: '@n2', text: 'idea', x: 340, y: 600 },
114
+ { op: 'group', ids: ['@n1', '@n2'] },
115
+ { op: 'connect', from: '@n1', to: existingId },
116
+ ],
117
+ };
118
+ const res = runAnnotate(['ui/Flow.tsx', '--root', root], JSON.stringify(ops));
119
+ expect(res.code).toBe(0);
120
+ const svg = readSvg();
121
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
122
+ const strokes = svgToStrokes(svg);
123
+ expect(strokesToSvg(svg && strokes ? strokes : [])).toBe(svg);
124
+ const stickies = strokes.filter((s): s is StickyStroke => s.tool === 'sticky');
125
+ expect(stickies).toHaveLength(2);
126
+ expect(stickies[0]?.groupIds).toEqual(stickies[1]?.groupIds);
127
+ expect(stickies[0]?.groupIds?.length).toBe(1);
128
+ const bound = strokes.filter(
129
+ (s): s is ArrowStroke => s.tool === 'arrow' && s.endBind?.hostId === existingId
130
+ );
131
+ expect(bound).toHaveLength(1);
132
+
133
+ // Delete one sticky — the element disappears and the file stays canonical.
134
+ const del = runAnnotate(
135
+ ['ui/Flow.tsx', '--root', root],
136
+ JSON.stringify({ ops: [{ op: 'delete', id: stickies[1]?.id }] })
137
+ );
138
+ expect(del.code).toBe(0);
139
+ const after = svgToStrokes(readSvg());
140
+ expect(after.filter((s) => s.tool === 'sticky')).toHaveLength(1);
141
+ expect(strokesToSvg(after)).toBe(readSvg());
142
+ });
143
+
144
+ test('unknown connect target fails loud, writes nothing', () => {
145
+ const before = readSvg();
146
+ const res = runAnnotate(
147
+ ['ui/Flow.tsx', '--root', root],
148
+ JSON.stringify({ ops: [{ op: 'connect', from: '@ghost', to: 'nope' }] })
149
+ );
150
+ expect(res.code).toBe(2);
151
+ expect(readSvg()).toBe(before);
152
+ });
153
+
154
+ test('--dry-run prints the merged SVG without writing', () => {
155
+ const before = readSvg();
156
+ const res = runAnnotate(
157
+ ['ui/Flow.tsx', '--root', root, '--dry-run'],
158
+ JSON.stringify({ ops: [{ op: 'create', type: 'text', text: 'ghost', x: 0, y: 0 }] })
159
+ );
160
+ expect(res.code).toBe(0);
161
+ expect(res.out).toContain('ghost');
162
+ expect(readSvg()).toBe(before);
163
+ });
164
+
165
+ test('non-loopback _server.json.url is refused — writes to file, never PUTs off-box (F2)', () => {
166
+ // A poisoned/foreign _server.json points the egress at an external host.
167
+ // The verb must NOT PUT there (SSRF/exfil); it falls back to the file write.
168
+ const serverJson = join(root, '.design', '_server.json');
169
+ writeFileSync(serverJson, JSON.stringify({ url: 'http://attacker.example.com:4399' }));
170
+ try {
171
+ const res = runAnnotate(
172
+ ['ui/F2.tsx', '--root', root],
173
+ JSON.stringify({ ops: [{ op: 'create', type: 'text', text: 'secret', x: 0, y: 0 }] })
174
+ );
175
+ expect(res.code).toBe(0);
176
+ // via:"file" proves the off-box PUT was skipped (a successful PUT → "server").
177
+ expect(JSON.parse(res.out).via).toBe('file');
178
+ const svg = readFileSync(join(root, '.design', 'ui-f2.annotations.svg'), 'utf8');
179
+ expect(svg).toContain('secret');
180
+ } finally {
181
+ rmSync(serverJson, { force: true });
182
+ }
183
+ });
184
+ });
@@ -0,0 +1,88 @@
1
+ // annotations-align — FigJam v3 align + distribute (pure bbox math, DOM-free).
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { alignStrokes, distributeStrokes } from '../annotations-align.ts';
6
+ import type { RectStroke, Stroke } from '../annotations-model.ts';
7
+
8
+ function rect(
9
+ id: string,
10
+ x: number,
11
+ y: number,
12
+ w = 50,
13
+ h = 50,
14
+ extra: Partial<RectStroke> = {}
15
+ ): RectStroke {
16
+ return { id, tool: 'rect', color: '#1f1f1f', width: 3, x, y, w, h, ...extra };
17
+ }
18
+
19
+ const byId = (strokes: readonly Stroke[], id: string): RectStroke =>
20
+ strokes.find((s) => s.id === id) as RectStroke;
21
+
22
+ describe('alignStrokes', () => {
23
+ test('align left snaps every unit to the selection bbox left edge', () => {
24
+ const strokes = [rect('a', 0, 0), rect('b', 100, 80), rect('c', 40, 200)];
25
+ const out = alignStrokes(strokes, ['a', 'b', 'c'], 'left');
26
+ expect(byId(out, 'a').x).toBe(0);
27
+ expect(byId(out, 'b').x).toBe(0);
28
+ expect(byId(out, 'c').x).toBe(0);
29
+ // y untouched by a horizontal align
30
+ expect(byId(out, 'b').y).toBe(80);
31
+ });
32
+
33
+ test('align h-center / right / bottom', () => {
34
+ const strokes = [rect('a', 0, 0, 100, 100), rect('b', 200, 50, 20, 20)];
35
+ const centered = alignStrokes(strokes, ['a', 'b'], 'h-center');
36
+ // Selection spans x:0..220 → center 110; b (w20) → x=100.
37
+ expect(byId(centered, 'b').x).toBe(100);
38
+ const right = alignStrokes(strokes, ['a', 'b'], 'right');
39
+ expect(byId(right, 'a').x).toBe(120);
40
+ const bottom = alignStrokes(strokes, ['a', 'b'], 'bottom');
41
+ expect(byId(bottom, 'b').y).toBe(80);
42
+ });
43
+
44
+ test('a group aligns as ONE unit — members keep their relative offsets', () => {
45
+ const strokes = [
46
+ rect('g1', 100, 0, 50, 50, { groupIds: ['g'] }),
47
+ rect('g2', 160, 30, 50, 50, { groupIds: ['g'] }),
48
+ rect('solo', 0, 200),
49
+ ];
50
+ const out = alignStrokes(strokes, ['g1', 'solo'], 'left');
51
+ // Group bbox spans x:100..210; aligning left moves the group by -100.
52
+ expect(byId(out, 'g1').x).toBe(0);
53
+ expect(byId(out, 'g2').x).toBe(60);
54
+ expect(byId(out, 'solo').x).toBe(0);
55
+ });
56
+
57
+ test('fewer than two units → referential no-op', () => {
58
+ const strokes = [
59
+ rect('a', 0, 0),
60
+ rect('b', 10, 10, 50, 50, { groupIds: ['g'] }),
61
+ rect('c', 70, 10, 50, 50, { groupIds: ['g'] }),
62
+ ];
63
+ expect(alignStrokes(strokes, ['b'], 'left')).toBe(strokes);
64
+ });
65
+ });
66
+
67
+ describe('distributeStrokes', () => {
68
+ test('equalizes gaps, pinning the first and last unit', () => {
69
+ // a: 0..50, b: 60..110, c: 200..250 → span 250, total 150, gap = 50.
70
+ const strokes = [rect('a', 0, 0), rect('b', 60, 0), rect('c', 200, 0)];
71
+ const out = distributeStrokes(strokes, ['a', 'b', 'c'], 'h');
72
+ expect(byId(out, 'a').x).toBe(0);
73
+ expect(byId(out, 'b').x).toBe(100);
74
+ expect(byId(out, 'c').x).toBe(200);
75
+ });
76
+
77
+ test('vertical distribute', () => {
78
+ // a: 0..50, b: 55..105, c: 300..350 → span 350, total 150, gap = 100.
79
+ const strokes = [rect('a', 0, 0), rect('b', 0, 55), rect('c', 0, 300)];
80
+ const out = distributeStrokes(strokes, ['a', 'b', 'c'], 'v');
81
+ expect(byId(out, 'b').y).toBe(150);
82
+ });
83
+
84
+ test('fewer than three units → referential no-op', () => {
85
+ const strokes = [rect('a', 0, 0), rect('b', 100, 0)];
86
+ expect(distributeStrokes(strokes, ['a', 'b'], 'h')).toBe(strokes);
87
+ });
88
+ });
@@ -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
+ });