@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.
- package/apps/studio/api.ts +54 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-lib.tsx +16 -3
- package/apps/studio/canvas-shell.tsx +34 -39
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "annotations-survive-cold-start",
|
|
6
|
+
"version": "0.60.5",
|
|
7
|
+
"date": "2026-08-14",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Your drawings survive a cloud restart",
|
|
10
|
+
"summary": "Stickers, drawings and dropped images on the canvas no longer vanish after the cloud restarts, and a freshly added image now reaches your other machines instead of showing a broken frame. Annotations keep their own edit time now, and an empty record can never silently replace real strokes.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "self-contained-asset-sweep",
|
|
15
|
+
"version": "0.60.5",
|
|
16
|
+
"date": "2026-08-14",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Asset upload works on a clean machine",
|
|
19
|
+
"summary": "The desktop app uploads your project images with its own bundled runtime, so the upload no longer fails with an executable-not-found error on machines without developer tools installed.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"id": "continuous-sync-discovery",
|
|
6
24
|
"version": "0.60.3",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.60.
|
|
3
|
+
"version": "0.60.5",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"@1agh/maude-darwin-arm64": "0.60.
|
|
58
|
-
"@1agh/maude-darwin-x64": "0.60.
|
|
59
|
-
"@1agh/maude-linux-arm64": "0.60.
|
|
60
|
-
"@1agh/maude-linux-arm64-musl": "0.60.
|
|
61
|
-
"@1agh/maude-linux-x64": "0.60.
|
|
62
|
-
"@1agh/maude-linux-x64-musl": "0.60.
|
|
63
|
-
"@1agh/maude-win32-x64": "0.60.
|
|
57
|
+
"@1agh/maude-darwin-arm64": "0.60.5",
|
|
58
|
+
"@1agh/maude-darwin-x64": "0.60.5",
|
|
59
|
+
"@1agh/maude-linux-arm64": "0.60.5",
|
|
60
|
+
"@1agh/maude-linux-arm64-musl": "0.60.5",
|
|
61
|
+
"@1agh/maude-linux-x64": "0.60.5",
|
|
62
|
+
"@1agh/maude-linux-x64-musl": "0.60.5",
|
|
63
|
+
"@1agh/maude-win32-x64": "0.60.5"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
66
66
|
"cli",
|