@1agh/maude 0.60.4 → 0.60.5

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.
@@ -196,3 +196,99 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
196
196
  : 'diverged — timestamps tied, falling back to hub-wins (recoverable: both sides snapshotted)',
197
197
  };
198
198
  }
199
+
200
+ /* ------------------------------------------------- annotations (per-lane) */
201
+
202
+ export interface AnnotationsColdStartInput {
203
+ /** Local `.annotations.svg` content, or null when the file doesn't exist. */
204
+ local: string | null;
205
+ /** Annotations currently held by the doc (`''` when the lane is unset). */
206
+ doc: string;
207
+ /** True when the value carries zero strokes — callers pass
208
+ * `isEmptyAnnotationsSvg` (codec.ts) so this table stays Y-free. */
209
+ isEmpty: (svg: string | null) => boolean;
210
+ /** Local annotations file mtime (ms epoch), or null when unavailable. */
211
+ localMtimeMs: number | null;
212
+ /** Doc-side syncMeta.annotationsEditAt stamp, or null when no peer ever
213
+ * stamped (pre-stamp interop). */
214
+ docEditAtMs: number | null;
215
+ /** The body lane's resolved winner — the legacy coupling, used only as the
216
+ * fallback when both sides are non-empty and neither is stamped. */
217
+ bodyWinner: 'local' | 'hub';
218
+ }
219
+
220
+ export interface AnnotationsColdStartDecision {
221
+ winner: 'local' | 'hub' | 'none';
222
+ reason: string;
223
+ }
224
+
225
+ /**
226
+ * Per-lane cold-start resolution for annotations (extends DDR-102 to the
227
+ * annotations lane; the 2026-08-14 annotations eraser).
228
+ *
229
+ * Annotations used to blindly follow the body winner — but annotation edits
230
+ * don't move the body's edit time, so a hub with a newer body and a STALE
231
+ * (empty-wrapper) annotations lane erased newer local strokes on every cold
232
+ * start, and with the strokes went the `assets/<sha8>` references the asset
233
+ * pull scans. The one load-bearing rule here: **unstamped emptiness never
234
+ * beats content.** A STAMPED emptiness that is provably newer is a deliberate
235
+ * delete-all and is honored; everything else prefers the side with strokes.
236
+ */
237
+ export function decideAnnotationsColdStart(
238
+ input: AnnotationsColdStartInput
239
+ ): AnnotationsColdStartDecision {
240
+ const { local, doc, isEmpty, localMtimeMs, docEditAtMs, bodyWinner } = input;
241
+ const localEmpty = isEmpty(local);
242
+ const docEmpty = isEmpty(doc === '' ? null : doc);
243
+
244
+ if (localEmpty && docEmpty) return { winner: 'none', reason: 'both sides empty' };
245
+
246
+ if (docEmpty && !localEmpty) {
247
+ // A stamped hub emptiness NEWER than the local file is a deliberate
248
+ // delete-all made while this peer was offline — honor it.
249
+ if (docEditAtMs !== null && localMtimeMs !== null && docEditAtMs > localMtimeMs) {
250
+ return {
251
+ winner: 'hub',
252
+ reason: `hub delete-all is newer than local strokes (annotationsEditAt ${new Date(docEditAtMs).toISOString()} > local mtime ${new Date(localMtimeMs).toISOString()})`,
253
+ };
254
+ }
255
+ return {
256
+ winner: 'local',
257
+ reason:
258
+ 'hub annotations are empty but local has strokes — keeping local + seeding it up (unstamped emptiness never beats content)',
259
+ };
260
+ }
261
+
262
+ if (!docEmpty && localEmpty) {
263
+ // Symmetric: a local delete-all (empty wrapper on disk) newer than the
264
+ // doc's stamp is honored; an absent/stale local file materializes the hub.
265
+ if (
266
+ local !== null &&
267
+ localMtimeMs !== null &&
268
+ docEditAtMs !== null &&
269
+ localMtimeMs > docEditAtMs
270
+ ) {
271
+ return {
272
+ winner: 'local',
273
+ reason: `local delete-all is newer than hub strokes (local mtime ${new Date(localMtimeMs).toISOString()} > annotationsEditAt ${new Date(docEditAtMs).toISOString()})`,
274
+ };
275
+ }
276
+ return { winner: 'hub', reason: 'local annotations empty/absent — materializing hub strokes' };
277
+ }
278
+
279
+ // Both non-empty.
280
+ if (doc === local) return { winner: 'none', reason: 'both sides equal' };
281
+ if (docEditAtMs !== null && localMtimeMs !== null && docEditAtMs !== localMtimeMs) {
282
+ const winner = localMtimeMs > docEditAtMs ? 'local' : 'hub';
283
+ return {
284
+ winner,
285
+ reason: `diverged — newest wins: local mtime ${new Date(localMtimeMs).toISOString()} ${
286
+ winner === 'local' ? '>' : '<'
287
+ } doc annotationsEditAt ${new Date(docEditAtMs).toISOString()}`,
288
+ };
289
+ }
290
+ return {
291
+ winner: bodyWinner,
292
+ reason: `diverged — no per-lane stamp on one side, following the body winner (${bodyWinner})`,
293
+ };
294
+ }
@@ -36,16 +36,19 @@ import type * as Y from 'yjs';
36
36
 
