@1agh/maude 0.55.0 → 0.57.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 (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,245 @@
1
+ // gemma-models.test.ts — the pure helpers behind the Gemma scout's two runtimes
2
+ // (feature-scene-aware-keyframes / DDR-183). The download spawn + the live probes
3
+ // are soft-dep gates covered by manual runs; this covers what a security review
4
+ // found load-bearing: the copy/paste install command the user pastes UNREAD, and
5
+ // the loopback pin that keeps the scout egress-free.
6
+
7
+ import { afterEach, describe, expect, test } from 'bun:test';
8
+ import {
9
+ getOllamaModel,
10
+ isLoopbackHostname,
11
+ listScoutModels,
12
+ mlxInstallCommand,
13
+ mlxSetup,
14
+ mlxVenvDir,
15
+ ollamaHost,
16
+ ollamaSetupOptions,
17
+ pickOllamaGemmaTag,
18
+ } from '../generation/gemma-models.ts';
19
+
20
+ const ENV_KEYS = ['XDG_CACHE_HOME', 'OLLAMA_HOST', 'MAUDE_OLLAMA_MODEL'] as const;
21
+ const saved = new Map<string, string | undefined>();
22
+ function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
23
+ if (!saved.has(key)) saved.set(key, process.env[key]);
24
+ if (value === undefined) delete process.env[key];
25
+ else process.env[key] = value;
26
+ }
27
+ afterEach(() => {
28
+ for (const [k, v] of saved) {
29
+ if (v === undefined) delete process.env[k];
30
+ else process.env[k] = v;
31
+ }
32
+ saved.clear();
33
+ });
34
+
35
+ describe('mlxVenvDir / mlxInstallCommand', () => {
36
+ test('honors XDG_CACHE_HOME, else ~/.maude', () => {
37
+ setEnv('XDG_CACHE_HOME', '/tmp/xdg');
38
+ expect(mlxVenvDir()).toBe('/tmp/xdg/maude/mlx-venv');
39
+ setEnv('XDG_CACHE_HOME', undefined);
40
+ expect(mlxVenvDir()).toMatch(/\/\.maude\/mlx-venv$/);
41
+ });
42
+
43
+ test('the shown command targets the SAME path the probe checks', () => {
44
+ setEnv('XDG_CACHE_HOME', '/tmp/xdg');
45
+ const cmd = mlxInstallCommand();
46
+ expect(cmd).toContain(mlxVenvDir());
47
+ expect(cmd).toContain('python3 -m venv');
48
+ expect(cmd).toContain('install -U mlx-vlm');
49
+ });
50
+
51
+ test('single-quotes the path so shell metacharacters cannot execute', () => {
52
+ setEnv('XDG_CACHE_HOME', '/tmp/$(touch /tmp/pwned)/`id`/"x"');
53
+ const cmd = mlxInstallCommand() ?? '';
54
+ // The dangerous characters are present only INSIDE single quotes — the shell
55
+ // expands nothing there.
56
+ expect(cmd).toContain('\'/tmp/$(touch /tmp/pwned)/`id`/"x"/maude/mlx-venv\'');
57
+ expect(cmd).not.toMatch(/"\/tmp\/\$\(/); // never inside double quotes
58
+ });
59
+
60
+ test('escapes an embedded single quote by closing, escaping, reopening', () => {
61
+ setEnv('XDG_CACHE_HOME', "/tmp/it's");
62
+ const cmd = mlxInstallCommand() ?? '';
63
+ expect(cmd).toContain(`'/tmp/it'\\''s/maude/mlx-venv'`);
64
+ });
65
+
66
+ test('refuses to build a command when the path holds a control char', () => {
67
+ setEnv('XDG_CACHE_HOME', '/tmp/a\nrm -rf ~');
68
+ expect(mlxInstallCommand()).toBe(null);
69
+ });
70
+ });
71
+
72
+ describe('ollamaHost — loopback pin (DDR-183 egress-free)', () => {
73
+ test('defaults to loopback and accepts loopback forms', () => {
74
+ setEnv('OLLAMA_HOST', undefined);
75
+ expect(ollamaHost()).toBe('http://127.0.0.1:11434');
76
+ setEnv('OLLAMA_HOST', '127.0.0.1:11434');
77
+ expect(ollamaHost()).toBe('http://127.0.0.1:11434');
78
+ setEnv('OLLAMA_HOST', 'http://localhost:11434/');
79
+ expect(ollamaHost()).toBe('http://localhost:11434');
80
+ });
81
+
82
+ test('refuses every non-loopback host rather than uploading frames to it', () => {
83
+ for (const host of [
84
+ '0.0.0.0:11434',
85
+ '192.168.1.20:11434',
86
+ 'http://169.254.169.254',
87
+ 'http://ollama.example.com',
88
+ 'not a url',
89
+ ]) {
90
+ setEnv('OLLAMA_HOST', host);
91
+ expect(ollamaHost()).toBe(null);
92
+ }
93
+ });
94
+
95
+ test('isLoopbackHostname accepts only literal loopback', () => {
96
+ expect(isLoopbackHostname('127.0.0.1')).toBe(true);
97
+ expect(isLoopbackHostname('127.1.2.3')).toBe(true);
98
+ expect(isLoopbackHostname('::1')).toBe(true);
99
+ expect(isLoopbackHostname('localhost')).toBe(true);
100
+ expect(isLoopbackHostname('127.0.0.1.evil.com')).toBe(false);
101
+ expect(isLoopbackHostname('10.0.0.1')).toBe(false);
102
+ });
103
+ });
104
+
105
+ // The card must never show an instruction this machine can't follow — the whole
106
+ // point of probing brew/python3/curl rather than hardcoding an install line.
107
+ describe('ollamaSetupOptions', () => {
108
+ const notInstalled = { available: false, model: null, tags: [], installed: false };
109
+
110
+ test('installed but not running → start it, never "install it" again', () => {
111
+ const opts = ollamaSetupOptions({ ...notInstalled, installed: true });
112
+ expect(opts.map((o) => o.id)).toEqual(['start']);
113
+ expect(opts[0].command).toBe('ollama serve');
114
+ });
115
+
116
+ test('running → the only thing missing is a model', () => {
117
+ const opts = ollamaSetupOptions({ available: true, model: null, tags: [], installed: true });
118
+ expect(opts.map((o) => o.id)).toEqual(['pull']);
119
+ expect(opts[0].command).toContain('ollama pull');
120
+ });
121
+
122
+ test('not installed → always offers a route, and a download link as the floor', () => {
123
+ const opts = ollamaSetupOptions(notInstalled);
124
+ expect(opts.length).toBeGreaterThan(0);
125
+ const download = opts.find((o) => o.id === 'download');
126
+ expect(download?.kind).toBe('link');
127
+ expect(download?.url).toBe('https://ollama.com/download');
128
+ // Whatever routes are offered, each is actionable as-is.
129
+ for (const o of opts) expect(o.kind === 'command' ? o.command : o.url).toBeTruthy();
130
+ });
131
+
132
+ test('on macOS/Linux the brew-free script route comes before brew', () => {
133
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return;
134
+ const ids = ollamaSetupOptions(notInstalled).map((o) => o.id);
135
+ if (ids.includes('script') && ids.includes('brew'))
136
+ expect(ids.indexOf('script')).toBeLessThan(ids.indexOf('brew'));
137
+ });
138
+ });
139
+
140
+ // The model list has to be actionable on whatever runtime the machine has —
141
+ // a dead "Needs mlx-vlm" on an Ollama box was the bug this replaced.
142
+ describe('listScoutModels', () => {
143
+ const offline = { available: false, model: null, tags: [], installed: false };
144
+ const running = {
145
+ available: true,
146
+ model: 'gemma3:4b',
147
+ tags: ['gemma3:4b', 'llama3:8b'],
148
+ installed: true,
149
+ };
150
+
151
+ test('every model declares the runtime that can download it', () => {
152
+ for (const m of listScoutModels(offline)) expect(['mlx', 'ollama']).toContain(m.runtime);
153
+ });
154
+
155
+ test('Ollama models sort first when Ollama is the runtime that can act', () => {
156
+ expect(listScoutModels(running)[0].runtime).toBe('ollama');
157
+ expect(listScoutModels(offline)[0].runtime).toBe('mlx');
158
+ });
159
+
160
+ test('an Ollama model counts as downloaded only on an EXACT tag match', () => {
161
+ const rows = listScoutModels(running).filter((m) => m.runtime === 'ollama');
162
+ expect(rows.find((m) => m.id === 'ollama:gemma3:4b')?.downloaded).toBe(true);
163
+ expect(rows.find((m) => m.id === 'ollama:gemma3:12b')?.downloaded).toBe(false);
164
+ // A near-miss tag is a different pull, not the same model.
165
+ const nearMiss = listScoutModels({ ...running, tags: ['gemma3:4b-it-q4_K_M'] });
166
+ expect(nearMiss.find((m) => m.id === 'ollama:gemma3:4b')?.downloaded).toBe(false);
167
+ });
168
+
169
+ test('getOllamaModel only resolves allowlisted ids (the pull target is frozen)', () => {
170
+ expect(getOllamaModel('ollama:gemma3:4b')?.tag).toBe('gemma3:4b');
171
+ expect(getOllamaModel('gemma-4-e4b-it-4bit')).toBe(null);
172
+ expect(getOllamaModel('ollama:../../evil')).toBe(null);
173
+ expect(getOllamaModel('ollama:llama3:8b')).toBe(null);
174
+ });
175
+ });
176
+
177
+ describe('mlxSetup', () => {
178
+ test('either a usable command or a stated reason — never a silent dead end', () => {
179
+ const s = mlxSetup();
180
+ if (s.supported) {
181
+ expect(s.command).toContain('mlx-vlm');
182
+ expect(s.reason).toBeUndefined();
183
+ } else {
184
+ expect(s.reason).toBeTruthy();
185
+ expect(s.command).toBeUndefined();
186
+ }
187
+ });
188
+ });
189
+
190
+ // The CLI (bin/_smart-frames.mjs, pure JS — no .ts imports) deliberately carries
191
+ // its OWN copy of these helpers. That duplication is an accepted constraint, so
192
+ // the drift it invites needs a guard: a review already caught the two copies
193
+ // disagreeing about the bare `gemma3` tag.
194
+ describe('CLI ↔ server helper parity', () => {
195
+ test('pickOllamaGemmaTag agrees on every tag shape', async () => {
196
+ const cli = await import('../bin/_smart-frames.mjs');
197
+ setEnv('MAUDE_OLLAMA_MODEL', undefined);
198
+ for (const tags of [
199
+ ['gemma3:4b'],
200
+ ['gemma3'],
201
+ ['gemma3:1b'],
202
+ ['gemma3n:e4b'],
203
+ ['llama3:8b', 'gemma3:12b'],
204
+ [],
205
+ ]) {
206
+ expect(cli.pickOllamaGemmaTag(tags, {})).toBe(pickOllamaGemmaTag(tags));
207
+ }
208
+ });
209
+
210
+ test('ollamaHost agrees on loopback acceptance and refusal', async () => {
211
+ const cli = await import('../bin/_smart-frames.mjs');
212
+ for (const host of [
213
+ '127.0.0.1:11434',
214
+ 'http://localhost:11434/',
215
+ '[::1]:11434',
216
+ '0.0.0.0:11434',
217
+ '192.168.1.20:11434',
218
+ 'http://ollama.example.com',
219
+ ]) {
220
+ setEnv('OLLAMA_HOST', host);
221
+ expect(cli.ollamaHost({ OLLAMA_HOST: host })).toBe(ollamaHost());
222
+ }
223
+ });
224
+
225
+ test('the venv path the CLI probes is the one the server installs into', async () => {
226
+ const cli = await import('../bin/_smart-frames.mjs');
227
+ setEnv('XDG_CACHE_HOME', '/tmp/xdg');
228
+ expect(cli.mlxVenvPython({ XDG_CACHE_HOME: '/tmp/xdg' })).toBe(`${mlxVenvDir()}/bin/python3`);
229
+ });
230
+ });
231
+
232
+ describe('pickOllamaGemmaTag', () => {
233
+ test('picks a vision-capable gemma3 tag, skips text-only ones', () => {
234
+ setEnv('MAUDE_OLLAMA_MODEL', undefined);
235
+ expect(pickOllamaGemmaTag(['llama3:8b', 'gemma3:4b'])).toBe('gemma3:4b');
236
+ expect(pickOllamaGemmaTag(['gemma3'])).toBe('gemma3');
237
+ expect(pickOllamaGemmaTag(['gemma3:1b'])).toBe(null);
238
+ expect(pickOllamaGemmaTag(['gemma3n:e4b'])).toBe(null);
239
+ });
240
+
241
+ test('$MAUDE_OLLAMA_MODEL wins verbatim', () => {
242
+ setEnv('MAUDE_OLLAMA_MODEL', 'gemma3:27b');
243
+ expect(pickOllamaGemmaTag(['gemma3:4b'])).toBe('gemma3:27b');
244
+ });
245
+ });
@@ -3,7 +3,13 @@
3
3
  import { describe, expect, test } from 'bun:test';
4
4
 
5
5
  import { type Context, createBus } from '../context.ts';
6
- import { createHmrBroadcaster, HMR_DEBOUNCE_MS, type HmrMessage } from '../hmr-broadcast.ts';
6
+ import {
7
+ createContainerWriteBridge,
8
+ createHmrBroadcaster,
9
+ HMR_DEBOUNCE_MS,
10
+ type HmrMessage,
11
+ SYNTHETIC_FS_DELAY_MS,
12
+ } from '../hmr-broadcast.ts';
7
13
 
8
14
  function mkCtx(): Context {
9
15
  const bus = createBus();
@@ -150,3 +156,53 @@ describe('hmr-broadcast / stop', () => {
150
156
  expect(got).toHaveLength(0);
151
157
  });
152
158
  });
