@1agh/maude 0.19.1 → 0.21.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 (105) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +17 -9
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -18,12 +18,15 @@ import { spawn } from 'node:child_process';
18
18
 
19
19
  import { createApi } from './api.ts';
20
20
  import { bootSelfHeal } from './boot-self-heal.ts';
21
+ import { createAiActivity } from './collab/ai-activity.ts';
22
+ import { createGitLifecycle } from './collab/git-lifecycle.ts';
23
+ import { createCollab } from './collab/index.ts';
21
24
  import { createContext } from './context.ts';
22
25
  import { createFsWatch } from './fs-watch.ts';
23
26
  import { createHttp } from './http.ts';
24
27
  import { createInspect } from './inspect.ts';
25
28
  import { startHeapWatch } from './mem.ts';
26
- import { createWs } from './ws.ts';
29
+ import { type WsData, createWs, isLoopbackHost, parseCollabSlug } from './ws.ts';
27
30
 
28
31
  // Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
29
32
  // node_modules/ ships empty (git clone honors .gitignore). Auto-installs +
@@ -32,17 +35,41 @@ await bootSelfHeal();
32
35
 
33
36
  const ctx = createContext();
34
37
 
35
- const api = createApi(ctx, async (file) => {
36
- // After every comments mutation, re-broadcast the updated list.
37
- const comments = await api.loadCommentsForFile(file);
38
- ctx.bus.emit('comments', { file, comments });
38
+ // Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
39
+ // reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
40
+ // synchronously below; the callback only fires at runtime, by which point the
41
+ // binding is set.
42
+ let collab: ReturnType<typeof createCollab> | null = null;
43
+
44
+ const api = createApi(ctx, {
45
+ onCommentsChanged: async (file) => {
46
+ // After every comments mutation, re-broadcast the updated list.
47
+ const comments = await api.loadCommentsForFile(file);
48
+ ctx.bus.emit('comments', { file, comments });
49
+ // Phase 8 Task 3 — bridge into the live Y.Array so collab peers see the
50
+ // change without waiting for cold-open re-seeding. No-op when no room is
51
+ // live for this canvas slug.
52
+ if (collab) {
53
+ collab.registry.syncRoomFromComments(api.fileSlug(file), comments);
54
+ }
55
+ },
56
+ // Phase 8 Task 5 — same bridge for annotations. PUT /_api/annotations writes
57
+ // the SVG blob to disk; we mirror it into the live Y.Map for collab peers.
58
+ onAnnotationsChanged: (file, svg) => {
59
+ if (collab) {
60
+ collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg);
61
+ }
62
+ },
39
63
  });
40
64
 
41
65
  const inspect = createInspect(ctx, (file) => api.loadCommentsForFile(file));
42
66
  await inspect.load();
43
67
 
44
- const ws = createWs(ctx, api, inspect);
45
- const http = createHttp(ctx, api, inspect);
68
+ collab = createCollab(ctx, api);
69
+ const aiActivity = createAiActivity(ctx);
70
+ const gitLifecycle = createGitLifecycle(ctx, collab.registry);
71
+ const ws = createWs(ctx, api, inspect, collab);
72
+ const http = createHttp(ctx, api, inspect, aiActivity);
46
73
  const fsWatch = createFsWatch(ctx);
47
74
 
48
75
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
@@ -59,21 +86,46 @@ function resolvePort(): { port: number; explicit: boolean } {
59
86
 
60
87
  const { port: BASE_PORT, explicit: PORT_EXPLICIT } = resolvePort();
61
88
 
62
- type BunServer = ReturnType<typeof Bun.serve<{ id: string; remote: string }, never>>;
89
+ type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
63
90
 
64
91
  function startServer(port: number): BunServer {
65
- return Bun.serve<{ id: string; remote: string }, never>({
92
+ return Bun.serve<WsData, never>({
66
93
  port,
67
94
  hostname: '127.0.0.1',
68
95
  development: process.env.NODE_ENV !== 'production',
69
96
  routes: http.routes,
70
97
  async fetch(req, srv) {
71
- // WebSocket upgrade.
72
- if (new URL(req.url).pathname.startsWith('/_ws')) {
98
+ const pathname = new URL(req.url).pathname;
99
+
100
+ // Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
101
+ // DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
102
+ // a `--bind 0.0.0.0` flag on this server.
103
+ const collabSlug = parseCollabSlug(pathname);
104
+ if (collabSlug !== null) {
105
+ if (!isLoopbackHost(req.headers.get('host'))) {
106
+ return new Response('cross-machine collab requires Phase 9 hub deploy', {
107
+ status: 403,
108
+ });
109
+ }
73
110
  const ok = srv.upgrade(req, {
74
111
  data: {
75
112
  id: crypto.randomUUID(),
76
113
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
114
+ kind: 'collab',
115
+ slug: collabSlug,
116
+ },
117
+ });
118
+ if (ok) return undefined as unknown as Response;
119
+ return new Response('Upgrade failed', { status: 400 });
120
+ }
121
+
122
+ // Legacy inspector WS — JSON frames, designer-facing live tab state.
123
+ if (pathname.startsWith('/_ws')) {
124
+ const ok = srv.upgrade(req, {
125
+ data: {
126
+ id: crypto.randomUUID(),
127
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
128
+ kind: 'inspector',
77
129
  },
78
130
  });
79
131
  if (ok) return undefined as unknown as Response;
@@ -167,6 +219,21 @@ if (!process.env.NO_OPEN) {
167
219
  async function shutdown() {
168
220
  console.log('\n Stopping…');
169
221
  fsWatch.stop();
222
+ try {
223
+ if (collab) await collab.registry.destroyAll();
224
+ } catch {
225
+ /* best-effort flush; the JSON snapshot is the ground truth anyway */
226
+ }
227
+ try {
228
+ aiActivity.stop();
229
+ } catch {
230
+ /* janitor cleanup is best-effort */
231
+ }
232
+ try {
233
+ gitLifecycle.stop();
234
+ } catch {
235
+ /* watcher cleanup is best-effort */
236
+ }
170
237
  try {
171
238
  await Bun.write(ctx.paths.serverInfoFile, '').catch(() => {});
172
239
  // Remove the file by writing empty then unlinking.
@@ -0,0 +1,113 @@
1
+ // Unit: AI activity tracker (Phase 8 Task 4).
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { HEARTBEAT_GRACE_MS, createAiActivity } from '../collab/ai-activity.ts';
6
+ import { type Context, createBus } from '../context.ts';
7
+
8
+ function makeCtx(): { ctx: Context; events: { file: string; entry: unknown }[] } {
9
+ const events: { file: string; entry: unknown }[] = [];
10
+ const bus = createBus();
11
+ bus.on('ai-activity', (p: { file: string; entry: unknown }) => events.push(p));
12
+ const ctx = {
13
+ cfg: {} as Context['cfg'],
14
+ projectLabel: 'test',
15
+ paths: {} as Context['paths'],
16
+ bus,
17
+ };
18
+ return { ctx, events };
19
+ }
20
+
21
+ describe('AiActivity', () => {
22
+ test('start adds an entry and emits bus event', () => {
23
+ const { ctx, events } = makeCtx();
24
+ const clock = 1000;
25
+ const ai = createAiActivity(ctx, () => clock);
26
+ const entry = ai.start('ui/Foo.tsx', 'Claude');
27
+ expect(entry.file).toBe('ui/Foo.tsx');
28
+ expect(entry.author).toBe('Claude');
29
+ expect(entry.startedAt).toBe(1000);
30
+ expect(entry.lastHeartbeat).toBe(1000);
31
+ expect(events).toHaveLength(1);
32
+ expect(events[0]?.entry).toBe(entry);
33
+ ai.stop();
34
+ });
35
+
36
+ test('start preserves startedAt across restart for the same file', () => {
37
+ const { ctx } = makeCtx();
38
+ let clock = 1000;
39
+ const ai = createAiActivity(ctx, () => clock);
40
+ ai.start('ui/Foo.tsx', 'Claude');
41
+ clock = 5000;
42
+ const restarted = ai.start('ui/Foo.tsx', 'Claude');
43
+ expect(restarted.startedAt).toBe(1000);
44
+ expect(restarted.lastHeartbeat).toBe(5000);
45
+ ai.stop();
46
+ });
47
+
48
+ test('heartbeat refreshes lastHeartbeat + emits', () => {
49
+ const { ctx, events } = makeCtx();
50
+ let clock = 1000;
51
+ const ai = createAiActivity(ctx, () => clock);
52
+ ai.start('ui/Foo.tsx', 'Claude');
53
+ clock = 2000;
54
+ const refreshed = ai.heartbeat('ui/Foo.tsx');
55
+ expect(refreshed?.lastHeartbeat).toBe(2000);
56
+ expect(refreshed?.startedAt).toBe(1000);
57
+ // Two bus events — one for start, one for heartbeat.
58
+ expect(events.length).toBe(2);
59
+ ai.stop();
60
+ });
61
+
62
+ test('heartbeat returns null when no entry exists', () => {
63
+ const { ctx } = makeCtx();
64
+ const ai = createAiActivity(ctx);
65
+ expect(ai.heartbeat('ui/Absent.tsx')).toBeNull();
66
+ ai.stop();
67
+ });
68
+
69
+ test('end clears the entry and emits a null', () => {
70
+ const { ctx, events } = makeCtx();
71
+ const clock = 1000;
72
+ const ai = createAiActivity(ctx, () => clock);
73
+ ai.start('ui/Foo.tsx', 'Claude');
74
+ const ok = ai.end('ui/Foo.tsx');
75
+ expect(ok).toBe(true);
76
+ expect(ai.get('ui/Foo.tsx')).toBeNull();
77
+ const last = events.at(-1);
78
+ expect(last?.entry).toBeNull();
79
+ ai.stop();
80
+ });
81
+
82
+ test('end on absent entry returns false (no emit)', () => {
83
+ const { ctx, events } = makeCtx();
84
+ const ai = createAiActivity(ctx);
85
+ expect(ai.end('ui/Absent.tsx')).toBe(false);
86
+ expect(events.length).toBe(0);
87
+ ai.stop();
88
+ });
89
+
90
+ test('list returns all live entries', () => {
91
+ const { ctx } = makeCtx();
92
+ const clock = 1000;
93
+ const ai = createAiActivity(ctx, () => clock);
94
+ ai.start('ui/Foo.tsx', 'Claude');
95
+ ai.start('ui/Bar.tsx', 'Claude');
96
+ expect(ai.list().length).toBe(2);
97
+ ai.end('ui/Foo.tsx');
98
+ expect(ai.list().length).toBe(1);
99
+ ai.stop();
100
+ });
101
+
102
+ test('grace period — entry stays inside HEARTBEAT_GRACE_MS', () => {
103
+ const { ctx } = makeCtx();
104
+ let clock = 1000;
105
+ const ai = createAiActivity(ctx, () => clock);
106
+ ai.start('ui/Foo.tsx', 'Claude');
107
+ // 29 s later — still within grace.
108
+ clock = 1000 + HEARTBEAT_GRACE_MS - 1000;
109
+ // We don't auto-tick the janitor in tests; just confirm entry still exists.
110
+ expect(ai.get('ui/Foo.tsx')).not.toBeNull();
111
+ ai.stop();
112
+ });
113
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+
3
+ import type { PenStroke, RectStroke, Stroke } from '../annotations-layer.tsx';
4
+ import { createAnnotationStrokesCommand } from '../commands/annotation-strokes-command.ts';
5
+
6
+ const pen1: PenStroke = {
7
+ id: 'pen-1',
8
+ tool: 'pen',
9
+ color: '#000',
10
+ width: 2,
11
+ points: [
12
+ [0, 0],
13
+ [10, 10],
14
+ ],
15
+ };
16
+
17
+ const pen2: PenStroke = {
18
+ id: 'pen-2',
19
+ tool: 'pen',
20
+ color: '#f00',
21
+ width: 4,
22
+ points: [[20, 20]],
23
+ };
24
+
25
+ const rect1: RectStroke = {
26
+ id: 'rect-1',
27
+ tool: 'rect',
28
+ color: '#000',
29
+ width: 2,
30
+ x: 0,
31
+ y: 0,
32
+ w: 50,
33
+ h: 30,
34
+ fill: null,
35
+ };
36
+
37
+ describe('createAnnotationStrokesCommand', () => {
38
+ test('do() PUTs the AFTER set', async () => {
39
+ const putFn = mock(() => Promise.resolve());
40
+ const cmd = createAnnotationStrokesCommand({
41
+ before: [],
42
+ after: [pen1],
43
+ putFn,
44
+ });
45
+ await cmd.do();
46
+ expect(putFn).toHaveBeenCalledTimes(1);
47
+ expect(putFn.mock.calls[0]?.[0]).toEqual([pen1]);
48
+ });
49
+
50
+ test('undo() PUTs the BEFORE set', async () => {
51
+ const putFn = mock(() => Promise.resolve());
52
+ const cmd = createAnnotationStrokesCommand({
53
+ before: [pen1],
54
+ after: [pen1, pen2],
55
+ putFn,
56
+ });
57
+ await cmd.undo();
58
+ expect(putFn).toHaveBeenCalledTimes(1);
59
+ expect(putFn.mock.calls[0]?.[0]).toEqual([pen1]);
60
+ });
61
+
62
+ test('round-trip — add stroke then undo restores empty', async () => {
63
+ const calls: Stroke[][] = [];
64
+ const putFn: (next: readonly Stroke[]) => Promise<void> = (next) => {
65
+ calls.push([...next]);
66
+ return Promise.resolve();
67
+ };
68
+ const cmd = createAnnotationStrokesCommand({
69
+ before: [],
70
+ after: [pen1],
71
+ putFn,
72
+ });
73
+ await cmd.do();
74
+ await cmd.undo();
75
+ await cmd.do(); // redo
76
+ expect(calls).toEqual([[pen1], [], [pen1]]);
77
+ });
78
+
79
+ test('eraser path — before has stroke, after is empty', async () => {
80
+ const putFn = mock(() => Promise.resolve());
81
+ const cmd = createAnnotationStrokesCommand({
82
+ before: [pen1],
83
+ after: [],
84
+ putFn,
85
+ });
86
+ expect(cmd.label).toBe('erase 1 stroke');
87
+ await cmd.do();
88
+ expect(putFn.mock.calls[0]?.[0]).toEqual([]);
89
+ await cmd.undo();
90
+ expect(putFn.mock.calls[1]?.[0]).toEqual([pen1]);
91
+ });
92
+
93
+ test('default label — add 2 strokes', () => {
94
+ const cmd = createAnnotationStrokesCommand({
95
+ before: [pen1],
96
+ after: [pen1, pen2, rect1],
97
+ putFn: () => {},
98
+ });
99
+ expect(cmd.label).toBe('add 2 strokes');
100
+ });
101
+
102
+ test('default label — same-count edit', () => {
103
+ const cmd = createAnnotationStrokesCommand({
104
+ before: [pen1],
105
+ after: [{ ...pen1, color: '#f00' }],
106
+ putFn: () => {},
107
+ });
108
+ expect(cmd.label).toBe('edit 1 stroke');
109
+ });
110
+
111
+ test('snapshots are deep-cloned — mutating source after construction does not poison', async () => {
112
+ // Use locally-owned strokes so the test's mutation only affects the
113
+ // source arrays, never the module-scope `pen1`/`pen2` shared with other
114
+ // tests.
115
+ const localPen: PenStroke = {
116
+ id: 'local',
117
+ tool: 'pen',
118
+ color: '#000',
119
+ width: 1,
120
+ points: [
121
+ [1, 1],
122
+ [2, 2],
123
+ ],
124
+ };
125
+ const localPenAfter: PenStroke = {
126
+ id: 'local-after',
127
+ tool: 'pen',
128
+ color: '#111',
129
+ width: 1,
130
+ points: [[3, 3]],
131
+ };
132
+ const sourceBefore: Stroke[] = [localPen];
133
+ const sourceAfter: Stroke[] = [localPen, localPenAfter];
134
+ const putFn = mock(() => Promise.resolve());
135
+ const cmd = createAnnotationStrokesCommand({
136
+ before: sourceBefore,
137
+ after: sourceAfter,
138
+ putFn,
139
+ });
140
+ // Mutate source arrays after construction — replace entries entirely so
141
+ // we don't reach through into shared sub-arrays.
142
+ sourceAfter[0] = { ...localPen, color: '#POISONED' };
143
+ sourceBefore.length = 0;
144
+ await cmd.do();
145
+ expect(putFn.mock.calls[0]?.[0]).toEqual([localPen, localPenAfter]);
146
+ await cmd.undo();
147
+ expect(putFn.mock.calls[1]?.[0]).toEqual([localPen]);
148
+ });
149
+ });
@@ -20,6 +20,7 @@ import {
20
20
  penPathD,
21
21
  rid,
22
22
  strokeHitTest,
23
+ strokesShallowEqual,
23
24
  strokesToSvg,
24
25
  } from '../annotations-layer.tsx';
25
26
 
@@ -417,3 +418,46 @@ describe('annotations-layer / strokes round-trip is stable for arrays', () => {
417
418
  expect(strokesToSvg(sample)).toBe(strokesToSvg(sample));
418
419
  });
419
420
  });
421
+
422
+ describe('annotations-layer / strokesShallowEqual (drag no-op gate)', () => {
423
+ const pen: PenStroke = {
424
+ id: 'p',
425
+ tool: 'pen',
426
+ color: '#000',
427
+ width: 2,
428
+ points: [[0, 0]],
429
+ };
430
+ const rect: RectStroke = {
431
+ id: 'r',
432
+ tool: 'rect',
433
+ color: '#000',
434
+ width: 2,
435
+ x: 0,
436
+ y: 0,
437
+ w: 10,
438
+ h: 10,
439
+ fill: null,
440
+ };
441
+
442
+ test('same reference → true', () => {
443
+ const arr = [pen, rect];
444
+ expect(strokesShallowEqual(arr, arr)).toBe(true);
445
+ });
446
+
447
+ test('different references but same entries (same order) → true', () => {
448
+ expect(strokesShallowEqual([pen, rect], [pen, rect])).toBe(true);
449
+ });
450
+
451
+ test('different entry reference at any slot → false', () => {
452
+ const penClone: PenStroke = { ...pen };
453
+ expect(strokesShallowEqual([pen, rect], [penClone, rect])).toBe(false);
454
+ });
455
+
456
+ test('different length → false', () => {
457
+ expect(strokesShallowEqual([pen], [pen, rect])).toBe(false);
458
+ });
459
+
460
+ test('empty arrays → true', () => {
461
+ expect(strokesShallowEqual([], [])).toBe(true);
462
+ });
463
+ });
@@ -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,55 @@
1
+ // Unit: Task 5 — Registry.syncRoomFromAnnotations bridges PUT /_api/annotations
2
+ // writes into the live Y.Map so collab peers see the SVG without cold-open.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import { Y_TYPES } from '../collab/persistence.ts';
7
+ import { createRegistry } from '../collab/registry.ts';
8
+ import type { RoomCallbacks } from '../collab/room.ts';
9
+
10
+ function noopCallbacks(): RoomCallbacks {
11
+ return {
12
+ async seed() {},
13
+ async persistJson() {},
14
+ async persistBinary() {},
15
+ };
16
+ }
17
+
18
+ describe('Registry.syncRoomFromAnnotations', () => {
19
+ test('populates Y.Map.svg', () => {
20
+ const r = createRegistry(noopCallbacks());
21
+ const room = r.get('canvas-slug');
22
+ const map = room.doc.getMap<string>(Y_TYPES.annotations);
23
+ expect(map.get('svg')).toBeUndefined();
24
+
25
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg"><g/></svg>';
26
+ r.syncRoomFromAnnotations('canvas-slug', svg);
27
+ expect(map.get('svg')).toBe(svg);
28
+ });
29
+
30
+ test('replaces existing svg on each call (LWW)', () => {
31
+ const r = createRegistry(noopCallbacks());
32
+ const room = r.get('canvas-slug');
33
+ r.syncRoomFromAnnotations('canvas-slug', '<svg>1</svg>');
34
+ r.syncRoomFromAnnotations('canvas-slug', '<svg>2</svg>');
35
+ expect(room.doc.getMap(Y_TYPES.annotations).get('svg')).toBe('<svg>2</svg>');
36
+ });
37
+
38
+ test('no-op when no room exists for slug', () => {
39
+ const r = createRegistry(noopCallbacks());
40
+ r.syncRoomFromAnnotations('absent', '<svg/>');
41
+ expect(r.peek('absent')).toBeNull();
42
+ expect(r.size()).toBe(0);
43
+ });
44
+
45
+ test('inspector-write origin propagates through doc.update', () => {
46
+ const r = createRegistry(noopCallbacks());
47
+ const room = r.get('echo-slug');
48
+ let lastOrigin: unknown = undefined;
49
+ room.doc.on('update', (_update, origin) => {
50
+ lastOrigin = origin;
51
+ });
52
+ r.syncRoomFromAnnotations('echo-slug', '<svg/>');
53
+ expect(lastOrigin).toBe('inspector-write');
54
+ });
55
+ });