37
37
  import { Y_TYPES } from '../collab/persistence.ts';
38
38
  import {
39
+ annotationsEditAtFromDoc,
39
40
  applyAnnotationsToDoc,
40
41
  applyCommentsToDoc,
41
42
  applyCssToDoc,
42
43
  applyHtmlToDoc,
43
44
  applyMetaToDoc,
44
45
  bodyEditAtFromDoc,
46
+ isEmptyAnnotationsSvg,
47
+ stampAnnotationsEdit,
45
48
  stampBodyEdit,
46
49
  Y_SYNC_TYPES,
47
50
  } from './codec.ts';
48
- import { decideColdStart, unionCommentsById } from './cold-start.ts';
51
+ import { decideAnnotationsColdStart, decideColdStart, unionCommentsById } from './cold-start.ts';
49
52
  import { hashBytes } from './echo-guard.ts';
50
53
  import type { SyncJournal } from './journal.ts';
51
54
  import { ORIGINS } from './origins.ts';
@@ -133,7 +136,17 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
133
136
  const parsed = tryParseJsonArray(localComments);
134
137
  if (parsed) applyCommentsToDoc(doc, parsed, ORIGINS.MIGRATION);
135
138
  }
136
- if (localAnnotations) applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
139
+ if (localAnnotations) {
140
+ if (applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION)) {
141
+ // Stamp with the FILE's mtime — adopt seeds pre-existing content,
142
+ // which must not claim apply-time freshness over a newer peer.
143
+ stampAnnotationsEdit(
144
+ doc,
145
+ ORIGINS.MIGRATION,
146
+ localMtimeMs(paths.annotations) ?? undefined
147
+ );
148
+ }
149
+ }
137
150
  if (paths.meta && localMeta) applyMetaToDoc(doc, localMeta, ORIGINS.MIGRATION);
138
151
  if (paths.css && localCss) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
139
152
  }, ORIGINS.MIGRATION);
@@ -164,17 +177,16 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
164
177
 
165
178
  let result: MigrateSeedResult = 'hub-wins';
166
179
 
167
- /** Rebuild body (+ visually-coupled css/annotations) from local, in ONE
168
- * MIGRATION transaction — one source per type, never a two-history merge. */
180
+ /** Rebuild body (+ visually-coupled css) from local, in ONE MIGRATION
181
+ * transaction — one source per type, never a two-history merge.
182
+ * Annotations are deliberately NOT here any more: they resolve per-lane
183
+ * below (decideAnnotationsColdStart), independent of the body winner. */
169
184
  const rebuildBodyFromLocal = (): void => {
170
185
  doc.transact(() => {
171
186
  if (applyHtmlToDoc(doc, localHtml as string, ORIGINS.MIGRATION)) {
172
187
  stampBodyEdit(doc, ORIGINS.MIGRATION);
173
188
  }
174
189
  if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, ORIGINS.MIGRATION);
175
- if (localAnnotations !== null) {
176
- applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION);
177
- }
178
190
  }, ORIGINS.MIGRATION);