159
+
160
+ describe('container write bridge — synthesises fs:any the container fs.watch misses', () => {
161
+ const collect = (ctx: Context) => {
162
+ const got: string[] = [];
163
+ ctx.bus.on('fs:any', (rel: string) => got.push(rel));
164
+ return got;
165
+ };
166
+
167
+ test('activity:suppress → a synthetic fs:any lands after the delay', async () => {
168
+ const ctx = mkCtx();
169
+ const got = collect(ctx);
170
+ const bridge = createContainerWriteBridge(ctx);
171
+ ctx.bus.emit('activity:suppress', 'ui/Home.tsx');
172
+ expect(got).toHaveLength(0); // not immediate — the write must settle first
173
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
174
+ expect(got).toEqual(['ui/Home.tsx']);
175
+ bridge.stop();
176
+ });
177
+
178
+ test('a no-op / failed edit disarms via activity:unsuppress — no reload for peers', async () => {
179
+ const ctx = mkCtx();
180
+ const got = collect(ctx);
181
+ const bridge = createContainerWriteBridge(ctx);
182
+ ctx.bus.emit('activity:suppress', 'ui/Home.tsx');
183
+ ctx.bus.emit('activity:unsuppress', 'ui/Home.tsx'); // delta 0 / threw
184
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
185
+ expect(got).toHaveLength(0);
186
+ bridge.stop();
187
+ });
188
+
189
+ test('backslash paths are normalised so they match the watcher/classifier shape', async () => {
190
+ const ctx = mkCtx();
191
+ const got = collect(ctx);
192
+ const bridge = createContainerWriteBridge(ctx);
193
+ ctx.bus.emit('activity:suppress', 'ui\\Home.tsx');
194
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
195
+ expect(got).toEqual(['ui/Home.tsx']);
196
+ bridge.stop();
197
+ });
198
+
199
+ test('stop() cancels a pending emit', async () => {
200
+ const ctx = mkCtx();
201
+ const got = collect(ctx);
202
+ const bridge = createContainerWriteBridge(ctx);
203
+ ctx.bus.emit('activity:suppress', 'ui/Home.tsx');
204
+ bridge.stop();
205
+ await new Promise((r) => setTimeout(r, SYNTHETIC_FS_DELAY_MS + 30));
206
+ expect(got).toHaveLength(0);
207
+ });
208
+ });
@@ -7,8 +7,10 @@ import {
7
7
  classify,
8
8
  crossedDragThreshold,
9
9
  DRAG_THRESHOLD_PX,
10
+ isArtboardDragChrome,
10
11
  isEditableTarget,
11
12
  type Tool,
13
+ yieldsToArtboardDrag,
12
14
  } from '../input-router.tsx';
