@1agh/maude 0.60.4 → 0.60.6

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 (34) hide show
  1. package/apps/studio/annotations-layer.tsx +21 -12
  2. package/apps/studio/api.ts +54 -5
  3. package/apps/studio/build.ts +10 -0
  4. package/apps/studio/canvas-comment-mount.tsx +7 -1
  5. package/apps/studio/canvas-icons.tsx +30 -2
  6. package/apps/studio/canvas-lib.tsx +41 -6
  7. package/apps/studio/canvas-shell.tsx +39 -43
  8. package/apps/studio/client/app.jsx +20 -13
  9. package/apps/studio/dist/client.bundle.js +1 -1
  10. package/apps/studio/dist/comment-mount.js +2 -2
  11. package/apps/studio/dom-selection.ts +67 -0
  12. package/apps/studio/hmr-broadcast.ts +40 -2
  13. package/apps/studio/input-router.tsx +26 -2
  14. package/apps/studio/sync/agent.ts +64 -13
  15. package/apps/studio/sync/codec.ts +47 -0
  16. package/apps/studio/sync/cold-start.ts +96 -0
  17. package/apps/studio/sync/migrate-seed.ts +52 -7
  18. package/apps/studio/sync/projection.ts +10 -1
  19. package/apps/studio/test/canvas-meta-api.test.ts +114 -3
  20. package/apps/studio/test/canvas-wheel-delta.test.ts +42 -0
  21. package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
  22. package/apps/studio/test/comment-mount.test.ts +10 -1
  23. package/apps/studio/test/dom-selection.test.ts +69 -1
  24. package/apps/studio/test/hmr-broadcast.test.ts +6 -4
  25. package/apps/studio/test/hmr-classify.test.ts +18 -1
  26. package/apps/studio/test/input-router.test.ts +69 -0
  27. package/apps/studio/test/mode-toggle.test.tsx +226 -0
  28. package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
  29. package/apps/studio/test/use-tool-mode.test.tsx +33 -3
  30. package/apps/studio/tool-palette.tsx +86 -24
  31. package/apps/studio/use-tool-mode.tsx +113 -14
  32. package/apps/studio/whats-new.json +45 -0
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +31 -0
@@ -59,7 +59,24 @@ describe('classifyChange', () => {
59
59
 
60
60
  test('unrelated extensions → null', () => {
61
61
  expect(classifyChange('ui/notes.md', noSibling)).toBeNull();
62
- expect(classifyChange('assets/logo.svg', noSibling)).toBeNull();
62
+ expect(classifyChange('assets/photo.png.part', noSibling)).toBeNull();
63
+ expect(classifyChange('system/x/assets/fonts/Gators-Bold.woff2', noSibling)).toBeNull();
64
+ });
65
+
66
+ test('media files → asset heal signal (2026-08-15 annotations-assets RCA)', () => {
67
+ // A synced-down media file landing on disk heals still-broken <img>/<image>
68
+ // elements in open canvases — no reload. Top-level assets/ and DS trees both.
69
+ for (const f of [
70
+ 'assets/65c3a940.png',
71
+ 'assets/807d7ba7.jpg',
72
+ 'assets/logo.svg',
73
+ 'assets/clip.mp4',
74
+ 'system/alligators/assets/photos-cut/frame-helmet.jpg',
75
+ ]) {
76
+ const msg = classifyChange(f, noSibling);
77
+ expect(msg?.mode).toBe('asset');
78
+ expect(msg?.file).toBe(f);
79
+ }
63
80
  });
64
81
 
65
82
  test('backslash paths are normalised before classification', () => {
@@ -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 —
@@ -0,0 +1,226 @@
1
+ // mode-toggle — DDR-223 (issue #93). The preview/edit mode layer over the
2
+ // DDR-187 browse/move tool split: boot posture, the mode⇄tool invariant
3
+ // (arming a resting tool moves the mode; annotation tools are mode-neutral),
4
+ // resetTool, read-only preview boot, and the palette's segmented toggle.
5
+ //
6
+ // Needs a live DOM (context callbacks fired via act) — register happy-dom +
7
+ // drive a createRoot, same convention as browse-posture.test.tsx.
8
+
9
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
10
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
11
+
12
+ beforeAll(() => {
13
+ GlobalRegistrator.register();
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+ });
16
+ afterAll(() => {
17
+ GlobalRegistrator.unregister();
18
+ });
19
+
20
+ import { act, type ReactNode } from 'react';
21
+ import { createRoot, type Root } from 'react-dom/client';
22
+ import type { Tool } from '../input-router.tsx';
23
+ import { _resetReadOnlyCache } from '../read-only-mode.ts';
24
+ import { ToolPalette } from '../tool-palette.tsx';
25
+ import { type CanvasMode, ToolProvider, useToolMode } from '../use-tool-mode.tsx';
26
+
27
+ // Capture the live context value so tests can drive setMode/setTool/resetTool
28
+ // directly and assert the resulting (tool, mode) pair.
29
+ let ctx: ReturnType<typeof useToolMode> | null = null;
30
+ function Capture() {
31
+ ctx = useToolMode();
32
+ return null;
33
+ }
34
+
35
+ let root: Root | null = null;
36
+ let host: HTMLElement | null = null;
37
+
38
+ function mount(ui: ReactNode): void {
39
+ host = document.createElement('div');
40
+ document.body.appendChild(host);
41
+ root = createRoot(host);
42
+ act(() => {
43
+ root?.render(ui);
44
+ });
45
+ }
46
+
47
+ afterEach(() => {
48
+ act(() => {
49
+ root?.unmount();
50
+ });
51
+ host?.remove();
52
+ root = null;
53
+ host = null;
54
+ ctx = null;
55
+ _resetReadOnlyCache();
56
+ setPageUrl('http://localhost/');
57
+ });
58
+
59
+ // happy-dom: `history.replaceState` does not update `location.search`; the
60
+ // DetachedWindowAPI `setURL` is the supported seam.
61
+ function setPageUrl(url: string): void {
62
+ (window as unknown as { happyDOM?: { setURL?: (u: string) => void } }).happyDOM?.setURL?.(url);
63
+ }
64
+
65
+ const pair = (): { tool: Tool; mode: CanvasMode } => ({
66
+ tool: ctx?.tool as Tool,
67
+ mode: ctx?.mode as CanvasMode,
68
+ });
69
+
70
+ describe('mode-toggle / boot posture (DDR-223)', () => {
71
+ test('default boot = edit with move armed', () => {
72
+ mount(
73
+ <ToolProvider>
74
+ <Capture />
75
+ </ToolProvider>
76
+ );
77
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
78
+ });
79
+
80
+ test('initial="browse" boots preview (comment-mount specimen posture)', () => {
81
+ mount(
82
+ <ToolProvider initial="browse">
83
+ <Capture />
84
+ </ToolProvider>
85
+ );
86
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
87
+ });
88
+
89
+ test('read-only (?ro=1) boots preview/browse — the DDR-187 viewer posture', () => {
90
+ setPageUrl('http://localhost/?ro=1');
91
+ _resetReadOnlyCache();
92
+ mount(
93
+ <ToolProvider>
94
+ <Capture />
95
+ </ToolProvider>
96
+ );
97
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
98
+ });
99
+ });
100
+
101
+ describe('mode-toggle / mode⇄tool invariant', () => {
102
+ test('setMode arms the resting tool both ways', () => {
103
+ mount(
104
+ <ToolProvider>
105
+ <Capture />
106
+ </ToolProvider>
107
+ );
108
+ act(() => ctx?.setMode('preview'));
109
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
110
+ act(() => ctx?.setMode('edit'));
111
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
112
+ });
113
+
114
+ test('arming a resting tool moves the mode (V / tool-set / escape hatch)', () => {
115
+ mount(
116
+ <ToolProvider>
117
+ <Capture />
118
+ </ToolProvider>
119
+ );
120
+ act(() => ctx?.setTool('browse'));
121
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
122
+ // The Cmd+click-in-browse escape hatch is a plain setTool('move') at its
123
+ // call site — the invariant is what turns it into a coherent mode flip.
124
+ act(() => ctx?.setTool('move'));
125
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
126
+ });
127
+
128
+ test('annotation tools are mode-neutral — pen in preview stays preview', () => {
129
+ mount(
130
+ <ToolProvider initial="browse">
131
+ <Capture />
132
+ </ToolProvider>
133
+ );
134
+ act(() => ctx?.setTool('pen'));
135
+ expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
136
+ act(() => ctx?.setTool('comment'));
137
+ expect(pair()).toEqual({ tool: 'comment', mode: 'preview' });
138
+ });
139
+
140
+ test('resetTool arms the CURRENT mode’s resting tool', () => {
141
+ mount(
142
+ <ToolProvider initial="browse">
143
+ <Capture />
144
+ </ToolProvider>
145
+ );
146
+ // Preview: drawing an annotation then resetting returns to browse (the
147
+ // alive posture) — never to move.
148
+ act(() => ctx?.setTool('pen'));
149
+ act(() => ctx?.resetTool());
150
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
151
+ // Edit: byte-identical to the pre-DDR-223 hardcoded setTool('move').
152
+ act(() => ctx?.setMode('edit'));
153
+ act(() => ctx?.setTool('pen'));
154
+ act(() => ctx?.resetTool());
155
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
156
+ });
157
+ });
158
+
159
+ describe('mode-toggle / palette segmented toggle', () => {
160
+ const mountPalette = () =>
161
+ mount(
162
+ <ToolProvider>
163
+ <Capture />
164
+ <ToolPalette />
165
+ </ToolProvider>
166
+ );
167
+ const seg = (which: 'preview' | 'edit' | 'present'): HTMLButtonElement | null =>
168
+ document.querySelector(`[data-testid="palette-mode-${which}"]`);
169
+
170
+ test('renders three icon segments; Edit is pressed on boot; no browse/move tool buttons', () => {
171
+ mountPalette();
172
+ expect(seg('preview')).not.toBeNull();
173
+ expect(seg('edit')?.getAttribute('aria-pressed')).toBe('true');
174
+ expect(seg('preview')?.getAttribute('aria-pressed')).toBe('false');
175
+ // Owner steer 2026-08-15 — icon-only segments (lucide eye / pencil-ruler /
176
+ // presentation); the words live in aria-label/title.
177
+ expect(seg('preview')?.querySelector('svg')).not.toBeNull();
178
+ expect(seg('edit')?.querySelector('svg')).not.toBeNull();
179
+ expect(seg('present')?.querySelector('svg')).not.toBeNull();
180
+ // The segments ARE the resting-tool affordances — no separate Select /
181
+ // Browse buttons in the nav group.
182
+ expect(document.querySelector('[aria-label^="Select (V)"]')).toBeNull();
183
+ expect(document.querySelector('[aria-label^="Browse ("]')).toBeNull();
184
+ // Present moved INTO the toggle — exactly one presentation affordance, and
185
+ // it is the segment (the palette's right-end button is gone).
186
+ const present = document.querySelectorAll('[aria-label^="Presentation mode"]');
187
+ expect(present.length).toBe(1);
188
+ expect(present[0]).toBe(seg('present') as Element);
189
+ expect(seg('present')?.getAttribute('aria-pressed')).toBe('false');
190
+ });
191
+
192
+ test('clicking Preview flips the posture; insert (+) is edit-only', () => {
193
+ mountPalette();
194
+ expect(
195
+ document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
196
+ ).not.toBeNull();
197
+ act(() => {
198
+ seg('preview')?.click();
199
+ });
200
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
201
+ expect(seg('preview')?.getAttribute('aria-pressed')).toBe('true');
202
+ expect(
203
+ document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
204
+ ).toBeNull();
205
+ // Draw tools stay available in preview (issue #93: annotations in preview).
206
+ expect(document.querySelector('[aria-label^="Pen (B)"]')).not.toBeNull();
207
+ // And the Edit segment arms move again.
208
+ act(() => {
209
+ seg('edit')?.click();
210
+ });
211
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
212
+ });
213
+
214
+ test('clicking the ACTIVE segment re-arms the resting tool', () => {
215
+ mountPalette();
216
+ act(() => {
217
+ seg('preview')?.click();
218
+ });
219
+ act(() => ctx?.setTool('pen'));
220
+ expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
221
+ act(() => {
222
+ seg('preview')?.click();
223
+ });
224
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
225
+ });
226
+ });
@@ -0,0 +1,302 @@
1
+ // Per-lane annotations cold start — the 2026-08-14 annotations eraser.
2
+ //
3
+ // The eraser: annotations used to follow the BODY winner at cold start, and
4
+ // the emptiness guard was `!== ''` — so a hub doc holding the bare 72-byte
5
+ // wrapper (`<svg …></svg>`, a non-empty STRING carrying zero strokes)
6
+ // overwrote a peer's real strokes whenever the hub won the body, taking the
7
+ // `assets/<sha8>` references the asset pull scans with them. Confirmed on
8
+ // alligators: a sidecar committed with two `<image>` strokes at 12:45 was the
9
+ // empty wrapper on every peer two minutes after the next fleet roll.
10
+ //
11
+ // The fix has three parts, each pinned here:
12
+ // 1. `isEmptyAnnotationsSvg` — the wrapper counts as empty.
13
+ // 2. `decideAnnotationsColdStart` — per-lane table; unstamped emptiness
14
+ // never beats content, stamped delete-all is honored by time.
15
+ // 3. `stampAnnotationsEdit` riding every local→doc annotations apply
16
+ // (applyFromFs, cold-start seed, adopt) in the same transaction.
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import * as Y from 'yjs';
23
+
24
+ import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
25
+ import {
26
+ annotationsEditAtFromDoc,
27
+ annotationsFromDoc,
28
+ applyHtmlToDoc,
29
+ isEmptyAnnotationsSvg,
30
+ stampAnnotationsEdit,
31
+ } from '../sync/codec.ts';
32
+ import { type AnnotationsColdStartInput, decideAnnotationsColdStart } from '../sync/cold-start.ts';
33
+ import { createEchoGuard, hashBytes } from '../sync/echo-guard.ts';
34
+ import { migrateSeed } from '../sync/migrate-seed.ts';
35
+
36
+ /** The exact serialization `strokesToSvg([])` emits — the eraser's payload. */
37
+ const EMPTY_WRAPPER = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>';
38
+ const STROKES =
39
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><image data-id="s_1" data-tool="image" x="0" y="0" width="160" height="160" href="assets/0327a8e5.png"/></svg>';
40
+ const STROKES_B =
41
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect data-id="s_2" x="1" y="1" width="10" height="10"/></svg>';
42
+
43
+ /* ------------------------------------------------- isEmptyAnnotationsSvg */
44
+
45
+ describe('isEmptyAnnotationsSvg', () => {
46
+ test('null / empty / whitespace are empty', () => {
47
+ expect(isEmptyAnnotationsSvg(null)).toBe(true);
48
+ expect(isEmptyAnnotationsSvg('')).toBe(true);
49
+ expect(isEmptyAnnotationsSvg(' \n\t ')).toBe(true);
50
+ });
51
+
52
+ test('the bare wrapper is empty — the eraser regression pin', () => {
53
+ expect(isEmptyAnnotationsSvg(EMPTY_WRAPPER)).toBe(true);
54
+ });
55
+
56
+ test('wrapper with surrounding whitespace is empty', () => {
57
+ expect(isEmptyAnnotationsSvg(` ${EMPTY_WRAPPER}\n`)).toBe(true);
58
+ });
59
+
60
+ test('a single stroke is NOT empty', () => {
61
+ expect(isEmptyAnnotationsSvg(STROKES)).toBe(false);
62
+ });
63
+ });
64
+
65
+ /* ------------------------------------------- decideAnnotationsColdStart */
66
+
67
+ function input(over: Partial<AnnotationsColdStartInput>): AnnotationsColdStartInput {
68
+ return {
69
+ local: null,
70
+ doc: '',
71
+ isEmpty: isEmptyAnnotationsSvg,
72
+ localMtimeMs: null,
73
+ docEditAtMs: null,
74
+ bodyWinner: 'hub',
75
+ ...over,
76
+ };
77
+ }
78
+
79
+ describe('decideAnnotationsColdStart — emptiness vs content', () => {
80
+ test('both empty → none', () => {
81
+ expect(decideAnnotationsColdStart(input({})).winner).toBe('none');
82
+ expect(
83
+ decideAnnotationsColdStart(input({ local: EMPTY_WRAPPER, doc: EMPTY_WRAPPER })).winner
84
+ ).toBe('none');
85
+ });
86
+
87
+ test('THE ERASER: unstamped hub wrapper + local strokes + hub body winner → local', () => {
88
+ const d = decideAnnotationsColdStart(
89
+ input({ local: STROKES, doc: EMPTY_WRAPPER, localMtimeMs: 1000, bodyWinner: 'hub' })
90
+ );
91
+ expect(d.winner).toBe('local');
92
+ });
93
+
94
+ test('unset hub lane + local strokes → local (seed up), even with hub body winner', () => {
95
+ const d = decideAnnotationsColdStart(
96
+ input({ local: STROKES, doc: '', localMtimeMs: 1000, bodyWinner: 'hub' })
97
+ );
98
+ expect(d.winner).toBe('local');
99
+ });
100
+
101
+ test('STAMPED hub delete-all newer than local strokes → hub (deletes are honored)', () => {
102
+ const d = decideAnnotationsColdStart(
103
+ input({ local: STROKES, doc: EMPTY_WRAPPER, localMtimeMs: 1000, docEditAtMs: 2000 })
104
+ );
105
+ expect(d.winner).toBe('hub');
106
+ });
107
+
108
+ test('stamped hub delete-all OLDER than local strokes → local', () => {
109
+ const d = decideAnnotationsColdStart(
110
+ input({ local: STROKES, doc: EMPTY_WRAPPER, localMtimeMs: 2000, docEditAtMs: 1000 })
111
+ );
112
+ expect(d.winner).toBe('local');
113
+ });
114
+
115
+ test('local absent + hub strokes → hub (clean first sync materializes)', () => {
116
+ const d = decideAnnotationsColdStart(input({ doc: STROKES }));
117
+ expect(d.winner).toBe('hub');
118
+ });
119
+
120
+ test('local delete-all (wrapper on disk) newer than hub stamp → local', () => {
121
+ const d = decideAnnotationsColdStart(
122
+ input({ local: EMPTY_WRAPPER, doc: STROKES, localMtimeMs: 2000, docEditAtMs: 1000 })
123
+ );
124
+ expect(d.winner).toBe('local');
125
+ });
126
+
127
+ test('local wrapper with no doc stamp → hub (unstamped local emptiness loses too)', () => {
128
+ const d = decideAnnotationsColdStart(
129
+ input({ local: EMPTY_WRAPPER, doc: STROKES, localMtimeMs: 2000 })
130
+ );
131
+ expect(d.winner).toBe('hub');
132
+ });
133
+ });
134
+
135
+ describe('decideAnnotationsColdStart — both non-empty', () => {
136
+ test('equal content → none', () => {
137
+ const d = decideAnnotationsColdStart(input({ local: STROKES, doc: STROKES }));
138
+ expect(d.winner).toBe('none');
139
+ });
140
+
141
+ test('newest wins by per-lane stamp: local newer → local', () => {
142
+ const d = decideAnnotationsColdStart(
143
+ input({ local: STROKES, doc: STROKES_B, localMtimeMs: 2000, docEditAtMs: 1000 })
144
+ );
145
+ expect(d.winner).toBe('local');
146
+ });
147
+
148
+ test('newest wins by per-lane stamp: doc newer → hub', () => {
149
+ const d = decideAnnotationsColdStart(
150
+ input({ local: STROKES, doc: STROKES_B, localMtimeMs: 1000, docEditAtMs: 2000 })
151
+ );
152
+ expect(d.winner).toBe('hub');
153
+ });
154
+
155
+ test('no per-lane stamp → follows the body winner (legacy coupling)', () => {
156
+ expect(
157
+ decideAnnotationsColdStart(
158
+ input({ local: STROKES, doc: STROKES_B, localMtimeMs: 1000, bodyWinner: 'local' })
159
+ ).winner
160
+ ).toBe('local');
161
+ expect(
162
+ decideAnnotationsColdStart(
163
+ input({ local: STROKES, doc: STROKES_B, localMtimeMs: 1000, bodyWinner: 'hub' })
164
+ ).winner
165
+ ).toBe('hub');
166
+ });
167
+ });
168
+
169
+ /* ------------------------------------------------------- codec round-trip */
170
+
171
+ describe('stampAnnotationsEdit / annotationsEditAtFromDoc', () => {
172
+ test('round-trips; absent stamp reads null', () => {
173
+ const doc = new Y.Doc();
174
+ expect(annotationsEditAtFromDoc(doc)).toBe(null);
175
+ stampAnnotationsEdit(doc, undefined, 12345);
176
+ expect(annotationsEditAtFromDoc(doc)).toBe(12345);
177
+ });
178
+ });
179
+
180
+ /* ------------------------------------------------------ agent integration */
181
+
182
+ let dir: string;
183
+ let agent: CanvasSyncAgent;
184
+ let docA: Y.Doc;
185
+ let docB: Y.Doc;
186
+
187
+ function paths() {
188
+ return {
189
+ html: join(dir, 'screen.html'),
190
+ comments: join(dir, '_comments', 'screen.json'),
191
+ annotations: join(dir, 'screen.annotations.svg'),
192
+ };
193
+ }
194
+
195
+ beforeEach(() => {
196
+ dir = mkdtempSync(join(tmpdir(), 'sync-ann-cold-'));
197
+ docA = new Y.Doc();
198
+ docB = new Y.Doc();
199
+ const TRANSPORT = Symbol('transport');
200
+ docA.on('update', (update: Uint8Array, origin: unknown) => {
201
+ if (origin === TRANSPORT) return;
202
+ Y.applyUpdate(docB, update, TRANSPORT);
203
+ });
204
+ docB.on('update', (update: Uint8Array, origin: unknown) => {
205
+ if (origin === TRANSPORT) return;
206
+ Y.applyUpdate(docA, update, TRANSPORT);
207
+ });
208
+ });
209
+
210
+ afterEach(() => {
211
+ agent?.stop();
212
+ rmSync(dir, { recursive: true, force: true });
213
+ });
214
+
215
+ function makeAgent(): CanvasSyncAgent {
216
+ const a = createCanvasSyncAgent({
217
+ slug: 'screen',
218
+ doc: docB,
219
+ paths: paths(),
220
+ echoGuard: createEchoGuard(),
221
+ flushMs: 0,
222
+ });
223
+ a.start();
224
+ return a;
225
+ }
226
+
227
+ describe('cold start — the eraser scenario end to end', () => {
228
+ test('unstamped hub wrapper does NOT erase local strokes; local seeds up + stamps', async () => {
229
+ // Hub holds the stale empty wrapper (what the eraser propagated) with no
230
+ // per-lane stamp — the exact pre-fix state of every wiped canvas.
231
+ docA.getMap('annotations').set('svg', EMPTY_WRAPPER);
232
+ // Local disk holds real strokes with an image reference.
233
+ writeFileSync(paths().annotations, STROKES);
234
+
235
+ agent = makeAgent();
236
+ await agent.reconcile();
237
+
238
+ // Disk keeps the strokes; the doc now carries them, stamped.
239
+ expect(readFileSync(paths().annotations, 'utf8')).toBe(STROKES);
240
+ expect(annotationsFromDoc(docB)).toBe(STROKES);
241
+ expect(annotationsFromDoc(docA)).toBe(STROKES); // propagated to the hub side
242
+ expect(annotationsEditAtFromDoc(docB)).not.toBe(null);
243
+ });
244
+
245
+ test('stamped hub delete-all newer than local strokes IS honored on disk', async () => {
246
+ writeFileSync(paths().annotations, STROKES);
247
+ // Backdate the local file so the hub's delete-all stamp is provably newer.
248
+ const past = new Date(Date.now() - 60_000);
249
+ utimesSync(paths().annotations, past, past);
250
+ docA.transact(() => {
251
+ docA.getMap('annotations').set('svg', EMPTY_WRAPPER);
252
+ });
253
+ stampAnnotationsEdit(docA, undefined, Date.now());
254
+
255
+ agent = makeAgent();
256
+ await agent.reconcile();
257
+
258
+ expect(readFileSync(paths().annotations, 'utf8')).toBe(EMPTY_WRAPPER);
259
+ });
260
+
261
+ test('clean first sync still materializes hub strokes to disk', async () => {
262
+ docA.getMap('annotations').set('svg', STROKES);
263
+
264
+ agent = makeAgent();
265
+ await agent.reconcile();
266
+
267
+ expect(readFileSync(paths().annotations, 'utf8')).toBe(STROKES);
268
+ });
269
+
270
+ test('SHARED-DOC eraser: migrateSeed rescues local strokes from an unstamped hub wrapper', async () => {
271
+ // The cell-side copy of the eraser: hub doc has a newer body (so the body
272
+ // resolution keeps the hub) and a stale unstamped wrapper in the
273
+ // annotations lane; local disk has real strokes.
274
+ const doc = new Y.Doc();
275
+ applyHtmlToDoc(doc, '<main>hub body</main>');
276
+ doc.getMap('annotations').set('svg', EMPTY_WRAPPER);
277
+ writeFileSync(paths().html, '<main>hub body</main>'); // body identical → noop path
278
+ writeFileSync(paths().annotations, STROKES);
279
+
280
+ await migrateSeed({ slug: 'screen', doc, paths: paths() });
281
+
282
+ // The doc now carries the local strokes, stamped — so the collab room's
283
+ // persistJson materializes strokes, not the wrapper.
284
+ expect(annotationsFromDoc(doc)).toBe(STROKES);
285
+ expect(annotationsEditAtFromDoc(doc)).not.toBe(null);
286
+ });
287
+
288
+ test('applyFromFs stamps annotationsEditAt in the same update', () => {
289
+ agent = makeAgent();
290
+ const bytes = new TextEncoder().encode(STROKES);
291
+ const changed = agent.applyFromFs({
292
+ path: paths().annotations,
293
+ bytes,
294
+ hash: hashBytes(STROKES),
295
+ });
296
+ expect(changed).toBe(true);
297
+ expect(annotationsFromDoc(docB)).toBe(STROKES);
298
+ expect(annotationsEditAtFromDoc(docB)).not.toBe(null);
299
+ // The stamp crossed to the other peer with the same update.
300
+ expect(annotationsEditAtFromDoc(docA)).not.toBe(null);
301
+ });
302
+ });