179
191
  opts.journal?.record(slug, {
180
192
  bodyHash: hashBytes(localHtml as string),
@@ -246,6 +258,39 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
246
258
  }
247
259
  }
248
260
 
261
+ // ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix; the
262
+ // same table as agent.ts reconcile). Under sharedDoc the collab room's
263
+ // persistJson materializes the doc's annotations to disk, so a stale hub
264
+ // EMPTY WRAPPER (non-'' but zero strokes) in the doc erased local strokes
265
+ // right after this seed ran. Resolve the lane here, before the room
266
+ // materializes: unstamped emptiness never beats content.
267
+ {
268
+ const docSvg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
269
+ const docAnnotations = typeof docSvg === 'string' ? docSvg : '';
270
+ const annDecision = decideAnnotationsColdStart({
271
+ local: localAnnotations,
272
+ doc: docAnnotations,
273
+ isEmpty: isEmptyAnnotationsSvg,
274
+ localMtimeMs: localMtimeMs(paths.annotations),
275
+ docEditAtMs: annotationsEditAtFromDoc(doc),
276
+ bodyWinner: result === 'body-seed-up' || result === 'conflict-local-wins' ? 'local' : 'hub',
277
+ });
278
+ if (annDecision.winner === 'local' && localAnnotations !== null) {
279
+ console.warn(`[sync/${slug}] shared-doc cold-start annotations: ${annDecision.reason}`);
280
+ doc.transact(() => {
281
+ if (applyAnnotationsToDoc(doc, localAnnotations, ORIGINS.MIGRATION)) {
282
+ stampAnnotationsEdit(
283
+ doc,
284
+ ORIGINS.MIGRATION,
285
+ localMtimeMs(paths.annotations) ?? undefined
286
+ );
287
+ }
288
+ }, ORIGINS.MIGRATION);
289
+ }
290
+ // winner 'hub' → nothing to write here: the collab room owns the
291
+ // annotations doc→file half and materializes it (persistJson).
292
+ }
293
+
249
294
  // Comments id-union (DDR-102): rebuild from merged JSON via the
250
295
  // delete-then-insert codec — same-id entries keep the doc's version, so the
251
296
  // duplication trap stays closed; local-only comments survive.
@@ -39,6 +39,7 @@ import {
39
39
  htmlFromDoc,
40
40
  mergeSharedMetaIntoLocal,
41
41
  metaFromDoc,
42
+ stampAnnotationsEdit,
42
43
  stampBodyEdit,
43
44
  } from './codec.ts';
44
45
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
@@ -354,7 +355,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
354
355
  }
355
356
  if (evt.path === paths.annotations) {
356
357
  clearStrike(evt.path);
357
- return applyAnnotationsToDoc(doc, str, importOrigin);
358
+ // Apply + per-lane stamp in ONE transaction (mirrors agent.applyFromFs)
359
+ // so a deliberate delete-all (empty wrapper) carries its freshness and
360
+ // survives cold start on other peers (the 2026-08-14 eraser fix).
361
+ let changed = false;
362
+ doc.transact(() => {
363
+ changed = applyAnnotationsToDoc(doc, str, importOrigin);
364
+ if (changed) stampAnnotationsEdit(doc, importOrigin);
365
+ }, importOrigin);
366
+ return changed;
358
367
  }
359
368
  return false;
360
369
  }
@@ -115,7 +115,7 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
115
115
  }
116
116
  });
117
117
 