13
15
 
14
16
  const base = (over: Partial<ClassifyInput>): ClassifyInput => ({
@@ -457,6 +459,99 @@ describe('input-router / isEditableTarget', () => {
457
459
  });
458
460
  });
459
461
 
462
+ // issue-71 — a bare (no-modifier) pointerdown/mousedown on an artboard's own
463
+ // drag chrome (the label strip + border, OUTSIDE `.dc-artboard-body`) must
464
+ // still be identified so useInputRouter can skip stopImmediatePropagation and
465
+ // let use-artboard-drag.tsx's own bubble-phase onPointerDown arm its
466
+ // pending→dragging state machine — otherwise no artboard could ever be
467
+ // repositioned by dragging its header (the router's capture-phase claim
468
+ // killed the event before it reached that listener).
469
+ describe('input-router / isArtboardDragChrome', () => {
470
+ // `closest(sel)` here mimics Element.closest: return a truthy stub for any
471
+ // selector this fake node "matches" (is, or is a descendant of), null else.
472
+ const fakeTarget = (memberOf: string[]): EventTarget =>
473
+ ({
474
+ closest: (sel: string) => (memberOf.includes(sel) ? {} : null),
475
+ }) as unknown as EventTarget;
476
+
477
+ test('null target → false', () => {
478
+ expect(isArtboardDragChrome(null)).toBe(false);
479
+ });
480
+
481
+ test('target with no closest() (non-Element) → false', () => {
482
+ expect(isArtboardDragChrome({} as EventTarget)).toBe(false);
483
+ });
484
+
485
+ test('outside any artboard → false', () => {
486
+ expect(isArtboardDragChrome(fakeTarget([]))).toBe(false);
487
+ });
488
+
489
+ test('the label strip / border chrome (inside an artboard, not its body) → true', () => {
490
+ expect(isArtboardDragChrome(fakeTarget(['[data-dc-screen]']))).toBe(true);
491
+ });
492
+
493
+ test('inside .dc-artboard-body (the canvas content, not chrome) → false', () => {
494
+ expect(isArtboardDragChrome(fakeTarget(['[data-dc-screen]', '.dc-artboard-body']))).toBe(false);
495
+ });
496
+ });
497
+
498
+ // issue-71 — the actual regression: before this fix, useInputRouter's
499
+ // pointerdown/mousedown handlers called `stopImmediatePropagation()` on EVERY
500
+ // claimed action, including a bare click on an artboard's own drag chrome —
501
+ // which killed the event before use-artboard-drag.tsx's bubble-phase
502
+ // onPointerDown (bound to that same artboard) ever saw it, so dragging an
503
+ // artboard by its header did nothing in either the Move or the (former)
504
+ // always-select tool state. `yieldsToArtboardDrag` is the exact decision the
505
+ // handlers now gate `stopImmediatePropagation` on.
506
+ describe('input-router / yieldsToArtboardDrag', () => {
507
+ const chrome = (): EventTarget =>
508
+ ({
509
+ closest: (sel: string) => (sel === '[data-dc-screen]' ? {} : null),
510
+ }) as unknown as EventTarget;
511
+ const body = (): EventTarget => ({ closest: () => ({}) }) as unknown as EventTarget; // matches every selector, incl. .dc-artboard-body
512
+
513
+ test('bare click on artboard chrome (the reported bug) → true (let the drag hook see it)', () => {
514
+ const action = classify({ type: 'pointerdown', activeTool: 'move', button: 0 });
515
+ expect(yieldsToArtboardDrag(action, chrome(), { metaKey: false, ctrlKey: false })).toBe(true);
516
+ });
517
+
518
+ test('shift+click on artboard chrome (multi-select-and-drag) → true', () => {
519
+ const action = classify({
520
+ type: 'pointerdown',
521
+ activeTool: 'move',
522
+ button: 0,
523
+ shiftKey: true,
524
+ });
525
+ expect(yieldsToArtboardDrag(action, chrome(), { metaKey: false, ctrlKey: false })).toBe(true);
526
+ });
527
+
528
+ test('cmd+click on artboard chrome (deep-select, not a drag) → false', () => {
529
+ const action = classify({
530
+ type: 'pointerdown',
531
+ activeTool: 'move',
532
+ button: 0,
533
+ metaKey: true,
534
+ });
535
+ expect(yieldsToArtboardDrag(action, chrome(), { metaKey: true, ctrlKey: false })).toBe(false);
536
+ });
537
+
538
+ test('bare click on .dc-artboard-body content (not chrome) → false', () => {
539
+ const action = classify({ type: 'pointerdown', activeTool: 'move', button: 0 });
540
+ expect(yieldsToArtboardDrag(action, body(), { metaKey: false, ctrlKey: false })).toBe(false);
541
+ });
542
+
543
+ test('bare click outside any artboard → false (nothing to yield to)', () => {
544
+ const action = classify({ type: 'pointerdown', activeTool: 'move', button: 0 });
545
+ const outside = { closest: () => null } as unknown as EventTarget;
546
+ expect(yieldsToArtboardDrag(action, outside, { metaKey: false, ctrlKey: false })).toBe(false);
547
+ });
548
+
549
+ test('right-click (context-menu) on chrome → false — not a select at all', () => {
550
+ const action = classify({ type: 'pointerdown', activeTool: 'move', button: 2 });
551
+ expect(yieldsToArtboardDrag(action, chrome(), { metaKey: false, ctrlKey: false })).toBe(false);
552
+ });
553
+ });
554
+
460
555
  // T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
461
556
  // (artboard drag, artboard marquee, element marquee, annotation pen/rect/etc.)
462
557
  // reads the same value. Microsoft Win32 SM_CXDRAG default + d3-drag convention.