118
- test('PATCH clamps zoom to [0.1, 4.0]', async () => {
118
+ test('PATCH clamps zoom to [0.02, 4.0]', async () => {
119
119
  const { root, designRoot } = makeSandbox();
120
120
  const port = nextPort();
121
121
  const proc = await bootServer(root, port);
@@ -137,10 +137,45 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
137
137
  const r2 = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
138
138
  method: 'PATCH',
139
139
  headers: { 'content-type': 'application/json' },
140
- body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.001 } } }),
140
+ body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.0001 } } }),
141
141
  });
142
142
  const m2 = (await r2.json()) as MetaShape;
143
- expect(m2.viewport?.zoom).toBe(0.1);
143
+ expect(m2.viewport?.zoom).toBe(0.02);
144
+ } finally {
145
+ await killProc(proc);
146
+ }
147
+ });
148
+
149
+ // Issue #91 — a board wider than ~10x the viewport needs a zoom below the
150
+ // OLD 0.1 floor to ever be framed whole ("fit to screen") or dragged across
151
+ // in one motion. Before the fix, this value was silently re-clamped up to
152
+ // 0.1 on save, so even a client that could reach 0.05 lost it on reload.
153
+ test('PATCH preserves a deep zoom-out that the old [0.1, 4.0] floor used to clamp away (issue #91)', async () => {
154
+ const { root, designRoot } = makeSandbox();
155
+ const port = nextPort();
156
+ const proc = await bootServer(root, port);
157
+ try {
158
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
159
+ const tsxAbs = join(designRoot, 'ui', 'DeepZoom.tsx');
160
+ writeFileSync(tsxAbs, 'export default function D(){return <main/>}\n');
161
+ writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"DeepZoom"}');
162
+ const file = repoRel(designRoot, tsxAbs);
163
+
164
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
165
+ method: 'PATCH',
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.05 } } }),
168
+ });
169
+ const m = (await r.json()) as MetaShape;
170
+ expect(m.viewport?.zoom).toBe(0.05);
171
+
172
+ // Round-trips through a fresh GET too (the persisted view file, not just
173
+ // the PATCH response) — this is what a page reload would read back.
174
+ const g = await fetch(
175
+ `http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
176
+ );
177
+ const gm = (await g.json()) as MetaShape;
178
+ expect(gm.viewport?.zoom).toBe(0.05);
144
179
  } finally {
145
180
  await killProc(proc);
146
181
  }
@@ -317,6 +352,82 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
317
352
  }
318
353
  });
319
354
 
355
+ // Security follow-up to issue #91 — `layout.artboards[]` is reachable from
356
+ // the untrusted canvas origin (DDR-054) and previously had no count/magnitude
357
+ // bound. Now that the zoom-floor fix lets `fit()` correctly frame whatever
358
+ // bounding box a synced layout describes, an unbounded artboard count could
359
+ // promote hundreds/thousands of simultaneous GPU layers on the viewer's
360
+ // client (a DoS). Caps mirror the existing overlays/locked lanes.
361
+ test('PATCH layout rejects an oversized artboards array as a silent no-op', async () => {
362
+ const { root, designRoot } = makeSandbox();
363
+ const port = nextPort();
364
+ const proc = await bootServer(root, port);
365
+ try {
366
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
367
+ const tsxAbs = join(designRoot, 'ui', 'Oversized.tsx');
368
+ writeFileSync(tsxAbs, 'export default function O(){return <main/>}\n');
369
+ const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
370
+ writeFileSync(metaAbs, JSON.stringify({ title: 'Oversized', sections: [] }));
371
+ const file = repoRel(designRoot, tsxAbs);
372
+
373
+ // Seed a valid, small layout first.
374
+ await fetch(`http://localhost:${port}/_api/canvas-meta`, {
375
+ method: 'PATCH',
376
+ headers: { 'content-type': 'application/json' },
377
+ body: JSON.stringify({ file, patch: { layout: { artboards: [{ id: 'a', x: 0, y: 0 }] } } }),
378
+ });
379
+
380
+ // A crafted, oversized layout (2001 > the 2000-entry cap) must not land.
381
+ const huge = Array.from({ length: 2001 }, (_, i) => ({ id: `a${i}`, x: i, y: 0 }));
382
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
383
+ method: 'PATCH',
384
+ headers: { 'content-type': 'application/json' },
385
+ body: JSON.stringify({ file, patch: { layout: { artboards: huge } } }),
386
+ });
387
+ expect(r.status).toBe(200);
388
+ const merged = (await r.json()) as MetaShape;
389
+ // Silent no-op — the prior small, valid layout is still what's live.
390
+ const arts = merged.layout?.artboards as Array<Record<string, unknown>> | undefined;
391
+ expect(arts).toEqual([{ id: 'a', x: 0, y: 0 }]);
392
+
393
+ const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
394
+ const diskArts = onDisk.layout?.artboards as Array<Record<string, unknown>> | undefined;
395
+ expect(diskArts).toEqual([{ id: 'a', x: 0, y: 0 }]);
396
+ } finally {
397
+ await killProc(proc);
398
+ }
399
+ });
400
+
401
+ test('PATCH layout rejects an out-of-range artboard coordinate as a silent no-op', async () => {
402
+ const { root, designRoot } = makeSandbox();
403
+ const port = nextPort();
404
+ const proc = await bootServer(root, port);
405
+ try {
406
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
407
+ const tsxAbs = join(designRoot, 'ui', 'OutOfRange.tsx');
408
+ writeFileSync(tsxAbs, 'export default function R(){return <main/>}\n');
409
+ writeFileSync(
410
+ tsxAbs.replace(/\.tsx$/, '.meta.json'),
411
+ JSON.stringify({ title: 'OutOfRange' })
412
+ );
413
+ const file = repoRel(designRoot, tsxAbs);
414
+
415
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
416
+ method: 'PATCH',
417
+ headers: { 'content-type': 'application/json' },
418
+ body: JSON.stringify({
419
+ file,
420
+ patch: { layout: { artboards: [{ id: 'a', x: 50_000_000, y: 0 }] } },
421
+ }),
422
+ });
423
+ expect(r.status).toBe(200);
424
+ const merged = (await r.json()) as MetaShape;
425
+ expect(merged.layout).toBeUndefined();
426
+ } finally {
427
+ await killProc(proc);
428
+ }
429
+ });
430
+
320
431
  test('PATCH viewport on a non-existent canvas is refused (404) and mints no file (DDR-115 F-A2)', async () => {
321
432
  const { root, designRoot } = makeSandbox();
322
433
  const port = nextPort();
@@ -0,0 +1,73 @@
1
+ // canvas-zoom-floor — issue #91 dogfood report: dragging an artboard across a
2
+ // large board, the user can't zoom out far enough to complete the move in one
3
+ // motion. Root cause: `clampZoom` applies a single hard-coded floor to every
4
+ // viewport write, including the programmatic `fit()` path — a board wider
5
+ // than ~10x the viewport could never be framed whole. See
6
+ // .ai/logs/rca/issue-91-canvas-zoom-floor.md.
7
+ //
8
+ // canvas-lib.tsx pulls in a React/DOM dependency graph, so we register
9
+ // happy-dom for this file (like camera-reveal.test.tsx) before importing.
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
12
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
13
+
14
+ beforeAll(() => {
15
+ GlobalRegistrator.register();
16
+ });
17
+ afterAll(() => {
18
+ GlobalRegistrator.unregister();
19
+ });
20
+
21
+ import { clampZoom, computeFit } from '../canvas-lib.tsx';
22
+
23
+ function fakeHost(clientWidth: number, clientHeight: number): HTMLElement {
24
+ const el = document.createElement('div');
25
+ Object.defineProperty(el, 'clientWidth', { value: clientWidth });
26
+ Object.defineProperty(el, 'clientHeight', { value: clientHeight });
27
+ return el;
28
+ }
29
+
30
+ describe('clampZoom — the interactive zoom floor (issue #91)', () => {
31
+ test('a deep zoom-out below the OLD 0.1 floor is no longer clamped', () => {
32
+ expect(clampZoom(0.05)).toBe(0.05);
33
+ expect(clampZoom(0.02)).toBe(0.02);
34
+ });
35
+
36
+ test('still clamps below the new floor', () => {
37
+ expect(clampZoom(0.0001)).toBe(0.02);
38
+ expect(clampZoom(-5)).toBe(0.02);
39
+ });
40
+
41
+ test('still clamps above the max and normalizes non-finite input', () => {
42
+ expect(clampZoom(99)).toBe(4);
43
+ expect(clampZoom(Number.NaN)).toBe(1);
44
+ expect(clampZoom(Number.POSITIVE_INFINITY)).toBe(1);
45
+ });
46
+ });
47
+
48
+ describe('computeFit → clampZoom — fit() can now frame a wide board (issue #91)', () => {
49
+ test('a board ~20x the viewport width computes a zoom the old floor would have clamped up', () => {
50
+ // Viewport 1000x800; board 20000 wide — the exact "more than a screenful
51
+ // of artboards" shape from the report. Ideal fit zoom is well under 0.1.
52
+ const host = fakeHost(1000, 800);
53
+ const fit = computeFit([{ x: 0, y: 0, w: 20000, h: 800 }], host, 24);
54
+
55
+ expect(fit.zoom).toBeLessThan(0.1); // the bug: old floor would have clamped this
56
+ expect(fit.zoom).toBeGreaterThanOrEqual(0.02); // still within the new interactive floor
57
+
58
+ // The regression proper: applying the viewport funnel's clamp must be a
59
+ // no-op for this value now — before the fix (ZOOM_MIN = 0.1) this would
60
+ // have raised fit.zoom back up to 0.1, cropping the board.
61
+ expect(clampZoom(fit.zoom)).toBe(fit.zoom);
62
+ });
63
+
64
+ test('an extremely wide board (>50x viewport) still bottoms out at the interactive floor', () => {
65
+ const host = fakeHost(1000, 800);
66
+ const fit = computeFit([{ x: 0, y: 0, w: 100000, h: 800 }], host, 24);
67
+
68
+ // The ideal fit zoom is far below even the new floor — clampZoom must
69
+ // still protect the interactive floor for pathological board sizes.
70
+ expect(fit.zoom).toBeLessThan(0.02);
71
+ expect(clampZoom(fit.zoom)).toBe(0.02);
72
+ });
73
+ });
@@ -19,7 +19,7 @@ afterAll(() => {
19
19
  GlobalRegistrator.unregister();
20
20
  });
21
21
 
22
- import { resolveByDomPath } from '../dom-selection.ts';
22
+ import { buildComposeSelection, openCommentComposer, resolveByDomPath } from '../dom-selection.ts';
23
23
 
24
24
  function setArtboard(html: string): void {
25
25
  document.body.innerHTML = `<div data-dc-screen="art1">${html}</div>`;
@@ -128,3 +128,71 @@ describe('resolveByDomPath', () => {
128
128
  expect(found?.getAttribute('data-cd-id')).toBe('aaa11111');
129
129
  });
130
130
  });
131
+
132
+ // issue-90 — right-click "Add comment" on empty canvas background or an
133
+ // artboard's chrome/border (neither carries a `data-cd-id`) used to have no
134
+ // menu entry at all, and even where the entry DID exist (on an element) it
135
+ // never actually opened the composer (see `openCommentComposer` below).
136
+ // `buildComposeSelection` is the shared builder canvas-shell.tsx's
137
+ // context-menu registry now calls for all three target kinds.
138
+ describe('buildComposeSelection', () => {
139
+ test('with an element under the cursor — delegates to hoverTargetToSelection (same anchor a comment-tool click gets)', () => {
140
+ setArtboard(`<button class="cta" data-cd-id="aaa11111">Buy now</button>`);
141
+ const el = document.querySelector('[data-cd-id="aaa11111"]');
142
+ const sel = buildComposeSelection({ el, cdId: 'aaa11111', artboardId: 'art1' }, 100, 200);
143
+ expect(sel.selector).toBe('[data-dc-screen="art1"] [data-cd-id="aaa11111"]');
144
+ expect(sel.id).toBe('aaa11111');
145
+ expect(sel.tag).toBe('button');
146
+ });
147
+
148
+ test('with no element (empty canvas background) — a FLOATING selection anchored to the click point', () => {
149
+ document.body.innerHTML = '';
150
+ const sel = buildComposeSelection({ el: null, cdId: null, artboardId: null }, 100, 200);
151
+ expect(sel.selector).toBe('');
152
+ expect(sel.tag).toBe('');
153
+ expect(sel.artboardId).toBeNull();
154
+ expect(sel.bounds).toEqual({ x: 88, y: 188, w: 24, h: 24 });
155
+ });
156
+
157
+ test('with no element but INSIDE an artboard (right-click on its chrome/border) — floating, but keeps the artboardId', () => {
158
+ document.body.innerHTML = '';
159
+ const sel = buildComposeSelection({ el: null, cdId: null, artboardId: 'art1' }, 50, 60);
160
+ expect(sel.selector).toBe('');
161
+ expect(sel.artboardId).toBe('art1');
162
+ expect(sel.bounds).toEqual({ x: 38, y: 48, w: 24, h: 24 });
163
+ });
164
+ });
165
+
166
+ describe('openCommentComposer', () => {
167
+ // The regression: canvas-shell.tsx's context-menu "Add comment" used to
168
+ // ONLY postMessage the parent shell (which merely mirrors the target for
169
+ // the StatusBar/sidebar) and never dispatched `cm:open-composer` — the one
170
+ // event `comments-overlay.tsx` listens on to actually render the composer.
171
+ // Selecting the menu item silently did nothing.
172
+ test('dispatches cm:open-composer carrying the selection + click point', () => {
173
+ let detail: { selection?: unknown; clientX?: number; clientY?: number } | null = null;
174
+ const onOpen = (e: Event) => {
175
+ detail = (e as CustomEvent).detail;
176
+ };
177
+ document.addEventListener('cm:open-composer', onOpen);
178
+ const sel = {
179
+ selector: '',
180
+ artboardId: null,
181
+ tag: '',
182
+ classes: '',
183
+ text: '',
184
+ dom_path: [],
185
+ bounds: { x: 30, y: 40, w: 24, h: 24 },
186
+ html: '',
187
+ };
188
+ try {
189
+ openCommentComposer(sel, 42, 52);
190
+ } finally {
191
+ document.removeEventListener('cm:open-composer', onOpen);
192
+ }
193
+ expect(detail).not.toBeNull();
194
+ expect(detail?.selection).toBe(sel);
195
+ expect(detail?.clientX).toBe(42);
196
+ expect(detail?.clientY).toBe(52);
197
+ });
198
+ });
@@ -9,6 +9,7 @@ import {
9
9
  DRAG_THRESHOLD_PX,
10
10
  isArtboardDragChrome,
11
11
  isEditableTarget,
12
+ isOverlayTarget,
12
13
  type Tool,
13
14
  yieldsClickToArtboardChrome,
14
15
  yieldsToArtboardDrag,
@@ -496,6 +497,74 @@ describe('input-router / isArtboardDragChrome', () => {
496
497
  });
497
498
  });
498
499
 
500
+ // issue-90 — once comment mode is armed, a bare click anywhere on the shell's
501
+ // floating chrome (tool palette, zoom pill, minimap, right-click context
502
+ // menu) was claimed as `drop-comment` and dropped a stray pin at the
503
+ // chrome's own coordinates instead of reaching the chrome's own React
504
+ // onClick — the palette became unusable the moment the user picked the
505
+ // Comment tool. `classifyContextKind` (canvas-shell.tsx) already treats
506
+ // these same selectors as non-canvas `'overlay'` chrome; `isOverlayTarget`
507
+ // was the one claim gate that didn't.
508
+ describe('input-router / isOverlayTarget — shell chrome exemptions (issue-90)', () => {
509
+ // isOverlayTarget makes ONE `closest(a, b, c, ...)` call against the full
510
+ // comma-joined selector list, unlike isArtboardDragChrome's fakeTarget
511
+ // above (which mimics one call per bare selector) — so the stub instead
512
+ // checks whether any of the target's own classes appears among the
513
+ // selector's comma-separated parts, same as a real `Element.closest` would
514
+ // resolve for a node carrying that class.
515
+ const fakeTarget = (memberOf: string[]): EventTarget =>
516
+ ({
517
+ closest: (sel: string) => (memberOf.some((cls) => sel.includes(cls)) ? {} : null),
518
+ }) as unknown as EventTarget;
519
+
520
+ test('null target → false', () => {
521
+ expect(isOverlayTarget(null)).toBe(false);
522
+ });
523
+
524
+ test('a click on the tool palette is exempt', () => {
525
+ expect(isOverlayTarget(fakeTarget(['.dc-tool-palette']))).toBe(true);
526
+ });
527
+
528
+ test('a click on the zoom toolbar is exempt', () => {
529
+ expect(isOverlayTarget(fakeTarget(['.dc-zoom-tb']))).toBe(true);
530
+ });
531
+
532
+ test('a click on the minimap is exempt', () => {
533
+ expect(isOverlayTarget(fakeTarget(['.dc-mm']))).toBe(true);
534
+ });
535
+
536
+ test('a click on the right-click context menu is exempt', () => {
537
+ expect(isOverlayTarget(fakeTarget(['.dc-context-menu']))).toBe(true);
538
+ });
539
+
540
+ test('a click on ordinary canvas content is NOT exempt', () => {
541
+ expect(isOverlayTarget(fakeTarget([]))).toBe(false);
542
+ });
543
+
544
+ test('the pre-existing comment-overlay exemptions still hold', () => {
545
+ expect(isOverlayTarget(fakeTarget(['.cm-composer']))).toBe(true);
546
+ expect(isOverlayTarget(fakeTarget(['.cm-thread']))).toBe(true);
547
+ expect(isOverlayTarget(fakeTarget(['.cm-mention-popup']))).toBe(true);
548
+ expect(isOverlayTarget(fakeTarget(['.cm-pin']))).toBe(true);
549
+ expect(isOverlayTarget(fakeTarget(['[data-mediaref-player]']))).toBe(true);
550
+ });
551
+
552
+ // Security review finding (issue-90) — `.dc-world` is where the ACTIVE
553
+ // CANVAS's own untrusted, AI/user-authored JSX renders. None of the real
554
+ // chrome ever lives there, so an element inside `.dc-world` that ALSO
555
+ // carries one of these class names is necessarily a same-name imposter —
556
+ // untrusted canvas content spoofing the shell's own chrome to make the
557
+ // router yield (skip preventDefault) on it instead of claiming the
558
+ // gesture. Must be rejected regardless of which chrome class it copies.
559
+ test('an element inside .dc-world carrying a spoofed chrome class is NOT exempt', () => {
560
+ expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-tool-palette']))).toBe(false);
561
+ expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-zoom-tb']))).toBe(false);
562
+ expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-mm']))).toBe(false);
563
+ expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-context-menu']))).toBe(false);
564
+ expect(isOverlayTarget(fakeTarget(['.dc-world', '.cm-composer']))).toBe(false);
565
+ });
566
+ });
567
+
499
568
  // issue-71 — the actual regression: before this fix, useInputRouter's
500
569
  // pointerdown/mousedown handlers called `stopImmediatePropagation()` on EVERY
501
570
  // claimed action, including a bare click on an artboard's own drag chrome —